From 4517b84023c4e845e13a8f33da0f53ae4e18f0af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 21 Sep 2026 10:36:16 +0200 Subject: [PATCH] feat: show my bookings on a month calendar --- assets/icons/lucide/repeat.svg | 1 + src/Controller/Booking/IndexController.php | 18 +- src/Controller/Booking/MonthController.php | 55 ++++ src/Model/MonthDay.php | 36 +++ src/Model/MonthGrid.php | 74 +++++ src/Routing/MonthParam.php | 46 +++ src/Service/BoardContextFactory.php | 35 +-- src/Service/BookingCalendarFactory.php | 78 +++++ templates/booking/_calendar.html.twig | 106 +++++++ templates/booking/index.html.twig | 11 + tests/Controller/BookingCalendarTest.php | 323 +++++++++++++++++++++ tests/Model/MonthGridTest.php | 129 ++++++++ 12 files changed, 891 insertions(+), 21 deletions(-) create mode 100644 assets/icons/lucide/repeat.svg create mode 100644 src/Controller/Booking/MonthController.php create mode 100644 src/Model/MonthDay.php create mode 100644 src/Model/MonthGrid.php create mode 100644 src/Routing/MonthParam.php create mode 100644 src/Service/BookingCalendarFactory.php create mode 100644 templates/booking/_calendar.html.twig create mode 100644 tests/Controller/BookingCalendarTest.php create mode 100644 tests/Model/MonthGridTest.php diff --git a/assets/icons/lucide/repeat.svg b/assets/icons/lucide/repeat.svg new file mode 100644 index 0000000..cd7a389 --- /dev/null +++ b/assets/icons/lucide/repeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Controller/Booking/IndexController.php b/src/Controller/Booking/IndexController.php index 6838d2b..7e8e350 100644 --- a/src/Controller/Booking/IndexController.php +++ b/src/Controller/Booking/IndexController.php @@ -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), ]); diff --git a/src/Controller/Booking/MonthController.php b/src/Controller/Booking/MonthController.php new file mode 100644 index 0000000..6afdd5b --- /dev/null +++ b/src/Controller/Booking/MonthController.php @@ -0,0 +1,55 @@ + 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')]), + ); + } +} diff --git a/src/Model/MonthDay.php b/src/Model/MonthDay.php new file mode 100644 index 0000000..0327371 --- /dev/null +++ b/src/Model/MonthDay.php @@ -0,0 +1,36 @@ +date->format('Y-m-d'); + } +} diff --git a/src/Model/MonthGrid.php b/src/Model/MonthGrid.php new file mode 100644 index 0000000..630a705 --- /dev/null +++ b/src/Model/MonthGrid.php @@ -0,0 +1,74 @@ +> $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); + } +} diff --git a/src/Routing/MonthParam.php b/src/Routing/MonthParam.php new file mode 100644 index 0000000..2e05083 --- /dev/null +++ b/src/Routing/MonthParam.php @@ -0,0 +1,46 @@ + 0 ? null : $month; + } + + public static function parseOrThisMonth(?string $value): \DateTimeImmutable + { + return self::parse($value) ?? new \DateTimeImmutable('first day of this month midnight'); + } +} diff --git a/src/Service/BoardContextFactory.php b/src/Service/BoardContextFactory.php index 798bf19..be8942f 100644 --- a/src/Service/BoardContextFactory.php +++ b/src/Service/BoardContextFactory.php @@ -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, diff --git a/src/Service/BookingCalendarFactory.php b/src/Service/BookingCalendarFactory.php new file mode 100644 index 0000000..b576070 --- /dev/null +++ b/src/Service/BookingCalendarFactory.php @@ -0,0 +1,78 @@ + + */ + 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'); + } +} diff --git a/templates/booking/_calendar.html.twig b/templates/booking/_calendar.html.twig new file mode 100644 index 0000000..c31c9d1 --- /dev/null +++ b/templates/booking/_calendar.html.twig @@ -0,0 +1,106 @@ +{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} +{% set visible_per_day = 2 %} + +
+
+ {% if has_prev %} + + {% else %} + + {% endif %} + +
+ {{ month_start|format_datetime(pattern: 'LLLL y') }} +
+ + {% if has_next %} + + {% else %} + + {% endif %} +
+ +
+ {% for weekday in weekdays %}
{{ weekday }}
{% endfor %} +
+ +
+ {% 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 %} + +
+
{{ day.date|date('j') }}
+ + {% if day.bookings is not empty %} +
+ {% for booking in day.bookings %} + + {% endfor %} +
+ {% endif %} + + +
+ {% endfor %} + {% endfor %} +
+ +
+
+ {{ ux_icon('lucide:repeat', { class: 'w-3 h-3' }) }} Teil einer Wiederholung +
+
+ Zeit weicht von der Wiederholung ab +
+
gestrichelt = vergangen, nicht mehr buchbar
+
Klick auf einen Termin öffnet den Plan des Tages
+
+
diff --git a/templates/booking/index.html.twig b/templates/booking/index.html.twig index b17d306..197dd2b 100644 --- a/templates/booking/index.html.twig +++ b/templates/booking/index.html.twig @@ -8,6 +8,17 @@

Meine Buchungen

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

+ {# + 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