diff --git a/assets/controllers/bookingform_controller.js b/assets/controllers/bookingform_controller.js new file mode 100644 index 0000000..7aca69c --- /dev/null +++ b/assets/controllers/bookingform_controller.js @@ -0,0 +1,25 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Shows and hides the parts of the booking form that only apply in some states: the time fields + * when "ganzer Tag" is off, the recurrence fields when a repetition is picked, and the weekday + * boxes only for a weekly rule. + * + * Purely client-side, so it is Stimulus rather than an htmx form refresh -- there is nothing the + * server needs to decide here. The server still validates every one of these rules, because a + * hidden field is not an absent one. + */ +export default class extends Controller { + static targets = ['times', 'repeat', 'weekdays']; + + toggleTimes(event) { + this.timesTarget.classList.toggle('hidden', event.target.checked); + } + + toggleRepeat(event) { + const rule = event.target.value; + + this.repeatTarget.classList.toggle('hidden', rule === ''); + this.weekdaysTarget.classList.toggle('hidden', rule !== 'weekly'); + } +} diff --git a/migrations/Version20260913133131.php b/migrations/Version20260913133131.php new file mode 100644 index 0000000..bdcb95a --- /dev/null +++ b/migrations/Version20260913133131.php @@ -0,0 +1,55 @@ +addSql('CREATE TABLE booking_series (id INT AUTO_INCREMENT NOT NULL, rule VARCHAR(255) NOT NULL, weekdays JSON NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, start_minute INT NOT NULL, end_minute INT NOT NULL, created_at DATETIME NOT NULL, user_id INT NOT NULL, resource_id INT NOT NULL, INDEX IDX_818A3AD3A76ED395 (user_id), INDEX IDX_818A3AD389329D25 (resource_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE booking_series ADD CONSTRAINT FK_818A3AD3A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE booking_series ADD CONSTRAINT FK_818A3AD389329D25 FOREIGN KEY (resource_id) REFERENCES resource (id) ON DELETE RESTRICT'); + // The unique index cannot express "no overlapping time range" -- and MariaDB has no + // exclusion constraints -- so the guard moves into BookingManager, which serialises on a + // SELECT ... FOR UPDATE of the resource row. This index makes that check cheap. + $this->addSql('ALTER TABLE booking DROP INDEX uniq_booking_resource_date, ADD INDEX idx_booking_resource_date (resource_id, date)'); + // The defaults are what backfills existing rows: every booking made so far was a whole + // day, which is exactly 0..1440. They are dropped again immediately so the application + // stays the only thing that decides a booking's span. + $this->addSql('ALTER TABLE booking ADD start_minute INT NOT NULL DEFAULT 0, ADD end_minute INT NOT NULL DEFAULT 1440, ADD overridden TINYINT(1) NOT NULL DEFAULT 0, ADD series_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE booking ALTER COLUMN start_minute DROP DEFAULT'); + $this->addSql('ALTER TABLE booking ALTER COLUMN end_minute DROP DEFAULT'); + $this->addSql('ALTER TABLE booking ALTER COLUMN overridden DROP DEFAULT'); + $this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDE5278319C FOREIGN KEY (series_id) REFERENCES booking_series (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX idx_booking_user_date ON booking (user_id, date)'); + $this->addSql('CREATE INDEX IDX_E00CEDDE5278319C ON booking (series_id)'); + } + + public function down(Schema $schema): void + { + // Note the down() is lossy by nature: restoring UNIQUE(resource_id, date) fails if any + // day holds two part-day bookings of the same resource, which is the whole point of this + // migration. Release those first if you really need to roll back. + $this->addSql('ALTER TABLE booking_series DROP FOREIGN KEY FK_818A3AD3A76ED395'); + $this->addSql('ALTER TABLE booking_series DROP FOREIGN KEY FK_818A3AD389329D25'); + $this->addSql('DROP TABLE booking_series'); + $this->addSql('ALTER TABLE booking DROP INDEX idx_booking_resource_date, ADD UNIQUE INDEX uniq_booking_resource_date (resource_id, date)'); + $this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDE5278319C'); + $this->addSql('DROP INDEX idx_booking_user_date ON booking'); + $this->addSql('DROP INDEX IDX_E00CEDDE5278319C ON booking'); + $this->addSql('ALTER TABLE booking DROP start_minute, DROP end_minute, DROP overridden, DROP series_id'); + } +} diff --git a/src/Booking/AlreadyHoldingResourceException.php b/src/Booking/AlreadyHoldingResourceException.php index 4212025..0013a28 100644 --- a/src/Booking/AlreadyHoldingResourceException.php +++ b/src/Booking/AlreadyHoldingResourceException.php @@ -7,16 +7,20 @@ namespace App\Booking; use App\Entity\Booking; /** - * One person may hold at most one desk and one parking spot per day. + * One person may not hold two resources of the same kind at overlapping times. A desk in the + * morning and a different one in the afternoon is fine -- only the overlap is refused. */ final class AlreadyHoldingResourceException extends \RuntimeException { public function __construct(public readonly Booking $existing) { + $time = $existing->getTimeRange(); + parent::__construct(\sprintf( - 'Du hast am %s bereits %s gebucht.', + 'Du hast am %s bereits %s gebucht (%s).', $existing->getDate()->format('d.m.Y'), $existing->getResource()->getLabel(), + $time, )); } } diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php index c838719..626b044 100644 --- a/src/Booking/BoardPresenter.php +++ b/src/Booking/BoardPresenter.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Booking; -use App\Entity\Booking; use App\Entity\ResourceType; use App\Entity\User; use App\Repository\BookingRepository; @@ -15,7 +14,12 @@ use App\Repository\ResourceRepository; * calendar and the floor plan. * * Both are always rendered from the same context so that one htmx round trip can swap both -- - * picking a day updates the grid and the plan in a single out-of-band response. + * picking a day (or a time span) updates the grid and the plan in a single out-of-band response. + * + * Everything here is relative to a TimeRange. With part-day bookings "is this desk free?" has no + * answer on its own, only "is it free between 09:00 and 13:00?", so the span the user picked is + * carried through the occupancy counts and the plan colours alike -- the number in a calendar + * cell always means the same thing as the colours on the plan. */ final readonly class BoardPresenter { @@ -30,7 +34,7 @@ final readonly class BoardPresenter /** * @return array */ - public function context(\DateTimeImmutable $selected, User $user): array + public function context(\DateTimeImmutable $selected, TimeRange $time, User $user): array { $selected = $selected->setTime(0, 0); $today = new \DateTimeImmutable('today'); @@ -39,7 +43,7 @@ final readonly class BoardPresenter $gridStart = $monthStart->modify('monday this week'); $gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1)); - $counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd); + $counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time); $mine = []; foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) { @@ -68,29 +72,39 @@ final readonly class BoardPresenter } $byResource = []; - foreach ($this->bookings->findForDate($selected) as $booking) { - $byResource[$booking->getResource()->getId()] = $booking; + foreach ($this->bookings->findForDate($selected, $time) as $booking) { + // Several bookings can overlap the span on one resource once times are in play; the + // first is enough to colour the shape, and the tooltip lists them all. + $byResource[$booking->getResource()->getId()][] = $booking; } return [ 'selected_date' => $selected, + 'selected_time' => $time, 'month_start' => $monthStart, 'weeks' => $weeks, 'is_past' => $selected < $today, 'desks' => $this->resources->findActiveByType(ResourceType::DESK), 'parking' => $this->resources->findActiveByType(ResourceType::PARKING), 'bookings_by_resource' => $byResource, - 'my_bookings_today' => $this->mineOn($selected, $mine), + 'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], ]; } /** - * @param array> $mine + * The query parameters that reproduce a board view, so links and HX-Push-Url agree. * - * @return list + * @return array */ - private function mineOn(\DateTimeImmutable $date, array $mine): array + public static function urlParameters(\DateTimeImmutable $date, TimeRange $time): array { - return $mine[$date->format('Y-m-d')] ?? []; + $parameters = ['tag' => $date->format('Y-m-d')]; + + if (!$time->isAllDay()) { + $parameters['von'] = $time->start(); + $parameters['bis'] = $time->end(); + } + + return $parameters; } } diff --git a/src/Booking/BookingManager.php b/src/Booking/BookingManager.php index 1bf1fa6..2aa5786 100644 --- a/src/Booking/BookingManager.php +++ b/src/Booking/BookingManager.php @@ -5,12 +5,33 @@ declare(strict_types=1); namespace App\Booking; use App\Entity\Booking; +use App\Entity\BookingSeries; use App\Entity\Resource; use App\Entity\User; use App\Repository\BookingRepository; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\DBAL\LockMode; use Doctrine\ORM\EntityManagerInterface; +/** + * Every write to a booking goes through here, because every write needs the same guard. + * + * **How double-booking is prevented, and why it changed.** While a booking was a whole day, a + * UNIQUE(resource_id, date) index did the job and the database rejected the loser of a race. A + * booking is now a time *range*, and an overlap is not something a unique index can express -- + * nor does MariaDB have PostgreSQL's exclusion constraints. So the serialisation point moved + * into the application: each write opens a transaction, takes a row lock on the Resource + * (SELECT ... FOR UPDATE), and only then looks for overlaps. Two concurrent requests for the + * same resource queue up on that lock, so the second one sees the first one's row. + * + * This is still a real guard rather than the racy check-then-insert the plan warned about -- the + * check happens while nobody else can be inserting for that resource. The cost is that the + * protection now lives in code, so it belongs to this class alone: nothing else may persist a + * Booking. + * + * The per-user rule ("not two desks at once") is deliberately NOT covered by the lock: it spans + * resources, and locking every resource would serialise the whole app to protect against a user + * racing themselves in two browser tabs. + */ final readonly class BookingManager { public function __construct( @@ -20,36 +41,84 @@ final readonly class BookingManager } /** - * @throws AlreadyHoldingResourceException if the user already holds a resource of this type that day - * @throws ResourceAlreadyBookedException if somebody else holds this resource that day + * @throws AlreadyHoldingResourceException if the user already holds a resource of this kind in that span + * @throws ResourceAlreadyBookedException if somebody else does */ - public function book(User $user, Resource $resource, \DateTimeImmutable $date): Booking + public function book(User $user, Resource $resource, \DateTimeImmutable $date, ?TimeRange $time = null): Booking { - $date = $date->setTime(0, 0); + // All day is the default, as it was before ranges existed. + $time ??= TimeRange::wholeDay(); - $existing = $this->bookings->findOneForUserDateAndType($user, $date, $resource->getType()); - if (null !== $existing) { - throw new AlreadyHoldingResourceException($existing); - } + return $this->em->wrapInTransaction(function () use ($user, $resource, $date, $time): Booking { + $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); - // Friendly path: almost every collision is caught here, with the entity manager healthy. - if (null !== $this->bookings->findOneBy(['resource' => $resource, 'date' => $date])) { - throw new ResourceAlreadyBookedException($resource, $date); - } + $this->assertFree($user, $resource, $date, $time); - $booking = new Booking($user, $resource, $date); - $this->em->persist($booking); - - try { + $booking = new Booking($user, $resource, $date, $time); + $this->em->persist($booking); $this->em->flush(); - } catch (UniqueConstraintViolationException $e) { - // The real guard: two concurrent requests both pass the check above and the database - // rejects the second. A failed flush CLOSES the entity manager, so whoever catches - // this must render its error page without touching the database again. - throw new ResourceAlreadyBookedException($resource, $date, $e); - } - return $booking; + return $booking; + }); + } + + /** + * Materialises a series into concrete bookings, skipping days that are already taken. + * + * The resource is locked once for the whole series rather than per occurrence, so the set of + * days either all see the same world or none do. Occurrences fall on distinct days, so the + * not-yet-flushed ones cannot collide with each other. + */ + public function bookSeries(BookingSeries $series): SeriesResult + { + return $this->em->wrapInTransaction(function () use ($series): SeriesResult { + $resource = $series->getResource(); + $user = $series->getUser(); + $time = $series->getTimeRange(); + + $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); + + $created = []; + $skipped = []; + + foreach ($series->occurrences() as $date) { + try { + $this->assertFree($user, $resource, $date, $time); + } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { + $skipped[$date->format('Y-m-d')] = $e->getMessage(); + + continue; + } + + $booking = new Booking($user, $resource, $date, $time, $series); + $this->em->persist($booking); + $created[] = $booking; + } + + if ([] !== $created) { + $this->em->persist($series); + } + + $this->em->flush(); + + return new SeriesResult($series, $created, $skipped); + }); + } + + /** + * Moves one occurrence to different times, leaving the rest of its series alone. + */ + public function reschedule(Booking $booking, TimeRange $time): void + { + $this->em->wrapInTransaction(function () use ($booking, $time): void { + $resource = $booking->getResource(); + $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); + + $this->assertFree($booking->getUser(), $resource, $booking->getDate(), $time, $booking); + + $booking->changeTime($time); + $this->em->flush(); + }); } public function release(Booking $booking): void @@ -57,4 +126,58 @@ final readonly class BookingManager $this->em->remove($booking); $this->em->flush(); } + + /** + * Ends a series from a day onwards: its remaining occurrences go, and the series itself is + * either shortened or dropped if nothing is left of it. + * + * @return int the number of occurrences removed + */ + public function endSeries(BookingSeries $series, \DateTimeImmutable $from): int + { + return $this->em->wrapInTransaction(function () use ($series, $from): int { + $from = $from->setTime(0, 0); + $removed = 0; + + foreach ($this->bookings->findForSeriesFrom($series, $from) as $booking) { + $this->em->remove($booking); + ++$removed; + } + + if ($series->getStartDate() >= $from) { + $this->em->remove($series); + } else { + $series->setEndDate($from->modify('-1 day')); + } + + $this->em->flush(); + + return $removed; + }); + } + + /** + * @throws AlreadyHoldingResourceException + * @throws ResourceAlreadyBookedException + */ + private function assertFree( + User $user, + Resource $resource, + \DateTimeImmutable $date, + TimeRange $time, + ?Booking $ignore = null, + ): void { + $mine = $this->bookings->findOverlappingForUser($user, $date, $time, $resource->getType(), $ignore); + if (null !== $mine) { + throw new AlreadyHoldingResourceException($mine); + } + + foreach ($this->bookings->findOverlapping($resource, $date, $time) as $clash) { + if (null !== $ignore && $clash->getId() === $ignore->getId()) { + continue; + } + + throw new ResourceAlreadyBookedException($resource, $date, $clash->getTimeRange()); + } + } } diff --git a/src/Booking/RecurrenceRule.php b/src/Booking/RecurrenceRule.php new file mode 100644 index 0000000..d6f5eff --- /dev/null +++ b/src/Booking/RecurrenceRule.php @@ -0,0 +1,19 @@ + 'Täglich', + self::WEEKLY => 'Wöchentlich', + }; + } +} diff --git a/src/Booking/ResourceAlreadyBookedException.php b/src/Booking/ResourceAlreadyBookedException.php index 34d6d6c..45f8991 100644 --- a/src/Booking/ResourceAlreadyBookedException.php +++ b/src/Booking/ResourceAlreadyBookedException.php @@ -11,11 +11,17 @@ final class ResourceAlreadyBookedException extends \RuntimeException public function __construct( public readonly Resource $resource, public readonly \DateTimeImmutable $date, - ?\Throwable $previous = null, + public readonly ?TimeRange $clashingWith = null, ) { - parent::__construct( - \sprintf('%s ist am %s bereits belegt.', $resource->getLabel(), $date->format('d.m.Y')), - previous: $previous, - ); + $when = null === $clashingWith || $clashingWith->isAllDay() + ? '' + : \sprintf(' (%s)', $clashingWith); + + parent::__construct(\sprintf( + '%s ist am %s bereits belegt%s.', + $resource->getLabel(), + $date->format('d.m.Y'), + $when, + )); } } diff --git a/src/Booking/SeriesResult.php b/src/Booking/SeriesResult.php new file mode 100644 index 0000000..ce9ef35 --- /dev/null +++ b/src/Booking/SeriesResult.php @@ -0,0 +1,52 @@ + $created + * @param array $skipped Y-m-d => reason + */ + public function __construct( + public BookingSeries $series, + public array $created, + public array $skipped, + ) { + } + + public function createdCount(): int + { + return \count($this->created); + } + + public function skippedCount(): int + { + return \count($this->skipped); + } + + public function isCompletelyBlocked(): bool + { + return [] === $this->created; + } + + /** + * @return list + */ + public function skippedDates(): array + { + return array_map( + static fn (string $date): string => (new \DateTimeImmutable($date))->format('j.n.'), + array_keys($this->skipped), + ); + } +} diff --git a/src/Booking/TimeRange.php b/src/Booking/TimeRange.php new file mode 100644 index 0000000..06b8c31 --- /dev/null +++ b/src/Booking/TimeRange.php @@ -0,0 +1,135 @@ + 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); + } +} diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php index d98296b..a7c7180 100644 --- a/src/Controller/BookingController.php +++ b/src/Controller/BookingController.php @@ -9,14 +9,19 @@ use App\Booking\BoardPresenter; use App\Booking\BookingManager; use App\Booking\DateParam; use App\Booking\ResourceAlreadyBookedException; +use App\Booking\SeriesResult; +use App\Booking\TimeRange; use App\Entity\Booking; +use App\Entity\BookingSeries; use App\Entity\Resource; use App\Form\BookingRequest; use App\Form\BookingType; use App\Htmx\HxTrait; use App\Repository\BookingRepository; +use App\Repository\BookingSeriesRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -30,6 +35,7 @@ final class BookingController extends AbstractController private readonly BoardPresenter $board, private readonly BookingManager $bookings, private readonly BookingRepository $repository, + private readonly BookingSeriesRepository $series, ) { } @@ -40,6 +46,7 @@ final class BookingController extends AbstractController return $this->render('booking/index.html.twig', [ 'bookings' => $this->repository->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')), + 'series' => $this->series->findActiveForUser($this->currentUser(), $today), ]); } @@ -48,7 +55,7 @@ final class BookingController extends AbstractController * * On an invalid submit this just render()s the modal again -- Symfony turns that into a 422 * and the responseHandling entry in assets/app.js lets htmx swap it, so the modal replaces - * itself with the errors showing. On success the response carries nothing but the two + * itself with the errors showing. On success the response carries nothing but the * out-of-band board blocks, so the modal's outerHTML target is swapped with an empty * fragment and the modal simply disappears. */ @@ -60,34 +67,36 @@ final class BookingController extends AbstractController } $selected = DateParam::parseOrToday($date); + $boardTime = DashboardController::timeOf($request); - $form = $this->createForm(BookingType::class, new BookingRequest($selected), [ - 'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date]), + // Prefilled from whatever span the board is showing, so narrowing the board and then + // clicking a desk books exactly what was on screen. + $data = new BookingRequest($selected, $boardTime); + + $form = $this->createForm(BookingType::class, $data, [ + 'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)), ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $requested = $form->getData(); - \assert($requested instanceof BookingRequest); - \assert($requested->date instanceof \DateTimeImmutable); + \assert($data->date instanceof \DateTimeImmutable); try { - $this->bookings->book($this->currentUser(), $resource, $requested->date); - } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { - // Deliberately renders from data already in hand: the losing side of a race has - // a CLOSED entity manager at this point (see BookingManager::book), so this - // branch must not query. - $form->addError(new FormError($e->getMessage())); + $result = $data->repeats() + ? $this->createSeries($data, $resource) + : null; - return $this->render('booking/_modal_create.html.twig', [ - 'form' => $form, - 'resource' => $resource, - ]); + if (null === $result) { + $this->bookings->book($this->currentUser(), $resource, $data->date, $data->timeRange()); + $this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange())); + } else { + $this->flashSeriesResult($result, $resource); + } + } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { + return $this->invalidModal($form, $resource, $e->getMessage()); } - $this->addFlash('success', \sprintf('%s gebucht.', $resource->getLabel())); - - return $this->boardResponse($requested->date); + return $this->boardResponse($data->date, $boardTime); } return $this->render('booking/_modal_create.html.twig', [ @@ -96,16 +105,50 @@ final class BookingController extends AbstractController ]); } + /** + * Moves one occurrence, which is what an "override" of a series is: the other occurrences + * are untouched and this one is flagged as differing from its series. + */ + #[Route('/buchungen/{id}/bearbeiten', name: 'app_booking_edit', methods: ['GET', 'POST'])] + public function edit(Request $request, Booking $booking): Response + { + $this->denyUnlessOwn($booking); + + $boardTime = DashboardController::timeOf($request); + $data = new BookingRequest($booking->getDate(), $booking->getTimeRange()); + + $form = $this->createForm(BookingType::class, $data, [ + 'action' => $this->generateUrl('app_booking_edit', ['id' => $booking->getId()] + $this->timeParameters($boardTime)), + ]); + // The day and any recurrence belong to the original booking; only the span is editable + // here, so a series is never silently rewritten by editing one of its occurrences. + $form->remove('date')->remove('repeat')->remove('weekdays')->remove('until'); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->bookings->reschedule($booking, $data->timeRange()); + } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { + return $this->invalidModal($form, $booking->getResource(), $e->getMessage(), $booking); + } + + $this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange())); + + return $this->boardResponse($booking->getDate(), $boardTime); + } + + return $this->render('booking/_modal_edit.html.twig', [ + 'form' => $form, + 'resource' => $booking->getResource(), + 'booking' => $booking, + ]); + } + #[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])] public function release(Request $request, Booking $booking): Response { - if (!$booking->belongsTo($this->currentUser())) { - throw $this->createAccessDeniedException('Fremde Buchungen können nicht freigegeben werden.'); - } - - if (!$this->isCsrfTokenValid('release-'.$booking->getId(), $request->request->getString('_token'))) { - throw $this->createAccessDeniedException('Ungültiges CSRF-Token.'); - } + $this->denyUnlessOwn($booking); + $this->denyUnlessCsrfValid($request, 'release-'.$booking->getId()); $date = $booking->getDate(); $label = $booking->getResource()->getLabel(); @@ -118,20 +161,109 @@ final class BookingController extends AbstractController return $this->redirectToRoute('app_booking'); } - return $this->boardResponse($date); + return $this->boardResponse($date, DashboardController::timeOf($request)); + } + + /** + * Ends a series from today onwards. Past occurrences are history and stay put. + */ + #[Route('/serien/{id}/beenden', name: 'app_series_end', methods: ['POST'])] + public function endSeries(Request $request, BookingSeries $series): Response + { + if ($series->getUser()->getId() !== $this->currentUser()->getId()) { + throw $this->createAccessDeniedException('Fremde Serien können nicht beendet werden.'); + } + + $this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId()); + + $removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today')); + + $this->addFlash('success', \sprintf('Serie beendet, %d Termine entfernt.', $removed)); + + return $this->redirectToRoute('app_booking'); + } + + private function createSeries(BookingRequest $data, Resource $resource): SeriesResult + { + \assert($data->date instanceof \DateTimeImmutable); + \assert($data->repeat instanceof \App\Booking\RecurrenceRule); + \assert($data->until instanceof \DateTimeImmutable); + + return $this->bookings->bookSeries(new BookingSeries( + $this->currentUser(), + $resource, + $data->repeat, + $data->date, + $data->until, + $data->timeRange(), + $data->weekdays, + )); + } + + private function flashSeriesResult(SeriesResult $result, Resource $resource): void + { + if ($result->isCompletelyBlocked()) { + $this->addFlash('warning', \sprintf('%s ist an keinem Tag der Serie frei.', $resource->getLabel())); + + return; + } + + $message = \sprintf('%s: %d Termine gebucht.', $resource->getLabel(), $result->createdCount()); + + if ($result->skippedCount() > 0) { + $message .= \sprintf(' %d Tage waren belegt (%s).', $result->skippedCount(), implode(', ', $result->skippedDates())); + } + + $this->addFlash($result->skippedCount() > 0 ? 'warning' : 'success', $message); + } + + /** + * Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by + * itself, which is exactly what htmx needs to swap it. + */ + private function invalidModal(FormInterface $form, Resource $resource, string $message, ?Booking $booking = null): Response + { + $form->addError(new FormError($message)); + + return $this->render( + null === $booking ? 'booking/_modal_create.html.twig' : 'booking/_modal_edit.html.twig', + ['form' => $form, 'resource' => $resource, 'booking' => $booking], + ); } /** * The whole response is out-of-band board blocks. Callers pair it with hx-swap="none" * (or an outerHTML target that should vanish, as the modal does). */ - private function boardResponse(\DateTimeImmutable $date): Response + private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response { return $this->htmxOobResponse( 'dashboard/index.html.twig', ['calendar', 'floorplan', 'flashes'], - $this->board->context($date, $this->currentUser()), - $this->generateUrl('app_dashboard', ['tag' => $date->format('Y-m-d')]), + $this->board->context($date, $time, $this->currentUser()), + $this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)), ); } + + /** + * @return array + */ + private function timeParameters(TimeRange $time): array + { + return $time->isAllDay() ? [] : ['von' => $time->start(), 'bis' => $time->end()]; + } + + private function denyUnlessOwn(Booking $booking): void + { + if (!$booking->belongsTo($this->currentUser())) { + throw $this->createAccessDeniedException('Fremde Buchungen können nicht geändert werden.'); + } + } + + private function denyUnlessCsrfValid(Request $request, string $tokenId): void + { + if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Ungültiges CSRF-Token.'); + } + } } diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index f9d16e0..249d47c 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -6,6 +6,7 @@ namespace App\Controller; use App\Booking\BoardPresenter; use App\Booking\DateParam; +use App\Booking\TimeRange; use App\Htmx\HxTrait; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -13,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; /** - * The booking board: a month calendar next to the floor plan for the selected day. + * The booking board: a month calendar next to the floor plan for the selected day and span. */ final class DashboardController extends AbstractController { @@ -29,11 +30,12 @@ final class DashboardController extends AbstractController { $date = DateParam::parseOrToday($request->query->getString('tag')); - return $this->render('dashboard/index.html.twig', $this->board->context($date, $this->currentUser())); + return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser())); } /** - * Picking a day swaps the calendar and the floor plan in one response. + * Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one + * response. * * This is the whole reason htmxOobResponse exists: two regions, one round trip, no * client-side event choreography. Requests without the htmx header get a plain redirect @@ -43,16 +45,27 @@ final class DashboardController extends AbstractController public function day(Request $request, string $date): Response { $selected = DateParam::parseOrToday($date); + $time = self::timeOf($request); if (!$request->headers->has('HX-Request')) { - return $this->redirectToRoute('app_dashboard', ['tag' => $selected->format('Y-m-d')]); + return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time)); } return $this->htmxOobResponse( 'dashboard/index.html.twig', ['calendar', 'floorplan'], - $this->board->context($selected, $this->currentUser()), - $this->generateUrl('app_dashboard', ['tag' => $selected->format('Y-m-d')]), + $this->board->context($selected, $time, $this->currentUser()), + $this->generateUrl('app_dashboard', BoardPresenter::urlParameters($selected, $time)), ); } + + /** + * A malformed or missing span falls back to the whole day rather than erroring: these are + * hand-editable query parameters, not route requirements. + */ + public static function timeOf(Request $request): TimeRange + { + return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis')) + ?? TimeRange::wholeDay(); + } } diff --git a/src/Entity/Booking.php b/src/Entity/Booking.php index 3ff0628..737d2f7 100644 --- a/src/Entity/Booking.php +++ b/src/Entity/Booking.php @@ -4,17 +4,22 @@ declare(strict_types=1); namespace App\Entity; +use App\Booking\TimeRange; use App\Repository\BookingRepository; use Doctrine\ORM\Mapping as ORM; /** - * One person holding one resource for one whole day. + * One person holding one resource for a span of one day. * - * The unique constraint is the actual guard against double-booking -- checking first and then - * inserting is racy, so callers insert and catch UniqueConstraintViolationException instead. + * There is deliberately NO unique constraint on (resource, date) any more: since a booking + * covers a time range, the thing to prevent is an *overlap*, which no unique index can express + * and which MariaDB has no exclusion constraint for either. BookingManager serialises instead -- + * it locks the resource row FOR UPDATE and checks for overlaps inside that transaction. The + * index below is what makes that check cheap. */ #[ORM\Entity(repositoryClass: BookingRepository::class)] -#[ORM\UniqueConstraint(name: 'uniq_booking_resource_date', columns: ['resource_id', 'date'])] +#[ORM\Index(name: 'idx_booking_resource_date', columns: ['resource_id', 'date'])] +#[ORM\Index(name: 'idx_booking_user_date', columns: ['user_id', 'date'])] class Booking { #[ORM\Id] @@ -33,15 +38,45 @@ class Booking #[ORM\Column(type: 'date_immutable')] private \DateTimeImmutable $date; + /** Minutes from midnight, end-exclusive. All-day is 0 to 1440. */ + #[ORM\Column] + private int $startMinute; + + #[ORM\Column] + private int $endMinute; + + /** + * The series this occurrence was materialised from, if any. SET NULL rather than CASCADE so + * that dropping a series never silently takes bookings with it -- ending a series removes + * its future occurrences explicitly, in BookingManager. + */ + #[ORM\ManyToOne(inversedBy: 'bookings')] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?BookingSeries $series = null; + + /** True once this occurrence's times were changed away from the series' own. */ + #[ORM\Column] + private bool $overridden = false; + #[ORM\Column(type: 'datetime_immutable')] private \DateTimeImmutable $createdAt; - public function __construct(User $user, Resource $resource, \DateTimeImmutable $date) - { + public function __construct( + User $user, + Resource $resource, + \DateTimeImmutable $date, + ?TimeRange $time = null, + ?BookingSeries $series = null, + ) { + $time ??= TimeRange::wholeDay(); + $this->user = $user; $this->resource = $resource; - // Normalised to midnight so the unique constraint cannot be sidestepped by a time part. + // Normalised to midnight so a stray time component cannot shift which day this belongs to. $this->date = $date->setTime(0, 0); + $this->startMinute = $time->startMinute; + $this->endMinute = $time->endMinute; + $this->series = $series; $this->createdAt = new \DateTimeImmutable(); } @@ -65,6 +100,40 @@ class Booking return $this->date; } + public function getTimeRange(): TimeRange + { + return TimeRange::fromMinutes($this->startMinute, $this->endMinute); + } + + /** + * Changing the times of an occurrence marks it as an override, so the UI can show that it + * no longer matches its series. + */ + public function changeTime(TimeRange $time): void + { + $this->startMinute = $time->startMinute; + $this->endMinute = $time->endMinute; + + if (null !== $this->series) { + $this->overridden = !$this->series->getTimeRange()->equals($time); + } + } + + public function getSeries(): ?BookingSeries + { + return $this->series; + } + + public function isPartOfSeries(): bool + { + return null !== $this->series; + } + + public function isOverridden(): bool + { + return $this->overridden; + } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; @@ -74,4 +143,9 @@ class Booking { return $this->user->getId() === $user->getId(); } + + public function overlaps(TimeRange $time): bool + { + return $this->getTimeRange()->overlaps($time); + } } diff --git a/src/Entity/BookingSeries.php b/src/Entity/BookingSeries.php new file mode 100644 index 0000000..4465833 --- /dev/null +++ b/src/Entity/BookingSeries.php @@ -0,0 +1,191 @@ + + */ + #[ORM\Column] + private array $weekdays = []; + + #[ORM\Column(type: 'date_immutable')] + private \DateTimeImmutable $startDate; + + #[ORM\Column(type: 'date_immutable')] + private \DateTimeImmutable $endDate; + + #[ORM\Column] + private int $startMinute; + + #[ORM\Column] + private int $endMinute; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'series')] + private Collection $bookings; + + /** + * @param list $weekdays + */ + public function __construct( + User $user, + Resource $resource, + RecurrenceRule $rule, + \DateTimeImmutable $startDate, + \DateTimeImmutable $endDate, + TimeRange $time, + array $weekdays = [], + ) { + $this->user = $user; + $this->resource = $resource; + $this->rule = $rule; + $this->startDate = $startDate->setTime(0, 0); + $this->endDate = $endDate->setTime(0, 0); + $this->startMinute = $time->startMinute; + $this->endMinute = $time->endMinute; + $this->weekdays = RecurrenceRule::WEEKLY === $rule ? array_values(array_unique($weekdays)) : []; + $this->createdAt = new \DateTimeImmutable(); + $this->bookings = new ArrayCollection(); + } + + /** + * Every day this series falls on, in order. + * + * @return list<\DateTimeImmutable> + */ + public function occurrences(): array + { + $dates = []; + + for ($day = $this->startDate; $day <= $this->endDate; $day = $day->modify('+1 day')) { + if (RecurrenceRule::WEEKLY === $this->rule && !\in_array((int) $day->format('N'), $this->weekdays, true)) { + continue; + } + + $dates[] = $day; + } + + return $dates; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getResource(): Resource + { + return $this->resource; + } + + public function getRule(): RecurrenceRule + { + return $this->rule; + } + + /** + * @return list + */ + public function getWeekdays(): array + { + return $this->weekdays; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getEndDate(): \DateTimeImmutable + { + return $this->endDate; + } + + public function setEndDate(\DateTimeImmutable $endDate): void + { + $this->endDate = $endDate->setTime(0, 0); + } + + public function getTimeRange(): TimeRange + { + return TimeRange::fromMinutes($this->startMinute, $this->endMinute); + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + /** + * @return Collection + */ + public function getBookings(): Collection + { + return $this->bookings; + } + + public function describe(): string + { + $description = $this->rule->label(); + + if (RecurrenceRule::WEEKLY === $this->rule && [] !== $this->weekdays) { + $names = ['', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + // Sort the numbers, not the names -- alphabetical would read "Di, Do, Fr, Mi, Mo". + $weekdays = $this->weekdays; + sort($weekdays); + $description .= ' ('.implode(', ', array_map(static fn (int $day): string => $names[$day], $weekdays)).')'; + } + + return $description.', '.$this->getTimeRange(); + } +} diff --git a/src/Form/BookingRequest.php b/src/Form/BookingRequest.php index 0b59057..a1da76a 100644 --- a/src/Form/BookingRequest.php +++ b/src/Form/BookingRequest.php @@ -4,18 +4,116 @@ declare(strict_types=1); namespace App\Form; +use App\Booking\RecurrenceRule; +use App\Booking\TimeRange; +use App\Entity\BookingSeries; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** - * What the booking modal edits. The resource is fixed by the URL -- only the day is adjustable, - * because the modal is opened by clicking a resource on the floor plan. + * What the booking modal edits. The resource is fixed by the URL -- it is chosen by clicking a + * shape on the floor plan, not in the form. */ final class BookingRequest { - public function __construct( - #[Assert\NotNull(message: 'Bitte einen Tag wählen.')] - #[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')] - public ?\DateTimeImmutable $date = null, - ) { + #[Assert\NotNull(message: 'Bitte einen Tag wählen.')] + #[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')] + public ?\DateTimeImmutable $date = null; + + public bool $allDay = true; + + public ?\DateTimeImmutable $startTime = null; + + public ?\DateTimeImmutable $endTime = null; + + public ?RecurrenceRule $repeat = null; + + /** @var list ISO weekdays, 1 = Monday */ + public array $weekdays = []; + + public ?\DateTimeImmutable $until = null; + + public function __construct(?\DateTimeImmutable $date = null, ?TimeRange $time = null) + { + $this->date = $date; + + if (null !== $time && !$time->isAllDay()) { + $this->allDay = false; + $this->startTime = self::timeOf($time->startMinute); + $this->endTime = self::timeOf($time->endMinute); + } + } + + public function timeRange(): TimeRange + { + if ($this->allDay || null === $this->startTime || null === $this->endTime) { + return TimeRange::wholeDay(); + } + + return TimeRange::fromTimes($this->startTime, $this->endTime); + } + + public function repeats(): bool + { + return null !== $this->repeat; + } + + /** + * Cross-field rules. These live here rather than in the form type so they hold for any caller, + * and so the messages land on the field the user has to fix. + */ + #[Assert\Callback] + public function validate(ExecutionContextInterface $context): void + { + if (!$this->allDay) { + foreach (['startTime' => $this->startTime, 'endTime' => $this->endTime] as $field => $value) { + if (null === $value) { + $context->buildViolation('Bitte eine Uhrzeit angeben.')->atPath($field)->addViolation(); + } + } + + if (null !== $this->startTime && null !== $this->endTime + && self::minutesOf($this->endTime) <= self::minutesOf($this->startTime)) { + $context->buildViolation('Das Ende muss nach dem Beginn liegen.')->atPath('endTime')->addViolation(); + } + } + + if (!$this->repeats()) { + return; + } + + if (RecurrenceRule::WEEKLY === $this->repeat && [] === $this->weekdays) { + $context->buildViolation('Bitte mindestens einen Wochentag wählen.')->atPath('weekdays')->addViolation(); + } + + if (null === $this->until) { + $context->buildViolation('Bitte ein Enddatum für die Serie angeben.')->atPath('until')->addViolation(); + + return; + } + + if (null !== $this->date && $this->until < $this->date) { + $context->buildViolation('Das Serienende muss nach dem Beginn liegen.')->atPath('until')->addViolation(); + + return; + } + + $latest = ($this->date ?? new \DateTimeImmutable('today'))->modify(\sprintf('+%d months', BookingSeries::MAX_MONTHS)); + if ($this->until > $latest) { + $context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate laufen.', BookingSeries::MAX_MONTHS)) + ->atPath('until') + ->addViolation(); + } + } + + private static function timeOf(int $minutes): \DateTimeImmutable + { + // 24:00 has no representation as a time-of-day, so an all-day end never reaches here. + return (new \DateTimeImmutable('today'))->setTime(intdiv($minutes, 60), $minutes % 60); + } + + private static function minutesOf(\DateTimeInterface $time): int + { + return (int) $time->format('G') * 60 + (int) $time->format('i'); } } diff --git a/src/Form/BookingType.php b/src/Form/BookingType.php index ef8063e..7011c26 100644 --- a/src/Form/BookingType.php +++ b/src/Form/BookingType.php @@ -4,8 +4,13 @@ declare(strict_types=1); namespace App\Form; +use App\Booking\RecurrenceRule; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\Extension\Core\Type\TimeType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -13,12 +18,48 @@ final class BookingType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->add('date', DateType::class, [ - 'label' => 'Tag', - 'widget' => 'single_text', - 'input' => 'datetime_immutable', - 'html5' => true, - ]); + $builder + ->add('date', DateType::class, [ + 'label' => 'Tag', + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + ]) + ->add('allDay', CheckboxType::class, [ + 'label' => 'Ganzer Tag', + 'required' => false, + ]) + ->add('startTime', TimeType::class, [ + 'label' => 'Von', + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'required' => false, + ]) + ->add('endTime', TimeType::class, [ + 'label' => 'Bis', + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'required' => false, + ]) + ->add('repeat', EnumType::class, [ + 'label' => 'Wiederholung', + 'class' => RecurrenceRule::class, + 'required' => false, + 'placeholder' => 'Einmalig', + 'choice_label' => static fn (RecurrenceRule $rule): string => $rule->label(), + ]) + ->add('weekdays', ChoiceType::class, [ + 'label' => 'Wochentage', + 'choices' => ['Mo' => 1, 'Di' => 2, 'Mi' => 3, 'Do' => 4, 'Fr' => 5, 'Sa' => 6, 'So' => 7], + 'multiple' => true, + 'expanded' => true, + 'required' => false, + ]) + ->add('until', DateType::class, [ + 'label' => 'Serie bis', + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'required' => false, + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php index 6febeca..19066a8 100644 --- a/src/Repository/BookingRepository.php +++ b/src/Repository/BookingRepository.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace App\Repository; +use App\Booking\TimeRange; use App\Entity\Booking; +use App\Entity\BookingSeries; +use App\Entity\Resource; use App\Entity\ResourceType; use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -21,24 +25,80 @@ class BookingRepository extends ServiceEntityRepository } /** - * Everything booked on one day, for colouring the floor plan. + * Everything on one day that overlaps the given span, for colouring the floor plan. * * Team view is in scope, so the booking holder's name is shown on occupied resources -- * hence the joins are eager, or the plan would fire a query per drawn element. * * @return list */ - public function findForDate(\DateTimeImmutable $date): array + public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null): array { - return $this->createQueryBuilder('b') + $qb = $this->createQueryBuilder('b') ->addSelect('u', 'r') ->join('b.user', 'u') ->join('b.resource', 'r') ->andWhere('b.date = :date') ->setParameter('date', $date->setTime(0, 0)) ->orderBy('r.label', 'ASC') - ->getQuery() - ->getResult(); + ->addOrderBy('b.startMinute', 'ASC'); + + if (null !== $within) { + self::restrictToOverlap($qb, $within); + } + + return $qb->getQuery()->getResult(); + } + + /** + * The availability check behind every booking. Only meaningful while the resource row is + * locked -- see BookingManager, which is the only caller. + * + * @return list + */ + public function findOverlapping(Resource $resource, \DateTimeImmutable $date, TimeRange $time): array + { + $qb = $this->createQueryBuilder('b') + ->andWhere('b.resource = :resource') + ->andWhere('b.date = :date') + ->setParameter('resource', $resource) + ->setParameter('date', $date->setTime(0, 0)); + + self::restrictToOverlap($qb, $time); + + return $qb->getQuery()->getResult(); + } + + /** + * One person may not hold two resources of the same kind at the same time -- but they may + * well hold a desk in the morning and a different one in the afternoon, so this is an + * overlap test rather than a per-day test. + */ + public function findOverlappingForUser( + User $user, + \DateTimeImmutable $date, + TimeRange $time, + ResourceType $type, + ?Booking $ignore = null, + ): ?Booking { + $qb = $this->createQueryBuilder('b') + ->addSelect('r') + ->join('b.resource', 'r') + ->andWhere('b.user = :user') + ->andWhere('b.date = :date') + ->andWhere('r.type = :type') + ->setParameter('user', $user) + ->setParameter('date', $date->setTime(0, 0)) + ->setParameter('type', $type) + ->setMaxResults(1); + + self::restrictToOverlap($qb, $time); + + if (null !== $ignore && null !== $ignore->getId()) { + $qb->andWhere('b.id != :ignore')->setParameter('ignore', $ignore->getId()); + } + + return $qb->getQuery()->getOneOrNullResult(); } /** @@ -49,14 +109,16 @@ class BookingRepository extends ServiceEntityRepository public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array { return $this->createQueryBuilder('b') - ->addSelect('r') + ->addSelect('r', 's') ->join('b.resource', 'r') + ->leftJoin('b.series', 's') ->andWhere('b.user = :user') ->andWhere('b.date BETWEEN :from AND :to') ->setParameter('user', $user) ->setParameter('from', $from->setTime(0, 0)) ->setParameter('to', $to->setTime(0, 0)) ->orderBy('b.date', 'ASC') + ->addOrderBy('b.startMinute', 'ASC') ->addOrderBy('r.label', 'ASC') ->getQuery() ->getResult(); @@ -64,50 +126,58 @@ class BookingRepository extends ServiceEntityRepository /** * Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as - * occupancy, and the same query team view reads. + * occupancy. Counts only what overlaps the span the board is currently showing, so the + * number in a day cell always means the same thing as the colours on the plan. * * @return array */ - public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array + public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to, ?TimeRange $within = null): array { - $rows = $this->createQueryBuilder('b') + $qb = $this->createQueryBuilder('b') ->select('b.date AS date', 'COUNT(b.id) AS total') ->andWhere('b.date BETWEEN :from AND :to') ->setParameter('from', $from->setTime(0, 0)) ->setParameter('to', $to->setTime(0, 0)) - ->groupBy('b.date') - ->getQuery() - ->getResult(); + ->groupBy('b.date'); + + if (null !== $within) { + self::restrictToOverlap($qb, $within); + } $counts = []; - foreach ($rows as $row) { + foreach ($qb->getQuery()->getResult() as $row) { $counts[$row['date']->format('Y-m-d')] = (int) $row['total']; } return $counts; } - public function findOneForUserAndDate(User $user, \DateTimeImmutable $date): ?Booking + /** + * The occurrences an "end this series" would remove. + * + * @return list + */ + public function findForSeriesFrom(BookingSeries $series, \DateTimeImmutable $from): array { - return $this->findOneBy(['user' => $user, 'date' => $date->setTime(0, 0)]); + return $this->createQueryBuilder('b') + ->andWhere('b.series = :series') + ->andWhere('b.date >= :from') + ->setParameter('series', $series) + ->setParameter('from', $from->setTime(0, 0)) + ->orderBy('b.date', 'ASC') + ->getQuery() + ->getResult(); } /** - * One person may hold at most one desk and one parking spot per day, so this is what the - * booking path checks before inserting. + * Half-open overlap, the same test as TimeRange::overlaps(): two spans collide unless one + * ends at or before the other begins. Written once here so the SQL and the PHP cannot drift. */ - public function findOneForUserDateAndType(User $user, \DateTimeImmutable $date, ResourceType $type): ?Booking + private static function restrictToOverlap(QueryBuilder $qb, TimeRange $time): void { - return $this->createQueryBuilder('b') - ->addSelect('r') - ->join('b.resource', 'r') - ->andWhere('b.user = :user') - ->andWhere('b.date = :date') - ->andWhere('r.type = :type') - ->setParameter('user', $user) - ->setParameter('date', $date->setTime(0, 0)) - ->setParameter('type', $type) - ->getQuery() - ->getOneOrNullResult(); + $qb->andWhere('b.startMinute < :rangeEnd') + ->andWhere(':rangeStart < b.endMinute') + ->setParameter('rangeStart', $time->startMinute) + ->setParameter('rangeEnd', $time->endMinute); } } diff --git a/src/Repository/BookingSeriesRepository.php b/src/Repository/BookingSeriesRepository.php new file mode 100644 index 0000000..e21b8fd --- /dev/null +++ b/src/Repository/BookingSeriesRepository.php @@ -0,0 +1,38 @@ + + */ +class BookingSeriesRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BookingSeries::class); + } + + /** + * @return list + */ + public function findActiveForUser(User $user, \DateTimeImmutable $on): array + { + return $this->createQueryBuilder('s') + ->addSelect('r') + ->join('s.resource', 'r') + ->andWhere('s.user = :user') + ->andWhere('s.endDate >= :on') + ->setParameter('user', $user) + ->setParameter('on', $on->setTime(0, 0)) + ->orderBy('s.startDate', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/templates/booking/_fields.html.twig b/templates/booking/_fields.html.twig new file mode 100644 index 0000000..e20a7f0 --- /dev/null +++ b/templates/booking/_fields.html.twig @@ -0,0 +1,41 @@ +{# + The shared body of both booking modals. Which fields exist is decided by the controller + (the edit modal removes date and the recurrence fields), so everything here is guarded with + `is defined` rather than duplicated into two templates. + + Show/hide is Stimulus, not htmx: toggling a fieldset needs no server round trip, and the + house rule keeps purely client-side behaviour out of htmx. +#} +
+ {% if form.date is defined %} + {{ form_row(form.date) }} + {% endif %} + +
+ {{ form_row(form.allDay, { attr: { 'data-action': 'bookingform#toggleTimes' } }) }} + +
+ {{ form_row(form.startTime) }} + {{ form_row(form.endTime) }} +
+
+ + {% if form.repeat is defined %} +
+ {{ form_row(form.repeat, { attr: { 'data-action': 'bookingform#toggleRepeat' } }) }} + +
+
+ {{ form_row(form.weekdays, { row_attr: { class: 'pb-2' } }) }} +
+ {{ form_row(form.until) }} +

