From 7c7576423f5b14001bebf41da1d8a68cc61027d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:21:07 +0200 Subject: [PATCH] fix(dashboard): give the score's meta lines their own row on a phone Below md the lines under the 80 px dial (the score delta, how long it has held, what it rests on) were capped at the dial's width. In German they broke into three to five rows each, and beside a short lead that column set the height of the read row and left a gap above the facts. The score column now dissolves into the grid below md: the dial stays the top-right cell beside the lead, and its meta lines take a full-width row right after the read row, flush with the dial's edge, one line each. The facts move one row down. From md up the column is the same flex column as before (desktop captures are pixel-identical). The Today overview e2e renders German at 390 and 360 px with a short and a long lead and asserts the dial size, one row per meta line, the lines flush with the dial, the meta row right after the read row, and exactly one row gap above the facts. --- e2e/today-overview.spec.ts | 154 ++++++++++++++++ .../daily/__tests__/today-hero.test.tsx | 41 ++++- src/components/daily/today-hero.tsx | 170 ++++++++++-------- 3 files changed, 290 insertions(+), 75 deletions(-) diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 4216f5b82..1b7782963 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -435,3 +435,157 @@ test.describe("Today overview geometry", () => { expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); }); }); + +/** + * The phone in German, where the score's meta lines are longest. In the + * dial's 80 px column "−6 vs. dein Mittel" and the basis sentence broke into + * three to five rows each; a short lead then left a gap above the facts as + * tall as that column. The lines now run on their own row under the dial. + */ +const GERMAN_SCORE: DailyDigest["score"] = { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + scoreBasis: { + domains: 2, + recommended: 3, + tier: "partial", + physiological: true, + }, +}; + +const GERMAN_FACTS: DailyDigest["today"] = [ + { + kind: "medications", + label: "Medikamente", + value: "1 von 2 genommen", + href: "/medications", + moduleKey: "medications", + }, + { + kind: "sleep", + label: "Letzte Nacht", + value: "5 Std. 45 Min., 42 Min. weniger als sonst", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitalwerte", + value: "Puls über deinem Bereich", + href: "/insights", + }, +]; + +const GERMAN_STATES: Record = { + "de-short": { + ...BASE, + score: GERMAN_SCORE, + lead: { text: "Dein Puls liegt heute erhöht.", source: "signal" }, + today: GERMAN_FACTS, + }, + "de-long": { + ...BASE, + score: GERMAN_SCORE, + lead: { + text: "Dein Puls liegt heute Abend deutlich über seinem üblichen Niveau, nach einer kürzeren Nacht als sonst; ein ruhiger Abend täte ihm gut.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs. dein 30-Tage-Mittel" }, + today: GERMAN_FACTS, + }, +}; + +test.describe("Today overview on a German phone", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + for (const viewport of VIEWPORTS.filter((v) => v.width < 768)) { + for (const [state, digest] of Object.entries(GERMAN_STATES)) { + test(`${state} at ${viewport.width}px`, async ({ page }) => { + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "reduce", + }); + await page.addInitScript(() => { + window.localStorage.setItem("healthlog-theme", "dark"); + }); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-dark-${state}.png`), + }); + + const box = async (slot: string) => + (await hero.locator(`[data-slot="${slot}"]`).first().boundingBox())!; + const ring = await box("score-ring"); + const lead = await box("today-hero-lead"); + const meta = await box("today-hero-score-meta"); + const block = await box("today-hero-today"); + + // The dial keeps its size and its place beside the lead. + expect(Math.round(ring.width)).toBe(RING_PX.phone); + expect(lead.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + + // Each meta line reads on one line, flush with the dial's edge. + const lines = await hero + .locator('[data-slot="today-hero-score-meta"] > span') + .evaluateAll((spans) => + spans.map((span) => { + const rect = span.getBoundingClientRect(); + const lineHeight = parseFloat(getComputedStyle(span).lineHeight); + return { + rows: Math.round(rect.height / lineHeight), + right: rect.right, + }; + }), + ); + expect(lines.length).toBe(2); + for (const line of lines) { + expect(line.rows).toBe(1); + expect( + Math.abs(line.right - (ring.x + ring.width)), + ).toBeLessThanOrEqual(1); + } + + // The meta row follows the read row at once: right under the dial + // beside a short lead, right under a long lead's last line. + // The lead's cell ends with its signal line when there is one. + const signalLine = hero.locator('[data-slot="today-hero-signal"]'); + const signal = + (await signalLine.count()) > 0 + ? await signalLine.boundingBox() + : null; + const leadBottom = signal + ? signal.y + signal.height + : lead.y + lead.height; + const readBottom = Math.max(ring.y + ring.height, leadBottom); + expect(meta.y - readBottom).toBeGreaterThanOrEqual(0); + expect(meta.y - readBottom).toBeLessThanOrEqual(6); + + // No gap above the facts beyond one section step: they start one + // row gap (16 px) under whatever ends lowest, the lead or the meta. + const above = Math.max(leadBottom, meta.y + meta.height); + expect(block.y - above).toBeGreaterThanOrEqual(15); + expect(block.y - above).toBeLessThanOrEqual(17); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } + } +}); diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index d8d8640cf..a334795ed 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -607,8 +607,45 @@ describe(" Today overview", () => { // Full width under lead and ring below md, the leading column from md. expect(cellClass("today-hero-today")).toContain("col-span-2"); expect(cellClass("today-hero-today")).toContain("md:col-span-1"); - expect(cellClass("today-hero-score")).toContain("col-start-2"); - expect(cellClass("today-hero-score")).toContain("md:row-span-2"); + // The slot's own classes, for elements that are grid items themselves. + const slotClass = (slot: string) => + new RegExp(`data-slot="${slot}" class="([^"]*)"`).exec(read)?.[1] ?? ""; + // Below md the dial is the top-right cell and its meta lines a row of + // their own under it; from md up the column spans both rows as before. + expect(slotClass("today-hero-score")).toContain("col-start-2"); + expect(slotClass("today-hero-score")).toContain("row-start-1"); + expect(slotClass("today-hero-score-column")).toContain("contents"); + expect(slotClass("today-hero-score-column")).toContain("md:row-span-2"); + }); + + it("gives the score's meta lines a full-width row on a phone, not the dial's width", () => { + const html = render( + , + "de", + ); + const meta = /data-slot="today-hero-score-meta" class="([^"]*)"/.exec( + html, + )?.[1]; + expect(meta).toContain("col-span-2"); + expect(meta).toContain("md:contents"); + // No line is capped to the 80 px dial any more. + expect(html).not.toContain("max-w-20"); + // The facts move one row down to make room for it. + expect(html).toMatch( + /class="[^"]*row-start-3[^"]*">]*data-slot="today-hero-today"/, + ); }); it("stacks each fact's label above its value, both on the reading edge", () => { diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 65bbd4f4e..d2325fd0c 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -322,76 +322,77 @@ export function TodayHero({ null score paints the ring's honest provisional face at the same footprint, so the column never collapses. The meta lines under it are capped to the dial's width on a phone so the column stays compact. */ - const scoreColumn = (size: "sm" | "responsive") => { - const phoneMeta = size === "responsive" && "max-w-20 md:max-w-none"; - return ( -
( +
+ {/* The ring opens the Insights overview — the destination the + health-score card owns — so every hero ring is a door, not a + poster. */} + -
- {/* The ring opens the Insights overview — the destination the - health-score card owns — so every hero ring is a door, not a - poster. */} - - - -
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} -
- ); - }; + + +
+ ); + + /* The score's meta lines: its delta, how long it has held, and what it + rests on. On the phone's full-width row the basis sentence keeps one + line; beside a dial it wraps at the dial column's width. */ + const scoreMetaLines = (size: "sm" | "responsive") => ( + <> + {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} + + ); + + /* Health score ring — `flat` (no sweep/bloom), server-computed band. A + null score paints the ring's honest provisional face at the same + footprint, so the column never collapses. The compact all-clear keeps + the ring and its meta lines in one centred column. */ + const compactScoreColumn = ( +
+ {scoreRing("sm")} + {scoreMetaLines("sm")} +
+ ); const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: @@ -460,7 +461,7 @@ export function TodayHero({ second row below the dial. */} {sleepPendingNote} - {scoreColumn("sm")} + {compactScoreColumn} ) : ( /* The day's read: the narrative lead on the reading edge, the @@ -496,20 +497,43 @@ export function TodayHero({ className={cn( "min-w-0", leadBlock - ? "col-span-2 row-start-2 md:col-span-1 md:col-start-1" + ? cn( + "col-span-2 md:col-span-1 md:col-start-1 md:row-start-2", + hasScoreMeta ? "row-start-3" : "row-start-2", + ) : "col-start-1 row-start-1", )} > {todayBlock} ) : null} + {/* One column from md up (the ring, its meta lines under it, + trailing edge), as before. Below md the column dissolves + (`contents`) into two grid items: the dial in the top-right + cell beside the lead, and the meta lines on a full-width row + right after the read row, flush with the dial's edge: under + the dial beside a short lead, under a long lead's last line. + In the dial's 80 px column the German lines broke into three + to five rows each and pushed the facts down. */}
- {scoreColumn("responsive")} + {scoreRing( + "responsive", + "col-start-2 row-start-1 self-start md:self-auto", + )} + {hasScoreMeta ? ( +
+ {scoreMetaLines("responsive")} +
+ ) : null}
)}