feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

@@ -0,0 +1,25 @@
import { Controller } from '@hotwired/stimulus';
/*
* Shows and hides the parts of the booking form that only apply in some states: the time fields
* when "ganzer Tag" is off, the recurrence fields when a repetition is picked, and the weekday
* boxes only for a weekly rule.
*
* Purely client-side, so it is Stimulus rather than an htmx form refresh -- there is nothing the
* server needs to decide here. The server still validates every one of these rules, because a
* hidden field is not an absent one.
*/
export default class extends Controller {
static targets = ['times', 'repeat', 'weekdays'];
toggleTimes(event) {
this.timesTarget.classList.toggle('hidden', event.target.checked);
}
toggleRepeat(event) {
const rule = event.target.value;
this.repeatTarget.classList.toggle('hidden', rule === '');
this.weekdaysTarget.classList.toggle('hidden', rule !== 'weekly');
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260913133131 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds time ranges and recurring series to bookings, and drops the unique (resource, date) guard';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE booking_series (id INT AUTO_INCREMENT NOT NULL, rule VARCHAR(255) NOT NULL, weekdays JSON NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, start_minute INT NOT NULL, end_minute INT NOT NULL, created_at DATETIME NOT NULL, user_id INT NOT NULL, resource_id INT NOT NULL, INDEX IDX_818A3AD3A76ED395 (user_id), INDEX IDX_818A3AD389329D25 (resource_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE booking_series ADD CONSTRAINT FK_818A3AD3A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE booking_series ADD CONSTRAINT FK_818A3AD389329D25 FOREIGN KEY (resource_id) REFERENCES resource (id) ON DELETE RESTRICT');
// The unique index cannot express "no overlapping time range" -- and MariaDB has no
// exclusion constraints -- so the guard moves into BookingManager, which serialises on a
// SELECT ... FOR UPDATE of the resource row. This index makes that check cheap.
$this->addSql('ALTER TABLE booking DROP INDEX uniq_booking_resource_date, ADD INDEX idx_booking_resource_date (resource_id, date)');
// The defaults are what backfills existing rows: every booking made so far was a whole
// day, which is exactly 0..1440. They are dropped again immediately so the application
// stays the only thing that decides a booking's span.
$this->addSql('ALTER TABLE booking ADD start_minute INT NOT NULL DEFAULT 0, ADD end_minute INT NOT NULL DEFAULT 1440, ADD overridden TINYINT(1) NOT NULL DEFAULT 0, ADD series_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE booking ALTER COLUMN start_minute DROP DEFAULT');
$this->addSql('ALTER TABLE booking ALTER COLUMN end_minute DROP DEFAULT');
$this->addSql('ALTER TABLE booking ALTER COLUMN overridden DROP DEFAULT');
$this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDE5278319C FOREIGN KEY (series_id) REFERENCES booking_series (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX idx_booking_user_date ON booking (user_id, date)');
$this->addSql('CREATE INDEX IDX_E00CEDDE5278319C ON booking (series_id)');
}
public function down(Schema $schema): void
{
// Note the down() is lossy by nature: restoring UNIQUE(resource_id, date) fails if any
// day holds two part-day bookings of the same resource, which is the whole point of this
// migration. Release those first if you really need to roll back.
$this->addSql('ALTER TABLE booking_series DROP FOREIGN KEY FK_818A3AD3A76ED395');
$this->addSql('ALTER TABLE booking_series DROP FOREIGN KEY FK_818A3AD389329D25');
$this->addSql('DROP TABLE booking_series');
$this->addSql('ALTER TABLE booking DROP INDEX idx_booking_resource_date, ADD UNIQUE INDEX uniq_booking_resource_date (resource_id, date)');
$this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDE5278319C');
$this->addSql('DROP INDEX idx_booking_user_date ON booking');
$this->addSql('DROP INDEX IDX_E00CEDDE5278319C ON booking');
$this->addSql('ALTER TABLE booking DROP start_minute, DROP end_minute, DROP overridden, DROP series_id');
}
}
@@ -7,16 +7,20 @@ namespace App\Booking;
use App\Entity\Booking;
/**
* One person may hold at most one desk and one parking spot per day.
* One person may not hold two resources of the same kind at overlapping times. A desk in the
* morning and a different one in the afternoon is fine -- only the overlap is refused.
*/
final class AlreadyHoldingResourceException extends \RuntimeException
{
public function __construct(public readonly Booking $existing)
{
$time = $existing->getTimeRange();
parent::__construct(\sprintf(
'Du hast am %s bereits %s gebucht.',
'Du hast am %s bereits %s gebucht (%s).',
$existing->getDate()->format('d.m.Y'),
$existing->getResource()->getLabel(),
$time,
));
}
}
+25 -11
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
@@ -15,7 +14,12 @@ use App\Repository\ResourceRepository;
* 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 updates the grid and the plan in a single out-of-band response.
* 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.
*/
final readonly class BoardPresenter
{
@@ -30,7 +34,7 @@ final readonly class BoardPresenter
/**
* @return array<string, mixed>
*/
public function context(\DateTimeImmutable $selected, User $user): array
public function context(\DateTimeImmutable $selected, TimeRange $time, User $user): array
{
$selected = $selected->setTime(0, 0);
$today = new \DateTimeImmutable('today');
@@ -39,7 +43,7 @@ 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);
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time);
$mine = [];
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) {
@@ -68,29 +72,39 @@ final readonly class BoardPresenter
}
$byResource = [];
foreach ($this->bookings->findForDate($selected) as $booking) {
$byResource[$booking->getResource()->getId()] = $booking;
foreach ($this->bookings->findForDate($selected, $time) 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 [
'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),
'bookings_by_resource' => $byResource,
'my_bookings_today' => $this->mineOn($selected, $mine),
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
];
}
/**
* @param array<string, list<Booking>> $mine
* The query parameters that reproduce a board view, so links and HX-Push-Url agree.
*
* @return list<Booking>
* @return array<string, string>
*/
private function mineOn(\DateTimeImmutable $date, array $mine): array
public static function urlParameters(\DateTimeImmutable $date, TimeRange $time): array
{
return $mine[$date->format('Y-m-d')] ?? [];
$parameters = ['tag' => $date->format('Y-m-d')];
if (!$time->isAllDay()) {
$parameters['von'] = $time->start();
$parameters['bis'] = $time->end();
}
return $parameters;
}
}
+147 -24
View File
@@ -5,12 +5,33 @@ declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Repository\BookingRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
/**
* Every write to a booking goes through here, because every write needs the same guard.
*
* **How double-booking is prevented, and why it changed.** While a booking was a whole day, a
* UNIQUE(resource_id, date) index did the job and the database rejected the loser of a race. A
* booking is now a time *range*, and an overlap is not something a unique index can express --
* nor does MariaDB have PostgreSQL's exclusion constraints. So the serialisation point moved
* into the application: each write opens a transaction, takes a row lock on the Resource
* (SELECT ... FOR UPDATE), and only then looks for overlaps. Two concurrent requests for the
* same resource queue up on that lock, so the second one sees the first one's row.
*
* This is still a real guard rather than the racy check-then-insert the plan warned about -- the
* check happens while nobody else can be inserting for that resource. The cost is that the
* protection now lives in code, so it belongs to this class alone: nothing else may persist a
* Booking.
*
* The per-user rule ("not two desks at once") is deliberately NOT covered by the lock: it spans
* resources, and locking every resource would serialise the whole app to protect against a user
* racing themselves in two browser tabs.
*/
final readonly class BookingManager
{
public function __construct(
@@ -20,36 +41,84 @@ final readonly class BookingManager
}
/**
* @throws AlreadyHoldingResourceException if the user already holds a resource of this type that day
* @throws ResourceAlreadyBookedException if somebody else holds this resource that day
* @throws AlreadyHoldingResourceException if the user already holds a resource of this kind in that span
* @throws ResourceAlreadyBookedException if somebody else does
*/
public function book(User $user, Resource $resource, \DateTimeImmutable $date): Booking
public function book(User $user, Resource $resource, \DateTimeImmutable $date, ?TimeRange $time = null): Booking
{
$date = $date->setTime(0, 0);
// All day is the default, as it was before ranges existed.
$time ??= TimeRange::wholeDay();
$existing = $this->bookings->findOneForUserDateAndType($user, $date, $resource->getType());
if (null !== $existing) {
throw new AlreadyHoldingResourceException($existing);
}
return $this->em->wrapInTransaction(function () use ($user, $resource, $date, $time): Booking {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
// Friendly path: almost every collision is caught here, with the entity manager healthy.
if (null !== $this->bookings->findOneBy(['resource' => $resource, 'date' => $date])) {
throw new ResourceAlreadyBookedException($resource, $date);
}
$this->assertFree($user, $resource, $date, $time);
$booking = new Booking($user, $resource, $date);
$this->em->persist($booking);
try {
$booking = new Booking($user, $resource, $date, $time);
$this->em->persist($booking);
$this->em->flush();
} catch (UniqueConstraintViolationException $e) {
// The real guard: two concurrent requests both pass the check above and the database
// rejects the second. A failed flush CLOSES the entity manager, so whoever catches
// this must render its error page without touching the database again.
throw new ResourceAlreadyBookedException($resource, $date, $e);
}
return $booking;
return $booking;
});
}
/**
* Materialises a series into concrete bookings, skipping days that are already taken.
*
* The resource is locked once for the whole series rather than per occurrence, so the set of
* days either all see the same world or none do. Occurrences fall on distinct days, so the
* not-yet-flushed ones cannot collide with each other.
*/
public function bookSeries(BookingSeries $series): SeriesResult
{
return $this->em->wrapInTransaction(function () use ($series): SeriesResult {
$resource = $series->getResource();
$user = $series->getUser();
$time = $series->getTimeRange();
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$created = [];
$skipped = [];
foreach ($series->occurrences() as $date) {
try {
$this->assertFree($user, $resource, $date, $time);
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
$skipped[$date->format('Y-m-d')] = $e->getMessage();
continue;
}
$booking = new Booking($user, $resource, $date, $time, $series);
$this->em->persist($booking);
$created[] = $booking;
}
if ([] !== $created) {
$this->em->persist($series);
}
$this->em->flush();
return new SeriesResult($series, $created, $skipped);
});
}
/**
* Moves one occurrence to different times, leaving the rest of its series alone.
*/
public function reschedule(Booking $booking, TimeRange $time): void
{
$this->em->wrapInTransaction(function () use ($booking, $time): void {
$resource = $booking->getResource();
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$this->assertFree($booking->getUser(), $resource, $booking->getDate(), $time, $booking);
$booking->changeTime($time);
$this->em->flush();
});
}
public function release(Booking $booking): void
@@ -57,4 +126,58 @@ final readonly class BookingManager
$this->em->remove($booking);
$this->em->flush();
}
/**
* Ends a series from a day onwards: its remaining occurrences go, and the series itself is
* either shortened or dropped if nothing is left of it.
*
* @return int the number of occurrences removed
*/
public function endSeries(BookingSeries $series, \DateTimeImmutable $from): int
{
return $this->em->wrapInTransaction(function () use ($series, $from): int {
$from = $from->setTime(0, 0);
$removed = 0;
foreach ($this->bookings->findForSeriesFrom($series, $from) as $booking) {
$this->em->remove($booking);
++$removed;
}
if ($series->getStartDate() >= $from) {
$this->em->remove($series);
} else {
$series->setEndDate($from->modify('-1 day'));
}
$this->em->flush();
return $removed;
});
}
/**
* @throws AlreadyHoldingResourceException
* @throws ResourceAlreadyBookedException
*/
private function assertFree(
User $user,
Resource $resource,
\DateTimeImmutable $date,
TimeRange $time,
?Booking $ignore = null,
): void {
$mine = $this->bookings->findOverlappingForUser($user, $date, $time, $resource->getType(), $ignore);
if (null !== $mine) {
throw new AlreadyHoldingResourceException($mine);
}
foreach ($this->bookings->findOverlapping($resource, $date, $time) as $clash) {
if (null !== $ignore && $clash->getId() === $ignore->getId()) {
continue;
}
throw new ResourceAlreadyBookedException($resource, $date, $clash->getTimeRange());
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Booking;
enum RecurrenceRule: string
{
case DAILY = 'daily';
case WEEKLY = 'weekly';
public function label(): string
{
return match ($this) {
self::DAILY => 'Täglich',
self::WEEKLY => 'Wöchentlich',
};
}
}
+11 -5
View File
@@ -11,11 +11,17 @@ final class ResourceAlreadyBookedException extends \RuntimeException
public function __construct(
public readonly Resource $resource,
public readonly \DateTimeImmutable $date,
?\Throwable $previous = null,
public readonly ?TimeRange $clashingWith = null,
) {
parent::__construct(
\sprintf('%s ist am %s bereits belegt.', $resource->getLabel(), $date->format('d.m.Y')),
previous: $previous,
);
$when = null === $clashingWith || $clashingWith->isAllDay()
? ''
: \sprintf(' (%s)', $clashingWith);
parent::__construct(\sprintf(
'%s ist am %s bereits belegt%s.',
$resource->getLabel(),
$date->format('d.m.Y'),
$when,
));
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
use App\Entity\BookingSeries;
/**
* What creating a series actually achieved. A series is materialised best-effort: days that were
* already taken are skipped and reported, rather than failing the whole series.
*/
final readonly class SeriesResult
{
/**
* @param list<Booking> $created
* @param array<string, string> $skipped Y-m-d => reason
*/
public function __construct(
public BookingSeries $series,
public array $created,
public array $skipped,
) {
}
public function createdCount(): int
{
return \count($this->created);
}
public function skippedCount(): int
{
return \count($this->skipped);
}
public function isCompletelyBlocked(): bool
{
return [] === $this->created;
}
/**
* @return list<string>
*/
public function skippedDates(): array
{
return array_map(
static fn (string $date): string => (new \DateTimeImmutable($date))->format('j.n.'),
array_keys($this->skipped),
);
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Booking;
/**
* A span of one day, as minutes from midnight, end-exclusive.
*
* Minutes rather than a TIME column on purpose: the end of an all-day booking is 24:00, which
* TIME cannot express, and half-open integer arithmetic makes the overlap test a plain
* comparison in both PHP and DQL.
*/
final readonly class TimeRange implements \Stringable
{
public const DAY_START = 0;
public const DAY_END = 1440;
private function __construct(
public int $startMinute,
public int $endMinute,
) {
if ($startMinute < self::DAY_START || $endMinute > self::DAY_END) {
throw new \InvalidArgumentException('Eine Buchung muss innerhalb eines Tages liegen.');
}
if ($startMinute >= $endMinute) {
throw new \InvalidArgumentException('Das Ende muss nach dem Beginn liegen.');
}
}
/**
* Named wholeDay() and not allDay() on purpose. Twig resolves `range.allDay` by looking for a
* method literally called `allDay` BEFORE it tries `isAllDay()`, and it does not care that
* the method is static -- so a factory of that name silently shadows the accessor and every
* template reads back a fresh all-day range, which is truthy. Keep the two names distinct.
*/
public static function wholeDay(): self
{
return new self(self::DAY_START, self::DAY_END);
}
public static function fromMinutes(int $startMinute, int $endMinute): self
{
return new self($startMinute, $endMinute);
}
/**
* Reads the clock time off two \DateTime values, ignoring their date part -- which is what
* the form's TimeType hands over.
*/
public static function fromTimes(\DateTimeInterface $start, \DateTimeInterface $end): self
{
return new self(self::minutesOf($start), self::minutesOf($end));
}
/**
* Parses the board's ?von=&bis= pair. Returns null for anything missing or malformed, so the
* caller can fall back to the whole day rather than 404 on a hand-edited URL.
*/
public static function parse(?string $start, ?string $end): ?self
{
$hasStart = null !== $start && '' !== $start;
$hasEnd = null !== $end && '' !== $end;
if (!$hasStart && !$hasEnd) {
return null;
}
// One-sided input is treated as open to that end of the day, so typing only a start time
// does something visible instead of silently resetting the board.
$startMinute = $hasStart ? self::parseClock($start) : self::DAY_START;
$endMinute = $hasEnd ? self::parseClock($end) : self::DAY_END;
if (null === $startMinute || null === $endMinute || $startMinute >= $endMinute) {
return null;
}
return new self($startMinute, $endMinute);
}
private static function parseClock(string $value): ?int
{
if (1 !== preg_match('/^([01]\d|2[0-4]):([0-5]\d)$/', $value, $matches)) {
return null;
}
$minutes = (int) $matches[1] * 60 + (int) $matches[2];
return $minutes > self::DAY_END ? null : $minutes;
}
public function isAllDay(): bool
{
return self::DAY_START === $this->startMinute && self::DAY_END === $this->endMinute;
}
/**
* Half-open overlap: 09:00-13:00 and 13:00-17:00 do NOT collide.
*/
public function overlaps(self $other): bool
{
return $this->startMinute < $other->endMinute && $other->startMinute < $this->endMinute;
}
public function equals(self $other): bool
{
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
}
public function start(): string
{
return self::format($this->startMinute);
}
public function end(): string
{
return self::format($this->endMinute);
}
public function __toString(): string
{
return $this->isAllDay() ? 'ganztägig' : $this->start().'–'.$this->end();
}
private static function minutesOf(\DateTimeInterface $time): int
{
return (int) $time->format('G') * 60 + (int) $time->format('i');
}
private static function format(int $minutes): string
{
return \sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
}
}
+162 -30
View File
@@ -9,14 +9,19 @@ use App\Booking\BoardPresenter;
use App\Booking\BookingManager;
use App\Booking\DateParam;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\SeriesResult;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Form\BookingRequest;
use App\Form\BookingType;
use App\Htmx\HxTrait;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -30,6 +35,7 @@ final class BookingController extends AbstractController
private readonly BoardPresenter $board,
private readonly BookingManager $bookings,
private readonly BookingRepository $repository,
private readonly BookingSeriesRepository $series,
) {
}
@@ -40,6 +46,7 @@ final class BookingController extends AbstractController
return $this->render('booking/index.html.twig', [
'bookings' => $this->repository->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
'series' => $this->series->findActiveForUser($this->currentUser(), $today),
]);
}
@@ -48,7 +55,7 @@ final class BookingController extends AbstractController
*
* On an invalid submit this just render()s the modal again -- Symfony turns that into a 422
* and the responseHandling entry in assets/app.js lets htmx swap it, so the modal replaces
* itself with the errors showing. On success the response carries nothing but the two
* itself with the errors showing. On success the response carries nothing but the
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
* fragment and the modal simply disappears.
*/
@@ -60,34 +67,36 @@ final class BookingController extends AbstractController
}
$selected = DateParam::parseOrToday($date);
$boardTime = DashboardController::timeOf($request);
$form = $this->createForm(BookingType::class, new BookingRequest($selected), [
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date]),
// Prefilled from whatever span the board is showing, so narrowing the board and then
// clicking a desk books exactly what was on screen.
$data = new BookingRequest($selected, $boardTime);
$form = $this->createForm(BookingType::class, $data, [
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$requested = $form->getData();
\assert($requested instanceof BookingRequest);
\assert($requested->date instanceof \DateTimeImmutable);
\assert($data->date instanceof \DateTimeImmutable);
try {
$this->bookings->book($this->currentUser(), $resource, $requested->date);
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
// Deliberately renders from data already in hand: the losing side of a race has
// a CLOSED entity manager at this point (see BookingManager::book), so this
// branch must not query.
$form->addError(new FormError($e->getMessage()));
$result = $data->repeats()
? $this->createSeries($data, $resource)
: null;
return $this->render('booking/_modal_create.html.twig', [
'form' => $form,
'resource' => $resource,
]);
if (null === $result) {
$this->bookings->book($this->currentUser(), $resource, $data->date, $data->timeRange());
$this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange()));
} else {
$this->flashSeriesResult($result, $resource);
}
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
return $this->invalidModal($form, $resource, $e->getMessage());
}
$this->addFlash('success', \sprintf('%s gebucht.', $resource->getLabel()));
return $this->boardResponse($requested->date);
return $this->boardResponse($data->date, $boardTime);
}
return $this->render('booking/_modal_create.html.twig', [
@@ -96,16 +105,50 @@ final class BookingController extends AbstractController
]);
}
/**
* Moves one occurrence, which is what an "override" of a series is: the other occurrences
* are untouched and this one is flagged as differing from its series.
*/
#[Route('/buchungen/{id}/bearbeiten', name: 'app_booking_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Booking $booking): Response
{
$this->denyUnlessOwn($booking);
$boardTime = DashboardController::timeOf($request);
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
$form = $this->createForm(BookingType::class, $data, [
'action' => $this->generateUrl('app_booking_edit', ['id' => $booking->getId()] + $this->timeParameters($boardTime)),
]);
// The day and any recurrence belong to the original booking; only the span is editable
// here, so a series is never silently rewritten by editing one of its occurrences.
$form->remove('date')->remove('repeat')->remove('weekdays')->remove('until');
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->bookings->reschedule($booking, $data->timeRange());
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
return $this->invalidModal($form, $booking->getResource(), $e->getMessage(), $booking);
}
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
return $this->boardResponse($booking->getDate(), $boardTime);
}
return $this->render('booking/_modal_edit.html.twig', [
'form' => $form,
'resource' => $booking->getResource(),
'booking' => $booking,
]);
}
#[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])]
public function release(Request $request, Booking $booking): Response
{
if (!$booking->belongsTo($this->currentUser())) {
throw $this->createAccessDeniedException('Fremde Buchungen können nicht freigegeben werden.');
}
if (!$this->isCsrfTokenValid('release-'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
}
$this->denyUnlessOwn($booking);
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
$date = $booking->getDate();
$label = $booking->getResource()->getLabel();
@@ -118,20 +161,109 @@ final class BookingController extends AbstractController
return $this->redirectToRoute('app_booking');
}
return $this->boardResponse($date);
return $this->boardResponse($date, DashboardController::timeOf($request));
}
/**
* Ends a series from today onwards. Past occurrences are history and stay put.
*/
#[Route('/serien/{id}/beenden', name: 'app_series_end', methods: ['POST'])]
public function endSeries(Request $request, BookingSeries $series): Response
{
if ($series->getUser()->getId() !== $this->currentUser()->getId()) {
throw $this->createAccessDeniedException('Fremde Serien können nicht beendet werden.');
}
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
$removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today'));
$this->addFlash('success', \sprintf('Serie beendet, %d Termine entfernt.', $removed));
return $this->redirectToRoute('app_booking');
}
private function createSeries(BookingRequest $data, Resource $resource): SeriesResult
{
\assert($data->date instanceof \DateTimeImmutable);
\assert($data->repeat instanceof \App\Booking\RecurrenceRule);
\assert($data->until instanceof \DateTimeImmutable);
return $this->bookings->bookSeries(new BookingSeries(
$this->currentUser(),
$resource,
$data->repeat,
$data->date,
$data->until,
$data->timeRange(),
$data->weekdays,
));
}
private function flashSeriesResult(SeriesResult $result, Resource $resource): void
{
if ($result->isCompletelyBlocked()) {
$this->addFlash('warning', \sprintf('%s ist an keinem Tag der Serie frei.', $resource->getLabel()));
return;
}
$message = \sprintf('%s: %d Termine gebucht.', $resource->getLabel(), $result->createdCount());
if ($result->skippedCount() > 0) {
$message .= \sprintf(' %d Tage waren belegt (%s).', $result->skippedCount(), implode(', ', $result->skippedDates()));
}
$this->addFlash($result->skippedCount() > 0 ? 'warning' : 'success', $message);
}
/**
* Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by
* itself, which is exactly what htmx needs to swap it.
*/
private function invalidModal(FormInterface $form, Resource $resource, string $message, ?Booking $booking = null): Response
{
$form->addError(new FormError($message));
return $this->render(
null === $booking ? 'booking/_modal_create.html.twig' : 'booking/_modal_edit.html.twig',
['form' => $form, 'resource' => $resource, 'booking' => $booking],
);
}
/**
* 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): Response
private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response
{
return $this->htmxOobResponse(
'dashboard/index.html.twig',
['calendar', 'floorplan', 'flashes'],
$this->board->context($date, $this->currentUser()),
$this->generateUrl('app_dashboard', ['tag' => $date->format('Y-m-d')]),
$this->board->context($date, $time, $this->currentUser()),
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)),
);
}
/**
* @return array<string, string>
*/
private function timeParameters(TimeRange $time): array
{
return $time->isAllDay() ? [] : ['von' => $time->start(), 'bis' => $time->end()];
}
private function denyUnlessOwn(Booking $booking): void
{
if (!$booking->belongsTo($this->currentUser())) {
throw $this->createAccessDeniedException('Fremde Buchungen können nicht geändert werden.');
}
}
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
{
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
}
}
}
+19 -6
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Booking\BoardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -13,7 +14,7 @@ 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.
* The booking board: a month calendar next to the floor plan for the selected day and span.
*/
final class DashboardController extends AbstractController
{
@@ -29,11 +30,12 @@ final class DashboardController extends AbstractController
{
$date = DateParam::parseOrToday($request->query->getString('tag'));
return $this->render('dashboard/index.html.twig', $this->board->context($date, $this->currentUser()));
return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser()));
}
/**
* Picking a day swaps the calendar and the floor plan in one response.
* 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
@@ -43,16 +45,27 @@ final class DashboardController extends AbstractController
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', ['tag' => $selected->format('Y-m-d')]);
return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time));
}
return $this->htmxOobResponse(
'dashboard/index.html.twig',
['calendar', 'floorplan'],
$this->board->context($selected, $this->currentUser()),
$this->generateUrl('app_dashboard', ['tag' => $selected->format('Y-m-d')]),
$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();
}
}
+81 -7
View File
@@ -4,17 +4,22 @@ declare(strict_types=1);
namespace App\Entity;
use App\Booking\TimeRange;
use App\Repository\BookingRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* One person holding one resource for one whole day.
* One person holding one resource for a span of one day.
*
* The unique constraint is the actual guard against double-booking -- checking first and then
* inserting is racy, so callers insert and catch UniqueConstraintViolationException instead.
* There is deliberately NO unique constraint on (resource, date) any more: since a booking
* covers a time range, the thing to prevent is an *overlap*, which no unique index can express
* and which MariaDB has no exclusion constraint for either. BookingManager serialises instead --
* it locks the resource row FOR UPDATE and checks for overlaps inside that transaction. The
* index below is what makes that check cheap.
*/
#[ORM\Entity(repositoryClass: BookingRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_booking_resource_date', columns: ['resource_id', 'date'])]
#[ORM\Index(name: 'idx_booking_resource_date', columns: ['resource_id', 'date'])]
#[ORM\Index(name: 'idx_booking_user_date', columns: ['user_id', 'date'])]
class Booking
{
#[ORM\Id]
@@ -33,15 +38,45 @@ class Booking
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $date;
/** Minutes from midnight, end-exclusive. All-day is 0 to 1440. */
#[ORM\Column]
private int $startMinute;
#[ORM\Column]
private int $endMinute;
/**
* The series this occurrence was materialised from, if any. SET NULL rather than CASCADE so
* that dropping a series never silently takes bookings with it -- ending a series removes
* its future occurrences explicitly, in BookingManager.
*/
#[ORM\ManyToOne(inversedBy: 'bookings')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?BookingSeries $series = null;
/** True once this occurrence's times were changed away from the series' own. */
#[ORM\Column]
private bool $overridden = false;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(User $user, Resource $resource, \DateTimeImmutable $date)
{
public function __construct(
User $user,
Resource $resource,
\DateTimeImmutable $date,
?TimeRange $time = null,
?BookingSeries $series = null,
) {
$time ??= TimeRange::wholeDay();
$this->user = $user;
$this->resource = $resource;
// Normalised to midnight so the unique constraint cannot be sidestepped by a time part.
// Normalised to midnight so a stray time component cannot shift which day this belongs to.
$this->date = $date->setTime(0, 0);
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
$this->series = $series;
$this->createdAt = new \DateTimeImmutable();
}
@@ -65,6 +100,40 @@ class Booking
return $this->date;
}
public function getTimeRange(): TimeRange
{
return TimeRange::fromMinutes($this->startMinute, $this->endMinute);
}
/**
* Changing the times of an occurrence marks it as an override, so the UI can show that it
* no longer matches its series.
*/
public function changeTime(TimeRange $time): void
{
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
if (null !== $this->series) {
$this->overridden = !$this->series->getTimeRange()->equals($time);
}
}
public function getSeries(): ?BookingSeries
{
return $this->series;
}
public function isPartOfSeries(): bool
{
return null !== $this->series;
}
public function isOverridden(): bool
{
return $this->overridden;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
@@ -74,4 +143,9 @@ class Booking
{
return $this->user->getId() === $user->getId();
}
public function overlaps(TimeRange $time): bool
{
return $this->getTimeRange()->overlaps($time);
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Booking\RecurrenceRule;
use App\Booking\TimeRange;
use App\Repository\BookingSeriesRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* A repeating reservation.
*
* The series is a description, not a source of truth for availability: creating one writes real
* Booking rows for every occurrence up to endDate. That is what keeps the conflict check
* database-backed -- a rule that is only expanded at render time is invisible to every other
* booking attempt.
*/
#[ORM\Entity(repositoryClass: BookingSeriesRepository::class)]
class BookingSeries
{
/** A series may not run further out than this; desks are not booked years ahead. */
public const MAX_MONTHS = 12;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
private Resource $resource;
#[ORM\Column(enumType: RecurrenceRule::class)]
private RecurrenceRule $rule;
/**
* ISO weekdays (1 = Monday) the series falls on. Empty for a DAILY rule, which means
* every calendar day.
*
* @var list<int>
*/
#[ORM\Column]
private array $weekdays = [];
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $startDate;
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $endDate;
#[ORM\Column]
private int $startMinute;
#[ORM\Column]
private int $endMinute;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
/** @var Collection<int, Booking> */
#[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'series')]
private Collection $bookings;
/**
* @param list<int> $weekdays
*/
public function __construct(
User $user,
Resource $resource,
RecurrenceRule $rule,
\DateTimeImmutable $startDate,
\DateTimeImmutable $endDate,
TimeRange $time,
array $weekdays = [],
) {
$this->user = $user;
$this->resource = $resource;
$this->rule = $rule;
$this->startDate = $startDate->setTime(0, 0);
$this->endDate = $endDate->setTime(0, 0);
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
$this->weekdays = RecurrenceRule::WEEKLY === $rule ? array_values(array_unique($weekdays)) : [];
$this->createdAt = new \DateTimeImmutable();
$this->bookings = new ArrayCollection();
}
/**
* Every day this series falls on, in order.
*
* @return list<\DateTimeImmutable>
*/
public function occurrences(): array
{
$dates = [];
for ($day = $this->startDate; $day <= $this->endDate; $day = $day->modify('+1 day')) {
if (RecurrenceRule::WEEKLY === $this->rule && !\in_array((int) $day->format('N'), $this->weekdays, true)) {
continue;
}
$dates[] = $day;
}
return $dates;
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function getResource(): Resource
{
return $this->resource;
}
public function getRule(): RecurrenceRule
{
return $this->rule;
}
/**
* @return list<int>
*/
public function getWeekdays(): array
{
return $this->weekdays;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getEndDate(): \DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate->setTime(0, 0);
}
public function getTimeRange(): TimeRange
{
return TimeRange::fromMinutes($this->startMinute, $this->endMinute);
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
/**
* @return Collection<int, Booking>
*/
public function getBookings(): Collection
{
return $this->bookings;
}
public function describe(): string
{
$description = $this->rule->label();
if (RecurrenceRule::WEEKLY === $this->rule && [] !== $this->weekdays) {
$names = ['', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Sort the numbers, not the names -- alphabetical would read "Di, Do, Fr, Mi, Mo".
$weekdays = $this->weekdays;
sort($weekdays);
$description .= ' ('.implode(', ', array_map(static fn (int $day): string => $names[$day], $weekdays)).')';
}
return $description.', '.$this->getTimeRange();
}
}
+105 -7
View File
@@ -4,18 +4,116 @@ declare(strict_types=1);
namespace App\Form;
use App\Booking\RecurrenceRule;
use App\Booking\TimeRange;
use App\Entity\BookingSeries;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* What the booking modal edits. The resource is fixed by the URL -- only the day is adjustable,
* because the modal is opened by clicking a resource on the floor plan.
* What the booking modal edits. The resource is fixed by the URL -- it is chosen by clicking a
* shape on the floor plan, not in the form.
*/
final class BookingRequest
{
public function __construct(
#[Assert\NotNull(message: 'Bitte einen Tag wählen.')]
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
public ?\DateTimeImmutable $date = null,
) {
#[Assert\NotNull(message: 'Bitte einen Tag wählen.')]
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
public ?\DateTimeImmutable $date = null;
public bool $allDay = true;
public ?\DateTimeImmutable $startTime = null;
public ?\DateTimeImmutable $endTime = null;
public ?RecurrenceRule $repeat = null;
/** @var list<int> ISO weekdays, 1 = Monday */
public array $weekdays = [];
public ?\DateTimeImmutable $until = null;
public function __construct(?\DateTimeImmutable $date = null, ?TimeRange $time = null)
{
$this->date = $date;
if (null !== $time && !$time->isAllDay()) {
$this->allDay = false;
$this->startTime = self::timeOf($time->startMinute);
$this->endTime = self::timeOf($time->endMinute);
}
}
public function timeRange(): TimeRange
{
if ($this->allDay || null === $this->startTime || null === $this->endTime) {
return TimeRange::wholeDay();
}
return TimeRange::fromTimes($this->startTime, $this->endTime);
}
public function repeats(): bool
{
return null !== $this->repeat;
}
/**
* Cross-field rules. These live here rather than in the form type so they hold for any caller,
* and so the messages land on the field the user has to fix.
*/
#[Assert\Callback]
public function validate(ExecutionContextInterface $context): void
{
if (!$this->allDay) {
foreach (['startTime' => $this->startTime, 'endTime' => $this->endTime] as $field => $value) {
if (null === $value) {
$context->buildViolation('Bitte eine Uhrzeit angeben.')->atPath($field)->addViolation();
}
}
if (null !== $this->startTime && null !== $this->endTime
&& self::minutesOf($this->endTime) <= self::minutesOf($this->startTime)) {
$context->buildViolation('Das Ende muss nach dem Beginn liegen.')->atPath('endTime')->addViolation();
}
}
if (!$this->repeats()) {
return;
}
if (RecurrenceRule::WEEKLY === $this->repeat && [] === $this->weekdays) {
$context->buildViolation('Bitte mindestens einen Wochentag wählen.')->atPath('weekdays')->addViolation();
}
if (null === $this->until) {
$context->buildViolation('Bitte ein Enddatum für die Serie angeben.')->atPath('until')->addViolation();
return;
}
if (null !== $this->date && $this->until < $this->date) {
$context->buildViolation('Das Serienende muss nach dem Beginn liegen.')->atPath('until')->addViolation();
return;
}
$latest = ($this->date ?? new \DateTimeImmutable('today'))->modify(\sprintf('+%d months', BookingSeries::MAX_MONTHS));
if ($this->until > $latest) {
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate laufen.', BookingSeries::MAX_MONTHS))
->atPath('until')
->addViolation();
}
}
private static function timeOf(int $minutes): \DateTimeImmutable
{
// 24:00 has no representation as a time-of-day, so an all-day end never reaches here.
return (new \DateTimeImmutable('today'))->setTime(intdiv($minutes, 60), $minutes % 60);
}
private static function minutesOf(\DateTimeInterface $time): int
{
return (int) $time->format('G') * 60 + (int) $time->format('i');
}
}
+47 -6
View File
@@ -4,8 +4,13 @@ declare(strict_types=1);
namespace App\Form;
use App\Booking\RecurrenceRule;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TimeType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -13,12 +18,48 @@ final class BookingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('date', DateType::class, [
'label' => 'Tag',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'html5' => true,
]);
$builder
->add('date', DateType::class, [
'label' => 'Tag',
'widget' => 'single_text',
'input' => 'datetime_immutable',
])
->add('allDay', CheckboxType::class, [
'label' => 'Ganzer Tag',
'required' => false,
])
->add('startTime', TimeType::class, [
'label' => 'Von',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('endTime', TimeType::class, [
'label' => 'Bis',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('repeat', EnumType::class, [
'label' => 'Wiederholung',
'class' => RecurrenceRule::class,
'required' => false,
'placeholder' => 'Einmalig',
'choice_label' => static fn (RecurrenceRule $rule): string => $rule->label(),
])
->add('weekdays', ChoiceType::class, [
'label' => 'Wochentage',
'choices' => ['Mo' => 1, 'Di' => 2, 'Mi' => 3, 'Do' => 4, 'Fr' => 5, 'Sa' => 6, 'So' => 7],
'multiple' => true,
'expanded' => true,
'required' => false,
])
->add('until', DateType::class, [
'label' => 'Serie bis',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
+99 -29
View File
@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace App\Repository;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -21,24 +25,80 @@ class BookingRepository extends ServiceEntityRepository
}
/**
* Everything booked on one day, for colouring the floor plan.
* Everything on one day that overlaps the given span, for colouring the floor plan.
*
* Team view is in scope, so the booking holder's name is shown on occupied resources --
* hence the joins are eager, or the plan would fire a query per drawn element.
*
* @return list<Booking>
*/
public function findForDate(\DateTimeImmutable $date): array
public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null): array
{
return $this->createQueryBuilder('b')
$qb = $this->createQueryBuilder('b')
->addSelect('u', 'r')
->join('b.user', 'u')
->join('b.resource', 'r')
->andWhere('b.date = :date')
->setParameter('date', $date->setTime(0, 0))
->orderBy('r.label', 'ASC')
->getQuery()
->getResult();
->addOrderBy('b.startMinute', 'ASC');
if (null !== $within) {
self::restrictToOverlap($qb, $within);
}
return $qb->getQuery()->getResult();
}
/**
* The availability check behind every booking. Only meaningful while the resource row is
* locked -- see BookingManager, which is the only caller.
*
* @return list<Booking>
*/
public function findOverlapping(Resource $resource, \DateTimeImmutable $date, TimeRange $time): array
{
$qb = $this->createQueryBuilder('b')
->andWhere('b.resource = :resource')
->andWhere('b.date = :date')
->setParameter('resource', $resource)
->setParameter('date', $date->setTime(0, 0));
self::restrictToOverlap($qb, $time);
return $qb->getQuery()->getResult();
}
/**
* One person may not hold two resources of the same kind at the same time -- but they may
* well hold a desk in the morning and a different one in the afternoon, so this is an
* overlap test rather than a per-day test.
*/
public function findOverlappingForUser(
User $user,
\DateTimeImmutable $date,
TimeRange $time,
ResourceType $type,
?Booking $ignore = null,
): ?Booking {
$qb = $this->createQueryBuilder('b')
->addSelect('r')
->join('b.resource', 'r')
->andWhere('b.user = :user')
->andWhere('b.date = :date')
->andWhere('r.type = :type')
->setParameter('user', $user)
->setParameter('date', $date->setTime(0, 0))
->setParameter('type', $type)
->setMaxResults(1);
self::restrictToOverlap($qb, $time);
if (null !== $ignore && null !== $ignore->getId()) {
$qb->andWhere('b.id != :ignore')->setParameter('ignore', $ignore->getId());
}
return $qb->getQuery()->getOneOrNullResult();
}
/**
@@ -49,14 +109,16 @@ class BookingRepository extends ServiceEntityRepository
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array
{
return $this->createQueryBuilder('b')
->addSelect('r')
->addSelect('r', 's')
->join('b.resource', 'r')
->leftJoin('b.series', 's')
->andWhere('b.user = :user')
->andWhere('b.date BETWEEN :from AND :to')
->setParameter('user', $user)
->setParameter('from', $from->setTime(0, 0))
->setParameter('to', $to->setTime(0, 0))
->orderBy('b.date', 'ASC')
->addOrderBy('b.startMinute', 'ASC')
->addOrderBy('r.label', 'ASC')
->getQuery()
->getResult();
@@ -64,50 +126,58 @@ class BookingRepository extends ServiceEntityRepository
/**
* Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as
* occupancy, and the same query team view reads.
* 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.
*
* @return array<string, int>
*/
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to, ?TimeRange $within = null): array
{
$rows = $this->createQueryBuilder('b')
$qb = $this->createQueryBuilder('b')
->select('b.date AS date', 'COUNT(b.id) AS total')
->andWhere('b.date BETWEEN :from AND :to')
->setParameter('from', $from->setTime(0, 0))
->setParameter('to', $to->setTime(0, 0))
->groupBy('b.date')
->getQuery()
->getResult();
->groupBy('b.date');
if (null !== $within) {
self::restrictToOverlap($qb, $within);
}
$counts = [];
foreach ($rows as $row) {
foreach ($qb->getQuery()->getResult() as $row) {
$counts[$row['date']->format('Y-m-d')] = (int) $row['total'];
}
return $counts;
}
public function findOneForUserAndDate(User $user, \DateTimeImmutable $date): ?Booking
/**
* The occurrences an "end this series" would remove.
*
* @return list<Booking>
*/
public function findForSeriesFrom(BookingSeries $series, \DateTimeImmutable $from): array
{
return $this->findOneBy(['user' => $user, 'date' => $date->setTime(0, 0)]);
return $this->createQueryBuilder('b')
->andWhere('b.series = :series')
->andWhere('b.date >= :from')
->setParameter('series', $series)
->setParameter('from', $from->setTime(0, 0))
->orderBy('b.date', 'ASC')
->getQuery()
->getResult();
}
/**
* One person may hold at most one desk and one parking spot per day, so this is what the
* booking path checks before inserting.
* Half-open overlap, the same test as TimeRange::overlaps(): two spans collide unless one
* ends at or before the other begins. Written once here so the SQL and the PHP cannot drift.
*/
public function findOneForUserDateAndType(User $user, \DateTimeImmutable $date, ResourceType $type): ?Booking
private static function restrictToOverlap(QueryBuilder $qb, TimeRange $time): void
{
return $this->createQueryBuilder('b')
->addSelect('r')
->join('b.resource', 'r')
->andWhere('b.user = :user')
->andWhere('b.date = :date')
->andWhere('r.type = :type')
->setParameter('user', $user)
->setParameter('date', $date->setTime(0, 0))
->setParameter('type', $type)
->getQuery()
->getOneOrNullResult();
$qb->andWhere('b.startMinute < :rangeEnd')
->andWhere(':rangeStart < b.endMinute')
->setParameter('rangeStart', $time->startMinute)
->setParameter('rangeEnd', $time->endMinute);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingSeries;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BookingSeries>
*/
class BookingSeriesRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BookingSeries::class);
}
/**
* @return list<BookingSeries>
*/
public function findActiveForUser(User $user, \DateTimeImmutable $on): array
{
return $this->createQueryBuilder('s')
->addSelect('r')
->join('s.resource', 'r')
->andWhere('s.user = :user')
->andWhere('s.endDate >= :on')
->setParameter('user', $user)
->setParameter('on', $on->setTime(0, 0))
->orderBy('s.startDate', 'ASC')
->getQuery()
->getResult();
}
}
+41
View File
@@ -0,0 +1,41 @@
{#
The shared body of both booking modals. Which fields exist is decided by the controller
(the edit modal removes date and the recurrence fields), so everything here is guarded with
`is defined` rather than duplicated into two templates.
Show/hide is Stimulus, not htmx: toggling a fieldset needs no server round trip, and the
house rule keeps purely client-side behaviour out of htmx.
#}
<div {{ stimulus_controller('bookingform') }}>
{% if form.date is defined %}
{{ form_row(form.date) }}
{% endif %}
<div class="pt-4">
{{ form_row(form.allDay, { attr: { 'data-action': 'bookingform#toggleTimes' } }) }}
<div class="grid grid-cols-2 gap-4 pt-2 {{ form.allDay.vars.data ? 'hidden' : '' }}"
{{ stimulus_target('bookingform', 'times') }}>
{{ form_row(form.startTime) }}
{{ form_row(form.endTime) }}
</div>
</div>
{% if form.repeat is defined %}
<div class="pt-4 border-t border-gray-200 mt-4">
{{ form_row(form.repeat, { attr: { 'data-action': 'bookingform#toggleRepeat' } }) }}
<div class="pt-2 {{ form.repeat.vars.data is null ? 'hidden' : '' }}"
{{ stimulus_target('bookingform', 'repeat') }}>
<div class="{{ form.repeat.vars.data is null or form.repeat.vars.data.value != 'weekly' ? 'hidden' : '' }}"
{{ stimulus_target('bookingform', 'weekdays') }}>
{{ form_row(form.weekdays, { row_attr: { class: 'pb-2' } }) }}
</div>
{{ form_row(form.until) }}
<p class="pt-1 text-xs text-gray-500">
Belegte Tage werden übersprungen und danach aufgelistet.
</p>
</div>
</div>
{% endif %}
</div>
+1 -1
View File
@@ -26,7 +26,7 @@
{{ resource.type.label }} · {{ resource.label }}
</p>
{{ form_row(form.date) }}
{% include 'booking/_fields.html.twig' %}
<div class="flex justify-end gap-2 pt-6">
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
+56
View File
@@ -0,0 +1,56 @@
{#
Editing one occurrence. For a booking that belongs to a series this is the "override": only
this day moves, the rest of the series is untouched and the occurrence is flagged as
differing from it.
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
or inline CSRF tokens -- clicking your own desk always just opens this.
#}
{% extends 'htmx_modal.html.twig' %}
{% block title %}{{ resource.label }} ändern{% endblock %}
{% block content %}
{{ form_start(form, { attr: {
'hx-post': form.vars.action,
'hx-target': '#htmx-modal',
'hx-swap': 'outerHTML',
} }) }}
{{ form_errors(form) }}
<p class="pb-4 text-sm text-gray-500">
{{ resource.type.label }} · {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
</p>
{% if booking.partOfSeries %}
<div class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm">
Teil einer Serie: {{ booking.series.describe }}.
<span class="text-gray-500">Eine Änderung hier gilt nur für diesen Tag.</span>
</div>
{% endif %}
{% include 'booking/_fields.html.twig' %}
<div class="flex flex-wrap justify-between gap-2 pt-6">
{# type="button" with its own hx-post: htmx issues the release request directly, so
this never submits the surrounding edit form and no nested <form> is needed. #}
<button type="button" class="button button--small button--warning"
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
hx-confirm="{{ resource.label }} am {{ booking.date|date('d.m.Y') }} freigeben?"
hx-target="#htmx-modal"
hx-swap="outerHTML">
Freigeben
</button>
<div class="flex gap-2">
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
<button type="submit" class="button button--primary">
Speichern
</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}
+33 -2
View File
@@ -6,20 +6,51 @@
<h1>Meine Buchungen</h1>
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
{% if series is not empty %}
<h2 class="pt-8">Serien</h2>
<ul class="divide-y divide-gray-200 border-y border-gray-200">
{% for entry in series %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">{{ entry.resource.label }}</div>
<div class="text-sm text-gray-500">
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
</div>
</div>
{# Ends the series from today on; past occurrences are history and stay put. #}
<form method="post" action="{{ path('app_series_end', { id: entry.id }) }}"
onsubmit="return confirm('Serie ab heute beenden? Alle künftigen Termine werden entfernt.')">
<input type="hidden" name="_token" value="{{ csrf_token('end-series-' ~ entry.id) }}">
<button type="submit" class="button button--small button--neutral">Serie beenden</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<h2 class="pt-8">Termine</h2>
{% if bookings is empty %}
<p class="mt-8 text-sm text-gray-500">
<p class="text-sm text-gray-500">
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
</p>
{% else %}
<ul class="mt-8 divide-y divide-gray-200 border-y border-gray-200">
<ul class="divide-y divide-gray-200 border-y border-gray-200">
{% for booking in bookings %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
<span class="font-normal text-gray-500">· {{ booking.timeRange }}</span>
</div>
<div class="text-sm text-gray-500">
{{ booking.resource.type.label }} · {{ booking.resource.label }}
{% if booking.partOfSeries %}
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
{% endif %}
{% if booking.overridden %}
<span class="badge bg-amber-100 text-amber-800 ml-1">abweichend</span>
{% endif %}
</div>
</div>
<div class="flex items-center gap-3">
+6 -4
View File
@@ -1,13 +1,15 @@
{# Server-rendered month grid. No calendar library: prev/next and day selection are plain hx-get
requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #}
{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
{# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex items-center justify-between pb-4">
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Voriger Monat"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }) }}"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button>
@@ -17,7 +19,7 @@
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
aria-label="Nächster Monat"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }) }}"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button>
@@ -47,7 +49,7 @@
<button type="button"
class="{{ classes }}"
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }) }}"
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none">
<span>{{ day.date|date('j') }}</span>
{# Team view: the occupancy count is the whole point of showing the month. #}
@@ -66,6 +68,6 @@
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
</div>
<div>Zahl = belegte Plätze an dem Tag</div>
<div>Zahl = belegte Plätze{{ selected_time.allDay ? ' an dem Tag' : ' zwischen ' ~ selected_time.start ~ ' und ' ~ selected_time.end }}</div>
</div>
</div>
+44 -18
View File
@@ -3,8 +3,14 @@
is the key that ties a database row to a shape here. A resource whose id is missing from the
map is simply not drawn (and vice versa), which is why app:seed and this map have to agree.
Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day,
so free/booked/yours is decided in Twig.
Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day
and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the
span in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for
an all-day one.
Clicking a shape always opens a modal: the create modal when it is free, the edit modal when
it is yours (which is where changing the times and releasing it live). Nothing else is
clickable, so the SVG carries no confirm dialogs or inline tokens.
#}
{% set desk_plan = {
'desk-01': { x: 50, y: 70 }, 'desk-02': { x: 190, y: 70 }, 'desk-03': { x: 330, y: 70 },
@@ -16,47 +22,65 @@
'parking-01': { x: 540, y: 60 }, 'parking-02': { x: 540, y: 125 }, 'parking-03': { x: 540, y: 190 },
'parking-04': { x: 540, y: 255 }, 'parking-05': { x: 540, y: 320 }, 'parking-06': { x: 540, y: 385 }
} %}
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %}
{% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %}
{% set mine = own is not null %}
{% macro spot(resource, geometry, width, height, booking, selected_date, is_past) %}
{% set mine = booking is not null and booking.user.id == app.user.id %}
{% if mine %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% elseif booking is not null %}
{% elseif bookings is not empty %}
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
{% else %}
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
{% endif %}
{% set interactive = not is_past and (mine or booking is null) %}
{% set interactive = not is_past and (mine or bookings is empty) %}
{# What the second line of the shape says. Several bookings can overlap the board's span once
part-day bookings exist, so the shape counts them and the tooltip spells them out. #}
{% if mine %}
{% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %}
{% elseif bookings|length > 1 %}
{% set caption = bookings|length ~ ' Buchungen' %}
{% elseif bookings is not empty %}
{% set caption = bookings|first.user.name %}
{% else %}
{% set caption = null %}
{% endif %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
hx-confirm="{{ resource.label }} wieder freigeben?"
hx-swap="none"
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- elseif interactive %}
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx. #}
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }) }}"
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<title>
{{- resource.label }}{% if booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%}
{{- resource.label }}
{%- if bookings is empty %} — frei
{%- else %}
{%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
{%- endif -%}
</title>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (booking ? height / 2 - 4 : height / 2 + 4) }}"
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
{% if booking %}
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ mine ? 'du' : booking.user.name }}</text>
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
{% endif %}
</g>
{% endmacro %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-4">
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-2">
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
@@ -65,6 +89,8 @@
</div>
</div>
{% include 'dashboard/_timerange.html.twig' %}
{% if is_past %}
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
{% endif %}
@@ -79,14 +105,14 @@
{% for resource in desks %}
{% if resource.svgElementId and desk_plan[resource.svgElementId] is defined %}
{{ _self.spot(resource, desk_plan[resource.svgElementId], 120, 70,
bookings_by_resource[resource.id]|default(null), selected_date, is_past) }}
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
{% for resource in parking %}
{% if resource.svgElementId and parking_plan[resource.svgElementId] is defined %}
{{ _self.spot(resource, parking_plan[resource.svgElementId], 230, 50,
bookings_by_resource[resource.id]|default(null), selected_date, is_past) }}
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
</svg>
+42
View File
@@ -0,0 +1,42 @@
{#
The span the whole board is relative to. Free/belegt has no answer without it once bookings
can cover part of a day, so this drives the plan colours AND the calendar occupancy counts.
Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the
same single round trip as changing the day. hx-include pulls in the sibling field, because
a span needs both ends to mean anything.
#}
{# An all-day board leaves both fields empty: 24:00 has no representation in <input type="time">,
and an empty pair is exactly what TimeRange::parse() reads back as "the whole day". #}
{% set day = selected_date|date('Y-m-d') %}
<div class="flex flex-wrap items-end gap-3 pb-4" id="board-timerange">
<div>
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-include="#board-bis"
hx-trigger="change"
hx-swap="none">
</div>
<div>
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-include="#board-von"
hx-trigger="change"
hx-swap="none">
</div>
{% if not selected_time.allDay %}
<button type="button" class="button button--small button--neutral"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-swap="none">
Ganzer Tag
</button>
{% else %}
<span class="text-xs text-gray-500 pb-2">ganzer Tag</span>
{% endif %}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
{% block content %}
<h1>Buchen</h1>
<p class="subline">Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.</p>
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.</p>
<div class="mt-8 grid gap-8 xl:grid-cols-3">
<div class="xl:col-span-1">
+300
View File
@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\AlreadyHoldingResourceException;
use App\Booking\BookingManager;
use App\Booking\RecurrenceRule;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The double-booking guard now lives here rather than in a unique index, so this is where it is
* tested. BookingManager opens its own transactions (it locks the resource row FOR UPDATE), so
* these tests cannot wrap themselves in one -- a rollback would fight the manager's commits.
* They clean up explicitly instead.
*/
class BookingManagerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private BookingManager $manager;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->manager = self::getContainer()->get(BookingManager::class);
$this->wipe();
}
protected function tearDown(): void
{
$this->wipe();
parent::tearDown();
}
public function testAFreeResourceIsBooked(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
self::assertNotNull($booking->getId());
self::assertSame('09:00–13:00', (string) $booking->getTimeRange());
}
public function testAnOverlappingSpanOnTheSameResourceIsRefused(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
$this->expectException(ResourceAlreadyBookedException::class);
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(660, 900));
}
public function testATouchingSpanOnTheSameResourceIsAllowed(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
$second = $this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020));
self::assertNotNull($second->getId());
}
public function testOnePersonMayNotHoldTwoDesksAtOnce(): void
{
$user = $this->user('[email protected]');
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$this->expectException(AlreadyHoldingResourceException::class);
$this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(600, 720));
}
public function testOnePersonMayHoldTwoDesksAtDifferentTimes(): void
{
$user = $this->user('[email protected]');
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$afternoon = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(780, 1020));
self::assertNotNull($afternoon->getId());
}
public function testADeskAndAParkingSpotAtTheSameTimeAreBothAllowed(): void
{
$user = $this->user('[email protected]');
$time = TimeRange::fromMinutes(540, 780);
$this->manager->book($user, $this->resource('D1'), $this->date(), $time);
$parking = $this->manager->book($user, $this->resource('P1', ResourceType::PARKING), $this->date(), $time);
self::assertNotNull($parking->getId());
}
public function testASeriesMaterialisesOneBookingPerOccurrence(): void
{
// 2026-10-05 is a Monday.
$series = new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-30'),
TimeRange::fromMinutes(540, 720),
[1, 4],
);
$result = $this->manager->bookSeries($series);
self::assertSame(8, $result->createdCount());
self::assertSame(0, $result->skippedCount());
foreach ($result->created as $booking) {
self::assertContains((int) $booking->getDate()->format('N'), [1, 4]);
self::assertSame($series, $booking->getSeries());
}
}
public function testADailySeriesCoversEveryDay(): void
{
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::DAILY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-11'),
TimeRange::fromMinutes(540, 720),
));
self::assertSame(7, $result->createdCount());
}
/**
* The point of materialising: a taken day is reported rather than failing the whole series.
*/
public function testASeriesSkipsDaysThatAreAlreadyTaken(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-08'), TimeRange::fromMinutes(600, 660));
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$desk,
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-30'),
TimeRange::fromMinutes(540, 720),
[1, 4],
));
self::assertSame(7, $result->createdCount());
self::assertSame(['8.10.'], $result->skippedDates());
self::assertFalse($result->isCompletelyBlocked());
}
public function testASeriesThatCollidesEverywhereReportsItself(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$desk,
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-09'),
TimeRange::fromMinutes(540, 720),
[1],
));
self::assertTrue($result->isCompletelyBlocked());
self::assertSame(0, $result->createdCount());
}
public function testReschedulingAnOccurrenceMarksItAsAnOverride(): void
{
$series = $this->weeklySeries();
$occurrence = $this->manager->bookSeries($series)->created[0];
$this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960));
self::assertTrue($occurrence->isOverridden());
self::assertSame('14:00–16:00', (string) $occurrence->getTimeRange());
}
public function testReschedulingBackOntoTheSeriesTimesClearsTheOverride(): void
{
$series = $this->weeklySeries();
$occurrence = $this->manager->bookSeries($series)->created[0];
$this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960));
$this->manager->reschedule($occurrence, $series->getTimeRange());
self::assertFalse($occurrence->isOverridden());
}
public function testReschedulingIntoSomebodyElsesSpanIsRefused(): void
{
$desk = $this->resource('D1');
$mine = $this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020));
$this->expectException(ResourceAlreadyBookedException::class);
$this->manager->reschedule($mine, TimeRange::fromMinutes(540, 900));
}
/**
* Rescheduling must not trip over the booking it is moving -- that would make every edit
* that keeps any part of its own span impossible.
*/
public function testAnOccurrenceCanBeShrunkWithoutCollidingWithItself(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
self::assertSame('10:00–12:00', (string) $booking->getTimeRange());
}
public function testEndingASeriesRemovesItsRemainingOccurrences(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$id = $series->getId();
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
self::assertSame(8, $removed);
self::assertSame([], $this->em->getRepository(Booking::class)->findBy(['series' => $id]));
// Nothing of the series is left, so the series row goes too -- note $series has no id
// any more at this point, which is why the lookup goes through the captured one.
self::assertNull($this->em->getRepository(BookingSeries::class)->find($id));
}
public function testEndingASeriesPartWayThroughKeepsThePastAndShortensIt(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-15'));
self::assertSame(5, $removed);
self::assertCount(3, $this->em->getRepository(Booking::class)->findBy(['series' => $series]));
self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d'));
}
private function weeklySeries(): BookingSeries
{
return new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-30'),
TimeRange::fromMinutes(540, 720),
[1, 4],
);
}
private function date(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-10-01');
}
private function user(string $email): User
{
$user = new User($email, 'Test');
$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, strtolower($label));
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
private function wipe(): void
{
$connection = $this->em->getConnection();
$connection->executeStatement('DELETE FROM booking');
$connection->executeStatement('DELETE FROM booking_series');
$connection->executeStatement('DELETE FROM resource');
$connection->executeStatement('DELETE FROM `user`');
$this->em->clear();
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\TimeRange;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
class TimeRangeTest extends TestCase
{
public function testAWholeDayRunsFromMidnightToMidnight(): void
{
$range = TimeRange::wholeDay();
self::assertTrue($range->isAllDay());
self::assertSame(0, $range->startMinute);
self::assertSame(1440, $range->endMinute);
}
public function testTheEndMustComeAfterTheStart(): void
{
$this->expectException(\InvalidArgumentException::class);
TimeRange::fromMinutes(13 * 60, 9 * 60);
}
public function testARangeCannotLeaveTheDay(): void
{
$this->expectException(\InvalidArgumentException::class);
TimeRange::fromMinutes(0, 1441);
}
public function testTouchingRangesDoNotOverlap(): void
{
$morning = TimeRange::fromMinutes(9 * 60, 13 * 60);
$afternoon = TimeRange::fromMinutes(13 * 60, 17 * 60);
// Half-open, so handing a desk over at 13:00 is not a collision.
self::assertFalse($morning->overlaps($afternoon));
self::assertFalse($afternoon->overlaps($morning));
}
public function testPartlyCoveringRangesOverlapBothWays(): void
{
$a = TimeRange::fromMinutes(9 * 60, 13 * 60);
$b = TimeRange::fromMinutes(12 * 60, 17 * 60);
self::assertTrue($a->overlaps($b));
self::assertTrue($b->overlaps($a));
}
public function testAWholeDayOverlapsEverything(): void
{
self::assertTrue(TimeRange::wholeDay()->overlaps(TimeRange::fromMinutes(0, 15)));
}
#[DataProvider('urlPairs')]
public function testParsingTheBoardsQueryParameters(?string $from, ?string $to, ?string $expected): void
{
$range = TimeRange::parse($from, $to);
self::assertSame($expected, null === $range ? null : $range->start().'-'.$range->end());
}
/**
* @return iterable<string, array{?string, ?string, ?string}>
*/
public static function urlPairs(): iterable
{
yield 'both given' => ['09:00', '13:00', '09:00-13:00'];
yield 'only a start is open to the end of the day' => ['09:00', null, '09:00-24:00'];
yield 'only an end is open from midnight' => [null, '13:00', '00:00-13:00'];
yield 'neither means the whole day, i.e. no filter' => [null, null, null];
yield 'empty strings are the same as absent' => ['', '', null];
yield 'reversed is rejected' => ['13:00', '09:00', null];
yield 'equal is rejected' => ['09:00', '09:00', null];
yield 'nonsense is rejected' => ['halb zehn', '13:00', null];
yield 'out of range is rejected' => ['25:00', '26:00', null];
}
/**
* Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by
* looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily
* calls a static one. While the factory was called allDay(), every template read back a
* freshly built all-day range -- always truthy -- so the whole board silently ignored the
* selected span. Renaming the factory to wholeDay() is what fixes it; this keeps it fixed.
*/
public function testTheAllDayAccessorIsNotShadowedInTwig(): void
{
$twig = new Environment(new ArrayLoader(['t' => '{{ range.allDay ? "yes" : "no" }}']));
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()]));
}
}
+225 -12
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Controller;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
@@ -71,7 +72,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
$response = $this->submitBookingModal($desk, '2026-10-01');
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
// The modal is gone from the response, which is what closes it: an out-of-band-only
@@ -89,7 +90,7 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', '2020-01-01');
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
@@ -107,7 +108,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
$response = $this->submitBookingModal($desk, '2026-10-01');
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
@@ -121,7 +122,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', '2026-10-01');
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01');
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('bereits D1 gebucht', (string) $response->getContent());
@@ -135,7 +136,7 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01', '2026-10-01');
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01');
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
@@ -183,9 +184,9 @@ class BookingControllerTest extends WebTestCase
$this->client->loginUser($anna);
// Read the token off the rendered plan rather than minting one: outside a request there
// is no session for the token storage to live in, and this is the real round trip anyway.
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
// opens that modal, and the token is rendered there.
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $id), server: ['HTTP_HX-Request' => 'true']);
$vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals');
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
@@ -196,17 +197,229 @@ class BookingControllerTest extends WebTestCase
self::assertNull($this->em->getRepository(Booking::class)->find($id));
}
private function submitBookingModal(Resource $resource, string $routeDate, string $submittedDate): Response
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
{
$anna = $this->user('[email protected]');
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
$this->em->persist($booking);
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId())));
}
public function testBookingPartOfADay(): void
{
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$response = $this->submitBookingModal($desk, '2026-10-01', [
'startTime' => '09:00',
'endTime' => '13:00',
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
self::assertNotNull($booking);
self::assertSame('09:00–13:00', (string) $booking->getTimeRange());
}
public function testABookingDefaultsToTheWholeDay(): void
{
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$this->submitBookingModal($desk, '2026-10-01');
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
self::assertNotNull($booking);
self::assertTrue($booking->getTimeRange()->isAllDay());
}
public function testAnEndBeforeTheStartIsRejected(): void
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [
'startTime' => '13:00',
'endTime' => '09:00',
]);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('Das Ende muss nach dem Beginn liegen.', (string) $response->getContent());
}
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
{
$desk = $this->resource('D1');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($desk, '2026-10-01', [
'startTime' => '13:00',
'endTime' => '17:00',
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
public function testAWeeklySeriesIsCreatedAndReported(): void
{
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
// 2026-10-05 is a Monday.
$response = $this->submitBookingModal($desk, '2026-10-05', [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'weekdays' => ['1', '4'],
'until' => '2026-10-30',
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertCount(8, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
self::assertStringContainsString('8 Termine gebucht', (string) $response->getContent());
}
public function testASeriesWithoutAnEndDateIsRejected(): void
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
'allDay' => '1',
'repeat' => 'daily',
]);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('Bitte ein Enddatum für die Serie angeben.', (string) $response->getContent());
}
public function testAWeeklySeriesWithoutWeekdaysIsRejected(): void
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
'allDay' => '1',
'repeat' => 'weekly',
'until' => '2026-10-30',
]);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('Bitte mindestens einen Wochentag wählen.', (string) $response->getContent());
}
public function testASeriesRunningTooFarOutIsRejected(): void
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
'allDay' => '1',
'repeat' => 'daily',
'until' => '2030-01-01',
]);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('höchstens 12 Monate', (string) $response->getContent());
}
/**
* The board's span is carried into the modal, so narrowing the board and clicking a desk
* books exactly what was on screen.
*/
public function testTheModalIsPrefilledFromTheBoardsSpan(): void
{
$this->client->loginUser($this->user('[email protected]'));
$desk = $this->resource('D1');
$crawler = $this->client->request(
'GET',
\sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate),
\sprintf('/buchungen/neu/%d/2026-10-01?von=09:00&bis=13:00', $desk->getId()),
server: ['HTTP_HX-Request' => 'true'],
);
self::assertSame('09:00', $crawler->filter('#booking_startTime')->attr('value'));
self::assertSame('13:00', $crawler->filter('#booking_endTime')->attr('value'));
self::assertNull($crawler->filter('#booking_allDay')->attr('checked'));
}
public function testTheBoardNarrowsToASpan(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
self::assertSame('/?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
}
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
{
$anna = $this->user('[email protected]');
$this->client->loginUser($anna);
$desk = $this->resource('D1');
$this->submitBookingModal($desk, '2026-10-05', [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'weekdays' => ['1'],
'until' => '2026-10-26',
]);
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
self::assertCount(4, $occurrences);
$first = $occurrences[0];
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $first->getId()), server: ['HTTP_HX-Request' => 'true']);
// Only the span is editable here: the day and the recurrence belong to the series.
self::assertCount(0, $crawler->filter('#booking_date'));
self::assertCount(0, $crawler->filter('#booking_repeat'));
$form = $crawler->selectButton('Speichern')->form([
'booking[startTime]' => '14:00',
'booking[endTime]' => '16:00',
]);
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
$this->em->clear();
$reloaded = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
self::assertSame('14:00–16:00', (string) $reloaded[0]->getTimeRange());
self::assertTrue($reloaded[0]->isOverridden());
// The other three are untouched.
foreach (\array_slice($reloaded, 1) as $untouched) {
self::assertSame('09:00–12:00', (string) $untouched->getTimeRange());
self::assertFalse($untouched->isOverridden());
}
}
/**
* Renders the modal, reads its CSRF token, and posts the given booking fields back to it.
*
* Posted explicitly rather than through DomCrawler's form(): the weekday checkboxes are an
* expanded multiple choice rendered as booking[weekdays][], which the crawler's field
* registry indexes positionally, so assigning ['1', '4'] sets the second checkbox instead of
* the fourth. The token still comes off the rendered form, so the round trip is real.
*
* @param array<string, mixed> $fields
*/
private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response
{
$url = \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate);
$crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
$form = $crawler->selectButton('Buchen')->form(['booking[date]' => $submittedDate]);
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
$fields += [
'date' => $routeDate,
'_token' => $crawler->filter('#booking__token')->attr('value'),
];
$this->client->request('POST', $url, ['booking' => $fields], server: ['HTTP_HX-Request' => 'true']);
return $this->client->getResponse();
}
+69 -15
View File
@@ -4,18 +4,22 @@ declare(strict_types=1);
namespace App\Tests\Repository;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Hits the real database, because the behaviour under test is the database's: the unique
* constraint is what actually prevents double-booking.
* Hits the real database, because these are queries.
*
* Note what is NOT here any more: the double-booking guard. It used to be a unique index on
* (resource, date) and was tested by asserting the insert blew up. A booking is now a time range,
* which no unique index can guard, so the guard lives in BookingManager and is tested there --
* what this class covers is the overlap query that guard relies on.
*
* Each test runs inside a transaction that is rolled back afterwards.
*/
@@ -38,7 +42,30 @@ class BookingRepositoryTest extends KernelTestCase
parent::tearDown();
}
public function testTheSameResourceCannotBeBookedTwiceOnOneDay(): void
public function testFindOverlappingSeesASpanThatCollides(): void
{
$desk = $this->resource('D1');
$date = new \DateTimeImmutable('2026-10-01');
$this->persist(new Booking($this->user('[email protected]'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60)));
$this->em->flush();
self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(11 * 60, 15 * 60)));
}
public function testFindOverlappingTreatsTouchingSpansAsFree(): void
{
$desk = $this->resource('D1');
$date = new \DateTimeImmutable('2026-10-01');
$this->persist(new Booking($this->user('[email protected]'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60)));
$this->em->flush();
// Half-open: 09:00-13:00 and 13:00-17:00 do not collide.
self::assertSame([], $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(13 * 60, 17 * 60)));
}
public function testAnAllDayBookingBlocksEverySpan(): void
{
$desk = $this->resource('D1');
$date = new \DateTimeImmutable('2026-10-01');
@@ -46,12 +73,7 @@ class BookingRepositoryTest extends KernelTestCase
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
$this->em->flush();
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
// The controller catches exactly this and re-renders the fragment with an inline error,
// rather than pre-checking, which would be racy.
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(8 * 60, 8 * 60 + 15)));
}
public function testTheSameResourceOnAnotherDayIsFine(): void
@@ -66,17 +88,31 @@ class BookingRepositoryTest extends KernelTestCase
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
}
public function testATimeComponentDoesNotCreateASecondSlotForTheSameDay(): void
public function testATimeComponentOnTheDateDoesNotShiftWhichDayABookingIsOn(): void
{
$desk = $this->resource('D1');
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 08:00')));
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
$this->em->flush();
$this->em->clear();
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
}
public function testFindForDateOnlyReturnsWhatOverlapsTheSpan(): void
{
$date = new \DateTimeImmutable('2026-10-01');
$user = $this->user('[email protected]');
$this->persist(new Booking($user, $this->resource('D1'), $date, TimeRange::fromMinutes(9 * 60, 12 * 60)));
$this->persist(new Booking($user, $this->resource('D2'), $date, TimeRange::fromMinutes(14 * 60, 17 * 60)));
$this->em->flush();
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
$morning = $this->bookings->findForDate($date, TimeRange::fromMinutes(8 * 60, 13 * 60));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
self::assertCount(1, $morning);
self::assertSame('D1', $morning[0]->getResource()->getLabel());
self::assertCount(2, $this->bookings->findForDate($date));
}
public function testFindForDateCarriesTheBookingHolderForTheTeamView(): void
@@ -92,6 +128,24 @@ class BookingRepositoryTest extends KernelTestCase
self::assertSame('D1', $bookings[0]->getResource()->getLabel());
}
public function testCountPerDateOnlyCountsWhatOverlapsTheSpan(): void
{
$user = $this->user('[email protected]');
$this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(9 * 60, 12 * 60)));
$this->persist(new Booking($this->user('[email protected]'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(14 * 60, 17 * 60)));
$this->em->flush();
$counts = $this->bookings->countPerDateInRange(
new \DateTimeImmutable('2026-10-01'),
new \DateTimeImmutable('2026-10-31'),
TimeRange::fromMinutes(8 * 60, 13 * 60),
);
// The calendar number has to mean the same thing as the colours on the plan, so it is
// filtered by the same span.
self::assertSame(['2026-10-01' => 1], $counts);
}
public function testCountPerDateInRangeIsKeyedByDay(): void
{
$user = $this->user('[email protected]');