+ Belegte Tage werden übersprungen und danach aufgelistet. +

+
+
+ {% endif %} +
diff --git a/templates/booking/_modal_create.html.twig b/templates/booking/_modal_create.html.twig index 7e371fc..fbb0b52 100644 --- a/templates/booking/_modal_create.html.twig +++ b/templates/booking/_modal_create.html.twig @@ -26,7 +26,7 @@ {{ resource.type.label }} · {{ resource.label }}

- {{ form_row(form.date) }} + {% include 'booking/_fields.html.twig' %}
+ +
+ + +
+
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/booking/index.html.twig b/templates/booking/index.html.twig index 4f53909..39cca2e 100644 --- a/templates/booking/index.html.twig +++ b/templates/booking/index.html.twig @@ -6,20 +6,51 @@

Meine Buchungen

Alles, was ab heute für dich reserviert ist.

+ {% if series is not empty %} +

Serien

+
    + {% for entry in series %} +
  • +
    +
    {{ entry.resource.label }}
    +
    + {{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }} +
    +
    + {# Ends the series from today on; past occurrences are history and stay put. #} + + + + +
  • + {% endfor %} +
+ {% endif %} + +

Termine

+ {% if bookings is empty %} -

+

Noch nichts gebucht. Zum Raumplan.

{% else %} -
    +
      {% for booking in bookings %}
    • {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }} + · {{ booking.timeRange }}
      {{ booking.resource.type.label }} · {{ booking.resource.label }} + {% if booking.partOfSeries %} + Serie + {% endif %} + {% if booking.overridden %} + abweichend + {% endif %}
      diff --git a/templates/dashboard/_calendar.html.twig b/templates/dashboard/_calendar.html.twig index fda65aa..09067ce 100644 --- a/templates/dashboard/_calendar.html.twig +++ b/templates/dashboard/_calendar.html.twig @@ -1,13 +1,15 @@ {# Server-rendered month grid. No calendar library: prev/next and day selection are plain hx-get requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #} {% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} +{# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #} +{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
      @@ -17,7 +19,7 @@ @@ -47,7 +49,7 @@
      diff --git a/templates/dashboard/_floorplan.html.twig b/templates/dashboard/_floorplan.html.twig index f990980..1eb6ad5 100644 --- a/templates/dashboard/_floorplan.html.twig +++ b/templates/dashboard/_floorplan.html.twig @@ -3,8 +3,14 @@ is the key that ties a database row to a shape here. A resource whose id is missing from the map is simply not drawn (and vice versa), which is why app:seed and this map have to agree. - Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day, - so free/booked/yours is decided in Twig. + Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day + and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the + span in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for + an all-day one. + + Clicking a shape always opens a modal: the create modal when it is free, the edit modal when + it is yours (which is where changing the times and releasing it live). Nothing else is + clickable, so the SVG carries no confirm dialogs or inline tokens. #} {% set desk_plan = { 'desk-01': { x: 50, y: 70 }, 'desk-02': { x: 190, y: 70 }, 'desk-03': { x: 330, y: 70 }, @@ -16,47 +22,65 @@ 'parking-01': { x: 540, y: 60 }, 'parking-02': { x: 540, y: 125 }, 'parking-03': { x: 540, y: 190 }, 'parking-04': { x: 540, y: 255 }, 'parking-05': { x: 540, y: 320 }, 'parking-06': { x: 540, y: 385 } } %} +{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} + +{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %} + {% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %} + {% set mine = own is not null %} -{% macro spot(resource, geometry, width, height, booking, selected_date, is_past) %} - {% set mine = booking is not null and booking.user.id == app.user.id %} {% if mine %} {% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %} - {% elseif booking is not null %} + {% elseif bookings is not empty %} {% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %} {% else %} {% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %} {% endif %} - {% set interactive = not is_past and (mine or booking is null) %} + {% set interactive = not is_past and (mine or bookings is empty) %} + + {# What the second line of the shape says. Several bookings can overlap the board's span once + part-day bookings exist, so the shape counts them and the tooltip spells them out. #} + {% if mine %} + {% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %} + {% elseif bookings|length > 1 %} + {% set caption = bookings|length ~ ' Buchungen' %} + {% elseif bookings is not empty %} + {% set caption = bookings|first.user.name %} + {% else %} + {% set caption = null %} + {% endif %} ; closing it is Stimulus, not htmx. #} - hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }) }}" + hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" hx-target="body" hx-swap="beforeend" {%- endif %}> - {{- resource.label }}{% if booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%} + {{- resource.label }} + {%- if bookings is empty %} — frei + {%- else %} + {%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %} + {%- endif -%} - {{ resource.label }} - {% if booking %} + {% if caption %} {{ mine ? 'du' : booking.user.name }} + text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }} {% endif %} {% endmacro %}
      -
      +

      {{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}

      frei @@ -65,6 +89,8 @@
      + {% include 'dashboard/_timerange.html.twig' %} + {% if is_past %}

      Vergangener Tag — hier lässt sich nichts mehr buchen.

      {% endif %} @@ -79,14 +105,14 @@ {% for resource in desks %} {% if resource.svgElementId and desk_plan[resource.svgElementId] is defined %} {{ _self.spot(resource, desk_plan[resource.svgElementId], 120, 70, - bookings_by_resource[resource.id]|default(null), selected_date, is_past) }} + bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }} {% endif %} {% endfor %} {% for resource in parking %} {% if resource.svgElementId and parking_plan[resource.svgElementId] is defined %} {{ _self.spot(resource, parking_plan[resource.svgElementId], 230, 50, - bookings_by_resource[resource.id]|default(null), selected_date, is_past) }} + bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }} {% endif %} {% endfor %} diff --git a/templates/dashboard/_timerange.html.twig b/templates/dashboard/_timerange.html.twig new file mode 100644 index 0000000..c501cae --- /dev/null +++ b/templates/dashboard/_timerange.html.twig @@ -0,0 +1,42 @@ +{# + The span the whole board is relative to. Free/belegt has no answer without it once bookings + can cover part of a day, so this drives the plan colours AND the calendar occupancy counts. + + Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the + same single round trip as changing the day. hx-include pulls in the sibling field, because + a span needs both ends to mean anything. +#} +{# An all-day board leaves both fields empty: 24:00 has no representation in , + and an empty pair is exactly what TimeRange::parse() reads back as "the whole day". #} +{% set day = selected_date|date('Y-m-d') %} + +
      +
      + + +
      +
      + + +
      + + {% if not selected_time.allDay %} + + {% else %} + ganzer Tag + {% endif %} +
      diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index 786593f..e636ab6 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -4,7 +4,7 @@ {% block content %}

      Buchen

      -

      Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.

      +

      Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.

      diff --git a/tests/Booking/BookingManagerTest.php b/tests/Booking/BookingManagerTest.php new file mode 100644 index 0000000..0731bdb --- /dev/null +++ b/tests/Booking/BookingManagerTest.php @@ -0,0 +1,300 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + $this->manager = self::getContainer()->get(BookingManager::class); + $this->wipe(); + } + + protected function tearDown(): void + { + $this->wipe(); + parent::tearDown(); + } + + public function testAFreeResourceIsBooked(): void + { + $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); + + self::assertNotNull($booking->getId()); + self::assertSame('09:00–13:00', (string) $booking->getTimeRange()); + } + + public function testAnOverlappingSpanOnTheSameResourceIsRefused(): void + { + $desk = $this->resource('D1'); + $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(540, 780)); + + $this->expectException(ResourceAlreadyBookedException::class); + $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(660, 900)); + } + + public function testATouchingSpanOnTheSameResourceIsAllowed(): void + { + $desk = $this->resource('D1'); + $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(540, 780)); + + $second = $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020)); + + self::assertNotNull($second->getId()); + } + + public function testOnePersonMayNotHoldTwoDesksAtOnce(): void + { + $user = $this->user('a@ep-reisen.de'); + $this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); + + $this->expectException(AlreadyHoldingResourceException::class); + $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(600, 720)); + } + + public function testOnePersonMayHoldTwoDesksAtDifferentTimes(): void + { + $user = $this->user('a@ep-reisen.de'); + $this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); + + $afternoon = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(780, 1020)); + + self::assertNotNull($afternoon->getId()); + } + + public function testADeskAndAParkingSpotAtTheSameTimeAreBothAllowed(): void + { + $user = $this->user('a@ep-reisen.de'); + $time = TimeRange::fromMinutes(540, 780); + $this->manager->book($user, $this->resource('D1'), $this->date(), $time); + + $parking = $this->manager->book($user, $this->resource('P1', ResourceType::PARKING), $this->date(), $time); + + self::assertNotNull($parking->getId()); + } + + public function testASeriesMaterialisesOneBookingPerOccurrence(): void + { + // 2026-10-05 is a Monday. + $series = new BookingSeries( + $this->user('a@ep-reisen.de'), + $this->resource('D1'), + RecurrenceRule::WEEKLY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-30'), + TimeRange::fromMinutes(540, 720), + [1, 4], + ); + + $result = $this->manager->bookSeries($series); + + self::assertSame(8, $result->createdCount()); + self::assertSame(0, $result->skippedCount()); + foreach ($result->created as $booking) { + self::assertContains((int) $booking->getDate()->format('N'), [1, 4]); + self::assertSame($series, $booking->getSeries()); + } + } + + public function testADailySeriesCoversEveryDay(): void + { + $result = $this->manager->bookSeries(new BookingSeries( + $this->user('a@ep-reisen.de'), + $this->resource('D1'), + RecurrenceRule::DAILY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-11'), + TimeRange::fromMinutes(540, 720), + )); + + self::assertSame(7, $result->createdCount()); + } + + /** + * The point of materialising: a taken day is reported rather than failing the whole series. + */ + public function testASeriesSkipsDaysThatAreAlreadyTaken(): void + { + $desk = $this->resource('D1'); + $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-08'), TimeRange::fromMinutes(600, 660)); + + $result = $this->manager->bookSeries(new BookingSeries( + $this->user('a@ep-reisen.de'), + $desk, + RecurrenceRule::WEEKLY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-30'), + TimeRange::fromMinutes(540, 720), + [1, 4], + )); + + self::assertSame(7, $result->createdCount()); + self::assertSame(['8.10.'], $result->skippedDates()); + self::assertFalse($result->isCompletelyBlocked()); + } + + public function testASeriesThatCollidesEverywhereReportsItself(): void + { + $desk = $this->resource('D1'); + $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-05')); + + $result = $this->manager->bookSeries(new BookingSeries( + $this->user('a@ep-reisen.de'), + $desk, + RecurrenceRule::WEEKLY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-09'), + TimeRange::fromMinutes(540, 720), + [1], + )); + + self::assertTrue($result->isCompletelyBlocked()); + self::assertSame(0, $result->createdCount()); + } + + public function testReschedulingAnOccurrenceMarksItAsAnOverride(): void + { + $series = $this->weeklySeries(); + $occurrence = $this->manager->bookSeries($series)->created[0]; + + $this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960)); + + self::assertTrue($occurrence->isOverridden()); + self::assertSame('14:00–16:00', (string) $occurrence->getTimeRange()); + } + + public function testReschedulingBackOntoTheSeriesTimesClearsTheOverride(): void + { + $series = $this->weeklySeries(); + $occurrence = $this->manager->bookSeries($series)->created[0]; + + $this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960)); + $this->manager->reschedule($occurrence, $series->getTimeRange()); + + self::assertFalse($occurrence->isOverridden()); + } + + public function testReschedulingIntoSomebodyElsesSpanIsRefused(): void + { + $desk = $this->resource('D1'); + $mine = $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(540, 780)); + $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020)); + + $this->expectException(ResourceAlreadyBookedException::class); + $this->manager->reschedule($mine, TimeRange::fromMinutes(540, 900)); + } + + /** + * Rescheduling must not trip over the booking it is moving -- that would make every edit + * that keeps any part of its own span impossible. + */ + public function testAnOccurrenceCanBeShrunkWithoutCollidingWithItself(): void + { + $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); + + $this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720)); + + self::assertSame('10:00–12:00', (string) $booking->getTimeRange()); + } + + public function testEndingASeriesRemovesItsRemainingOccurrences(): void + { + $series = $this->weeklySeries(); + $this->manager->bookSeries($series); + + $id = $series->getId(); + + $removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05')); + + self::assertSame(8, $removed); + self::assertSame([], $this->em->getRepository(Booking::class)->findBy(['series' => $id])); + // Nothing of the series is left, so the series row goes too -- note $series has no id + // any more at this point, which is why the lookup goes through the captured one. + self::assertNull($this->em->getRepository(BookingSeries::class)->find($id)); + } + + public function testEndingASeriesPartWayThroughKeepsThePastAndShortensIt(): void + { + $series = $this->weeklySeries(); + $this->manager->bookSeries($series); + + $removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-15')); + + self::assertSame(5, $removed); + self::assertCount(3, $this->em->getRepository(Booking::class)->findBy(['series' => $series])); + self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d')); + } + + private function weeklySeries(): BookingSeries + { + return new BookingSeries( + $this->user('a@ep-reisen.de'), + $this->resource('D1'), + RecurrenceRule::WEEKLY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-30'), + TimeRange::fromMinutes(540, 720), + [1, 4], + ); + } + + private function date(): \DateTimeImmutable + { + return new \DateTimeImmutable('2026-10-01'); + } + + private function user(string $email): User + { + $user = new User($email, 'Test'); + $user->setRoles(['ROLE_EMPLOYEE']); + $this->em->persist($user); + $this->em->flush(); + + return $user; + } + + private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource + { + $resource = new Resource($type, $label, strtolower($label)); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + + private function wipe(): void + { + $connection = $this->em->getConnection(); + $connection->executeStatement('DELETE FROM booking'); + $connection->executeStatement('DELETE FROM booking_series'); + $connection->executeStatement('DELETE FROM resource'); + $connection->executeStatement('DELETE FROM `user`'); + $this->em->clear(); + } +} diff --git a/tests/Booking/TimeRangeTest.php b/tests/Booking/TimeRangeTest.php new file mode 100644 index 0000000..fe85946 --- /dev/null +++ b/tests/Booking/TimeRangeTest.php @@ -0,0 +1,100 @@ +isAllDay()); + self::assertSame(0, $range->startMinute); + self::assertSame(1440, $range->endMinute); + } + + public function testTheEndMustComeAfterTheStart(): void + { + $this->expectException(\InvalidArgumentException::class); + + TimeRange::fromMinutes(13 * 60, 9 * 60); + } + + public function testARangeCannotLeaveTheDay(): void + { + $this->expectException(\InvalidArgumentException::class); + + TimeRange::fromMinutes(0, 1441); + } + + public function testTouchingRangesDoNotOverlap(): void + { + $morning = TimeRange::fromMinutes(9 * 60, 13 * 60); + $afternoon = TimeRange::fromMinutes(13 * 60, 17 * 60); + + // Half-open, so handing a desk over at 13:00 is not a collision. + self::assertFalse($morning->overlaps($afternoon)); + self::assertFalse($afternoon->overlaps($morning)); + } + + public function testPartlyCoveringRangesOverlapBothWays(): void + { + $a = TimeRange::fromMinutes(9 * 60, 13 * 60); + $b = TimeRange::fromMinutes(12 * 60, 17 * 60); + + self::assertTrue($a->overlaps($b)); + self::assertTrue($b->overlaps($a)); + } + + public function testAWholeDayOverlapsEverything(): void + { + self::assertTrue(TimeRange::wholeDay()->overlaps(TimeRange::fromMinutes(0, 15))); + } + + #[DataProvider('urlPairs')] + public function testParsingTheBoardsQueryParameters(?string $from, ?string $to, ?string $expected): void + { + $range = TimeRange::parse($from, $to); + + self::assertSame($expected, null === $range ? null : $range->start().'-'.$range->end()); + } + + /** + * @return iterable + */ + public static function urlPairs(): iterable + { + yield 'both given' => ['09:00', '13:00', '09:00-13:00']; + yield 'only a start is open to the end of the day' => ['09:00', null, '09:00-24:00']; + yield 'only an end is open from midnight' => [null, '13:00', '00:00-13:00']; + yield 'neither means the whole day, i.e. no filter' => [null, null, null]; + yield 'empty strings are the same as absent' => ['', '', null]; + yield 'reversed is rejected' => ['13:00', '09:00', null]; + yield 'equal is rejected' => ['09:00', '09:00', null]; + yield 'nonsense is rejected' => ['halb zehn', '13:00', null]; + yield 'out of range is rejected' => ['25:00', '26:00', null]; + } + + /** + * Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by + * looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily + * calls a static one. While the factory was called allDay(), every template read back a + * freshly built all-day range -- always truthy -- so the whole board silently ignored the + * selected span. Renaming the factory to wholeDay() is what fixes it; this keeps it fixed. + */ + public function testTheAllDayAccessorIsNotShadowedInTwig(): void + { + $twig = new Environment(new ArrayLoader(['t' => '{{ range.allDay ? "yes" : "no" }}'])); + + self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)])); + self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()])); + } +} diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php index e4b4d9a..4317cba 100644 --- a/tests/Controller/BookingControllerTest.php +++ b/tests/Controller/BookingControllerTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Controller; +use App\Booking\TimeRange; use App\Entity\Booking; use App\Entity\Resource; use App\Entity\ResourceType; @@ -71,7 +72,7 @@ class BookingControllerTest extends WebTestCase $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); - $response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01'); + $response = $this->submitBookingModal($desk, '2026-10-01'); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); // The modal is gone from the response, which is what closes it: an out-of-band-only @@ -89,7 +90,7 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', '2020-01-01'); + $response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent()); @@ -107,7 +108,7 @@ class BookingControllerTest extends WebTestCase $this->client->loginUser($this->user('anna@ep-reisen.de')); - $response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01'); + $response = $this->submitBookingModal($desk, '2026-10-01'); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent()); @@ -121,7 +122,7 @@ class BookingControllerTest extends WebTestCase $this->client->loginUser($anna); - $response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', '2026-10-01'); + $response = $this->submitBookingModal($this->resource('D2'), '2026-10-01'); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('bereits D1 gebucht', (string) $response->getContent()); @@ -135,7 +136,7 @@ class BookingControllerTest extends WebTestCase $this->client->loginUser($anna); - $response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01', '2026-10-01'); + $response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01'); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); } @@ -183,9 +184,9 @@ class BookingControllerTest extends WebTestCase $this->client->loginUser($anna); - // Read the token off the rendered plan rather than minting one: outside a request there - // is no session for the token storage to live in, and this is the real round trip anyway. - $crawler = $this->client->request('GET', '/?tag=2026-10-01'); + // Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk + // opens that modal, and the token is rendered there. + $crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $id), server: ['HTTP_HX-Request' => 'true']); $vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals'); $token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; @@ -196,17 +197,229 @@ class BookingControllerTest extends WebTestCase self::assertNull($this->em->getRepository(Booking::class)->find($id)); } - private function submitBookingModal(Resource $resource, string $routeDate, string $submittedDate): Response + public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void { + $anna = $this->user('anna@ep-reisen.de'); + $booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01')); + $this->em->persist($booking); + $this->em->flush(); + + $this->client->loginUser($anna); + $crawler = $this->client->request('GET', '/?tag=2026-10-01'); + + self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId()))); + } + + public function testBookingPartOfADay(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $desk = $this->resource('D1'); + + $response = $this->submitBookingModal($desk, '2026-10-01', [ + 'startTime' => '09:00', + 'endTime' => '13:00', + ]); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]); + self::assertNotNull($booking); + self::assertSame('09:00–13:00', (string) $booking->getTimeRange()); + } + + public function testABookingDefaultsToTheWholeDay(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $desk = $this->resource('D1'); + + $this->submitBookingModal($desk, '2026-10-01'); + + $booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]); + self::assertNotNull($booking); + self::assertTrue($booking->getTimeRange()->isAllDay()); + } + + public function testAnEndBeforeTheStartIsRejected(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [ + 'startTime' => '13:00', + 'endTime' => '09:00', + ]); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('Das Ende muss nach dem Beginn liegen.', (string) $response->getContent()); + } + + public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void + { + $desk = $this->resource('D1'); + $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780))); + $this->em->flush(); + + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($desk, '2026-10-01', [ + 'startTime' => '13:00', + 'endTime' => '17:00', + ]); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testAWeeklySeriesIsCreatedAndReported(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $desk = $this->resource('D1'); + + // 2026-10-05 is a Monday. + $response = $this->submitBookingModal($desk, '2026-10-05', [ + 'startTime' => '09:00', + 'endTime' => '12:00', + 'repeat' => 'weekly', + 'weekdays' => ['1', '4'], + 'until' => '2026-10-30', + ]); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertCount(8, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk])); + self::assertStringContainsString('8 Termine gebucht', (string) $response->getContent()); + } + + public function testASeriesWithoutAnEndDateIsRejected(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [ + 'allDay' => '1', + 'repeat' => 'daily', + ]); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('Bitte ein Enddatum für die Serie angeben.', (string) $response->getContent()); + } + + public function testAWeeklySeriesWithoutWeekdaysIsRejected(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [ + 'allDay' => '1', + 'repeat' => 'weekly', + 'until' => '2026-10-30', + ]); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('Bitte mindestens einen Wochentag wählen.', (string) $response->getContent()); + } + + public function testASeriesRunningTooFarOutIsRejected(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [ + 'allDay' => '1', + 'repeat' => 'daily', + 'until' => '2030-01-01', + ]); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('höchstens 12 Monate', (string) $response->getContent()); + } + + /** + * The board's span is carried into the modal, so narrowing the board and clicking a desk + * books exactly what was on screen. + */ + public function testTheModalIsPrefilledFromTheBoardsSpan(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $desk = $this->resource('D1'); + $crawler = $this->client->request( 'GET', - \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate), + \sprintf('/buchungen/neu/%d/2026-10-01?von=09:00&bis=13:00', $desk->getId()), server: ['HTTP_HX-Request' => 'true'], ); + + self::assertSame('09:00', $crawler->filter('#booking_startTime')->attr('value')); + self::assertSame('13:00', $crawler->filter('#booking_endTime')->attr('value')); + self::assertNull($crawler->filter('#booking_allDay')->attr('checked')); + } + + public function testTheBoardNarrowsToASpan(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $this->client->request('GET', '/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']); + + self::assertResponseIsSuccessful(); + self::assertSame('/?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); + } + + public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void + { + $anna = $this->user('anna@ep-reisen.de'); + $this->client->loginUser($anna); + $desk = $this->resource('D1'); + + $this->submitBookingModal($desk, '2026-10-05', [ + 'startTime' => '09:00', + 'endTime' => '12:00', + 'repeat' => 'weekly', + 'weekdays' => ['1'], + 'until' => '2026-10-26', + ]); + + $occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']); + self::assertCount(4, $occurrences); + $first = $occurrences[0]; + + $crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $first->getId()), server: ['HTTP_HX-Request' => 'true']); + // Only the span is editable here: the day and the recurrence belong to the series. + self::assertCount(0, $crawler->filter('#booking_date')); + self::assertCount(0, $crawler->filter('#booking_repeat')); + + $form = $crawler->selectButton('Speichern')->form([ + 'booking[startTime]' => '14:00', + 'booking[endTime]' => '16:00', + ]); + $this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']); + + self::assertResponseIsSuccessful(); + $this->em->clear(); + $reloaded = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']); + self::assertSame('14:00–16:00', (string) $reloaded[0]->getTimeRange()); + self::assertTrue($reloaded[0]->isOverridden()); + // The other three are untouched. + foreach (\array_slice($reloaded, 1) as $untouched) { + self::assertSame('09:00–12:00', (string) $untouched->getTimeRange()); + self::assertFalse($untouched->isOverridden()); + } + } + + /** + * Renders the modal, reads its CSRF token, and posts the given booking fields back to it. + * + * Posted explicitly rather than through DomCrawler's form(): the weekday checkboxes are an + * expanded multiple choice rendered as booking[weekdays][], which the crawler's field + * registry indexes positionally, so assigning ['1', '4'] sets the second checkbox instead of + * the fourth. The token still comes off the rendered form, so the round trip is real. + * + * @param array $fields + */ + private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response + { + $url = \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate); + $crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); - $form = $crawler->selectButton('Buchen')->form(['booking[date]' => $submittedDate]); - $this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']); + $fields += [ + 'date' => $routeDate, + '_token' => $crawler->filter('#booking__token')->attr('value'), + ]; + + $this->client->request('POST', $url, ['booking' => $fields], server: ['HTTP_HX-Request' => 'true']); return $this->client->getResponse(); } diff --git a/tests/Repository/BookingRepositoryTest.php b/tests/Repository/BookingRepositoryTest.php index d04e6fb..6bd847c 100644 --- a/tests/Repository/BookingRepositoryTest.php +++ b/tests/Repository/BookingRepositoryTest.php @@ -4,18 +4,22 @@ declare(strict_types=1); namespace App\Tests\Repository; +use App\Booking\TimeRange; use App\Entity\Booking; use App\Entity\Resource; use App\Entity\ResourceType; use App\Entity\User; use App\Repository\BookingRepository; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** - * Hits the real database, because the behaviour under test is the database's: the unique - * constraint is what actually prevents double-booking. + * Hits the real database, because these are queries. + * + * Note what is NOT here any more: the double-booking guard. It used to be a unique index on + * (resource, date) and was tested by asserting the insert blew up. A booking is now a time range, + * which no unique index can guard, so the guard lives in BookingManager and is tested there -- + * what this class covers is the overlap query that guard relies on. * * Each test runs inside a transaction that is rolled back afterwards. */ @@ -38,7 +42,30 @@ class BookingRepositoryTest extends KernelTestCase parent::tearDown(); } - public function testTheSameResourceCannotBeBookedTwiceOnOneDay(): void + public function testFindOverlappingSeesASpanThatCollides(): void + { + $desk = $this->resource('D1'); + $date = new \DateTimeImmutable('2026-10-01'); + + $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60))); + $this->em->flush(); + + self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(11 * 60, 15 * 60))); + } + + public function testFindOverlappingTreatsTouchingSpansAsFree(): void + { + $desk = $this->resource('D1'); + $date = new \DateTimeImmutable('2026-10-01'); + + $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60))); + $this->em->flush(); + + // Half-open: 09:00-13:00 and 13:00-17:00 do not collide. + self::assertSame([], $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(13 * 60, 17 * 60))); + } + + public function testAnAllDayBookingBlocksEverySpan(): void { $desk = $this->resource('D1'); $date = new \DateTimeImmutable('2026-10-01'); @@ -46,12 +73,7 @@ class BookingRepositoryTest extends KernelTestCase $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, $date)); $this->em->flush(); - $this->persist(new Booking($this->user('second@ep-reisen.de'), $desk, $date)); - - // The controller catches exactly this and re-renders the fragment with an inline error, - // rather than pre-checking, which would be racy. - $this->expectException(UniqueConstraintViolationException::class); - $this->em->flush(); + self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(8 * 60, 8 * 60 + 15))); } public function testTheSameResourceOnAnotherDayIsFine(): void @@ -66,17 +88,31 @@ class BookingRepositoryTest extends KernelTestCase self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01'))); } - public function testATimeComponentDoesNotCreateASecondSlotForTheSameDay(): void + public function testATimeComponentOnTheDateDoesNotShiftWhichDayABookingIsOn(): void { $desk = $this->resource('D1'); - $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01 08:00'))); + $this->persist(new Booking($this->user('first@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01 17:30'))); + $this->em->flush(); + $this->em->clear(); + + self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01'))); + } + + public function testFindForDateOnlyReturnsWhatOverlapsTheSpan(): void + { + $date = new \DateTimeImmutable('2026-10-01'); + $user = $this->user('anna@ep-reisen.de'); + + $this->persist(new Booking($user, $this->resource('D1'), $date, TimeRange::fromMinutes(9 * 60, 12 * 60))); + $this->persist(new Booking($user, $this->resource('D2'), $date, TimeRange::fromMinutes(14 * 60, 17 * 60))); $this->em->flush(); - $this->persist(new Booking($this->user('second@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01 17:30'))); + $morning = $this->bookings->findForDate($date, TimeRange::fromMinutes(8 * 60, 13 * 60)); - $this->expectException(UniqueConstraintViolationException::class); - $this->em->flush(); + self::assertCount(1, $morning); + self::assertSame('D1', $morning[0]->getResource()->getLabel()); + self::assertCount(2, $this->bookings->findForDate($date)); } public function testFindForDateCarriesTheBookingHolderForTheTeamView(): void @@ -92,6 +128,24 @@ class BookingRepositoryTest extends KernelTestCase self::assertSame('D1', $bookings[0]->getResource()->getLabel()); } + public function testCountPerDateOnlyCountsWhatOverlapsTheSpan(): void + { + $user = $this->user('anna@ep-reisen.de'); + $this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(9 * 60, 12 * 60))); + $this->persist(new Booking($this->user('bea@ep-reisen.de'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(14 * 60, 17 * 60))); + $this->em->flush(); + + $counts = $this->bookings->countPerDateInRange( + new \DateTimeImmutable('2026-10-01'), + new \DateTimeImmutable('2026-10-31'), + TimeRange::fromMinutes(8 * 60, 13 * 60), + ); + + // The calendar number has to mean the same thing as the colours on the plan, so it is + // filtered by the same span. + self::assertSame(['2026-10-01' => 1], $counts); + } + public function testCountPerDateInRangeIsKeyedByDay(): void { $user = $this->user('anna@ep-reisen.de');