feat: render the floor plan in html instead of svg

This commit is contained in:
fromme committed 2026-09-17 16:10:50 +02:00
1 parent 5a01054b0e
commit ad5d5acb00
20 files changed
+256 -356

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260917120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drops the SVG element ids now that the floor plan is plain HTML';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE resource DROP svg_element_id');
$this->addSql('ALTER TABLE resource_group DROP svg_element_id');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE resource ADD svg_element_id VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE resource_group ADD svg_element_id VARCHAR(50) DEFAULT NULL');
}
}
+12 -20
View File
@@ -24,12 +24,10 @@ use Symfony\Component\DependencyInjection\Attribute\When;
* Seeds the office rooms, the desks, the parking spots and a handful of employees.
*
* Dev only, and idempotent: it matches on the natural key (group and resource label, user email)
* so it can be re-run after a migration without duplicating anything. The svgElementId values --
* on both resources and groups -- are the contract with
* templates/board/plan/_<type>.html.twig: change one and the other stops drawing it.
* so it can be re-run after a migration without duplicating anything.
*
* The room names and their desk membership are placeholders standing in for the real office
* layout, and they only make sense together with the geometry in _desk.html.twig.
* layout.
*/
#[When('dev')]
#[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')]
@@ -50,15 +48,10 @@ final class SeedCommand extends Command
$created = 0;
// Four rooms of three desks, in the order they are drawn down the plan.
// Four rooms of three desks, in the order they are listed on the plan.
$rooms = [];
foreach ([
['Raum Nord', 'room-01'],
['Raum Ost', 'room-02'],
['Raum Süd', 'room-03'],
['Besprechungsraum', 'room-04'],
] as $position => [$label, $svgElementId]) {
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position, $svgElementId);
foreach (['Raum Nord', 'Raum Ost', 'Raum Süd', 'Besprechungsraum'] as $position => $label) {
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position);
$rooms[] = $room;
$created += $isNew;
}
@@ -67,15 +60,14 @@ final class SeedCommand extends Command
$created += $this->ensureResource(
ResourceType::DESK,
\sprintf('Schreibtisch %02d', $i),
\sprintf('desk-%02d', $i),
$rooms[intdiv($i - 1, 3)],
);
}
// Parking is deliberately left ungrouped: the car park is one area, and it is what keeps
// the "a plan without groups draws its own frame" path exercised by real data.
// the "a plan without rooms" path exercised by real data.
for ($i = 1; $i <= 6; ++$i) {
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i));
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i));
}
// One administrator on purpose, so the dev environment exercises both roles that open
@@ -97,13 +89,13 @@ final class SeedCommand extends Command
return Command::SUCCESS;
}
private function ensureResource(ResourceType $type, string $label, string $svgElementId, ?ResourceGroup $group = null): int
private function ensureResource(ResourceType $type, string $label, ?ResourceGroup $group = null): int
{
$resource = $this->resources->findOneBy(['type' => $type, 'label' => $label]);
if (null !== $resource) {
// Backfill only. A resource seeded before groups existed has no room and would never
// be drawn inside one, but a group somebody moved it into by hand is left alone.
// be listed under one, but a group somebody moved it into by hand is left alone.
if (null !== $group && null === $resource->getGroup()) {
$resource->setGroup($group);
}
@@ -111,7 +103,7 @@ final class SeedCommand extends Command
return 0;
}
$resource = new Resource($type, $label, $svgElementId);
$resource = new Resource($type, $label);
$resource->setGroup($group);
$this->em->persist($resource);
@@ -121,7 +113,7 @@ final class SeedCommand extends Command
/**
* @return array{ResourceGroup, int} the group, and 1 if it was created
*/
private function ensureGroup(ResourceType $type, string $label, int $position, string $svgElementId): array
private function ensureGroup(ResourceType $type, string $label, int $position): array
{
$group = $this->groups->findOneBy(['type' => $type, 'label' => $label]);
@@ -129,7 +121,7 @@ final class SeedCommand extends Command
return [$group, 0];
}
$group = new ResourceGroup($type, $label, $position, $svgElementId);
$group = new ResourceGroup($type, $label, $position);
$this->em->persist($group);
return [$group, 1];
+2 -20
View File
@@ -28,16 +28,9 @@ class Resource
#[Assert\NotBlank]
private string $label;
/**
* Id of the matching element in the hand-authored floor plan SVG. Null for anything that is
* bookable but not drawn on the plan.
*/
#[ORM\Column(length: 50, nullable: true)]
private ?string $svgElementId = null;
/**
* Where it physically is. Nullable because a group is optional: a kind of resource that is
* not split across rooms simply has none, and its plan draws its own single frame.
* not split across rooms simply has none, and the plan lists it without a room heading.
*/
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -50,11 +43,10 @@ class Resource
#[ORM\Column]
private bool $active = true;
public function __construct(ResourceType $type, string $label, ?string $svgElementId = null)
public function __construct(ResourceType $type, string $label)
{
$this->type = $type;
$this->label = $label;
$this->svgElementId = $svgElementId;
}
public function getId(): ?int
@@ -77,16 +69,6 @@ class Resource
$this->label = $label;
}
public function getSvgElementId(): ?string
{
return $this->svgElementId;
}
public function setSvgElementId(?string $svgElementId): void
{
$this->svgElementId = $svgElementId;
}
public function getGroup(): ?ResourceGroup
{
return $this->group;
+1 -19
View File
@@ -40,19 +40,11 @@ class ResourceGroup
#[ORM\Column]
private int $position = 0;
/**
* Id of the matching frame in the hand-authored floor plan SVG -- the same role svgElementId
* plays on Resource. Null for a group that is not drawn.
*/
#[ORM\Column(length: 50, nullable: true)]
private ?string $svgElementId = null;
public function __construct(ResourceType $type, string $label, int $position = 0, ?string $svgElementId = null)
public function __construct(ResourceType $type, string $label, int $position = 0)
{
$this->type = $type;
$this->label = $label;
$this->position = $position;
$this->svgElementId = $svgElementId;
}
public function getId(): ?int
@@ -84,14 +76,4 @@ class ResourceGroup
{
$this->position = $position;
}
public function getSvgElementId(): ?string
{
return $this->svgElementId;
}
public function setSvgElementId(?string $svgElementId): void
{
$this->svgElementId = $svgElementId;
}
}
+3 -4
View File
@@ -8,10 +8,9 @@ namespace App\Enum;
* The kinds of thing that can be booked.
*
* **This enum is the extension point.** The dashboard builds one tile per case, the board route
* accepts one backing value per case, and the floor plan is looked up by convention from that
* same value. Adding a third bookable kind is therefore: a case here, a
* `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing
* else needs editing — in particular no template lists the kinds by hand.
* accepts one backing value per case, and the floor plan renders every kind the same way.
* Adding a third bookable kind is therefore: a case here and rows in `app:seed`.
* Nothing else needs editing — in particular no template lists the kinds by hand.
*
* The backing value is both what is persisted in `resource.type` and what appears in a URL, so
* there is no slug mapping to keep in sync. The label, plural and description are the only
+5 -5
View File
@@ -8,10 +8,10 @@ use App\Entity\ResourceGroup;
use App\Enum\SpotState;
/**
* The spots of one room, in draw order.
* The spots of one room, in display order.
*
* The floor plan needs the resources of a room together so it can frame them, and it needs the
* room's own label and geometry key alongside them -- which is all this is. The grouping is done
* The floor plan needs the resources of a room together so it can list them under a heading, and
* it needs the room's own label alongside them -- which is all this is. The grouping is done
* once in the presenter rather than in Twig, because deciding what goes where is not a
* presentation concern and a template cannot express it without a nested loop over every spot
* per room.
@@ -32,7 +32,7 @@ final readonly class SpotGroup
*
* Ungrouped spots collect into a single trailing entry whose group is null. That entry is
* not an error case: a kind of resource that is not split across rooms has no groups at all,
* and its plan draws its own frame instead.
* and the plan lists its spots without a heading instead.
*
* @param list<Spot> $spots
*
@@ -56,7 +56,7 @@ final readonly class SpotGroup
// instance per row, so this groups exactly as an id would -- and it also works for a
// group that has not been persisted yet, where every id is still null and every
// group would otherwise collapse into one. The repository orders by group, so
// insertion order is already draw order.
// insertion order is already display order.
$grouped[spl_object_id($group)][] = $spot;
}
+1 -1
View File
@@ -97,7 +97,7 @@ final readonly class BoardContextFactory
'is_past' => $selected < $today,
'resources' => $resources,
'spots' => $spots,
// The same spots again, partitioned into the rooms the plan frames them with.
// The same spots again, partitioned into the rooms the plan lists them under.
'spot_groups' => SpotGroup::over(array_values($spots)),
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
];
+30 -4
View File
@@ -1,7 +1,6 @@
{#
The frame around a plan: heading, legend, span picker. The drawing itself lives in
board/plan/_<type>.html.twig and is included by convention, so a new ResourceType needs a new
partial there and nothing else.
The plan: heading, legend, span picker, then one section per room with a card per spot.
Every ResourceType renders the same way, so a new one needs no template of its own.
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day
and span, so free/taken/yours is decided in Twig. Note "free" is always relative to the span
@@ -28,6 +27,33 @@
{% if resources is empty %}
<p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p>
{% else %}
{% include 'board/plan/_' ~ type.value ~ '.html.twig' %}
{% import 'board/_spot.html.twig' as plan %}
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<div class="space-y-6">
{#
Rooms in their configured order, then anything without one. Ungrouped spots get no
heading: a kind of resource that is not split across rooms (the car park) has
nothing to say there that the page heading has not already said.
#}
{% for spot_group in spot_groups %}
<section>
{% if spot_group.group %}
{% set free = spot_group.freeCount %}
<h3 class="!mb-0 pb-2 text-sm font-semibold text-gray-500 md:text-sm">
{{- spot_group.group.label -}}
{%- if free > 0 %} <span class="text-emerald-600">· {{ free }} frei</span>
{%- else %} <span>· belegt</span>{% endif -%}
</h3>
{% endif %}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{% for spot in spot_group.spots %}
{{ plan.spot(spot, selected_date, is_past, time_params) }}
{% endfor %}
</div>
</section>
{% endfor %}
</div>
{% endif %}
</div>
+99
View File
@@ -0,0 +1,99 @@
{#
One bookable thing on the plan: a card coloured by its state, with who holds it listed inside.
The state comes from App\Model\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.
Who holds what is listed INSIDE the card rather than in a title tooltip: 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.
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 a card carries no confirm dialogs or inline CSRF tokens.
#}
{% macro spot(spot, selected_date, is_past, time_params) %}
{% set MAX_LINES = 3 %}
{% set state = spot.state.name %}
{% if state == 'MINE' %}
{% set colours = 'bg-primary border-primary-dark text-white' %}
{% elseif state == 'TAKEN' %}
{% set colours = 'bg-gray-200 border-gray-400 text-gray-600' %}
{% elseif state == 'PARTLY' %}
{% set colours = 'bg-amber-50 border-amber-500 text-amber-900' %}
{% else %}
{% set colours = 'bg-emerald-50 border-emerald-500 text-emerald-900' %}
{% endif %}
{% set interactive = not is_past and spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
should go back into the pool. Colours are untouched: MINE still means mine, and only
clickability widens. Past days stay inert for admins too; there is no past-write path.
Only a card that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach
that booking from /admin/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
{# One line per booking, then the window still free if there is one -- that last line is the
reason a partly booked card 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." : keeps a card on one line per booking at phone width. #}
{% 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 %}
{# Summarise rather than let one busy desk stretch its whole row. 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 %}
{% set tag = interactive or manageable ? 'button' : 'div' %}
<{{ tag }} data-spot
{%- if tag == 'button' %} type="button"{% endif %}
class="flex min-h-[5.5rem] w-full flex-col items-center rounded-md border-2 px-2 py-2 text-center {{ colours }}
{{- tag == 'button' ? ' cursor-pointer hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2' : ' cursor-default' }}"
{%- 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.
It prefills the free window itself -- see Booking\CreateController. #}
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"
{%- elseif manageable %}
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin
releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<span class="text-xs font-semibold{{ lines is empty ? ' my-auto' : '' }}">{{ spot.resource.label }}</span>
{% if lines is empty %}
{# The colour already says "free" to anyone who can see it. #}
<span class="sr-only">frei</span>
{% else %}
<ul class="pt-1 text-[11px] leading-4">
{% for line in lines %}
<li>{{ line }}</li>
{% endfor %}
</ul>
{% endif %}
</{{ tag }}>
{% endmacro %}
-47
View File
@@ -1,47 +0,0 @@
{#
The office. Hand-authored geometry in two maps: `rooms` is keyed by ResourceGroup.svgElementId
and `geometry` by Resource.svgElementId, so a row whose id is missing from its map is simply
not drawn (and vice versa) -- app:seed and these maps 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.
A room band is 118 high: 16 of margin, an 86-high desk, 16 again. The 30 between two bands is
what the next room's caption sits in, since room() draws it above the rect. Moving a desk
between rooms therefore means moving its y here as well as its group in the database -- the
drawing is artwork, and no amount of indirection changes that.
Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
#}
{% import 'board/plan/_spot.html.twig' as plan %}
{% set rooms = {
'room-01': { x: 10, y: 22, width: 500, height: 118 },
'room-02': { x: 10, y: 170, width: 500, height: 118 },
'room-03': { x: 10, y: 318, width: 500, height: 118 },
'room-04': { x: 10, y: 466, width: 500, height: 118 }
} %}
{% set geometry = {
'desk-01': { x: 40, y: 38 }, 'desk-02': { x: 200, y: 38 }, 'desk-03': { x: 360, y: 38 },
'desk-04': { x: 40, y: 186 }, 'desk-05': { x: 200, y: 186 }, 'desk-06': { x: 360, y: 186 },
'desk-07': { x: 40, y: 334 }, 'desk-08': { x: 200, y: 334 }, 'desk-09': { x: 360, y: 334 },
'desk-10': { x: 40, y: 482 }, 'desk-11': { x: 200, y: 482 }, 'desk-12': { x: 360, y: 482 }
} %}
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 594" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
{% for spot_group in spot_groups %}
{% if spot_group.group and rooms[spot_group.group.svgElementId] is defined %}
{{ plan.room(spot_group.group.label, rooms[spot_group.group.svgElementId], spot_group.freeCount) }}
{% endif %}
{% for spot in spot_group.spots %}
{% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %}
{{ plan.spot(spot, geometry[spot.resource.svgElementId], 140, 86,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
{% endfor %}
</svg>
-32
View File
@@ -1,32 +0,0 @@
{#
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: 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 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="36" x2="265" y2="310" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
{#
The car park is one area, so nothing here is grouped and every spot arrives in the single
null-group entry -- which is why this plan keeps drawing its own frame above instead of
letting room() do it. See board/plan/_desk.html.twig for the grouped case.
#}
{% for spot_group in spot_groups %}
{% for spot in spot_group.spots %}
{% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %}
{{ plan.spot(spot, geometry[spot.resource.svgElementId], 200, 78,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
{% endfor %}
</svg>
-120
View File
@@ -1,120 +0,0 @@
{#
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 the states look identical everywhere.
The state comes from App\Model\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.
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.
room() lives here for the same reason spot() does: a plan that frames its shapes into rooms
and one that does not must otherwise draw their borders in two places and drift apart.
#}
{#
The frame around one room, captioned with its name and how many of its spots are still free
for the whole of the board's span.
The caption sits above the rect rather than inside it, so it never collides with a spot box,
and the geometry only has to say where the room is.
#}
{% macro room(label, geometry, free) %}
<rect x="{{ geometry.x }}" y="{{ geometry.y }}"
width="{{ geometry.width }}" height="{{ geometry.height }}" rx="8"
class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="{{ geometry.x + 10 }}" y="{{ geometry.y - 6 }}" class="fill-gray-400 text-xs font-semibold">
{{- label -}}
{%- if free > 0 %} <tspan class="fill-emerald-600">· {{ free }} frei</tspan>
{%- else %} <tspan class="fill-gray-400">· belegt</tspan>{% endif -%}
</text>
{% endmacro %}
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
{% set MAX_LINES = 3 %}
{% set state = spot.state.name %}
{% if state == 'MINE' %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% 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 spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
should go back into the pool. Colours are untouched: MINE still means mine, and only
clickability widens. Past days stay inert for admins too; there is no past-write path.
Only a shape that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach
that booking from /admin/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
{# 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 or manageable ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive or manageable %} 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"
hx-swap="beforeend"
{%- elseif interactive %}
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx.
It prefills the free window itself -- see Booking\CreateController. #}
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"
{%- elseif manageable %}
{# Not app_booking_edit: a shape can hold several bookings on one day, and an admin
releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
{# 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>
{% 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 %}
+1 -1
View File
@@ -3,7 +3,7 @@
this day moves, the rest of the series is untouched and the occurrence is flagged as
differing from it.
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
Releasing lives here too rather than on the floor plan, so a card needs no confirm dialogs
or inline CSRF tokens -- clicking your own desk always just opens this. The release button
opens the shared confirmation modal over this one.
#}
@@ -150,22 +150,22 @@ class BookingAdminControllerTest extends WebTestCase
*/
public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void
{
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
$desk = $this->resource('D1');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(0, $crawler->filter('g[hx-get*="/admin/spot/"]'));
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('g[hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
}
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
{
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
$desk = $this->resource('D1');
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$this->client->loginUser($this->admin());
@@ -185,9 +185,9 @@ class BookingAdminControllerTest extends WebTestCase
*/
public function testTheSpotModalShowsOnlyThatResourcesBookings(): void
{
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
$desk = $this->resource('D1');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2', ResourceType::DESK, 'desk-02'));
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request(
@@ -273,12 +273,9 @@ class BookingAdminControllerTest extends WebTestCase
return $user;
}
/**
* The floor plan only draws resources whose svgElementId appears in the geometry map.
*/
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
{
$resource = new Resource($type, $label, $svgElementId);
$resource = new Resource($type, $label);
$this->em->persist($resource);
$this->em->flush();
+8 -12
View File
@@ -178,7 +178,7 @@ class BookingControllerTest extends WebTestCase
public function testReleasingOwnBookingSwapsTheBoard(): void
{
$anna = $this->user('[email protected]');
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
$this->em->persist($booking);
$this->em->flush();
$id = $booking->getId();
@@ -205,7 +205,7 @@ class BookingControllerTest extends WebTestCase
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
{
$anna = $this->user('[email protected]');
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
$this->em->persist($booking);
$this->em->flush();
@@ -225,7 +225,7 @@ class BookingControllerTest extends WebTestCase
*/
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
{
$desk = $this->resource('D1', svgElementId: 'desk-01');
$desk = $this->resource('D1');
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
@@ -233,7 +233,7 @@ class BookingControllerTest extends WebTestCase
// 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()));
$opener = $crawler->filter(\sprintf('[data-spot][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.
@@ -255,14 +255,14 @@ class BookingControllerTest extends WebTestCase
public function testAFullyBookedDeskIsNotClickable(): void
{
$desk = $this->resource('D1', svgElementId: 'desk-01');
$desk = $this->resource('D1');
$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())));
self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())));
}
public function testBookingPartOfADay(): void
@@ -606,13 +606,9 @@ class BookingControllerTest extends WebTestCase
return $user;
}
/**
* Note $svgElementId: the floor plan only draws resources whose id appears in the geometry
* map in _floorplan.html.twig, so a test that needs to click a shape must use a real one.
*/
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
{
$resource = new Resource($type, $label, $svgElementId);
$resource = new Resource($type, $label);
$this->em->persist($resource);
$this->em->flush();
+4 -4
View File
@@ -121,8 +121,8 @@ class DashboardControllerTest extends WebTestCase
public function testABoardOnlyShowsItsOwnKindOfResource(): void
{
$this->resource('D1', svgElementId: 'desk-01');
$this->resource('P1', ResourceType::PARKING, 'parking-01');
$this->resource('D1');
$this->resource('P1', ResourceType::PARKING);
$this->client->loginUser($this->user());
@@ -163,9 +163,9 @@ class DashboardControllerTest extends WebTestCase
return $user;
}
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
{
$resource = new Resource($type, $label, $svgElementId);
$resource = new Resource($type, $label);
$this->em->persist($resource);
$this->em->flush();
+50 -52
View File
@@ -15,10 +15,10 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* What a shape on the floor plan says.
* What a card 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.
* Who holds what is listed inside the card rather than in a title tooltip: a tooltip only shows
* on hover, which is useless on touch and invisible when scanning the plan.
*/
class FloorPlanTest extends WebTestCase
{
@@ -39,20 +39,20 @@ class FloorPlanTest extends WebTestCase
parent::tearDown();
}
public function testNoShapeCarriesATooltip(): void
public function testNoCardCarriesATooltip(): void
{
$desk = $this->desk('desk-01');
$desk = $this->desk();
$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'));
self::assertCount(1, $crawler->filter('[data-spot]'));
self::assertCount(0, $crawler->filter('[data-spot][title], [data-spot] [title]'));
}
public function testABookingIsListedInsideTheShape(): void
{
$desk = $this->desk('desk-01');
$desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(
@@ -62,11 +62,11 @@ class FloorPlanTest extends WebTestCase
}
/**
* Full names do not fit a 140px box at this size, so they are shortened.
* Full names are shortened so a card stays one line per booking at phone width.
*/
public function testALongNameIsShortenedToAFirstNameAndInitial(): void
{
$desk = $this->desk('desk-01');
$desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Maximilian Wolkenstein'), 0, 1440);
self::assertContains('Maximilian W.', $this->linesOf($this->board()));
@@ -74,7 +74,7 @@ class FloorPlanTest extends WebTestCase
public function testASingleWordNameIsLeftAlone(): void
{
$desk = $this->desk('desk-01');
$desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Cher'), 0, 1440);
self::assertContains('Cher', $this->linesOf($this->board()));
@@ -82,7 +82,7 @@ class FloorPlanTest extends WebTestCase
public function testYourOwnBookingReadsAsDu(): void
{
$desk = $this->desk('desk-01');
$desk = $this->desk();
$me = $this->user('[email protected]', 'Anna Berger');
$this->book($desk, $me, 540, 780);
@@ -91,19 +91,19 @@ class FloorPlanTest extends WebTestCase
public function testAnAllDayBookingOmitsTheTimes(): void
{
$desk = $this->desk('desk-01');
$desk = $this->desk();
$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 --
* A card lists 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');
$desk = $this->desk();
$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);
@@ -119,33 +119,29 @@ class FloorPlanTest extends WebTestCase
self::assertLessThanOrEqual(4, \count($lines));
}
public function testAFreeShapeShowsOnlyItsLabel(): void
public function testAFreeCardShowsOnlyItsLabel(): void
{
$this->desk('desk-01');
$this->desk();
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.
* Free is otherwise only a colour, which a screen reader cannot see.
*/
public function testTheShapeCarriesAnAccessibleLabel(): void
public function testAFreeCardSaysSoToScreenReaders(): void
{
$desk = $this->desk('desk-01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
$this->desk();
$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);
self::assertSame('frei', $this->board()->filter('[data-spot] .sr-only')->text());
}
public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void
{
$room = $this->room('Raum Nord', 'room-01');
$this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
$this->deskIn($room, 'desk-03', 'Schreibtisch 03');
$room = $this->room('Raum Nord');
$this->deskIn($room, 'Schreibtisch 01');
$this->deskIn($room, 'Schreibtisch 02');
$this->deskIn($room, 'Schreibtisch 03');
self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board()));
}
@@ -157,9 +153,9 @@ class FloorPlanTest extends WebTestCase
*/
public function testAPartlyBookedDeskIsNotCountedAsFree(): void
{
$room = $this->room('Raum Nord', 'room-01');
$partly = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
$room = $this->room('Raum Nord');
$partly = $this->deskIn($room, 'Schreibtisch 01');
$this->deskIn($room, 'Schreibtisch 02');
$this->book($partly, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board()));
@@ -171,8 +167,8 @@ class FloorPlanTest extends WebTestCase
*/
public function testTheFreeCountFollowsTheSelectedSpan(): void
{
$room = $this->room('Raum Nord', 'room-01');
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$room = $this->room('Raum Nord');
$desk = $this->deskIn($room, 'Schreibtisch 01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
@@ -181,28 +177,28 @@ class FloorPlanTest extends WebTestCase
public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void
{
$room = $this->room('Raum Nord', 'room-01');
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$room = $this->room('Raum Nord');
$desk = $this->deskIn($room, 'Schreibtisch 01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
}
public function testRoomsAreDrawnInTheirConfiguredOrder(): void
public function testRoomsAreListedInTheirConfiguredOrder(): void
{
$this->deskIn($this->room('Zweiter Raum', 'room-02', 1), 'desk-04', 'Schreibtisch 04');
$this->deskIn($this->room('Erster Raum', 'room-01', 0), 'desk-01', 'Schreibtisch 01');
$this->deskIn($this->room('Zweiter Raum', 1), 'Schreibtisch 04');
$this->deskIn($this->room('Erster Raum', 0), 'Schreibtisch 01');
self::assertSame(['Erster Raum · 1 frei', 'Zweiter Raum · 1 frei'], $this->roomsOf($this->board()));
}
/**
* A resource with no group is still bookable and still drawn -- the plan simply does not
* frame it. That is the path the car park runs on.
* A resource with no group is still bookable and still listed -- the plan simply gives it no
* room heading. That is the path the car park runs on.
*/
public function testAnUngroupedDeskIsDrawnWithoutARoom(): void
public function testAnUngroupedDeskIsListedWithoutARoom(): void
{
$this->desk('desk-01');
$this->desk();
$crawler = $this->board();
@@ -211,29 +207,29 @@ class FloorPlanTest extends WebTestCase
}
/**
* The room captions, which sit directly under the <svg> rather than inside a shape's <g>.
* The room headings.
*
* @return list<string>
*/
private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
{
return $crawler->filter('svg > text')->each(
return $crawler->filter('#board-floorplan section > h3')->each(
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim(preg_replace('/\s+/', ' ', $node->text()) ?? ''),
);
}
private function room(string $label, string $svgElementId, int $position = 0): ResourceGroup
private function room(string $label, int $position = 0): ResourceGroup
{
$group = new ResourceGroup(ResourceType::DESK, $label, $position, $svgElementId);
$group = new ResourceGroup(ResourceType::DESK, $label, $position);
$this->em->persist($group);
$this->em->flush();
return $group;
}
private function deskIn(ResourceGroup $group, string $svgElementId, string $label): Resource
private function deskIn(ResourceGroup $group, string $label): Resource
{
$resource = new Resource(ResourceType::DESK, $label, $svgElementId);
$resource = new Resource(ResourceType::DESK, $label);
$resource->setGroup($group);
$this->em->persist($resource);
$this->em->flush();
@@ -242,11 +238,13 @@ class FloorPlanTest extends WebTestCase
}
/**
* The label, then one entry per listed line.
*
* @return list<string>
*/
private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
{
return $crawler->filter('svg g text')->each(
return $crawler->filter('[data-spot] > span:not(.sr-only), [data-spot] li')->each(
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim($node->text()),
);
}
@@ -258,9 +256,9 @@ class FloorPlanTest extends WebTestCase
return $this->client->request('GET', '/book/desk?date=2026-10-01'.$query);
}
private function desk(string $svgElementId): Resource
private function desk(): Resource
{
$resource = new Resource(ResourceType::DESK, 'Schreibtisch 01', $svgElementId);
$resource = new Resource(ResourceType::DESK, 'Schreibtisch 01');
$this->em->persist($resource);
$this->em->flush();
+2 -2
View File
@@ -109,7 +109,7 @@ class SpotGroupTest extends TestCase
*/
private function spot(string $label, ?ResourceGroup $group, array $bookings = []): Spot
{
$resource = new Resource(ResourceType::DESK, $label, strtolower($label));
$resource = new Resource(ResourceType::DESK, $label);
$resource->setGroup($group);
return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer());
@@ -119,7 +119,7 @@ class SpotGroupTest extends TestCase
{
return new Booking(
new UserWithId('[email protected]', 'Bea', 2),
new Resource(ResourceType::DESK, 'X', 'x'),
new Resource(ResourceType::DESK, 'X'),
new \DateTimeImmutable('2026-10-01'),
TimeRange::fromMinutes($start, $end),
);
+1 -1
View File
@@ -134,7 +134,7 @@ class SpotTest extends TestCase
private function resource(): Resource
{
return new Resource(ResourceType::DESK, 'D1', 'desk-01');
return new Resource(ResourceType::DESK, 'D1');
}
private function viewer(): User
+1 -1
View File
@@ -316,7 +316,7 @@ class BookingManagerTest extends KernelTestCase
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
{
$resource = new Resource($type, $label, strtolower($label));
$resource = new Resource($type, $label);
$this->em->persist($resource);
$this->em->flush();