feat: replace the navigation with a dashboard and per-area boards

This commit is contained in:
fromme committed 2026-09-13 18:08:39 +02:00
1 parent d63df2cc1b
commit bce8f58be3
26 files changed
+810 -328

No files matched your search

+15 -15
View File
@@ -10,16 +10,16 @@ use App\Repository\BookingRepository;
use App\Repository\ResourceRepository;
/**
* Builds the context behind the two fragments that make up the booking board: the month
* calendar and the floor plan.
* Builds the context behind the two fragments that make up a booking board: the month calendar
* and the floor plan.
*
* Both are always rendered from the same context so that one htmx round trip can swap both --
* picking a day (or a time span) updates the grid and the plan in a single out-of-band response.
*
* Everything here is relative to a TimeRange. With part-day bookings "is this desk free?" has no
* answer on its own, only "is it free between 09:00 and 13:00?", so the span the user picked is
* carried through the occupancy counts and the plan colours alike -- the number in a calendar
* cell always means the same thing as the colours on the plan.
* Everything here is relative to a ResourceType **and** a TimeRange. One board shows one kind of
* resource, and with part-day bookings "is this free?" has no answer without a span -- so both
* are carried through the occupancy counts and the plan colours alike, and the number in a
* calendar cell always means the same thing as the colours on the plan beside it.
*/
final readonly class BoardPresenter
{
@@ -34,7 +34,7 @@ final readonly class BoardPresenter
/**
* @return array<string, mixed>
*/
public function context(\DateTimeImmutable $selected, TimeRange $time, User $user): array
public function context(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array
{
$selected = $selected->setTime(0, 0);
$today = new \DateTimeImmutable('today');
@@ -43,10 +43,10 @@ final readonly class BoardPresenter
$gridStart = $monthStart->modify('monday this week');
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1));
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time);
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time, $type);
$mine = [];
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) {
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd, $type) as $booking) {
$mine[$booking->getDate()->format('Y-m-d')][] = $booking;
}
@@ -72,33 +72,33 @@ final readonly class BoardPresenter
}
$byResource = [];
foreach ($this->bookings->findForDate($selected, $time) as $booking) {
foreach ($this->bookings->findForDate($selected, $time, $type) as $booking) {
// Several bookings can overlap the span on one resource once times are in play; the
// first is enough to colour the shape, and the tooltip lists them all.
$byResource[$booking->getResource()->getId()][] = $booking;
}
return [
'type' => $type,
'selected_date' => $selected,
'selected_time' => $time,
'month_start' => $monthStart,
'weeks' => $weeks,
'is_past' => $selected < $today,
'desks' => $this->resources->findActiveByType(ResourceType::DESK),
'parking' => $this->resources->findActiveByType(ResourceType::PARKING),
'resources' => $this->resources->findActiveByType($type),
'bookings_by_resource' => $byResource,
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
];
}
/**
* The query parameters that reproduce a board view, so links and HX-Push-Url agree.
* The route parameters that reproduce a board view, so links and HX-Push-Url agree.
*
* @return array<string, string>
*/
public static function urlParameters(\DateTimeImmutable $date, TimeRange $time): array
public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array
{
$parameters = ['tag' => $date->format('Y-m-d')];
$parameters = ['type' => $type->slug(), 'tag' => $date->format('Y-m-d')];
if (!$time->isAllDay()) {
$parameters['von'] = $time->start();
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
use App\Repository\ResourceRepository;
/**
* The dashboard's tiles: one per bookable kind, built by walking ResourceType.
*
* Nothing here names a kind. Adding a case to the enum adds a tile with a working link and a
* correct count, which is what "extensible" has to mean if it is to survive contact with the
* next requirement.
*/
final readonly class DashboardPresenter
{
public function __construct(
private BookingRepository $bookings,
private ResourceRepository $resources,
) {
}
/**
* @return list<array<string, mixed>>
*/
public function tiles(User $user, \DateTimeImmutable $on): array
{
$on = $on->setTime(0, 0);
$tiles = [];
foreach (ResourceType::cases() as $type) {
$resources = $this->resources->findActiveByType($type);
// Availability is judged over the whole day: a resource booked for any part of it is
// not "free today" without qualification, and the dashboard is not the place to ask
// the user for a time span before it can say anything.
$taken = [];
$mine = null;
foreach ($this->bookings->findForDate($on, null, $type) as $booking) {
$taken[$booking->getResource()->getId()] = true;
if ($booking->belongsTo($user)) {
$mine = $booking;
}
}
$tiles[] = [
'type' => $type,
'total' => \count($resources),
'free' => \count($resources) - \count($taken),
'mine' => $mine,
];
}
return $tiles;
}
/**
* @return list<\App\Entity\Booking>
*/
public function upcoming(User $user, \DateTimeImmutable $from, int $limit = 5): array
{
$bookings = $this->bookings->findForUserInRange($user, $from, $from->modify('+1 year'));
return \array_slice($bookings, 0, $limit);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ use Symfony\Component\DependencyInjection\Attribute\When;
*
* Dev only, and idempotent: it matches on the natural key (resource label, user email) so it can
* be re-run after a migration without duplicating anything. The resource svgElementId values are
* the contract with templates/dashboard/_floorplan.html.twig -- change one and the other stops
* the contract with templates/board/plan/_<type>.html.twig -- change one and the other stops
* drawing that resource.
*/
#[When('dev')]
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Booking\BoardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Entity\ResourceType;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* One booking board per resource kind: a month calendar next to that kind's floor plan.
*
* The kind is a route parameter rather than a separate controller per kind, so a new
* ResourceType case gets a working board without touching this file.
*/
final class BoardController extends AbstractController
{
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardPresenter $board)
{
}
#[Route('/buchen/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])]
public function index(Request $request, string $type): Response
{
$resourceType = $this->resourceType($type);
$date = DateParam::parseOrToday($request->query->getString('tag'));
return $this->render('board/index.html.twig', $this->board->context(
$resourceType,
$date,
self::timeOf($request),
$this->currentUser(),
));
}
/**
* Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one
* response.
*
* This is the whole reason htmxOobResponse exists: two regions, one round trip, no
* client-side event choreography. Requests without the htmx header get a plain redirect
* rather than a bare pair of fragments, so the URL stays shareable.
*/
#[Route('/buchen/{type}/tag/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])]
public function day(Request $request, string $type, string $date): Response
{
$resourceType = $this->resourceType($type);
$selected = DateParam::parseOrToday($date);
$time = self::timeOf($request);
if (!$request->headers->has('HX-Request')) {
return $this->redirectToRoute('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time));
}
return $this->htmxOobResponse(
'board/index.html.twig',
['calendar', 'floorplan'],
$this->board->context($resourceType, $selected, $time, $this->currentUser()),
$this->generateUrl('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time)),
);
}
/**
* A malformed or missing span falls back to the whole day rather than erroring: these are
* hand-editable query parameters, not route requirements.
*/
public static function timeOf(Request $request): TimeRange
{
return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis'))
?? TimeRange::wholeDay();
}
/**
* Route requirements cannot call a method, so the slug is matched loosely and resolved here.
*/
private function resourceType(string $slug): ResourceType
{
try {
return ResourceType::fromSlug($slug);
} catch (\ValueError $e) {
throw $this->createNotFoundException($e->getMessage(), $e);
}
}
}
+11 -9
View File
@@ -14,6 +14,7 @@ use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Form\BookingRequest;
use App\Form\BookingType;
use App\Htmx\HxTrait;
@@ -67,7 +68,7 @@ final class BookingController extends AbstractController
}
$selected = DateParam::parseOrToday($date);
$boardTime = DashboardController::timeOf($request);
$boardTime = BoardController::timeOf($request);
// Prefilled from whatever span the board is showing, so narrowing the board and then
// clicking a desk books exactly what was on screen.
@@ -96,7 +97,7 @@ final class BookingController extends AbstractController
return $this->invalidModal($form, $resource, $e->getMessage());
}
return $this->boardResponse($data->date, $boardTime);
return $this->boardResponse($resource->getType(), $data->date, $boardTime);
}
return $this->render('booking/_modal_create.html.twig', [
@@ -114,7 +115,7 @@ final class BookingController extends AbstractController
{
$this->denyUnlessOwn($booking);
$boardTime = DashboardController::timeOf($request);
$boardTime = BoardController::timeOf($request);
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
$form = $this->createForm(BookingType::class, $data, [
@@ -134,7 +135,7 @@ final class BookingController extends AbstractController
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
return $this->boardResponse($booking->getDate(), $boardTime);
return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime);
}
return $this->render('booking/_modal_edit.html.twig', [
@@ -151,6 +152,7 @@ final class BookingController extends AbstractController
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
$date = $booking->getDate();
$type = $booking->getResource()->getType();
$label = $booking->getResource()->getLabel();
$this->bookings->release($booking);
@@ -161,7 +163,7 @@ final class BookingController extends AbstractController
return $this->redirectToRoute('app_booking');
}
return $this->boardResponse($date, DashboardController::timeOf($request));
return $this->boardResponse($type, $date, BoardController::timeOf($request));
}
/**
@@ -235,13 +237,13 @@ final class BookingController extends AbstractController
* The whole response is out-of-band board blocks. Callers pair it with hx-swap="none"
* (or an outerHTML target that should vanish, as the modal does).
*/
private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response
private function boardResponse(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): Response
{
return $this->htmxOobResponse(
'dashboard/index.html.twig',
'board/index.html.twig',
['calendar', 'floorplan', 'flashes'],
$this->board->context($date, $time, $this->currentUser()),
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)),
$this->board->context($type, $date, $time, $this->currentUser()),
$this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)),
);
}
+15 -47
View File
@@ -4,68 +4,36 @@ declare(strict_types=1);
namespace App\Controller;
use App\Booking\BoardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Htmx\HxTrait;
use App\Booking\DashboardPresenter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The booking board: a month calendar next to the floor plan for the selected day and span.
* The landing screen: one tile per bookable kind, plus what this person has coming up.
*
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
* bookings list -- which is why the tiles are generated from ResourceType rather than written
* out: a kind with no tile would be unreachable.
*/
final class DashboardController extends AbstractController
{
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardPresenter $board)
public function __construct(private readonly DashboardPresenter $dashboard)
{
}
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
public function index(Request $request): Response
public function index(): Response
{
$date = DateParam::parseOrToday($request->query->getString('tag'));
$today = new \DateTimeImmutable('today');
$user = $this->currentUser();
return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser()));
}
/**
* Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one
* response.
*
* This is the whole reason htmxOobResponse exists: two regions, one round trip, no
* client-side event choreography. Requests without the htmx header get a plain redirect
* rather than a bare pair of fragments, so the URL stays shareable.
*/
#[Route('/tag/{date}', name: 'app_dashboard_day', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
public function day(Request $request, string $date): Response
{
$selected = DateParam::parseOrToday($date);
$time = self::timeOf($request);
if (!$request->headers->has('HX-Request')) {
return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time));
}
return $this->htmxOobResponse(
'dashboard/index.html.twig',
['calendar', 'floorplan'],
$this->board->context($selected, $time, $this->currentUser()),
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($selected, $time)),
);
}
/**
* A malformed or missing span falls back to the whole day rather than erroring: these are
* hand-editable query parameters, not route requirements.
*/
public static function timeOf(Request $request): TimeRange
{
return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis'))
?? TimeRange::wholeDay();
return $this->render('dashboard/index.html.twig', [
'tiles' => $this->dashboard->tiles($user, $today),
'upcoming' => $this->dashboard->upcoming($user, $today),
'today' => $today,
]);
}
}
+56
View File
@@ -4,6 +4,18 @@ declare(strict_types=1);
namespace App\Entity;
/**
* The kinds of thing that can be booked.
*
* **This enum is the extension point.** The dashboard builds one tile per case, the board route
* accepts one slug per case, and the floor plan is looked up by convention from the case's
* value. Adding a third bookable kind is therefore: a case here, a
* `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing
* else needs editing — in particular no template lists the kinds by hand.
*
* The backing values are persisted in `resource.type`, which is why they stay English while the
* URLs do not: `slug()` is what appears in a path, so the routes read German like the rest.
*/
enum ResourceType: string
{
case DESK = 'desk';
@@ -16,4 +28,48 @@ enum ResourceType: string
self::PARKING => 'Parkplatz',
};
}
public function plural(): string
{
return match ($this) {
self::DESK => 'Schreibtische',
self::PARKING => 'Parkplätze',
};
}
public function description(): string
{
return match ($this) {
self::DESK => 'Einen Platz im Büro reservieren.',
self::PARKING => 'Einen Stellplatz reservieren.',
};
}
public function icon(): string
{
return match ($this) {
self::DESK => 'lucide:armchair',
self::PARKING => 'lucide:car',
};
}
/** What appears in a URL. */
public function slug(): string
{
return match ($this) {
self::DESK => 'schreibtische',
self::PARKING => 'parkplaetze',
};
}
public static function fromSlug(string $slug): self
{
foreach (self::cases() as $case) {
if ($case->slug() === $slug) {
return $case;
}
}
throw new \ValueError(\sprintf('"%s" ist kein bekannter Buchungsbereich.', $slug));
}
}
+27 -10
View File
@@ -32,7 +32,7 @@ class BookingRepository extends ServiceEntityRepository
*
* @return list<Booking>
*/
public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null): array
public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null, ?ResourceType $type = null): array
{
$qb = $this->createQueryBuilder('b')
->addSelect('u', 'r')
@@ -47,6 +47,10 @@ class BookingRepository extends ServiceEntityRepository
self::restrictToOverlap($qb, $within);
}
if (null !== $type) {
$qb->andWhere('r.type = :type')->setParameter('type', $type);
}
return $qb->getQuery()->getResult();
}
@@ -106,9 +110,9 @@ class BookingRepository extends ServiceEntityRepository
*
* @return list<Booking>
*/
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to, ?ResourceType $type = null): array
{
return $this->createQueryBuilder('b')
$qb = $this->createQueryBuilder('b')
->addSelect('r', 's')
->join('b.resource', 'r')
->leftJoin('b.series', 's')
@@ -119,20 +123,29 @@ class BookingRepository extends ServiceEntityRepository
->setParameter('to', $to->setTime(0, 0))
->orderBy('b.date', 'ASC')
->addOrderBy('b.startMinute', 'ASC')
->addOrderBy('r.label', 'ASC')
->getQuery()
->getResult();
->addOrderBy('r.label', 'ASC');
if (null !== $type) {
$qb->andWhere('r.type = :type')->setParameter('type', $type);
}
return $qb->getQuery()->getResult();
}
/**
* Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as
* occupancy. Counts only what overlaps the span the board is currently showing, so the
* number in a day cell always means the same thing as the colours on the plan.
* occupancy. Counts only what overlaps the span the board is currently showing, and only the
* kind of resource that board is about, so the number in a day cell always means the same
* thing as the colours on the plan next to it.
*
* @return array<string, int>
*/
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to, ?TimeRange $within = null): array
{
public function countPerDateInRange(
\DateTimeImmutable $from,
\DateTimeImmutable $to,
?TimeRange $within = null,
?ResourceType $type = null,
): array {
$qb = $this->createQueryBuilder('b')
->select('b.date AS date', 'COUNT(b.id) AS total')
->andWhere('b.date BETWEEN :from AND :to')
@@ -144,6 +157,10 @@ class BookingRepository extends ServiceEntityRepository
self::restrictToOverlap($qb, $within);
}
if (null !== $type) {
$qb->join('b.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
}
$counts = [];
foreach ($qb->getQuery()->getResult() as $row) {
$counts[$row['date']->format('Y-m-d')] = (int) $row['total'];