From d433d271d03a7c8a713ae12b685f090d27b17e0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?=
Date: Sun, 13 Sep 2026 17:34:28 +0200
Subject: [PATCH] feat: add booking time ranges and repeating series
---
assets/controllers/bookingform_controller.js | 25 ++
migrations/Version20260913133131.php | 55 ++++
.../AlreadyHoldingResourceException.php | 8 +-
src/Booking/BoardPresenter.php | 36 ++-
src/Booking/BookingManager.php | 171 ++++++++--
src/Booking/RecurrenceRule.php | 19 ++
.../ResourceAlreadyBookedException.php | 16 +-
src/Booking/SeriesResult.php | 52 +++
src/Booking/TimeRange.php | 135 ++++++++
src/Controller/BookingController.php | 192 +++++++++--
src/Controller/DashboardController.php | 25 +-
src/Entity/Booking.php | 88 ++++-
src/Entity/BookingSeries.php | 191 +++++++++++
src/Form/BookingRequest.php | 112 ++++++-
src/Form/BookingType.php | 53 +++-
src/Repository/BookingRepository.php | 128 ++++++--
src/Repository/BookingSeriesRepository.php | 38 +++
templates/booking/_fields.html.twig | 41 +++
templates/booking/_modal_create.html.twig | 2 +-
templates/booking/_modal_edit.html.twig | 56 ++++
templates/booking/index.html.twig | 35 +-
templates/dashboard/_calendar.html.twig | 10 +-
templates/dashboard/_floorplan.html.twig | 62 ++--
templates/dashboard/_timerange.html.twig | 42 +++
templates/dashboard/index.html.twig | 2 +-
tests/Booking/BookingManagerTest.php | 300 ++++++++++++++++++
tests/Booking/TimeRangeTest.php | 100 ++++++
tests/Controller/BookingControllerTest.php | 237 +++++++++++++-
tests/Repository/BookingRepositoryTest.php | 84 ++++-
29 files changed, 2135 insertions(+), 180 deletions(-)
create mode 100644 assets/controllers/bookingform_controller.js
create mode 100644 migrations/Version20260913133131.php
create mode 100644 src/Booking/RecurrenceRule.php
create mode 100644 src/Booking/SeriesResult.php
create mode 100644 src/Booking/TimeRange.php
create mode 100644 src/Entity/BookingSeries.php
create mode 100644 src/Repository/BookingSeriesRepository.php
create mode 100644 templates/booking/_fields.html.twig
create mode 100644 templates/booking/_modal_edit.html.twig
create mode 100644 templates/dashboard/_timerange.html.twig
create mode 100644 tests/Booking/BookingManagerTest.php
create mode 100644 tests/Booking/TimeRangeTest.php
diff --git a/assets/controllers/bookingform_controller.js b/assets/controllers/bookingform_controller.js
new file mode 100644
index 0000000..7aca69c
--- /dev/null
+++ b/assets/controllers/bookingform_controller.js
@@ -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');
+ }
+}
diff --git a/migrations/Version20260913133131.php b/migrations/Version20260913133131.php
new file mode 100644
index 0000000..bdcb95a
--- /dev/null
+++ b/migrations/Version20260913133131.php
@@ -0,0 +1,55 @@
+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');
+ }
+}
diff --git a/src/Booking/AlreadyHoldingResourceException.php b/src/Booking/AlreadyHoldingResourceException.php
index 4212025..0013a28 100644
--- a/src/Booking/AlreadyHoldingResourceException.php
+++ b/src/Booking/AlreadyHoldingResourceException.php
@@ -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,
));
}
}
diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php
index c838719..626b044 100644
--- a/src/Booking/BoardPresenter.php
+++ b/src/Booking/BoardPresenter.php
@@ -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
*/
- 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> $mine
+ * The query parameters that reproduce a board view, so links and HX-Push-Url agree.
*
- * @return list
+ * @return array
*/
- 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;
}
}
diff --git a/src/Booking/BookingManager.php b/src/Booking/BookingManager.php
index 1bf1fa6..2aa5786 100644
--- a/src/Booking/BookingManager.php
+++ b/src/Booking/BookingManager.php
@@ -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());
+ }
+ }
}
diff --git a/src/Booking/RecurrenceRule.php b/src/Booking/RecurrenceRule.php
new file mode 100644
index 0000000..d6f5eff
--- /dev/null
+++ b/src/Booking/RecurrenceRule.php
@@ -0,0 +1,19 @@
+ 'Täglich',
+ self::WEEKLY => 'Wöchentlich',
+ };
+ }
+}
diff --git a/src/Booking/ResourceAlreadyBookedException.php b/src/Booking/ResourceAlreadyBookedException.php
index 34d6d6c..45f8991 100644
--- a/src/Booking/ResourceAlreadyBookedException.php
+++ b/src/Booking/ResourceAlreadyBookedException.php
@@ -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,
+ ));
}
}
diff --git a/src/Booking/SeriesResult.php b/src/Booking/SeriesResult.php
new file mode 100644
index 0000000..ce9ef35
--- /dev/null
+++ b/src/Booking/SeriesResult.php
@@ -0,0 +1,52 @@
+ $created
+ * @param array $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
+ */
+ public function skippedDates(): array
+ {
+ return array_map(
+ static fn (string $date): string => (new \DateTimeImmutable($date))->format('j.n.'),
+ array_keys($this->skipped),
+ );
+ }
+}
diff --git a/src/Booking/TimeRange.php b/src/Booking/TimeRange.php
new file mode 100644
index 0000000..06b8c31
--- /dev/null
+++ b/src/Booking/TimeRange.php
@@ -0,0 +1,135 @@
+ 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);
+ }
+}
diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php
index d98296b..a7c7180 100644
--- a/src/Controller/BookingController.php
+++ b/src/Controller/BookingController.php
@@ -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
+ */
+ 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.');
+ }
+ }
}
diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php
index f9d16e0..249d47c 100644
--- a/src/Controller/DashboardController.php
+++ b/src/Controller/DashboardController.php
@@ -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();
+ }
}
diff --git a/src/Entity/Booking.php b/src/Entity/Booking.php
index 3ff0628..737d2f7 100644
--- a/src/Entity/Booking.php
+++ b/src/Entity/Booking.php
@@ -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);
+ }
}
diff --git a/src/Entity/BookingSeries.php b/src/Entity/BookingSeries.php
new file mode 100644
index 0000000..4465833
--- /dev/null
+++ b/src/Entity/BookingSeries.php
@@ -0,0 +1,191 @@
+
+ */
+ #[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 */
+ #[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'series')]
+ private Collection $bookings;
+
+ /**
+ * @param list $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
+ */
+ 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
+ */
+ 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();
+ }
+}
diff --git a/src/Form/BookingRequest.php b/src/Form/BookingRequest.php
index 0b59057..a1da76a 100644
--- a/src/Form/BookingRequest.php
+++ b/src/Form/BookingRequest.php
@@ -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 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');
}
}
diff --git a/src/Form/BookingType.php b/src/Form/BookingType.php
index ef8063e..7011c26 100644
--- a/src/Form/BookingType.php
+++ b/src/Form/BookingType.php
@@ -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
diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php
index 6febeca..19066a8 100644
--- a/src/Repository/BookingRepository.php
+++ b/src/Repository/BookingRepository.php
@@ -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
*/
- 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
+ */
+ 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
*/
- 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
+ */
+ 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);
}
}
diff --git a/src/Repository/BookingSeriesRepository.php b/src/Repository/BookingSeriesRepository.php
new file mode 100644
index 0000000..e21b8fd
--- /dev/null
+++ b/src/Repository/BookingSeriesRepository.php
@@ -0,0 +1,38 @@
+
+ */
+class BookingSeriesRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, BookingSeries::class);
+ }
+
+ /**
+ * @return list
+ */
+ 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();
+ }
+}
diff --git a/templates/booking/_fields.html.twig b/templates/booking/_fields.html.twig
new file mode 100644
index 0000000..e20a7f0
--- /dev/null
+++ b/templates/booking/_fields.html.twig
@@ -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.
+#}
+
+ {% if form.date is defined %}
+ {{ form_row(form.date) }}
+ {% endif %}
+
+