+ {#
+ Swapped out of band by Booking\MonthController when you page the month. It owns a stable id
+ and emits hx-swap-oob only when rendered through htmxOobResponse -- rendered normally it is
+ plain markup. Same idiom as board/index.html.twig.
+ #}
+ {% block calendar %}
+
+ {% include 'booking/_calendar.html.twig' %}
+
+ {% endblock %}
+
{% if series is not empty %}
Wiederkehrende Buchungen
diff --git a/tests/Controller/BookingCalendarTest.php b/tests/Controller/BookingCalendarTest.php
new file mode 100644
index 0000000..c49f1d7
--- /dev/null
+++ b/tests/Controller/BookingCalendarTest.php
@@ -0,0 +1,323 @@
+client = self::createClient(server: ['HTTPS' => true]);
+ $this->client->disableReboot();
+ $this->em = self::getContainer()->get(EntityManagerInterface::class);
+ $this->em->getConnection()->beginTransaction();
+ $this->today = new \DateTimeImmutable('today');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->rollBack();
+ parent::tearDown();
+ }
+
+ public function testTheBookingsPageRendersTheCurrentMonthAsAFullGrid(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $crawler = $this->client->request('GET', '/bookings');
+
+ self::assertResponseIsSuccessful();
+ self::assertCount(1, $crawler->filter('#booking-calendar'));
+ // Six weeks of seven days, always -- the grid must not change height as you page it.
+ self::assertCount(42, $crawler->filter('#booking-calendar [data-date]'));
+ self::assertCount(1, $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $this->today->format('Y-m-d'))));
+ }
+
+ public function testABookingAppearsInTheCellForItsDay(): void
+ {
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $this->em->persist(new Booking($anna, $this->resource('D1'), $date));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $cell = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $date->format('Y-m-d')));
+
+ self::assertStringContainsString('D1', $cell->text());
+ }
+
+ public function testOnlyTheUsersOwnBookingsAppearInTheCalendar(): void
+ {
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $this->em->persist(new Booking($anna, $this->resource('MINE'), $date));
+ $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $this->resource('THEIRS'), $date));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $calendar = $crawler->filter('#booking-calendar')->text();
+
+ self::assertStringContainsString('MINE', $calendar);
+ self::assertStringNotContainsString('THEIRS', $calendar);
+ }
+
+ public function testAnOccurrenceOfASeriesAppearsLikeAnyOtherBooking(): void
+ {
+ // Guards the assumption the whole feature rests on: a series is materialised into real
+ // Booking rows, so the calendar needs no recurrence expansion of its own.
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $resource = $this->resource('SERIES');
+
+ $series = new BookingSeries($anna, $resource, RecurrenceRule::WEEKLY, $date, $date->modify('+14 days'), TimeRange::wholeDay());
+ $this->em->persist($series);
+ $this->em->persist(new Booking($anna, $resource, $date, null, $series));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $cell = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $date->format('Y-m-d')));
+
+ self::assertStringContainsString('SERIES', $cell->text());
+ self::assertStringContainsString('wiederkehrend', $cell->filter('a')->attr('title') ?? '');
+ }
+
+ public function testAPartDayChipCarriesItsStartTimeAndTheFullSpanInTheTitle(): void
+ {
+ // Also pins the TimeRange accessor: Twig resolves .allDay to isAllDay(), and a chip that
+ // silently lost its time would still render a plausible-looking label.
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $this->em->persist(new Booking($anna, $this->resource('D9'), $date, TimeRange::fromMinutes(9 * 60, 12 * 60)));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $chip = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"] a', $date->format('Y-m-d')));
+
+ self::assertStringContainsString('09:00', $chip->text());
+ self::assertStringContainsString('D9', $chip->text());
+ self::assertStringContainsString('09:00-12:00', str_replace("\u{2013}", '-', (string) $chip->attr('title')));
+ }
+
+ public function testAnAllDayChipShowsNoTime(): void
+ {
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $this->em->persist(new Booking($anna, $this->resource('D8'), $date));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $chip = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"] a', $date->format('Y-m-d')));
+
+ self::assertSame('D8', trim($chip->text()));
+ }
+
+ public function testOnlyTwoChipsAreShownPerDayAndTheRestAreCounted(): void
+ {
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ foreach (['D1', 'D2', 'D3', 'D4'] as $label) {
+ $this->em->persist(new Booking($anna, $this->resource($label), $date));
+ }
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $cell = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $date->format('Y-m-d')));
+
+ self::assertCount(2, $cell->filter('a'), 'A cell cannot hold four labels legibly.');
+ self::assertStringContainsString('+2 weitere', $cell->text());
+ }
+
+ public function testAChipLinksToTheBoardForThatDayAndKind(): void
+ {
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $this->em->persist(new Booking($anna, $this->resource('P1', ResourceType::PARKING), $date));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+ $crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
+
+ $href = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"] a', $date->format('Y-m-d')))->attr('href');
+
+ self::assertSame('/book/parking?date='.$date->format('Y-m-d'), $href);
+ }
+
+ public function testPagingTheMonthReturnsNothingButTheCalendarOutOfBand(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $next = $this->today->modify('first day of next month');
+ $this->client->request('GET', '/bookings/month/'.$next->format('Y-m'), server: ['HTTP_HX-Request' => 'true']);
+
+ $response = $this->client->getResponse();
+ $content = (string) $response->getContent();
+
+ self::assertResponseIsSuccessful();
+ self::assertStringContainsString('id="booking-calendar"', $content);
+ self::assertSame(1, substr_count($content, 'hx-swap-oob="true"'));
+ self::assertStringNotContainsString('format('Y-m'), $response->headers->get('HX-Push-Url'));
+ }
+
+ public function testPagingTheMonthWithoutHtmxRedirectsToTheShareablePage(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $next = $this->today->modify('first day of next month');
+ $this->client->request('GET', '/bookings/month/'.$next->format('Y-m'));
+
+ self::assertResponseRedirects('/bookings?month='.$next->format('Y-m'));
+ }
+
+ public function testAMonthThatDoesNotExistIsNotFound(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ // Two digits, so the route requirement lets it through; it would otherwise roll over into
+ // January of the following year.
+ $this->client->request('GET', '/bookings/month/2026-13');
+
+ self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testAMonthThatIsNotAMonthIsNotFound(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $this->client->request('GET', '/bookings/month/oktober');
+
+ self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testTheListBelowDoesNotNarrowToTheMonthOnScreen(): void
+ {
+ $anna = $this->user('anna@ep-reisen.de');
+ $date = $this->bookableDate();
+ $this->em->persist(new Booking($anna, $this->resource('D1'), $date));
+ $this->em->flush();
+
+ $this->client->loginUser($anna);
+
+ // A month with nothing in it. The calendar follows, the list must not.
+ $empty = $this->today->modify('first day of this month')->modify('+2 months');
+ $crawler = $this->client->request('GET', '/bookings?month='.$empty->format('Y-m'));
+
+ self::assertCount(1, $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $empty->format('Y-m-d'))));
+ // "Freigeben" only exists on a list row, so its presence proves the row survived.
+ self::assertGreaterThan(0, $crawler->filter('#booking-calendar')->count());
+ self::assertStringContainsString('Freigeben', $crawler->filter('body')->text());
+ }
+
+ public function testPagingStopsAtTheCurrentMonthGoingBack(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $crawler = $this->client->request('GET', '/bookings');
+
+ self::assertCount(0, $crawler->filter('[aria-label="Voriger Monat"]'), 'Nothing before today is shown.');
+ self::assertCount(1, $crawler->filter('[aria-label="Nächster Monat"]'));
+ }
+
+ public function testPagingStopsAtTheBookingHorizonGoingForward(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $horizon = BookingHorizon::latest($this->today);
+ $crawler = $this->client->request('GET', '/bookings?month='.$horizon->format('Y-m'));
+
+ self::assertCount(0, $crawler->filter('[aria-label="Nächster Monat"]'), 'Nothing past the horizon can be booked.');
+ self::assertCount(1, $crawler->filter('[aria-label="Voriger Monat"]'));
+ }
+
+ public function testAMalformedMonthQueryFallsBackToThisMonth(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $crawler = $this->client->request('GET', '/bookings?month=nonsense');
+
+ self::assertResponseIsSuccessful();
+ self::assertCount(1, $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $this->today->format('Y-m-d'))));
+ }
+
+ public function testAMonthWithoutBookingsStillRendersTheGrid(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $crawler = $this->client->request('GET', '/bookings');
+
+ self::assertResponseIsSuccessful();
+ self::assertCount(42, $crawler->filter('#booking-calendar [data-date]'));
+ self::assertCount(0, $crawler->filter('#booking-calendar a'));
+ }
+
+ /**
+ * A day that is both bookable and inside the month the page opens on -- tomorrow, unless that
+ * crosses into the next month, in which case yesterday's month would be shown instead.
+ */
+ private function bookableDate(): \DateTimeImmutable
+ {
+ $tomorrow = $this->today->modify('+1 day');
+
+ return $tomorrow->format('Y-m') === $this->today->format('Y-m') ? $tomorrow : $this->today;
+ }
+
+ private function user(string $email, string $name = 'Test'): User
+ {
+ $user = new User($email, $name);
+ $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);
+ $this->em->persist($resource);
+ $this->em->flush();
+
+ return $resource;
+ }
+}
diff --git a/tests/Model/MonthGridTest.php b/tests/Model/MonthGridTest.php
new file mode 100644
index 0000000..848bd01
--- /dev/null
+++ b/tests/Model/MonthGridTest.php
@@ -0,0 +1,129 @@
+weeks);
+
+ foreach ($grid->weeks as $week) {
+ self::assertCount(7, $week);
+ }
+ }
+
+ public function testItStartsOnTheMondayOnOrBeforeTheFirst(): void
+ {
+ // 1 September 2026 is a Tuesday, so the grid opens on 31 August.
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
+
+ self::assertSame('2026-08-31', $grid->gridStart->format('Y-m-d'));
+ self::assertSame('2026-09-01', $grid->monthStart->format('Y-m-d'));
+ self::assertSame('1', $grid->gridStart->format('N'));
+ }
+
+ public function testAMonthBeginningOnAMondayStartsOnThatMonday(): void
+ {
+ // 1 June 2026 is a Monday: no leading days at all.
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-06-10'), new \DateTimeImmutable('2026-06-10'));
+
+ self::assertSame('2026-06-01', $grid->gridStart->format('Y-m-d'));
+ }
+
+ public function testTheEndIsTheLastCellOfTheGrid(): void
+ {
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
+
+ self::assertSame('2026-10-11', $grid->gridEnd->format('Y-m-d'));
+ self::assertEquals($grid->weeks[5][6]->date, $grid->gridEnd);
+ }
+
+ public function testEveryCellIsTheDayAfterTheOneBefore(): void
+ {
+ // Guards the modify('+1 day') walk: March and October carry the DST switches, where naive
+ // arithmetic on timestamps would repeat or skip a day.
+ foreach (['2026-03-15', '2026-10-15'] as $inMonth) {
+ $grid = MonthGrid::around(new \DateTimeImmutable($inMonth), new \DateTimeImmutable($inMonth));
+
+ $expected = $grid->gridStart;
+ foreach ($grid->weeks as $week) {
+ foreach ($week as $day) {
+ self::assertSame($expected->format('Y-m-d'), $day->date->format('Y-m-d'));
+ self::assertSame('00:00:00', $day->date->format('H:i:s'));
+ $expected = $expected->modify('+1 day');
+ }
+ }
+ }
+ }
+
+ public function testDaysOutsideTheMonthAreFlagged(): void
+ {
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
+
+ self::assertFalse($grid->weeks[0][0]->inMonth, '31 August is a leading day.');
+ self::assertTrue($grid->weeks[0][1]->inMonth, '1 September is in the month.');
+ self::assertFalse($grid->weeks[5][6]->inMonth, '11 October is a trailing day.');
+ }
+
+ public function testTodayIsNeitherPastNorAnyOtherDay(): void
+ {
+ $today = new \DateTimeImmutable('2026-09-21');
+ $grid = MonthGrid::around($today, $today);
+
+ $flagged = [];
+ foreach ($grid->weeks as $week) {
+ foreach ($week as $day) {
+ if ($day->isToday) {
+ $flagged[] = $day->date->format('Y-m-d');
+ self::assertFalse($day->isPast, 'Today is not past.');
+ }
+ }
+ }
+
+ self::assertSame(['2026-09-21'], $flagged);
+ }
+
+ public function testPastAndWeekendFlags(): void
+ {
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
+
+ $days = [];
+ foreach ($grid->weeks as $week) {
+ foreach ($week as $day) {
+ $days[$day->key()] = $day;
+ }
+ }
+
+ self::assertTrue($days['2026-09-20']->isPast);
+ self::assertFalse($days['2026-09-22']->isPast);
+ // 19/20 September 2026 is a Saturday/Sunday.
+ self::assertTrue($days['2026-09-19']->isWeekend);
+ self::assertTrue($days['2026-09-20']->isWeekend);
+ self::assertFalse($days['2026-09-21']->isWeekend);
+ }
+
+ public function testTheTimePartOfTheGivenDayIsIgnored(): void
+ {
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21 17:43:12'), new \DateTimeImmutable('2026-09-21'));
+
+ self::assertSame('2026-09-01 00:00:00', $grid->monthStart->format('Y-m-d H:i:s'));
+ self::assertTrue($grid->weeks[3][0]->isToday, 'Today must still be recognised.');
+ }
+
+ public function testTheKeyIsTheDayItIndexesPayloadBy(): void
+ {
+ $grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
+
+ self::assertSame('2026-08-31', $grid->weeks[0][0]->key());
+ }
+}