Files
ephub/src/Booking/TimeRange.php
T

136 lines
4.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}