From bdca4f5724112ba46c05acac1460e5b9d6d9cad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 17:05:53 +0200 Subject: [PATCH] fix(dashboard): compact score-only hero --- e2e/v1341-ui-evidence.spec.ts | 77 +++++++++++++++++++ .../daily/__tests__/today-hero.test.tsx | 38 +++++++++ src/components/daily/today-hero.tsx | 62 +++++++++++++-- 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index 845b04143..6eea31bf5 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -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( diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index d96d246e0..f21a9ae03 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -260,4 +260,42 @@ describe("", () => { 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( + , + ); + 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( + , + ); + + 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"'); + }); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index b5a18d4f9..58850ded9 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -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({
+
{/* 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}

) : null} + {compactAllClear ? ( +

+ {t("daily.today.allClear")} +

+ ) : 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) ? ( +
+ {digest.sleepPending ? ( +

+

+ ) : null} + {digest.justIn ? ( +

+ {mounted && justInTime + ? t("daily.today.justInAt", { time: justInTime }) + : t("daily.today.justIn")} +

+ ) : null} +
+ ) : null}
{/* 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) ? (
{/* 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({ ))}
- ) : ( + ) : !compactAllClear ? (

{t("daily.today.allClear")}

- )} + ) : null}
);