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
+246 -41

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
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 } %}
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" 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>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Büro</text>
<svg viewBox="0 0 520 486" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
<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="12" class="fill-gray-400 text-xs font-semibold">Büro</text>
{% 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 %}
+9 -9
View File
@@ -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 } %}
<svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz">
<rect x="10" y="20" width="500" height="260" 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>
<svg viewBox="0 0 520 346" class="w-full h-auto" role="group" aria-label="Parkplatz">
<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="12" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
{# 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 %}
{% 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 %}
+38 -25
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
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
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 %}
<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' %}
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
hx-target="body"
@@ -52,21 +70,16 @@
hx-target="body"
hx-swap="beforeend"
{%- 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"
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>
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
{% endif %}
{% for line in lines %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + 34 + loop.index0 * 13 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ line }}</text>
{% endfor %}
</g>
{% 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;
}
}