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. * 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) * 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 -- * so it can be re-run after a migration without duplicating anything.
* on both resources and groups -- are the contract with
* templates/board/plan/_<type>.html.twig: change one and the other stops drawing it.
* *
* The room names and their desk membership are placeholders standing in for the real office * 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')] #[When('dev')]
#[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')] #[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; $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 = []; $rooms = [];
foreach ([ foreach (['Raum Nord', 'Raum Ost', 'Raum Süd', 'Besprechungsraum'] as $position => $label) {
['Raum Nord', 'room-01'], [$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position);
['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);
$rooms[] = $room; $rooms[] = $room;
$created += $isNew; $created += $isNew;
} }
@@ -67,15 +60,14 @@ final class SeedCommand extends Command
$created += $this->ensureResource( $created += $this->ensureResource(
ResourceType::DESK, ResourceType::DESK,
\sprintf('Schreibtisch %02d', $i), \sprintf('Schreibtisch %02d', $i),
\sprintf('desk-%02d', $i),
$rooms[intdiv($i - 1, 3)], $rooms[intdiv($i - 1, 3)],
); );
} }
// Parking is deliberately left ungrouped: the car park is one area, and it is what keeps // 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) { 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 // 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; 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]); $resource = $this->resources->findOneBy(['type' => $type, 'label' => $label]);
if (null !== $resource) { if (null !== $resource) {
// Backfill only. A resource seeded before groups existed has no room and would never // 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()) { if (null !== $group && null === $resource->getGroup()) {
$resource->setGroup($group); $resource->setGroup($group);
} }
@@ -111,7 +103,7 @@ final class SeedCommand extends Command
return 0; return 0;
} }
$resource = new Resource($type, $label, $svgElementId); $resource = new Resource($type, $label);
$resource->setGroup($group); $resource->setGroup($group);
$this->em->persist($resource); $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 * @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]); $group = $this->groups->findOneBy(['type' => $type, 'label' => $label]);
@@ -129,7 +121,7 @@ final class SeedCommand extends Command
return [$group, 0]; return [$group, 0];
} }
$group = new ResourceGroup($type, $label, $position, $svgElementId); $group = new ResourceGroup($type, $label, $position);
$this->em->persist($group); $this->em->persist($group);
return [$group, 1]; return [$group, 1];
+2 -20
View File
@@ -28,16 +28,9 @@ class Resource
#[Assert\NotBlank] #[Assert\NotBlank]
private string $label; 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 * 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\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -50,11 +43,10 @@ class Resource
#[ORM\Column] #[ORM\Column]
private bool $active = true; 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->type = $type;
$this->label = $label; $this->label = $label;
$this->svgElementId = $svgElementId;
} }
public function getId(): ?int public function getId(): ?int
@@ -77,16 +69,6 @@ class Resource
$this->label = $label; $this->label = $label;
} }
public function getSvgElementId(): ?string
{
return $this->svgElementId;
}
public function setSvgElementId(?string $svgElementId): void
{
$this->svgElementId = $svgElementId;
}
public function getGroup(): ?ResourceGroup public function getGroup(): ?ResourceGroup
{ {
return $this->group; return $this->group;
+1 -19
View File
@@ -40,19 +40,11 @@ class ResourceGroup
#[ORM\Column] #[ORM\Column]
private int $position = 0; private int $position = 0;
/** public function __construct(ResourceType $type, string $label, 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)
{ {
$this->type = $type; $this->type = $type;
$this->label = $label; $this->label = $label;
$this->position = $position; $this->position = $position;
$this->svgElementId = $svgElementId;
} }
public function getId(): ?int public function getId(): ?int
@@ -84,14 +76,4 @@ class ResourceGroup
{ {
$this->position = $position; $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. * The kinds of thing that can be booked.
* *
* **This enum is the extension point.** The dashboard builds one tile per case, the board route * **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 * accepts one backing value per case, and the floor plan renders every kind the same way.
* same value. Adding a third bookable kind is therefore: a case here, a * Adding a third bookable kind is therefore: a case here and rows in `app:seed`.
* `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing * Nothing else needs editing — in particular no template lists the kinds by hand.
* 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 * 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 * 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; 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 * The floor plan needs the resources of a room together so it can list them under a heading, and
* room's own label and geometry key alongside them -- which is all this is. The grouping is done * 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 * 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 * presentation concern and a template cannot express it without a nested loop over every spot
* per room. * 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 * 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, * 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 * @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 // 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 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 // 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; $grouped[spl_object_id($group)][] = $spot;
} }
+1 -1
View File
@@ -97,7 +97,7 @@ final readonly class BoardContextFactory
'is_past' => $selected < $today, 'is_past' => $selected < $today,
'resources' => $resources, 'resources' => $resources,
'spots' => $spots, '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)), 'spot_groups' => SpotGroup::over(array_values($spots)),
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], '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 The plan: heading, legend, span picker, then one section per room with a card per spot.
board/plan/_<type>.html.twig and is included by convention, so a new ResourceType needs a new Every ResourceType renders the same way, so a new one needs no template of its own.
partial there and nothing else.
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day 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 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 %} {% if resources is empty %}
<p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p> <p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p>
{% else %} {% 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 %} {% endif %}
</div> </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 this day moves, the rest of the series is untouched and the occurrence is flagged as
differing from it. 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 or inline CSRF tokens -- clicking your own desk always just opens this. The release button
opens the shared confirmation modal over this one. opens the shared confirmation modal over this one.
#} #}
@@ -150,22 +150,22 @@ class BookingAdminControllerTest extends WebTestCase
*/ */
public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void 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); $this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$date = (new \DateTimeImmutable('today'))->format('Y-m-d'); $date = (new \DateTimeImmutable('today'))->format('Y-m-d');
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date='.$date); $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()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/book/desk?date='.$date); $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 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); $booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
@@ -185,9 +185,9 @@ class BookingAdminControllerTest extends WebTestCase
*/ */
public function testTheSpotModalShowsOnlyThatResourcesBookings(): void 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]', '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()); $this->client->loginUser($this->admin());
$crawler = $this->client->request( $crawler = $this->client->request(
@@ -273,12 +273,9 @@ class BookingAdminControllerTest extends WebTestCase
return $user; return $user;
} }
/** private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
* 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
{ {
$resource = new Resource($type, $label, $svgElementId); $resource = new Resource($type, $label);
$this->em->persist($resource); $this->em->persist($resource);
$this->em->flush(); $this->em->flush();
+8 -12
View File
@@ -178,7 +178,7 @@ class BookingControllerTest extends WebTestCase
public function testReleasingOwnBookingSwapsTheBoard(): void public function testReleasingOwnBookingSwapsTheBoard(): void
{ {
$anna = $this->user('[email protected]'); $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->persist($booking);
$this->em->flush(); $this->em->flush();
$id = $booking->getId(); $id = $booking->getId();
@@ -205,7 +205,7 @@ class BookingControllerTest extends WebTestCase
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
{ {
$anna = $this->user('[email protected]'); $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->persist($booking);
$this->em->flush(); $this->em->flush();
@@ -225,7 +225,7 @@ class BookingControllerTest extends WebTestCase
*/ */
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void 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->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->flush(); $this->em->flush();
@@ -233,7 +233,7 @@ class BookingControllerTest extends WebTestCase
// 1. The shape is clickable on the all-day board. // 1. The shape is clickable on the all-day board.
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); $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'); 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. // 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 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->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
$this->em->flush(); $this->em->flush();
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); $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 public function testBookingPartOfADay(): void
@@ -606,13 +606,9 @@ class BookingControllerTest extends WebTestCase
return $user; return $user;
} }
/** private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
* 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
{ {
$resource = new Resource($type, $label, $svgElementId); $resource = new Resource($type, $label);
$this->em->persist($resource); $this->em->persist($resource);
$this->em->flush(); $this->em->flush();
+4 -4
View File
@@ -121,8 +121,8 @@ class DashboardControllerTest extends WebTestCase
public function testABoardOnlyShowsItsOwnKindOfResource(): void public function testABoardOnlyShowsItsOwnKindOfResource(): void
{ {
$this->resource('D1', svgElementId: 'desk-01'); $this->resource('D1');
$this->resource('P1', ResourceType::PARKING, 'parking-01'); $this->resource('P1', ResourceType::PARKING);
$this->client->loginUser($this->user()); $this->client->loginUser($this->user());
@@ -163,9 +163,9 @@ class DashboardControllerTest extends WebTestCase
return $user; 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->persist($resource);
$this->em->flush(); $this->em->flush();
+50 -52
View File
@@ -15,10 +15,10 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; 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 * Who holds what is listed inside the card rather than in a title tooltip: a tooltip only shows
* hover, which is useless on touch and invisible when scanning the plan. * on hover, which is useless on touch and invisible when scanning the plan.
*/ */
class FloorPlanTest extends WebTestCase class FloorPlanTest extends WebTestCase
{ {
@@ -39,20 +39,20 @@ class FloorPlanTest extends WebTestCase
parent::tearDown(); 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); $this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
$crawler = $this->board(); $crawler = $this->board();
self::assertCount(0, $crawler->filter('svg title')); self::assertCount(1, $crawler->filter('[data-spot]'));
self::assertCount(0, $crawler->filter('svg g title')); self::assertCount(0, $crawler->filter('[data-spot][title], [data-spot] [title]'));
} }
public function testABookingIsListedInsideTheShape(): void public function testABookingIsListedInsideTheShape(): void
{ {
$desk = $this->desk('desk-01'); $desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780); $this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame( 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 public function testALongNameIsShortenedToAFirstNameAndInitial(): void
{ {
$desk = $this->desk('desk-01'); $desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Maximilian Wolkenstein'), 0, 1440); $this->book($desk, $this->user('[email protected]', 'Maximilian Wolkenstein'), 0, 1440);
self::assertContains('Maximilian W.', $this->linesOf($this->board())); self::assertContains('Maximilian W.', $this->linesOf($this->board()));
@@ -74,7 +74,7 @@ class FloorPlanTest extends WebTestCase
public function testASingleWordNameIsLeftAlone(): void public function testASingleWordNameIsLeftAlone(): void
{ {
$desk = $this->desk('desk-01'); $desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Cher'), 0, 1440); $this->book($desk, $this->user('[email protected]', 'Cher'), 0, 1440);
self::assertContains('Cher', $this->linesOf($this->board())); self::assertContains('Cher', $this->linesOf($this->board()));
@@ -82,7 +82,7 @@ class FloorPlanTest extends WebTestCase
public function testYourOwnBookingReadsAsDu(): void public function testYourOwnBookingReadsAsDu(): void
{ {
$desk = $this->desk('desk-01'); $desk = $this->desk();
$me = $this->user('[email protected]', 'Anna Berger'); $me = $this->user('[email protected]', 'Anna Berger');
$this->book($desk, $me, 540, 780); $this->book($desk, $me, 540, 780);
@@ -91,19 +91,19 @@ class FloorPlanTest extends WebTestCase
public function testAnAllDayBookingOmitsTheTimes(): void public function testAnAllDayBookingOmitsTheTimes(): void
{ {
$desk = $this->desk('desk-01'); $desk = $this->desk();
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440); $this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
self::assertSame(['Schreibtisch 01', 'Bea K.'], $this->linesOf($this->board())); 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. * and the counter has to account for the line the counter itself occupies.
*/ */
public function testTooManyBookingsAreSummarised(): void 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]', 'Ann Alpha'), 0, 360);
$this->book($desk, $this->user('[email protected]', 'Bea Beta'), 360, 720); $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]', 'Cem Gamma'), 720, 1080);
@@ -119,33 +119,29 @@ class FloorPlanTest extends WebTestCase
self::assertLessThanOrEqual(4, \count($lines)); 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())); self::assertSame(['Schreibtisch 01'], $this->linesOf($this->board()));
} }
/** /**
* Screen readers get the same information the box shows, because role="img" on the <svg> * Free is otherwise only a colour, which a screen reader cannot see.
* would otherwise hide every one of those <text> elements.
*/ */
public function testTheShapeCarriesAnAccessibleLabel(): void public function testAFreeCardSaysSoToScreenReaders(): void
{ {
$desk = $this->desk('desk-01'); $this->desk();
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
$label = $this->board()->filter('svg g[aria-label]')->attr('aria-label'); self::assertSame('frei', $this->board()->filter('[data-spot] .sr-only')->text());
self::assertSame('Schreibtisch 01 — Bea K. 09:00–13:00, frei 13:00–24:00', $label);
} }
public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void
{ {
$room = $this->room('Raum Nord', 'room-01'); $room = $this->room('Raum Nord');
$this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $this->deskIn($room, 'Schreibtisch 01');
$this->deskIn($room, 'desk-02', 'Schreibtisch 02'); $this->deskIn($room, 'Schreibtisch 02');
$this->deskIn($room, 'desk-03', 'Schreibtisch 03'); $this->deskIn($room, 'Schreibtisch 03');
self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board())); self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board()));
} }
@@ -157,9 +153,9 @@ class FloorPlanTest extends WebTestCase
*/ */
public function testAPartlyBookedDeskIsNotCountedAsFree(): void public function testAPartlyBookedDeskIsNotCountedAsFree(): void
{ {
$room = $this->room('Raum Nord', 'room-01'); $room = $this->room('Raum Nord');
$partly = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $partly = $this->deskIn($room, 'Schreibtisch 01');
$this->deskIn($room, 'desk-02', 'Schreibtisch 02'); $this->deskIn($room, 'Schreibtisch 02');
$this->book($partly, $this->user('[email protected]', 'Bea Kranz'), 540, 780); $this->book($partly, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board())); self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board()));
@@ -171,8 +167,8 @@ class FloorPlanTest extends WebTestCase
*/ */
public function testTheFreeCountFollowsTheSelectedSpan(): void public function testTheFreeCountFollowsTheSelectedSpan(): void
{ {
$room = $this->room('Raum Nord', 'room-01'); $room = $this->room('Raum Nord');
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $desk = $this->deskIn($room, 'Schreibtisch 01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780); $this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board())); self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
@@ -181,28 +177,28 @@ class FloorPlanTest extends WebTestCase
public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void
{ {
$room = $this->room('Raum Nord', 'room-01'); $room = $this->room('Raum Nord');
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $desk = $this->deskIn($room, 'Schreibtisch 01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440); $this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board())); 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('Zweiter Raum', 1), 'Schreibtisch 04');
$this->deskIn($this->room('Erster Raum', 'room-01', 0), 'desk-01', 'Schreibtisch 01'); $this->deskIn($this->room('Erster Raum', 0), 'Schreibtisch 01');
self::assertSame(['Erster Raum · 1 frei', 'Zweiter Raum · 1 frei'], $this->roomsOf($this->board())); 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 * A resource with no group is still bookable and still listed -- the plan simply gives it no
* frame it. That is the path the car park runs on. * 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(); $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> * @return list<string>
*/ */
private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array 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()) ?? ''), 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->persist($group);
$this->em->flush(); $this->em->flush();
return $group; 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); $resource->setGroup($group);
$this->em->persist($resource); $this->em->persist($resource);
$this->em->flush(); $this->em->flush();
@@ -242,11 +238,13 @@ class FloorPlanTest extends WebTestCase
} }
/** /**
* The label, then one entry per listed line.
*
* @return list<string> * @return list<string>
*/ */
private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array 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()), 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); 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->persist($resource);
$this->em->flush(); $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 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); $resource->setGroup($group);
return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer()); return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer());
@@ -119,7 +119,7 @@ class SpotGroupTest extends TestCase
{ {
return new Booking( return new Booking(
new UserWithId('[email protected]', 'Bea', 2), new UserWithId('[email protected]', 'Bea', 2),
new Resource(ResourceType::DESK, 'X', 'x'), new Resource(ResourceType::DESK, 'X'),
new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-01'),
TimeRange::fromMinutes($start, $end), TimeRange::fromMinutes($start, $end),
); );
+1 -1
View File
@@ -134,7 +134,7 @@ class SpotTest extends TestCase
private function resource(): Resource private function resource(): Resource
{ {
return new Resource(ResourceType::DESK, 'D1', 'desk-01'); return new Resource(ResourceType::DESK, 'D1');
} }
private function viewer(): User 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 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->persist($resource);
$this->em->flush(); $this->em->flush();