Give a short meeting enough room to say what it is

A quarter of an hour is 1/96th of the day column — about twelve pixels at
the height the grid usually gets — and the chip inside needs twenty-four
for one line of "9:00 Standup". So a fifteen-minute meeting drew a sliver
with its own label hanging out of it. It reached the README screenshot,
which is where it was noticed.

The floor is 1.75rem, which is the all-day lane's floor directly above it:
one number for "the smallest a chip may be drawn" rather than a second
one that agrees with it today.

A floored block is then taller than its duration and reaches over the one
after it. That is the trade, taken deliberately: the block's TOP is where
the meeting starts and stays honest, only the padded bottom is a fiction,
and the alternative — reflowing so short events push their neighbours
down — moves a 10:00 meeting off the 10:00 line, which is the one thing a
time grid exists to get right. Blocks are emitted in start order, so the
later one paints over the fiction rather than under it.

Presentational only, and worth saying because it looks like it should not
be: the drag reads data-starts-at and data-ends-at, never the rendered
box, so a floored block still resizes from its real duration. The resize
preview writes a pixel height computed from minutes, which the floor
clamps — the value posted is unaffected.

Also corrects this file's own header. It claimed the screenshot suite
cannot run against the E2E stack because nothing seeds the demo mailbox.
app:test:seed-demo does, so it runs — and leaves a you@example.com account
behind, which makes account-scope.spec.ts fail on the FIRST account in the
sidebar and read as a regression in account scoping. Found by doing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-05 21:36:52 +02:00
1 parent 394bf60fde
commit 7d4838f143
9 files changed
+97 -6

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

@@ -178,9 +178,37 @@ away and is a single full-width column. Worth revisiting; not worth guessing at.
{% set event = occurrence.event %}
{% set readOnly = event.calendar.isReadOnly %}
{# min-height is the floor a short meeting needs to
be readable. A quarter of an hour is 1/96th of
the column — about twelve pixels at the height
this grid usually gets — and the chip inside
wants twenty-four for one line of "9:00 Standup",
so a fifteen-minute event drew a sliver with its
own label spilling out of it. 1.75rem is the
all-day lane's floor above, deliberately: one
number for "the smallest a chip may be drawn".
The floored block is then taller than its
duration and can reach over the one after it.
That is the right trade and not a compromise: the
block's TOP is where the meeting starts and stays
honest, only the padded bottom is a fiction, and
the alternative — reflowing so short events push
their neighbours down — would move a 10:00
meeting off the 10:00 line, which is the one
thing a time grid exists to get right. The
overlap is drawn under the later block, because
they are emitted in start order and the fiction
is what gets covered.
Presentational only. The drag reads
data-starts-at and data-ends-at, never the
rendered box, so a floored block resizes from its
real duration. #}
<div class="absolute p-px"
style="top: {{ (placed.top * 100)|round(4) }}%;
height: {{ (placed.height * 100)|round(4) }}%;
min-height: 1.75rem;
left: {{ (placed.lane * 100 / placed.lanes)|round(4) }}%;
width: {{ (100 / placed.lanes)|round(4) }}%;"
data-calendar--time-grid-target="block"
+56
View File
@@ -31,6 +31,9 @@ import { seed } from "./support/config";
const TIMED = "E2E grid timed";
const ALL_DAY = "E2E grid all day";
// Not from the seeder: this one is made through the dialog, so it is cleared by
// the editor rather than by `seed-grid-events --clear`.
const SHORT = "E2E grid quarter hour";
const DAILY = "E2E grid daily";
/** The positioned blocks holding one title. */
@@ -142,6 +145,59 @@ test.describe("calendar time-grid", () => {
await expect(blocks(page, ALL_DAY)).toHaveCount(0);
});
/**
* A quarter of an hour is 1/96th of the column — about twelve pixels at the
* height this grid gets — and the chip inside needs twenty-four for one line
* of "9:00 Standup". Without a floor the block was a sliver with its own
* label hanging out of it, which is how it reached the README screenshot.
*
* Created through the dialog rather than seeded, for the reason the
* screenshot suite gives: an event is a JSCalendar object whose occurrences
* are materialised on write, and putting rows in around the writer is how a
* test comes to assert a state the app cannot produce.
*/
test("a fifteen-minute meeting is still tall enough to read", async ({ page }) => {
const day = new Date().toISOString().slice(0, 10);
await page.goto("/calendar/day");
await page.getByRole("button", { name: "New event", exact: true }).click();
const modal = page.locator("#modal-backdrop");
await expect(modal).toBeVisible();
await modal.locator("#event-title").fill(SHORT);
await modal.locator("#event-starts").fill(`${day}T09:00`);
await modal.locator("#event-ends").fill(`${day}T09:15`);
await modal.getByRole("button", { name: "Save" }).click();
const block = blocks(page, SHORT).first();
await expect(block).toBeVisible();
const box = await block.boundingBox();
// 28px is the 1.75rem floor the template sets, which is also the all-day
// lane's — one number for "the smallest a chip may be drawn".
expect(box?.height ?? 0).toBeGreaterThanOrEqual(28);
// And the label is inside it rather than spilling: the title's own box
// has to fit within the block it belongs to.
const label = await block.getByText(SHORT).boundingBox();
expect(label).not.toBeNull();
expect((label?.y ?? 0) + (label?.height ?? 0)).toBeLessThanOrEqual(
(box?.y ?? 0) + (box?.height ?? 0) + 1,
);
// Taken away again. `seed-grid-events --clear` removes the three titles
// the seeder owns and this is not one of them, so without this the
// fixture user collects a meeting per run for ever.
await block.click();
await expect(modal).toBeVisible();
await modal.getByRole("button", { name: "Delete" }).click();
await expect(blocks(page, SHORT)).toHaveCount(0);
});
test("dragging a block down the column moves the event later", async ({ page }) => {
await page.goto("/calendar/day");
+13 -6
View File
@@ -6,14 +6,21 @@ import { TEST_ADMIN, consoleCommand, login, seed, seedUser } from "./support/con
*
* Not part of the regression suite — it asserts nothing about behaviour, it
* just drives the UI to the states worth showing and writes PNGs into
* docs/screenshots/. It also cannot run on the E2E fixtures: it looks for the
* demo mailbox by subject and sender, and no seed command in this repo
* produces that data, so on the test stack it can only fail and overwrite the
* committed PNGs with pictures of an empty inbox.
*
* Hence the opt-in. Point the suite at an app holding the demo mailbox and:
* docs/screenshots/. Hence the opt-in:
*
* npm run test:e2e:screenshots
*
* **It will run against the E2E stack, and it poisons it.** This used to say
* the suite could not run there at all, which is not true — app:test:seed-demo
* makes exactly the mailbox it wants — and the truth is worse, because it works
* and then leaves an account called you@example.com on the shared fixture user.
* account-scope.spec.ts asserts on the FIRST account in the sidebar, so it
* fails afterwards with "expected E2E Mailbox, received you@example.com", which
* looks like a regression in account scoping and is not.
*
* Point it at an app of its own, or clear the demo account afterwards:
*
* php bin/console dbal:run-sql "DELETE FROM account WHERE email = 'you@example.com'"
*/
const OUT = "docs/screenshots";