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,
+1
View File
@@ -13,6 +13,7 @@
<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>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-amber-50 border-2 border-amber-500"></span> teilweise frei</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
</div>
+2 -2
View File
@@ -21,8 +21,8 @@
{% for resource in resources %}
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
{{ plan.spot(resource, geometry[resource.svgElementId], 140, 70,
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 70,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
</svg>
+2 -2
View File
@@ -18,8 +18,8 @@
{% for resource in resources %}
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
{{ plan.spot(resource, geometry[resource.svgElementId], 200, 55,
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 55,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
</svg>
+36 -26
View File
@@ -1,59 +1,69 @@
{#
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.html.twig, so the
per-type partials carry geometry only and free/taken/yours looks identical everywhere.
per-type partials carry geometry only and the states look identical everywhere.
Clicking always opens a modal: the create modal when free, the edit modal when it is yours
(which is where changing the times and releasing live). Nothing else is clickable, so the SVG
carries no confirm dialogs or inline CSRF tokens.
The state comes from App\Booking\Spot, not from counting bookings here. That distinction is
the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all
day on an all-day board, and carried no hx-get either -- so a second person could not book the
afternoon at all unless they happened to narrow the span by hand first.
Clicking always opens a modal: the create modal when anything is still free, the edit modal
when it is yours (which is where changing the times and releasing live). Nothing else is
clickable, so the SVG carries no confirm dialogs or inline CSRF tokens.
#}
{% 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(spot, geometry, width, height, selected_date, is_past, time_params) %}
{% set state = spot.state.name %}
{% if mine %}
{% if state == 'MINE' %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% elseif bookings is not empty %}
{% elseif state == 'TAKEN' %}
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
{% elseif state == 'PARTLY' %}
{% set fill = 'fill-amber-50' %}{% set stroke = 'stroke-amber-500' %}{% set text = 'fill-amber-900' %}
{% 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 bookings is empty) %}
{% set interactive = not is_past and spot.actionable %}
{# 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 %}
{# The second line: what you would be told at a glance. For a partly-free spot that is the
window still available, because that is the reason to click it. #}
{% if state == 'MINE' %}
{% set caption = spot.own.timeRange.allDay ? 'du' : 'du ' ~ spot.own.timeRange %}
{% elseif state == 'PARTLY' %}
{% set caption = 'frei ' ~ spot.suggested %}
{% elseif spot.bookings|length > 1 %}
{% set caption = spot.bookings|length ~ ' Buchungen' %}
{% elseif spot.bookings is not empty %}
{% set caption = spot.bookings|first.user.name %}
{% else %}
{% set caption = null %}
{% endif %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
{%- if interactive and state == 'MINE' %}
hx-get="{{ path('app_booking_edit', { id: spot.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') }|merge(time_params)) }}"
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx.
It prefills the free window itself -- see BookingController::create. #}
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<title>
{{- resource.label }}
{%- if bookings is empty %} — frei
{{- spot.resource.label }}
{%- if spot.bookings is empty %} — frei
{%- else %}
{%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
{%- for booking in spot.bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
{%- for window in spot.free %} — frei {{ window }}{% 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 + (caption ? height / 2 - 4 : height / 2 + 4) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.resource.label }}</text>
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
+12 -13
View File
@@ -7,23 +7,22 @@
<div class="w-full max-w-md text-center">
<img class="h-8 w-auto mx-auto mb-8" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
<h1>Anmelden</h1>
<p class="subline pb-8">Schreibtische und Parkplätze buchen.</p>
<h1>
Login E&P Hub
</h1>
<p class="subline pb-8">
Schreibtische und Parkplätze buchen.
</p>
{% if oauth2_configured %}
<a class="button button--primary w-full" href="{{ path('app_auth_init') }}">
Mit myep anmelden
</a>
{% else %}
<div class="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-left">
<strong>Anmeldung noch nicht eingerichtet.</strong>
Für ephub muss auf myep erst ein OAuth2-Client registriert werden.
</div>
{% endif %}
<a class="button button--primary w-full" href="{{ path('app_auth_init') }}">
Mit myep anmelden
</a>
{% if app.environment == 'dev' %}
<p class="pt-6 text-sm">
<a class="underline text-gray-500" href="{{ path('app_dev_login') }}">Entwickler-Login</a>
<a class="underline text-gray-500" href="{{ path('app_dev_login') }}">
Entwickler-Login
</a>
</p>
{% endif %}
+166
View File
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\Spot;
use App\Booking\SpotState;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class SpotTest extends TestCase
{
public function testAResourceWithNothingBookedIsFree(): void
{
$spot = $this->spot([]);
self::assertSame(SpotState::FREE, $spot->state);
self::assertTrue($spot->isBookable());
self::assertNull($spot->own);
}
/**
* The reported bug: somebody else holds 09:00-13:00, the board shows the whole day, and the
* afternoon is still bookable. This used to come out as TAKEN and carried no link at all.
*/
public function testAResourceBookedForPartOfTheDayIsStillBookable(): void
{
$spot = $this->spot([$this->booking($this->someoneElse(), 540, 780)]);
self::assertSame(SpotState::PARTLY, $spot->state);
self::assertTrue($spot->isBookable());
self::assertTrue($spot->isActionable());
}
public function testTheSuggestedWindowIsTheLongestFreeOne(): void
{
// Taken 09:00-13:00 leaves 00:00-09:00 (9h) and 13:00-24:00 (11h).
$spot = $this->spot([$this->booking($this->someoneElse(), 540, 780)]);
self::assertSame('13:00–24:00', (string) $spot->suggested());
}
public function testTheSuggestedWindowIsAlwaysBookable(): void
{
$taken = $this->booking($this->someoneElse(), 540, 780);
$spot = $this->spot([$taken]);
// The whole point of suggesting a gap rather than the board's span: submitting it must
// not collide with what is already there.
self::assertFalse($spot->suggested()->overlaps($taken->getTimeRange()));
}
public function testAResourceBookedAllDayIsTaken(): void
{
$spot = $this->spot([$this->booking($this->someoneElse(), 0, 1440)]);
self::assertSame(SpotState::TAKEN, $spot->state);
self::assertFalse($spot->isBookable());
self::assertFalse($spot->isActionable());
self::assertNull($spot->suggested());
}
public function testSeveralBookingsCanLeaveNothingFree(): void
{
$spot = $this->spot([
$this->booking($this->someoneElse(), 0, 720),
$this->booking($this->someoneElse(), 720, 1440),
]);
self::assertSame(SpotState::TAKEN, $spot->state);
}
public function testSeveralBookingsCanStillLeaveAGapBetweenThem(): void
{
$spot = $this->spot([
$this->booking($this->someoneElse(), 0, 540),
$this->booking($this->someoneElse(), 780, 1440),
]);
self::assertSame(SpotState::PARTLY, $spot->state);
self::assertSame('09:00–13:00', (string) $spot->suggested());
}
public function testYourOwnBookingWinsOverEverythingElse(): void
{
$viewer = $this->viewer();
$spot = Spot::for(
$this->resource(),
[$this->booking($viewer, 540, 780)],
TimeRange::wholeDay(),
$viewer,
);
self::assertSame(SpotState::MINE, $spot->state);
// Clicking opens the edit modal rather than the create one, so it is actionable but not
// offered as a new booking.
self::assertTrue($spot->isActionable());
self::assertNotNull($spot->own);
}
/**
* A narrowed board only asks about its own window, so what happens outside it is irrelevant.
*/
public function testStateIsJudgedWithinTheBoardsSpanOnly(): void
{
$spot = Spot::for(
$this->resource(),
[],
TimeRange::fromMinutes(780, 1020),
$this->viewer(),
);
self::assertSame(SpotState::FREE, $spot->state);
self::assertSame('13:00–17:00', (string) $spot->suggested());
}
/**
* @param list<Booking> $bookings
*/
private function spot(array $bookings): Spot
{
return Spot::for($this->resource(), $bookings, TimeRange::wholeDay(), $this->viewer());
}
private function booking(User $user, int $start, int $end): Booking
{
return new Booking($user, $this->resource(), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end));
}
private function resource(): Resource
{
return new Resource(ResourceType::DESK, 'D1', 'desk-01');
}
private function viewer(): User
{
return new UserWithId('[email protected]', 'Anna', 1);
}
private function someoneElse(): User
{
return new UserWithId('[email protected]', 'Bea', 2);
}
}
/**
* Spot compares users by id, and an unpersisted User has none -- which would make every booking
* look like everyone's. This gives the fixtures stable ids without touching the database.
*/
class UserWithId extends User
{
public function __construct(string $email, string $name, private readonly int $fixedId)
{
parent::__construct($email, $name);
}
public function getId(): ?int
{
return $this->fixedId;
}
}
+61
View File
@@ -83,6 +83,57 @@ class TimeRangeTest extends TestCase
yield 'out of range is rejected' => ['25:00', '26:00', null];
}
public function testRemainingIsTheWholeSpanWhenNothingIsTaken(): void
{
$free = TimeRange::remaining(TimeRange::wholeDay(), []);
self::assertSame(['00:00–24:00'], self::render($free));
}
public function testRemainingSplitsAroundABookingInTheMiddle(): void
{
$free = TimeRange::remaining(TimeRange::wholeDay(), [TimeRange::fromMinutes(540, 780)]);
self::assertSame(['00:00–09:00', '13:00–24:00'], self::render($free));
}
public function testRemainingIsEmptyWhenTheSpanIsFullyCovered(): void
{
self::assertSame([], TimeRange::remaining(TimeRange::wholeDay(), [TimeRange::wholeDay()]));
}
public function testRemainingHandlesOverlappingAndUnsortedInput(): void
{
// Bookings on one resource cannot overlap, but the span may be covered by several that
// touch, and nothing guarantees the order they arrive in.
$free = TimeRange::remaining(TimeRange::wholeDay(), [
TimeRange::fromMinutes(780, 1020),
TimeRange::fromMinutes(540, 900),
]);
self::assertSame(['00:00–09:00', '17:00–24:00'], self::render($free));
}
public function testRemainingIgnoresWhatFallsOutsideTheSpan(): void
{
$free = TimeRange::remaining(TimeRange::fromMinutes(780, 1020), [TimeRange::fromMinutes(0, 540)]);
self::assertSame(['13:00–17:00'], self::render($free));
}
public function testRemainingClipsABookingThatOverhangsTheSpan(): void
{
$free = TimeRange::remaining(TimeRange::fromMinutes(480, 1080), [TimeRange::fromMinutes(0, 600)]);
self::assertSame(['10:00–18:00'], self::render($free));
}
public function testDurationIsInMinutes(): void
{
self::assertSame(240, TimeRange::fromMinutes(540, 780)->durationInMinutes());
self::assertSame(1440, TimeRange::wholeDay()->durationInMinutes());
}
/**
* 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
@@ -97,4 +148,14 @@ class TimeRangeTest extends TestCase
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()]));
}
/**
* @param list<TimeRange> $ranges
*
* @return list<string>
*/
private static function render(array $ranges): array
{
return array_map(static fn (TimeRange $range): string => $range->start().'–'.$range->end(), $ranges);
}
}
@@ -210,6 +210,56 @@ class BookingControllerTest extends WebTestCase
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
}
/**
* The reported bug, end to end: one person holds 09:00-13:00, a second opens the board on
* its default all-day span, and must be able to take the rest of the day.
*
* The plan used to render that desk exactly like a fully booked one and give it no hx-get at
* all, so the second person could not reach the modal unless they happened to narrow the
* span by hand first.
*/
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
{
$desk = $this->resource('D1', svgElementId: 'desk-01');
$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]'));
// 1. The shape is clickable on the all-day board.
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
$opener = $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId()));
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
// 2. The modal it opens is prefilled with a window that is actually free.
$modal = $this->client->request('GET', $opener->attr('hx-get'), server: ['HTTP_HX-Request' => 'true']);
self::assertSame('13:00', $modal->filter('#booking_startTime')->attr('value'));
// Blank, not 24:00 -- <input type="time"> cannot hold it, and blank already means
// "to the end of the day" here just as it does on the board's span picker.
self::assertNull($modal->filter('#booking_endTime')->attr('value'));
// 3. Submitting it works.
$response = $this->submitBookingModal($desk, '2026-10-01', [
'startTime' => '13:00',
'endTime' => '17:00',
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertCount(2, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
}
public function testAFullyBookedDeskIsNotClickable(): void
{
$desk = $this->resource('D1', svgElementId: 'desk-01');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
self::assertCount(0, $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId())));
}
public function testBookingPartOfADay(): void
{
$this->client->loginUser($this->user('[email protected]'));