fix(dashboard): compact score-only hero

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 17:05:53 +02:00
1 parent 8a19699a59
commit bdca4f5724
3 files changed
+172 -5

No files matched your search

+77
View File
@@ -1,6 +1,7 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import type { DailyDigest } from "@/lib/daily/digest";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -13,6 +14,26 @@ import { settleBeforeMeasure } from "./utils/settle";
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
const NARRATIVE_DIGEST: DailyDigest = {
generatedAt: "2026-07-29T08:30:00.000Z",
phase: "final",
sleepPending: false,
score: {
value: 84,
band: "green",
delta: -2,
deltaReason: null,
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
line: LONG_HEADLINE_BRIEFING.paragraph,
worthALook: [],
justIn: null,
reactionLine: null,
};
async function settleAndCapture(
page: Page,
testInfo: TestInfo,
@@ -46,14 +67,70 @@ test.describe("v1.34.1 deterministic UI evidence", () => {
await mkdir(EVIDENCE_DIR, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 });
let dailyDigest = NARRATIVE_DIGEST;
// TodayHero reads the unified digest endpoint, not the dashboard
// snapshot's legacy briefing field. Keep a mutable, deterministic reader
// so this evidence covers both the narrative composition captured below
// and the compact score-only fallback through the exact production seam.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: dailyDigest, error: null }),
}),
);
await mockDashboardSnapshot(page, {
briefing: LONG_HEADLINE_BRIEFING,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("#main-content")).toBeVisible();
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toHaveAttribute("data-layout", "narrative");
await expect(hero.locator('[data-slot="today-hero-lead"]')).toContainText(
"Dein systolischer Blutdruck",
);
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
"width",
"168px",
);
await settleAndCapture(page, testInfo, "dashboard-desktop.png");
dailyDigest = {
...NARRATIVE_DIGEST,
topSignal: null,
briefingLead: null,
line: "Dein Gesundheitsscore liegt heute bei 84.",
reactionLine: null,
};
await page.reload({ waitUntil: "domcontentloaded" });
await expect(hero).toHaveAttribute("data-layout", "compact-all-clear");
await expect(hero.locator('[data-slot="today-hero-lead"]')).toHaveCount(0);
await expect(
hero.locator('[data-slot="today-hero-all-clear"]'),
).toBeVisible();
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
"width",
"120px",
);
await expect(
hero.locator('[data-slot="score-ring"]').getByText("84", { exact: true }),
).toHaveCount(1);
await settleAndCapture(page, testInfo, "dashboard-score-only-desktop.png");
await page.setViewportSize({ width: 390, height: 844 });
const compactHeroBox = await hero.boundingBox();
expect(compactHeroBox).not.toBeNull();
expect(
compactHeroBox?.width ?? Number.POSITIVE_INFINITY,
).toBeLessThanOrEqual(390);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth,
),
).toBe(true);
await mockPopulatedInsights(page);
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await expect(
@@ -260,4 +260,42 @@ describe("<TodayHero>", () => {
expect(html).toContain('data-slot="today-hero-all-clear"');
expect(html).not.toContain('data-slot="today-hero-rail"');
});
it("uses the compact score-only composition when all-clear has no narrative", () => {
const html = render(
<TodayHero
digest={digest({
topSignal: null,
briefingLead: null,
reactionLine: null,
line: "Your health score today is 82.",
worthALook: [],
})}
/>,
);
const text = visibleText(html);
expect(html).toContain('data-layout="compact-all-clear"');
expect(html).toContain('data-slot="today-hero-all-clear"');
expect(html).not.toContain('data-slot="today-hero-lead"');
expect(html).toContain('style="width:120px;height:120px"');
// The compact fallback keeps exactly one score face: the ring.
expect(text.match(/\b82\b/g)).toHaveLength(1);
});
it("keeps the full narrative composition and md score ring when a lead exists", () => {
const html = render(
<TodayHero
digest={digest({
topSignal: null,
worthALook: [],
})}
/>,
);
expect(html).toContain('data-layout="narrative"');
expect(html).toContain('data-slot="today-hero-lead"');
expect(html).toContain('style="width:168px;height:168px"');
expect(html).not.toContain('data-layout="compact-all-clear"');
});
});
+57 -5
View File
@@ -160,6 +160,14 @@ export function TodayHero({
const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null);
const topSignal = digest.topSignal;
const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null;
// A score-only all-clear digest has no narrative content for the leading
// column. Keeping the full md ring in that two-column shell left a blank
// column beside a 168 px dial and pushed the actual all-clear read below it.
// Treat that honest fallback as its own compact composition: the all-clear
// copy occupies the leading column and a smaller version of the SAME score
// ring remains the one numeric face. This branch depends only on the
// server-delivered digest, so SSR and hydration choose it identically.
const compactAllClear = !lead && !topSignal && !hasItems;
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
// items, no cached briefing lead, and no fresh arrival — surfaces nothing
@@ -186,6 +194,7 @@ export function TodayHero({
<section
data-slot="today-hero"
data-phase={digest.phase}
data-layout={compactAllClear ? "compact-all-clear" : "narrative"}
className={cn(
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
@@ -204,7 +213,14 @@ export function TodayHero({
{/* The day's read — the numeric face on the trailing edge, the
narrative lead leading. On md+ the lead sits flush-top with the
score ring (items-start) rather than floating centred beside it. */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between md:gap-6">
<div
className={cn(
"flex gap-4",
compactAllClear
? "flex-row items-center justify-between"
: "flex-col md:flex-row md:items-start md:justify-between md:gap-6",
)}
>
<div className="min-w-0 flex-1 space-y-2">
{/* Hero numeric face: the read leads large in the foreground
token, calm and legible — the day's read, not a slogan. */}
@@ -232,6 +248,42 @@ export function TodayHero({
) : null}
</p>
) : null}
{compactAllClear ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
) : null}
{/* In the compact fallback the quiet freshness tier belongs next
to the smaller ring. That uses the otherwise-empty leading
space and avoids adding a second row below the dial. */}
{compactAllClear && (digest.sleepPending || digest.justIn) ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{digest.sleepPending ? (
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
) : null}
{digest.justIn ? (
<p
data-slot="today-hero-just-in"
data-just-in-kind={digest.justIn.kind}
className="flex items-center gap-1.5"
>
{mounted && justInTime
? t("daily.today.justInAt", { time: justInTime })
: t("daily.today.justIn")}
</p>
) : null}
</div>
) : null}
</div>
{/* Health score ring — `flat` (no sweep/bloom), server-computed
@@ -254,7 +306,7 @@ export function TodayHero({
band={
digest.score ? (digest.score.band as ScoreBand) : undefined
}
size="md"
size={compactAllClear ? "sm" : "md"}
flat
label={t("daily.today.scoreLabel")}
/>
@@ -279,7 +331,7 @@ export function TodayHero({
they can legitimately co-occur (a weight landed while last night's
sleep is still pending), so they share one wrapping row rather than
competing for the same slot. */}
{digest.sleepPending || digest.justIn ? (
{!compactAllClear && (digest.sleepPending || digest.justIn) ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{/* Freshness note (plan §2.4) — provisional day, last night's
sleep not yet folded in. Muted, non-blocking, refreshes in
@@ -338,14 +390,14 @@ export function TodayHero({
))}
</div>
</div>
) : (
) : !compactAllClear ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
)}
) : null}
</div>
</section>
);