feat: show my bookings on a month calendar
This commit is contained in:
1 parent
5a8227f4d9
commit
4517b84023
12 files changed
+891
-21
No files matched your search
@@ -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')]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user