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

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