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); } }