fix: allow booking the free part of a partly booked day

This commit is contained in:
fromme committed 2026-09-13 19:11:51 +02:00
1 parent a88ff7ff24
commit 78839838f0
15 files changed
+541 -60

No files matched your search

+11 -4
View File
@@ -73,11 +73,18 @@ final readonly class BoardPresenter
$byResource = [];
foreach ($this->bookings->findForDate($selected, $time, $type) as $booking) {
// Several bookings can overlap the span on one resource once times are in play; the
// first is enough to colour the shape, and the tooltip lists them all.
$byResource[$booking->getResource()->getId()][] = $booking;
}
// One Spot per resource, carrying its own state and what is still free of the span --
// several bookings can overlap the span on one resource once times are in play.
$resources = $this->resources->findActiveByType($type);
$spots = [];
foreach ($resources as $resource) {
$spots[$resource->getId()] = Spot::for($resource, $byResource[$resource->getId()] ?? [], $time, $user);
}
return [
'type' => $type,
'selected_date' => $selected,
@@ -85,8 +92,8 @@ final readonly class BoardPresenter
'month_start' => $monthStart,
'weeks' => $weeks,
'is_past' => $selected < $today,
'resources' => $this->resources->findActiveByType($type),
'bookings_by_resource' => $byResource,
'resources' => $resources,
'spots' => $spots,
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
];
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\User;
/**
* One shape on the floor plan: a resource, what is booked on it within the board's span, and
* what is still free.
*
* The state decision lives here rather than in Twig so it can be tested directly -- it was wrong
* in the template once already.
*/
final readonly class Spot
{
/**
* @param list<Booking> $bookings overlapping the span, in start order
* @param list<TimeRange> $free what is left of the span
*/
private function __construct(
public Resource $resource,
public array $bookings,
public ?Booking $own,
public array $free,
public SpotState $state,
) {
}
/**
* @param list<Booking> $bookings everything booked on this resource that overlaps $span
*/
public static function for(Resource $resource, array $bookings, TimeRange $span, User $viewer): self
{
$own = null;
foreach ($bookings as $booking) {
if ($booking->belongsTo($viewer)) {
$own = $booking;
break;
}
}
$free = TimeRange::remaining($span, array_map(
static fn (Booking $booking): TimeRange => $booking->getTimeRange(),
$bookings,
));
// Yours wins over partly-free: clicking your own booking should open it, and the same
// person can still take another window on the same resource from the edit modal's day.
$state = match (true) {
null !== $own => SpotState::MINE,
[] === $bookings => SpotState::FREE,
[] === $free => SpotState::TAKEN,
default => SpotState::PARTLY,
};
return new self($resource, $bookings, $own, $free, $state);
}
/**
* The longest free window, which is what the booking modal prefills -- so the suggested span
* is always one that can actually be booked.
*/
public function suggested(): ?TimeRange
{
$longest = null;
foreach ($this->free as $range) {
if (null === $longest || $range->durationInMinutes() > $longest->durationInMinutes()) {
$longest = $range;
}
}
return $longest;
}
/** Whether a NEW booking can start here -- i.e. any of the span is still free. */
public function isBookable(): bool
{
return $this->state->isBookable();
}
/**
* Whether clicking the shape does anything. Wider than isBookable(): your own booking is
* clickable too, it just opens the edit modal instead of the create one.
*/
public function isActionable(): bool
{
return SpotState::MINE === $this->state || $this->isBookable();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Booking;
/**
* How one resource looks on the floor plan, for the day and span the board is showing.
*
* PARTLY exists because of a real bug: with only "free" and "taken", a desk booked 09:00-13:00
* looked identical to one booked all day on an all-day board, and was equally unclickable -- so
* nobody could book the afternoon without first knowing to narrow the span by hand.
*/
enum SpotState
{
case FREE;
case PARTLY;
case TAKEN;
case MINE;
public function isBookable(): bool
{
return self::FREE === $this || self::PARTLY === $this;
}
}
+46
View File
@@ -103,6 +103,52 @@ final readonly class TimeRange implements \Stringable
return $this->startMinute < $other->endMinute && $other->startMinute < $this->endMinute;
}
public function durationInMinutes(): int
{
return $this->endMinute - $this->startMinute;
}
/**
* What is left of $within once every range in $taken is removed.
*
* This is what makes a partly-booked resource bookable: the floor plan needs to know not
* "is anything booked here" but "is anything still free here", and the booking modal needs a
* span it can actually prefill without colliding.
*
* @param list<self> $taken may overlap each other and may reach outside $within
*
* @return list<self> the free sub-ranges, in order; empty when nothing is left
*/
public static function remaining(self $within, array $taken): array
{
usort($taken, static fn (self $a, self $b): int => $a->startMinute <=> $b->startMinute);
$free = [];
$cursor = $within->startMinute;
foreach ($taken as $range) {
if ($range->endMinute <= $cursor) {
continue;
}
if ($range->startMinute > $cursor) {
$free[] = new self($cursor, min($range->startMinute, $within->endMinute));
}
$cursor = max($cursor, $range->endMinute);
if ($cursor >= $within->endMinute) {
return $free;
}
}
if ($cursor < $within->endMinute) {
$free[] = new self($cursor, $within->endMinute);
}
return $free;
}
public function equals(self $other): bool
{
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
+12 -3
View File
@@ -9,6 +9,7 @@ use App\Booking\BoardPresenter;
use App\Booking\BookingManager;
use App\Booking\DateParam;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\Spot;
use App\Booking\SeriesResult;
use App\Booking\TimeRange;
use App\Entity\Booking;
@@ -70,9 +71,17 @@ final class BookingController extends AbstractController
$selected = DateParam::parseOrToday($date);
$boardTime = BoardController::timeOf($request);
// Prefilled from whatever span the board is showing, so narrowing the board and then
// clicking a desk books exactly what was on screen.
$data = new BookingRequest($selected, $boardTime);
// Prefilled with the longest window still free inside the board's span -- NOT the span
// itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered
// as 00:00-24:00, collide with itself on submit, and leave the user exactly as stuck as
// before. Falls back to the board's span when nothing is booked at all.
$spot = Spot::for(
$resource,
$this->repository->findOverlapping($resource, $selected, $boardTime),
$boardTime,
$this->currentUser(),
);
$data = new BookingRequest($selected, $spot->suggested() ?? $boardTime);
$form = $this->createForm(BookingType::class, $data, [
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
+20 -10
View File
@@ -37,20 +37,30 @@ final class BookingRequest
{
$this->date = $date;
if (null !== $time && !$time->isAllDay()) {
$this->allDay = false;
$this->startTime = self::timeOf($time->startMinute);
$this->endTime = self::timeOf($time->endMinute);
if (null === $time || $time->isAllDay()) {
return;
}
$this->allDay = false;
// An edge that is open to the edge of the day is left blank rather than filled in:
// 24:00 has no representation in <input type="time"> at all, and a blank already means
// "to the end of the day" here, exactly as it does on the board's span picker.
$this->startTime = TimeRange::DAY_START === $time->startMinute ? null : self::timeOf($time->startMinute);
$this->endTime = TimeRange::DAY_END === $time->endMinute ? null : self::timeOf($time->endMinute);
}
public function timeRange(): TimeRange
{
if ($this->allDay || null === $this->startTime || null === $this->endTime) {
if ($this->allDay || (null === $this->startTime && null === $this->endTime)) {
return TimeRange::wholeDay();
}
return TimeRange::fromTimes($this->startTime, $this->endTime);
// One-sided input is open to that edge of the day -- the same rule TimeRange::parse()
// applies to the board's ?from=&to= pair, so the two never disagree.
return TimeRange::fromMinutes(
null === $this->startTime ? TimeRange::DAY_START : self::minutesOf($this->startTime),
null === $this->endTime ? TimeRange::DAY_END : self::minutesOf($this->endTime),
);
}
public function repeats(): bool
@@ -66,10 +76,10 @@ final class BookingRequest
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) {
$context->buildViolation('Bitte eine Uhrzeit angeben oder „Ganzer Tag" wählen.')
->atPath('startTime')
->addViolation();
}
if (null !== $this->startTime && null !== $this->endTime
+2
View File
@@ -30,12 +30,14 @@ final class BookingType extends AbstractType
])
->add('startTime', TimeType::class, [
'label' => 'Von',
'help' => 'leer = ab Tagesbeginn',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('endTime', TimeType::class, [
'label' => 'Bis',
'help' => 'leer = bis Tagesende',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,