feat: list bookings inside the floor plan shapes

This commit is contained in:
fromme committed 2026-09-13 19:16:56 +02:00
1 parent 78839838f0
commit a17c61400a
4 files changed
+245 -40

No files matched your search

+11 -7
View File
@@ -3,25 +3,29 @@
database row to a shape here, so a resource whose id is missing from the map is simply not 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. 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. Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
#} #}
{% import 'board/plan/_spot.html.twig' as plan %} {% import 'board/plan/_spot.html.twig' as plan %}
{% set geometry = { {% set geometry = {
'desk-01': { x: 40, y: 60 }, 'desk-02': { x: 200, y: 60 }, 'desk-03': { x: 360, y: 60 }, 'desk-01': { x: 40, y: 50 }, 'desk-02': { x: 200, y: 50 }, 'desk-03': { x: 360, y: 50 },
'desk-04': { x: 40, y: 160 }, 'desk-05': { x: 200, y: 160 }, 'desk-06': { x: 360, y: 160 }, '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-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 } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro"> <svg viewBox="0 0 520 486" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect> <rect x="10" y="18" width="500" height="448" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Büro</text> <text x="20" y="12" class="fill-gray-400 text-xs font-semibold">Büro</text>
{% for resource in resources %} {% for resource in resources %}
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %} {% 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) }} selected_date, is_past, time_params) }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
+9 -9
View File
@@ -1,24 +1,24 @@
{# {#
The car park. See board/plan/_desk.html.twig for how geometry and Resource.svgElementId The car park. See board/plan/_desk.html.twig for how geometry, Resource.svgElementId and the
relate; found by convention from ResourceType::PARKING->value. box height relate; found by convention from ResourceType::PARKING->value.
#} #}
{% import 'board/plan/_spot.html.twig' as plan %} {% import 'board/plan/_spot.html.twig' as plan %}
{% set geometry = { {% set geometry = {
'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, 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: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 } '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 } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz"> <svg viewBox="0 0 520 346" class="w-full h-auto" role="group" aria-label="Parkplatz">
<rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect> <rect x="10" y="18" width="500" height="308" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Parkplatz</text> <text x="20" y="12" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
{# The lane between the two rows. #} {# The lane between the two rows. #}
<line x1="265" y1="40" x2="265" y2="260" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line> <line x1="265" y1="36" x2="265" y2="310" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
{% for resource in resources %} {% for resource in resources %}
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %} {% 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) }} selected_date, is_past, time_params) }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
+37 -24
View File
@@ -7,11 +7,18 @@
day on an all-day board, and carried no hx-get either -- so a second person could not book the 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. 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 <title> 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 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 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. clickable, so the SVG carries no confirm dialogs or inline CSRF tokens.
#} #}
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %} {% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
{% set MAX_LINES = 3 %}
{% set state = spot.state.name %} {% set state = spot.state.name %}
{% if state == 'MINE' %} {% if state == 'MINE' %}
@@ -26,21 +33,32 @@
{% set interactive = not is_past and spot.actionable %} {% 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 {# One line per booking, then the window still free if there is one -- that last line is the
window still available, because that is the reason to click it. #} reason a partly booked shape is worth clicking, so it earns its place. #}
{% if state == 'MINE' %} {% set lines = [] %}
{% set caption = spot.own.timeRange.allDay ? 'du' : 'du ' ~ spot.own.timeRange %} {% for booking in spot.bookings %}
{% elseif state == 'PARTLY' %} {% if booking.user.id == app.user.id %}
{% set caption = 'frei ' ~ spot.suggested %} {% set who = 'du' %}
{% 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 %} {% else %}
{% set caption = null %} {# "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 %} {% endif %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}" <g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive %} role="button"{% endif %}
aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}"
{%- if interactive and state == 'MINE' %} {%- if interactive and state == 'MINE' %}
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}" hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
hx-target="body" hx-target="body"
@@ -52,21 +70,16 @@
hx-target="body" hx-target="body"
hx-swap="beforeend" hx-swap="beforeend"
{%- endif %}> {%- endif %}>
<title>
{{- 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 -%}
</title>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6" <rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect> class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
{# Nothing to list: the colour already says "free", so the label sits centred as before. #}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (lines is empty ? height / 2 + 4 : 18) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.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 }}" {% for line in lines %}
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text> <text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + 34 + loop.index0 * 13 }}"
{% endif %} text-anchor="middle" class="{{ text }} text-[10px]">{{ line }}</text>
{% endfor %}
</g> </g>
{% endmacro %} {% endmacro %}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* What a shape on the floor plan says.
*
* Who holds what is listed inside the box rather than in an SVG <title>: 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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', 'Cher'), 0, 1440);
self::assertContains('Cher', $this->linesOf($this->board()));
}
public function testYourOwnBookingReadsAsDu(): void
{
$desk = $this->desk('desk-01');
$me = $this->user('[email protected]', '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('[email protected]', '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('[email protected]', 'Ann Alpha'), 0, 360);
$this->book($desk, $this->user('[email protected]', 'Bea Beta'), 360, 720);
$this->book($desk, $this->user('[email protected]', 'Cem Gamma'), 720, 1080);
$this->book($desk, $this->user('[email protected]', '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 <svg>
* would otherwise hide every one of those <text> elements.
*/
public function testTheShapeCarriesAnAccessibleLabel(): void
{
$desk = $this->desk('desk-01');
$this->book($desk, $this->user('[email protected]', '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<string>
*/
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('[email protected]', '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;
}
}