feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
<?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);
}
}