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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 22:21:07 +02:00
1 parent 43361bd74e
commit 7c7576423f
3 files changed
+290 -75

No files matched your search

+154
View File
@@ -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<string, DailyDigest> = {
"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);
});
}
}
});
@@ -607,8 +607,45 @@ describe("<TodayHero> 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(
<TodayHero
digest={digest({
today: FACTS,
worthALook: [],
score: {
value: 63,
band: "yellow",
delta: -6,
deltaReason: null,
steadyWeeks: null,
},
})}
/>,
"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[^"]*"><div[^>]*data-slot="today-hero-today"/,
);
});
it("stacks each fact's label above its value, both on the reading edge", () => {
+97 -73
View File
@@ -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 (
<div
className={cn(
"flex shrink-0 flex-col gap-1 md:items-end",
// The phone dial sits flush with the card's trailing edge, mirroring
// the lead's leading one. Its meta lines align under it and are no
// wider than the dial, so the lead keeps the width beside it.
size === "responsive"
? "items-end text-right"
: "items-center text-center",
)}
const hasScoreMeta =
(deltaShown && digest.score?.delta != null) || !!steadyLine || !!basisLine;
const scoreRing = (size: "sm" | "responsive", className?: string) => (
<div data-slot="today-hero-score" className={className}>
{/* The ring opens the Insights overview — the destination the
health-score card owns — so every hero ring is a door, not a
poster. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<div data-slot="today-hero-score">
{/* The ring opens the Insights overview — the destination the
health-score card owns — so every hero ring is a door, not a
poster. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<ScoreRing
score={digest.score?.value ?? null}
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
size={size}
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
</div>
{deltaShown && digest.score?.delta != null ? (
<span
data-slot="today-hero-score-delta"
className={cn(
"text-muted-foreground text-xs tabular-nums",
phoneMeta,
)}
>
{formatDelta(digest.score.delta, t)}
</span>
) : null}
{steadyLine ? (
<span
data-slot="today-hero-score-steady"
className={cn(
"text-muted-foreground text-xs text-balance",
phoneMeta,
)}
>
{steadyLine}
</span>
) : null}
{basisLine ? (
<span
data-slot="today-hero-score-basis"
className={cn(
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-right",
size === "responsive" ? "max-w-20" : "max-w-[11rem]",
)}
>
{basisLine}
</span>
) : null}
</div>
);
};
<ScoreRing
score={digest.score?.value ?? null}
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
size={size}
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
</div>
);
/* 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 ? (
<span
data-slot="today-hero-score-delta"
className="text-muted-foreground text-xs tabular-nums"
>
{formatDelta(digest.score.delta, t)}
</span>
) : null}
{steadyLine ? (
<span
data-slot="today-hero-score-steady"
className="text-muted-foreground text-xs text-balance"
>
{steadyLine}
</span>
) : null}
{basisLine ? (
<span
data-slot="today-hero-score-basis"
className={cn(
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-right",
size === "sm" && "max-w-[11rem]",
)}
>
{basisLine}
</span>
) : 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 = (
<div className="flex shrink-0 flex-col items-center gap-1 text-center md:items-end">
{scoreRing("sm")}
{scoreMetaLines("sm")}
</div>
);
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}
</div>
{scoreColumn("sm")}
{compactScoreColumn}
</div>
) : (
/* 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}
</div>
) : 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. */}
<div
data-slot="today-hero-score-column"
className={cn(
"col-start-2 row-start-1 self-start",
"contents md:col-start-2 md:row-start-1 md:flex md:shrink-0 md:flex-col md:items-end md:gap-1 md:self-start md:text-right",
leadBlock && todayBlock && "md:row-span-2",
)}
>
{scoreColumn("responsive")}
{scoreRing(
"responsive",
"col-start-2 row-start-1 self-start md:self-auto",
)}
{hasScoreMeta ? (
<div
data-slot="today-hero-score-meta"
className="col-span-2 row-start-2 -mt-3 flex flex-col items-end gap-1 text-right md:contents"
>
{scoreMetaLines("responsive")}
</div>
) : null}
</div>
</div>
)}