feat: show my bookings on a month calendar

This commit is contained in:
fromme committed 2026-09-21 10:43:37 +02:00
1 parent 5a8227f4d9
commit 4517b84023
12 files changed
+891 -21

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m17 2l4 4l-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4l4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></g></svg>

After

Width:  |  Height:  |  Size: 284 B

+16 -2
View File
@@ -7,7 +7,10 @@ namespace App\Controller\Booking;
use App\Controller\Traits\CurrentUserTrait;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Routing\MonthParam;
use App\Service\BookingCalendarFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -18,15 +21,26 @@ final class IndexController extends AbstractController
public function __construct(
private readonly BookingRepository $bookings,
private readonly BookingSeriesRepository $series,
private readonly BookingCalendarFactory $calendar,
) {
}
#[Route('/bookings', name: 'app_booking', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
$today = new \DateTimeImmutable('today');
return $this->render('booking/index.html.twig', [
// ?month is what MonthController pushes onto history, so the URL a user lands on -- or
// bookmarks -- reproduces the month they were looking at. A malformed one falls back to
// this month rather than erroring: it is a hand-editable query parameter, not a route
// requirement, the same split BoardResponseTrait::boardTime() makes for the span.
$month = MonthParam::parseOrThisMonth($request->query->getString('month'));
// The calendar answers "when am I booked?" at a glance; the lists below it stay the
// authoritative enumeration and carry the actions. They are deliberately NOT scoped to
// the month on screen -- paging the calendar is a look-ahead gesture, and re-cutting the
// list under it would make "Freigeben" appear to act on a subset.
return $this->render('booking/index.html.twig', $this->calendar->create($this->currentUser(), $month) + [
'bookings' => $this->bookings->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
'series' => $this->series->findActiveForUser($this->currentUser(), $today),
]);
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use App\Controller\Traits\CurrentUserTrait;
use App\Htmx\HxTrait;
use App\Routing\MonthParam;
use App\Service\BookingCalendarFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Paging the "Meine Buchungen" calendar to another month.
*
* Its own route rather than a mode of the index action, because the two owe the caller different
* things: the index must return a whole page, this must return nothing but the out-of-band
* calendar block. Same division Board\DayController makes against Board\IndexController.
*
* Only the calendar is swapped. The lists underneath keep showing everything from today onwards
* -- see IndexController for why that is deliberate.
*/
final class MonthController extends AbstractController
{
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BookingCalendarFactory $calendar)
{
}
#[Route('/bookings/month/{month}', name: 'app_booking_month', requirements: ['month' => MonthParam::PATTERN], methods: ['GET'])]
public function index(Request $request, string $month): Response
{
// The route requirement only proves two digits, not a real month: "2026-13" would parse
// as January 2027 if it were allowed through. A month in the path either exists or the
// URL is wrong, so this is a 404 rather than a fallback to today.
$start = MonthParam::parse($month)
?? throw $this->createNotFoundException(\sprintf('"%s" is not a month.', $month));
if (!$request->headers->has('HX-Request')) {
return $this->redirectToRoute('app_booking', ['month' => $start->format('Y-m')]);
}
return $this->htmxOobResponse(
'booking/index.html.twig',
['calendar'],
$this->calendar->create($this->currentUser(), $start),
$this->generateUrl('app_booking', ['month' => $start->format('Y-m')]),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* One cell of a month grid: a day, and the handful of facts every calendar needs about it in
* order to style it.
*
* Deliberately carries nothing domain-specific. What is booked on the day, how many places are
* taken, whether it is the day the user picked -- none of that is here, because the two calendars
* in this app disagree about all of it. See MonthGrid for why.
*/
final readonly class MonthDay
{
public function __construct(
public \DateTimeImmutable $date,
public bool $inMonth,
public bool $isToday,
public bool $isPast,
public bool $isWeekend,
) {
}
/**
* The key a caller indexes its own per-day payload by.
*
* Bookings come back from the repository as one flat list; bucketing them by this string is
* what lets a template ask "what is on this cell?" without scanning the list per cell.
*/
public function key(): string
{
return $this->date->format('Y-m-d');
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* The six-week skeleton behind a month calendar: always whole weeks, always starting on a Monday.
*
* Six weeks rather than "as many as the month needs" so the grid never changes height as you page
* through it -- a month can span six week rows (a 31-day month starting on a Sunday does), and a
* calendar that grows and shrinks under the cursor is worse than one with a trailing empty row.
*
* This is only the skeleton. Both calendars in this app draw the same grid and then disagree
* entirely about what goes in a cell: the board shows everybody's occupancy for one kind of
* resource plus the day you picked, "Meine Buchungen" shows one person's own bookings across all
* kinds and has no selected day at all. So the payload stays with the caller, which decorates
* these days by MonthDay::key(). gridStart and gridEnd are public precisely so that decoration
* can be one range query over the visible window instead of 42 per-day ones.
*/
final readonly class MonthGrid
{
private const WEEKS = 6;
/**
* @param list<list<MonthDay>> $weeks
*/
private function __construct(
public \DateTimeImmutable $monthStart,
public \DateTimeImmutable $gridStart,
public \DateTimeImmutable $gridEnd,
public array $weeks,
) {
}
/**
* The grid containing $date, with "today" injectable so the flags can be tested without
* waiting for the calendar to roll over. BookingHorizon::latest() takes the same optional
* argument for the same reason.
*/
public static function around(\DateTimeImmutable $date, ?\DateTimeImmutable $today = null): self
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
$monthStart = $date->setTime(0, 0)->modify('first day of this month');
// "monday this week" is a no-op when the month already starts on a Monday, so this is the
// whole leading-days calculation -- no weekday arithmetic needed.
$gridStart = $monthStart->modify('monday this week');
$weeks = [];
$cursor = $gridStart;
for ($week = 0; $week < self::WEEKS; ++$week) {
$days = [];
for ($weekday = 0; $weekday < 7; ++$weekday) {
$days[] = new MonthDay(
date: $cursor,
inMonth: $cursor->format('Y-m') === $monthStart->format('Y-m'),
isToday: $cursor == $today,
isPast: $cursor < $today,
isWeekend: (int) $cursor->format('N') >= 6,
);
$cursor = $cursor->modify('+1 day');
}
$weeks[] = $days;
}
// The cursor has already stepped past the grid, hence the day back.
return new self($monthStart, $gridStart, $cursor->modify('-1 day'), $weeks);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Routing;
/**
* The Y-m month identifier that the "Meine Buchungen" calendar speaks.
*
* Separate from DateParam rather than a mode of it: that one is the Y-m-d day every board route
* carries, and widening it to accept two shapes would make every route requirement that uses it
* looser than it wants to be.
*/
final class MonthParam
{
/** Route requirement, so a malformed month 404s instead of silently falling back. */
public const PATTERN = '\d{4}-\d{2}';
public static function parse(?string $value): ?\DateTimeImmutable
{
if (null === $value || '' === $value) {
return null;
}
// The leading "!" resets everything not in the format, so the result is the first of the
// month at midnight rather than today's day-of-month at the current time -- which would
// skip a month outright whenever the 31st met a shorter one.
$month = \DateTimeImmutable::createFromFormat('!Y-m', $value);
if (false === $month) {
return null;
}
// A month out of range does not fail the parse, it silently rolls over: "2026-13" comes
// back as January 2027, and the route requirement cannot catch it because it is two
// digits. Only the warning distinguishes the two, so it has to be read.
$errors = \DateTimeImmutable::getLastErrors();
return false !== $errors && $errors['warning_count'] > 0 ? null : $month;
}
public static function parseOrThisMonth(?string $value): \DateTimeImmutable
{
return self::parse($value) ?? new \DateTimeImmutable('first day of this month midnight');
}
}
+16 -19
View File
@@ -6,6 +6,7 @@ namespace App\Service;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\MonthGrid;
use App\Model\Spot;
use App\Model\SpotGroup;
use App\Model\TimeRange;
@@ -26,8 +27,6 @@ use App\Repository\ResourceRepository;
*/
final readonly class BoardContextFactory
{
private const WEEKS = 6;
public function __construct(
private BookingRepository $bookings,
private ResourceRepository $resources,
@@ -42,34 +41,32 @@ final readonly class BoardContextFactory
$selected = $selected->setTime(0, 0);
$today = new \DateTimeImmutable('today');
$monthStart = $selected->modify('first day of this month');
$gridStart = $monthStart->modify('monday this week');
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1));
$grid = MonthGrid::around($selected, $today);
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time, $type);
$counts = $this->bookings->countPerDateInRange($grid->gridStart, $grid->gridEnd, $time, $type);
$mine = [];
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd, $type) as $booking) {
foreach ($this->bookings->findForUserInRange($user, $grid->gridStart, $grid->gridEnd, $type) as $booking) {
$mine[$booking->getDate()->format('Y-m-d')][] = $booking;
}
// The grid is shared with "Meine Buchungen"; the occupancy count and the selected day are
// this board's alone, so they are layered on here rather than pushed down into MonthGrid.
$weeks = [];
$cursor = $gridStart;
for ($week = 0; $week < self::WEEKS; ++$week) {
foreach ($grid->weeks as $week) {
$days = [];
for ($weekday = 0; $weekday < 7; ++$weekday) {
$key = $cursor->format('Y-m-d');
foreach ($week as $day) {
$key = $day->key();
$days[] = [
'date' => $cursor,
'in_month' => $cursor->format('Y-m') === $monthStart->format('Y-m'),
'is_today' => $cursor == $today,
'is_selected' => $cursor == $selected,
'is_past' => $cursor < $today,
'is_weekend' => (int) $cursor->format('N') >= 6,
'date' => $day->date,
'in_month' => $day->inMonth,
'is_today' => $day->isToday,
'is_selected' => $day->date == $selected,
'is_past' => $day->isPast,
'is_weekend' => $day->isWeekend,
'count' => $counts[$key] ?? 0,
'mine' => $mine[$key] ?? [],
];
$cursor = $cursor->modify('+1 day');
}
$weeks[] = $days;
}
@@ -92,7 +89,7 @@ final readonly class BoardContextFactory
'type' => $type,
'selected_date' => $selected,
'selected_time' => $time,
'month_start' => $monthStart,
'month_start' => $grid->monthStart,
'weeks' => $weeks,
'is_past' => $selected < $today,
'resources' => $resources,
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Config\BookingHorizon;
use App\Entity\User;
use App\Model\MonthGrid;
use App\Repository\BookingRepository;
/**
* Builds the month calendar on "Meine Buchungen": one person's own bookings, across every kind of
* resource, laid out on the grid the booking board also uses.
*
* The counterpart to BoardContextFactory, and deliberately not part of it -- that one answers
* "what is free on this kind of resource?" for everybody and needs a TimeRange to mean anything,
* this one answers "when am I booked?" and needs neither a resource type nor a span.
*
* No recurrence is expanded here. Creating a series writes a real Booking row per occurrence (see
* BookingSeries), so a plain range query already returns every occurrence individually.
*/
final readonly class BookingCalendarFactory
{
public function __construct(private BookingRepository $bookings)
{
}
/**
* @return array<string, mixed>
*/
public function create(User $user, \DateTimeImmutable $month): array
{
$today = new \DateTimeImmutable('today');
$grid = MonthGrid::around($month, $today);
// One query for the whole visible window, bucketed by day. findForUserInRange eager-selects
// the resource and the series, which is what keeps drawing 42 cells free of further
// queries -- every chip reads both.
$mine = [];
foreach ($this->bookings->findForUserInRange($user, $grid->gridStart, $grid->gridEnd) as $booking) {
$mine[$booking->getDate()->format('Y-m-d')][] = $booking;
}
$weeks = [];
foreach ($grid->weeks as $week) {
$days = [];
foreach ($week as $day) {
$days[] = [
'date' => $day->date,
'in_month' => $day->inMonth,
'is_today' => $day->isToday,
'is_past' => $day->isPast,
'is_weekend' => $day->isWeekend,
'bookings' => $mine[$day->key()] ?? [],
];
}
$weeks[] = $days;
}
return [
'month_start' => $grid->monthStart,
'weeks' => $weeks,
// Paging is bounded on both sides so it never leads into a month that cannot hold
// anything: the page is explicitly "ab heute", and nothing can be booked -- single or
// series alike -- past the horizon.
'prev_month' => $grid->monthStart->modify('-1 month'),
'next_month' => $grid->monthStart->modify('+1 month'),
'has_prev' => $grid->monthStart > $this->firstOfMonth($today),
'has_next' => $grid->monthStart < $this->firstOfMonth(BookingHorizon::latest($today)),
];
}
private function firstOfMonth(\DateTimeImmutable $date): \DateTimeImmutable
{
return $date->setTime(0, 0)->modify('first day of this month');
}
}
+106
View File
@@ -0,0 +1,106 @@
{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
{% set visible_per_day = 2 %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex items-center justify-between pb-4">
{% if has_prev %}
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Voriger Monat"
hx-get="{{ path('app_booking_month', { month: prev_month|date('Y-m') }) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button>
{% else %}
<span class="p-1 text-gray-300" aria-hidden="true">{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}</span>
{% endif %}
<div class="font-bold">
{{ month_start|format_datetime(pattern: 'LLLL y') }}
</div>
{% if has_next %}
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Nächster Monat"
hx-get="{{ path('app_booking_month', { month: next_month|date('Y-m') }) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button>
{% else %}
<span class="p-1 text-gray-300" aria-hidden="true"
title="Weiter voraus kann nicht gebucht werden">{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}</span>
{% endif %}
</div>
<div class="grid grid-cols-7 gap-1 text-center text-xs text-gray-500 pb-1">
{% for weekday in weekdays %}<div>{{ weekday }}</div>{% endfor %}
</div>
<div class="grid grid-cols-7 gap-1">
{% for week in weeks %}
{% for day in week %}
{% set classes = 'flex flex-col gap-0.5 rounded-md border p-1 text-left h-16 sm:h-24 overflow-hidden' %}
{% if not day.in_month %}
{% set classes = classes ~ ' border-transparent text-gray-300' %}
{% elseif day.is_past %}
{% set classes = classes ~ ' border-dashed border-gray-200 text-gray-400' %}
{% elseif day.is_weekend %}
{% set classes = classes ~ ' border-transparent bg-gray-50 text-gray-400' %}
{% else %}
{% set classes = classes ~ ' border-gray-200' %}
{% endif %}
{% if day.is_today %}
{% set classes = classes ~ ' ring-1 ring-primary' %}
{% endif %}
<div class="{{ classes }}" data-date="{{ day.date|date('Y-m-d') }}">
<div class="text-xs leading-none">{{ day.date|date('j') }}</div>
{% if day.bookings is not empty %}
<div class="flex flex-wrap gap-0.5 sm:hidden">
{% for booking in day.bookings %}
<span class="w-1.5 h-1.5 rounded-full {{ day.is_past ? 'bg-gray-300' : 'bg-primary' }}"></span>
{% endfor %}
</div>
{% endif %}
<div class="hidden sm:flex sm:flex-col sm:gap-0.5 sm:min-w-0">
{% for booking in day.bookings|slice(0, visible_per_day) %}
<a class="flex items-center gap-1 rounded px-1 py-0.5 text-[10px] leading-tight min-w-0
{{ day.is_past ? 'bg-gray-100 text-gray-500' : 'bg-primary-bg text-primary-dark hover:bg-primary hover:text-white' }}"
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}"
title="{{ booking.resource.type.label }} · {{ booking.resource.label }} · {{ booking.timeRange }}{{ booking.partOfSeries ? ' · wiederkehrend' : '' }}{{ booking.overridden ? ' · abweichend' : '' }}">
{{ ux_icon(booking.resource.type.icon, { class: 'w-3 h-3 shrink-0' }) }}
<span class="truncate">
{{- booking.timeRange.allDay ? booking.resource.label : booking.timeRange.start ~ ' ' ~ booking.resource.label -}}
</span>
{% if booking.partOfSeries %}
{{ ux_icon('lucide:repeat', { class: 'w-2.5 h-2.5 shrink-0 opacity-60' }) }}
{% endif %}
{% if booking.overridden %}
<span class="w-1.5 h-1.5 shrink-0 rounded-full bg-amber-500"></span>
{% endif %}
</a>
{% endfor %}
{% if day.bookings|length > visible_per_day %}
<span class="text-[10px] leading-tight text-gray-500">+{{ day.bookings|length - visible_per_day }} weitere</span>
{% endif %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
<div class="pt-4 text-xs text-gray-500 space-y-1">
<div class="flex items-center gap-2">
{{ ux_icon('lucide:repeat', { class: 'w-3 h-3' }) }} Teil einer Wiederholung
</div>
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Zeit weicht von der Wiederholung ab
</div>
<div>gestrichelt = vergangen, nicht mehr buchbar</div>
<div>Klick auf einen Termin öffnet den Plan des Tages</div>
</div>
</div>
+11
View File
@@ -8,6 +8,17 @@
<h1 class="pt-2">Meine Buchungen</h1>
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
{#
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 %}
<div id="booking-calendar" class="mt-8"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_calendar.html.twig' %}
</div>
{% endblock %}
{% if series is not empty %}
<h2 class="pt-8">Wiederkehrende Buchungen</h2>
<ul class="divide-y divide-gray-200 border-y border-gray-200">
+323
View File
@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Config\BookingHorizon;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The month calendar on "Meine Buchungen".
*
* Rebooting is disabled so the whole test stays on one connection and the surrounding transaction
* can be rolled back -- with the default reboot-per-request the connection would be recreated and
* the rollback would take nothing with it.
*
* Dates are all derived from today rather than written out, because the page only ever shows from
* today on and the booking horizon moves with the calendar.
*/
class BookingCalendarTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
private \DateTimeImmutable $today;
protected function setUp(): void
{
$this->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('[email protected]'));
$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('[email protected]');
$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('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('MINE'), $date));
$this->em->persist(new Booking($this->user('[email protected]'), $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('[email protected]');
$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('[email protected]');
$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('[email protected]');
$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('[email protected]');
$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('[email protected]');
$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('[email protected]'));
$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('<html', $content, 'Only the calendar block, not the page.');
self::assertStringNotContainsString('Wiederkehrende Buchungen', $content, 'The lists are not swapped.');
self::assertSame('/bookings?month='.$next->format('Y-m'), $response->headers->get('HX-Push-Url'));
}
public function testPagingTheMonthWithoutHtmxRedirectsToTheShareablePage(): void
{
$this->client->loginUser($this->user('[email protected]'));
$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('[email protected]'));
// 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('[email protected]'));
$this->client->request('GET', '/bookings/month/oktober');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
public function testTheListBelowDoesNotNarrowToTheMonthOnScreen(): void
{
$anna = $this->user('[email protected]');
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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;
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\MonthGrid;
use PHPUnit\Framework\TestCase;
class MonthGridTest extends TestCase
{
public function testTheGridIsAlwaysSixWeeksOfSevenDays(): void
{
// February 2026 needs four rows; the grid still renders six, so paging never changes its
// height.
$grid = MonthGrid::around(new \DateTimeImmutable('2026-02-15'), new \DateTimeImmutable('2026-02-15'));
self::assertCount(6, $grid->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());
}
}