feat(dashboard): promote a Today hero above the tile strip

Surface the day's read at the top of the dashboard: a Today hero that
renders the daily digest — the health score with an honest
provisional/final and sleep-pending face, the briefing lead with a
read-the-full-briefing affordance, and the worth-a-look rail of priority
cards. The dense tile grid and chart rows stay exactly where they are,
one scroll below, so muscle memory survives and no new nav destination
is added.

The hero reads one cached digest cell (GET /api/daily/digest) through the
centralised query-key factory and unwraps the envelope; it recomputes
nothing and never warms a provider on mount. It is gated on the insights
module and self-gates its empty state: a genuinely empty account renders
nothing rather than an alarming card, while an account with a score but
nothing notable gets a calm all-clear line in the rail's place. A shimmer
skeleton holds the footprint while the digest loads.

Priority-card actions wire to their existing destinations by construction
(log dose, reconnect a sync, view check-ups). New copy lands in all six
locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 19:31:13 +02:00
1 parent ba6c0990bf
commit 168c1c85d7
14 files changed
+581

No files matched your search

+50
View File
@@ -98,6 +98,45 @@ test.describe("authenticated dashboard render", () => {
}),
);
// S2 — the Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
// one worth-a-look item) so the hero paints without reaching the real
// route, and so a slow/absent digest can never hang the render or
// surface a console error under the significant-error gate below.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
generatedAt: new Date().toISOString(),
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [
{
kind: "sync_issue",
title: "Sync needs attention",
body: "Withings isn't syncing.",
status: "warning",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
},
],
},
error: null,
}),
}),
);
// v1.18.6 — the dashboard now batches every visible chart's daily
// series into ONE `/api/measurements/series-batch` round-trip and
// hands each chart its slice via `preloadedSeries`. The route is a
@@ -209,6 +248,17 @@ test.describe("authenticated dashboard render", () => {
const tileCount = Number(await strip.getAttribute("data-tile-count"));
expect(tileCount).toBeGreaterThan(0);
// S2 — the Today hero paints above the tile strip from the mocked
// digest: its score ring and its worth-a-look rail render.
const todayHero = page.locator('[data-slot="today-hero"]');
await expect(todayHero).toBeVisible({ timeout: 10_000 });
await expect(
todayHero.locator('[data-slot="today-hero-score"]'),
).toBeVisible();
await expect(
todayHero.locator('[data-slot="today-hero-rail"]'),
).toBeVisible();
// At least one chart must render. Recharts mounts a `<svg>` with
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
// around it). Use the recharts class so we don't accidentally match