From a17c61400a75192f74bfc385ee23e6e5bdd5f294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 13 Sep 2026 19:16:56 +0200 Subject: [PATCH] feat: list bookings inside the floor plan shapes --- templates/board/plan/_desk.html.twig | 18 ++- templates/board/plan/_parking.html.twig | 18 +-- templates/board/plan/_spot.html.twig | 63 ++++---- tests/Controller/FloorPlanTest.php | 188 ++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 41 deletions(-) create mode 100644 tests/Controller/FloorPlanTest.php diff --git a/templates/board/plan/_desk.html.twig b/templates/board/plan/_desk.html.twig index 8ec5d8f..ab1f29d 100644 --- a/templates/board/plan/_desk.html.twig +++ b/templates/board/plan/_desk.html.twig @@ -3,25 +3,29 @@ database row to a shape here, so a resource whose id is missing from the map is simply not drawn (and vice versa) -- app:seed and this map have to agree. + Box height is set by what goes inside it, not by taste: _spot.html.twig puts the label at + +18 and up to three lines at +34, +47 and +60, so 86 leaves a comfortable margin below the + last one. Changing MAX_LINES there means changing the height here. + Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig. #} {% import 'board/plan/_spot.html.twig' as plan %} {% set geometry = { - 'desk-01': { x: 40, y: 60 }, 'desk-02': { x: 200, y: 60 }, 'desk-03': { x: 360, y: 60 }, - 'desk-04': { x: 40, y: 160 }, 'desk-05': { x: 200, y: 160 }, 'desk-06': { x: 360, y: 160 }, + 'desk-01': { x: 40, y: 50 }, 'desk-02': { x: 200, y: 50 }, 'desk-03': { x: 360, y: 50 }, + 'desk-04': { x: 40, y: 155 }, 'desk-05': { x: 200, y: 155 }, 'desk-06': { x: 360, y: 155 }, 'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 }, - 'desk-10': { x: 40, y: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 } + 'desk-10': { x: 40, y: 365 }, 'desk-11': { x: 200, y: 365 }, 'desk-12': { x: 360, y: 365 } } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} - - - Büro + + + Büro {% for resource in resources %} {% if resource.svgElementId and geometry[resource.svgElementId] is defined %} - {{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 70, + {{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 86, 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 0e68598..ba99765 100644 --- a/templates/board/plan/_parking.html.twig +++ b/templates/board/plan/_parking.html.twig @@ -1,24 +1,24 @@ {# - The car park. See board/plan/_desk.html.twig for how geometry and Resource.svgElementId - relate; found by convention from ResourceType::PARKING->value. + The car park. See board/plan/_desk.html.twig for how geometry, Resource.svgElementId and the + box height relate; found by convention from ResourceType::PARKING->value. #} {% import 'board/plan/_spot.html.twig' as plan %} {% set geometry = { - 'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 }, - 'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 } + 'parking-01': { x: 40, y: 50 }, 'parking-02': { x: 40, y: 140 }, 'parking-03': { x: 40, y: 230 }, + 'parking-04': { x: 280, y: 50 }, 'parking-05': { x: 280, y: 140 }, 'parking-06': { x: 280, y: 230 } } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} - - - Parkplatz + + + Parkplatz {# The lane between the two rows. #} - + {% for resource in resources %} {% if resource.svgElementId and geometry[resource.svgElementId] is defined %} - {{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 55, + {{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 78, 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 02ff622..943e725 100644 --- a/templates/board/plan/_spot.html.twig +++ b/templates/board/plan/_spot.html.twig @@ -7,11 +7,18 @@ 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. + Who holds what is listed INSIDE the shape rather than in an SVG flyout: a tooltip only + appears on hover, so it is useless on touch and invisible when scanning the plan, which is the + one thing a floor plan is for. The box therefore has a fixed layout -- label on top, one line + per booking below -- and the per-type partials size their boxes to fit MAX_LINES of them. + 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(spot, geometry, width, height, selected_date, is_past, time_params) %} + {% set MAX_LINES = 3 %} {% set state = spot.state.name %} {% if state == 'MINE' %} @@ -26,21 +33,32 @@ {% set interactive = not is_past and spot.actionable %} - {# 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 %} + {# One line per booking, then the window still free if there is one -- that last line is the + reason a partly booked shape is worth clicking, so it earns its place. #} + {% set lines = [] %} + {% for booking in spot.bookings %} + {% if booking.user.id == app.user.id %} + {% set who = 'du' %} + {% else %} + {# "Anna Berger" -> "Anna B." : full names do not fit a 140px box at this size. #} + {% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %} + {% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %} + {% endif %} + {% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %} + {% endfor %} + {% if state == 'PARTLY' %} + {% set lines = lines|merge(['frei ' ~ spot.suggested]) %} + {% endif %} + + {# Overflow rather than spill out of the box. Two lines plus a counter beats three lines and + a silent omission. #} + {% if lines|length > MAX_LINES %} + {% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %} {% endif %} - - {{- spot.resource.label }} - {%- if spot.bookings is empty %} — frei - {%- else %} - {%- 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 -%} - - {{ spot.resource.label }} - {% if caption %} - {{ caption }} - {% endif %} + + {% for line in lines %} + {{ line }} + {% endfor %} {% endmacro %} diff --git a/tests/Controller/FloorPlanTest.php b/tests/Controller/FloorPlanTest.php new file mode 100644 index 0000000..3f57af6 --- /dev/null +++ b/tests/Controller/FloorPlanTest.php @@ -0,0 +1,188 @@ +: a tooltip only shows on + * hover, which is useless on touch and invisible when scanning the plan. + */ +class FloorPlanTest extends WebTestCase +{ + private KernelBrowser $client; + private EntityManagerInterface $em; + + protected function setUp(): void + { + $this->client = self::createClient(); + $this->client->disableReboot(); + $this->em = self::getContainer()->get(EntityManagerInterface::class); + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testNoShapeCarriesATooltip(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); + + $crawler = $this->board(); + + self::assertCount(0, $crawler->filter('svg title')); + self::assertCount(0, $crawler->filter('svg g title')); + } + + public function testABookingIsListedInsideTheShape(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); + + self::assertSame( + ['Schreibtisch 01', 'Bea K. 09:00–13:00', 'frei 13:00–24:00'], + $this->linesOf($this->board()), + ); + } + + /** + * Full names do not fit a 140px box at this size, so they are shortened. + */ + public function testALongNameIsShortenedToAFirstNameAndInitial(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('x@ep-reisen.de', 'Maximilian Wolkenstein'), 0, 1440); + + self::assertContains('Maximilian W.', $this->linesOf($this->board())); + } + + public function testASingleWordNameIsLeftAlone(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('x@ep-reisen.de', 'Cher'), 0, 1440); + + self::assertContains('Cher', $this->linesOf($this->board())); + } + + public function testYourOwnBookingReadsAsDu(): void + { + $desk = $this->desk('desk-01'); + $me = $this->user('anna@ep-reisen.de', 'Anna Berger'); + $this->book($desk, $me, 540, 780); + + self::assertContains('du 09:00–13:00', $this->linesOf($this->board($me))); + } + + public function testAnAllDayBookingOmitsTheTimes(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 0, 1440); + + self::assertSame(['Schreibtisch 01', 'Bea K.'], $this->linesOf($this->board())); + } + + /** + * A box fits three lines. More than that is summarised rather than allowed to spill out -- + * and the counter has to account for the line the counter itself occupies. + */ + public function testTooManyBookingsAreSummarised(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('a@ep-reisen.de', 'Ann Alpha'), 0, 360); + $this->book($desk, $this->user('b@ep-reisen.de', 'Bea Beta'), 360, 720); + $this->book($desk, $this->user('c@ep-reisen.de', 'Cem Gamma'), 720, 1080); + $this->book($desk, $this->user('d@ep-reisen.de', 'Dia Delta'), 1080, 1440); + + $lines = $this->linesOf($this->board()); + + self::assertSame( + ['Schreibtisch 01', 'Ann A. 00:00–06:00', 'Bea B. 06:00–12:00', '+2 weitere'], + $lines, + ); + // Label plus MAX_LINES, never more. + self::assertLessThanOrEqual(4, \count($lines)); + } + + public function testAFreeShapeShowsOnlyItsLabel(): void + { + $this->desk('desk-01'); + + self::assertSame(['Schreibtisch 01'], $this->linesOf($this->board())); + } + + /** + * Screen readers get the same information the box shows, because role="img" on the + * would otherwise hide every one of those elements. + */ + public function testTheShapeCarriesAnAccessibleLabel(): void + { + $desk = $this->desk('desk-01'); + $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); + + $label = $this->board()->filter('svg g[aria-label]')->attr('aria-label'); + + self::assertSame('Schreibtisch 01 — Bea K. 09:00–13:00, frei 13:00–24:00', $label); + } + + /** + * @return list + */ + private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array + { + return $crawler->filter('svg g text')->each( + static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim($node->text()), + ); + } + + private function board(?User $as = null): \Symfony\Component\DomCrawler\Crawler + { + $this->client->loginUser($as ?? $this->user('anna@ep-reisen.de', 'Anna Berger')); + + return $this->client->request('GET', '/book/desk?date=2026-10-01'); + } + + private function desk(string $svgElementId): Resource + { + $resource = new Resource(ResourceType::DESK, 'Schreibtisch 01', $svgElementId); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + + private function book(Resource $resource, User $user, int $start, int $end): void + { + $this->em->persist(new Booking($user, $resource, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end))); + $this->em->flush(); + } + + private function user(string $email, string $name): User + { + $existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]); + if (null !== $existing) { + return $existing; + } + + $user = new User($email, $name); + $user->setRoles(['ROLE_EMPLOYEE']); + $this->em->persist($user); + $this->em->flush(); + + return $user; + } +}