diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php index 10d134c..81ef02a 100644 --- a/src/Booking/BoardPresenter.php +++ b/src/Booking/BoardPresenter.php @@ -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')] ?? [], ]; } diff --git a/src/Booking/Spot.php b/src/Booking/Spot.php new file mode 100644 index 0000000..e33ecef --- /dev/null +++ b/src/Booking/Spot.php @@ -0,0 +1,95 @@ + $bookings overlapping the span, in start order + * @param list $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 $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(); + } +} diff --git a/src/Booking/SpotState.php b/src/Booking/SpotState.php new file mode 100644 index 0000000..28c8907 --- /dev/null +++ b/src/Booking/SpotState.php @@ -0,0 +1,25 @@ +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 $taken may overlap each other and may reach outside $within + * + * @return list 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; diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php index ee07af4..25c009c 100644 --- a/src/Controller/BookingController.php +++ b/src/Controller/BookingController.php @@ -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)), diff --git a/src/Form/BookingRequest.php b/src/Form/BookingRequest.php index a1da76a..332ef9b 100644 --- a/src/Form/BookingRequest.php +++ b/src/Form/BookingRequest.php @@ -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 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 diff --git a/src/Form/BookingType.php b/src/Form/BookingType.php index 7011c26..eec84c4 100644 --- a/src/Form/BookingType.php +++ b/src/Form/BookingType.php @@ -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, diff --git a/templates/board/_floorplan.html.twig b/templates/board/_floorplan.html.twig index eed0167..151238c 100644 --- a/templates/board/_floorplan.html.twig +++ b/templates/board/_floorplan.html.twig @@ -13,6 +13,7 @@

{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}

frei + teilweise frei belegt deine
diff --git a/templates/board/plan/_desk.html.twig b/templates/board/plan/_desk.html.twig index 5c7bf93..8ec5d8f 100644 --- a/templates/board/plan/_desk.html.twig +++ b/templates/board/plan/_desk.html.twig @@ -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 %} diff --git a/templates/board/plan/_parking.html.twig b/templates/board/plan/_parking.html.twig index 68e20a8..0e68598 100644 --- a/templates/board/plan/_parking.html.twig +++ b/templates/board/plan/_parking.html.twig @@ -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 %} diff --git a/templates/board/plan/_spot.html.twig b/templates/board/plan/_spot.html.twig index 617dfc0..02ff622 100644 --- a/templates/board/plan/_spot.html.twig +++ b/templates/board/plan/_spot.html.twig @@ -1,59 +1,69 @@ {# The shared shape a plan is drawn from. Imported by each board/plan/_.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 %} ; 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 ; 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 %}> - {{- 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 -%} {{ resource.label }} + text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.resource.label }} {% if caption %} {{ caption }} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 845b7a3..463dc5d 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -7,23 +7,22 @@
ephub -

Anmelden

-

Schreibtische und Parkplätze buchen.

+

+ Login E&P Hub +

+

+ Schreibtische und Parkplätze buchen. +

- {% if oauth2_configured %} - - Mit myep anmelden - - {% else %} -
- Anmeldung noch nicht eingerichtet. - Für ephub muss auf myep erst ein OAuth2-Client registriert werden. -
- {% endif %} + + Mit myep anmelden + {% if app.environment == 'dev' %}

- Entwickler-Login + + Entwickler-Login +

{% endif %} diff --git a/tests/Booking/SpotTest.php b/tests/Booking/SpotTest.php new file mode 100644 index 0000000..3ebe169 --- /dev/null +++ b/tests/Booking/SpotTest.php @@ -0,0 +1,166 @@ +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 $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('anna@ep-reisen.de', 'Anna', 1); + } + + private function someoneElse(): User + { + return new UserWithId('bea@ep-reisen.de', '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; + } +} diff --git a/tests/Booking/TimeRangeTest.php b/tests/Booking/TimeRangeTest.php index fe85946..9523427 100644 --- a/tests/Booking/TimeRangeTest.php +++ b/tests/Booking/TimeRangeTest.php @@ -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 $ranges + * + * @return list + */ + private static function render(array $ranges): array + { + return array_map(static fn (TimeRange $range): string => $range->start().'–'.$range->end(), $ranges); + } } diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php index da4b081..ec49b35 100644 --- a/tests/Controller/BookingControllerTest.php +++ b/tests/Controller/BookingControllerTest.php @@ -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('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780))); + $this->em->flush(); + + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + // 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 -- 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('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'))); + $this->em->flush(); + + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $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('anna@ep-reisen.de'));