136 lines
4.3 KiB
PHP
136 lines
4.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Booking;
|
||
|
||
/**
|
||
* A span of one day, as minutes from midnight, end-exclusive.
|
||
*
|
||
* Minutes rather than a TIME column on purpose: the end of an all-day booking is 24:00, which
|
||
* TIME cannot express, and half-open integer arithmetic makes the overlap test a plain
|
||
* comparison in both PHP and DQL.
|
||
*/
|
||
final readonly class TimeRange implements \Stringable
|
||
{
|
||
public const DAY_START = 0;
|
||
public const DAY_END = 1440;
|
||
|
||
private function __construct(
|
||
public int $startMinute,
|
||
public int $endMinute,
|
||
) {
|
||
if ($startMinute < self::DAY_START || $endMinute > self::DAY_END) {
|
||
throw new \InvalidArgumentException('Eine Buchung muss innerhalb eines Tages liegen.');
|
||
}
|
||
|
||
if ($startMinute >= $endMinute) {
|
||
throw new \InvalidArgumentException('Das Ende muss nach dem Beginn liegen.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Named wholeDay() and not allDay() on purpose. Twig resolves `range.allDay` by looking for a
|
||
* method literally called `allDay` BEFORE it tries `isAllDay()`, and it does not care that
|
||
* the method is static -- so a factory of that name silently shadows the accessor and every
|
||
* template reads back a fresh all-day range, which is truthy. Keep the two names distinct.
|
||
*/
|
||
public static function wholeDay(): self
|
||
{
|
||
return new self(self::DAY_START, self::DAY_END);
|
||
}
|
||
|
||
public static function fromMinutes(int $startMinute, int $endMinute): self
|
||
{
|
||
return new self($startMinute, $endMinute);
|
||
}
|
||
|
||
/**
|
||
* Reads the clock time off two \DateTime values, ignoring their date part -- which is what
|
||
* the form's TimeType hands over.
|
||
*/
|
||
public static function fromTimes(\DateTimeInterface $start, \DateTimeInterface $end): self
|
||
{
|
||
return new self(self::minutesOf($start), self::minutesOf($end));
|
||
}
|
||
|
||
/**
|
||
* Parses the board's ?von=&bis= pair. Returns null for anything missing or malformed, so the
|
||
* caller can fall back to the whole day rather than 404 on a hand-edited URL.
|
||
*/
|
||
public static function parse(?string $start, ?string $end): ?self
|
||
{
|
||
$hasStart = null !== $start && '' !== $start;
|
||
$hasEnd = null !== $end && '' !== $end;
|
||
|
||
if (!$hasStart && !$hasEnd) {
|
||
return null;
|
||
}
|
||
|
||
// One-sided input is treated as open to that end of the day, so typing only a start time
|
||
// does something visible instead of silently resetting the board.
|
||
$startMinute = $hasStart ? self::parseClock($start) : self::DAY_START;
|
||
$endMinute = $hasEnd ? self::parseClock($end) : self::DAY_END;
|
||
|
||
if (null === $startMinute || null === $endMinute || $startMinute >= $endMinute) {
|
||
return null;
|
||
}
|
||
|
||
return new self($startMinute, $endMinute);
|
||
}
|
||
|
||
private static function parseClock(string $value): ?int
|
||
{
|
||
if (1 !== preg_match('/^([01]\d|2[0-4]):([0-5]\d)$/', $value, $matches)) {
|
||
return null;
|
||
}
|
||
|
||
$minutes = (int) $matches[1] * 60 + (int) $matches[2];
|
||
|
||
return $minutes > self::DAY_END ? null : $minutes;
|
||
}
|
||
|
||
public function isAllDay(): bool
|
||
{
|
||
return self::DAY_START === $this->startMinute && self::DAY_END === $this->endMinute;
|
||
}
|
||
|
||
/**
|
||
* Half-open overlap: 09:00-13:00 and 13:00-17:00 do NOT collide.
|
||
*/
|
||
public function overlaps(self $other): bool
|
||
{
|
||
return $this->startMinute < $other->endMinute && $other->startMinute < $this->endMinute;
|
||
}
|
||
|
||
public function equals(self $other): bool
|
||
{
|
||
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
|
||
}
|
||
|
||
public function start(): string
|
||
{
|
||
return self::format($this->startMinute);
|
||
}
|
||
|
||
public function end(): string
|
||
{
|
||
return self::format($this->endMinute);
|
||
}
|
||
|
||
public function __toString(): string
|
||
{
|
||
return $this->isAllDay() ? 'ganztägig' : $this->start().'–'.$this->end();
|
||
}
|
||
|
||
private static function minutesOf(\DateTimeInterface $time): int
|
||
{
|
||
return (int) $time->format('G') * 60 + (int) $time->format('i');
|
||
}
|
||
|
||
private static function format(int $minutes): string
|
||
{
|
||
return \sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
|
||
}
|
||
}
|