From af86a14824a53fc0d6befff4844e5eaf76775ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:54:09 +0200 Subject: [PATCH] feat(dashboard): surface the daily briefing on the hero The briefing only reached the dashboard as the hero's lowest-priority verdict, which the severity ladder almost always pre-empted, so a fresh briefing was invisible above the fold. Lift its own signals (falling back to key findings) onto the hero as a self-gating strip beneath the verdict row, reusing the insights briefing's tone-bar row language. Placement only: the verdict ladder and the briefing content are unchanged. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../__tests__/briefing-spotlight.test.tsx | 180 ++++++++++++++ .../dashboard/hero/briefing-spotlight.tsx | 219 ++++++++++++++++++ .../dashboard/hero/dashboard-hero.tsx | 13 ++ 9 files changed, 436 insertions(+) create mode 100644 src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx create mode 100644 src/components/dashboard/hero/briefing-spotlight.tsx diff --git a/messages/de.json b/messages/de.json index 9bf4e25e8..31bd8ee23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -301,6 +301,10 @@ "viewSleep": "Schlaf ansehen", "logMeasurement": "Messung erfassen", "viewInsights": "Insights öffnen" + }, + "briefing": { + "title": "Tagesbriefing", + "viewAll": "Insights öffnen" } }, "greetingSalutation": "Hallo, {name}", diff --git a/messages/en.json b/messages/en.json index c95da55f1..ac76e0b75 100644 --- a/messages/en.json +++ b/messages/en.json @@ -301,6 +301,10 @@ "viewSleep": "View sleep", "logMeasurement": "Log measurement", "viewInsights": "Open Insights" + }, + "briefing": { + "title": "Today’s briefing", + "viewAll": "Open Insights" } }, "greetingSalutation": "Hi, {name}", diff --git a/messages/es.json b/messages/es.json index 92f869149..e325e4055 100644 --- a/messages/es.json +++ b/messages/es.json @@ -301,6 +301,10 @@ "viewSleep": "Ver sueño", "logMeasurement": "Registrar medición", "viewInsights": "Abrir Insights" + }, + "briefing": { + "title": "Resumen de hoy", + "viewAll": "Abrir Insights" } }, "greetingSalutation": "Hola, {name}", diff --git a/messages/fr.json b/messages/fr.json index 8d8d40a7e..fa9ffc99e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -301,6 +301,10 @@ "viewSleep": "Voir le sommeil", "logMeasurement": "Saisir une mesure", "viewInsights": "Ouvrir Insights" + }, + "briefing": { + "title": "Briefing du jour", + "viewAll": "Ouvrir Insights" } }, "greetingSalutation": "Bonjour, {name}", diff --git a/messages/it.json b/messages/it.json index 93037e331..08c442904 100644 --- a/messages/it.json +++ b/messages/it.json @@ -301,6 +301,10 @@ "viewSleep": "Vedi sonno", "logMeasurement": "Registra misurazione", "viewInsights": "Apri Insights" + }, + "briefing": { + "title": "Briefing di oggi", + "viewAll": "Apri Insights" } }, "greetingSalutation": "Ciao, {name}", diff --git a/messages/pl.json b/messages/pl.json index 13ec008aa..b97894578 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -301,6 +301,10 @@ "viewSleep": "Zobacz sen", "logMeasurement": "Zapisz pomiar", "viewInsights": "Otwórz Insights" + }, + "briefing": { + "title": "Briefing dnia", + "viewAll": "Otwórz Insights" } }, "greetingSalutation": "Cześć, {name}", diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx new file mode 100644 index 000000000..8c72401be --- /dev/null +++ b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DailyBriefing } from "@/lib/ai/schema"; +import { BriefingSpotlight } from "../briefing-spotlight"; + +/** + * `` contract: + * + * - renders nothing unless the briefing is FRESH (`ready` + not stale) + * and carries at least one signal or finding; + * - prefers `signalsOfDay`, falling back to `keyFindings`; + * - caps the strip at 3 rows; + * - every row links to `/insights` and carries the tone-keyed accent; + * - headlines render as plain text children (no HTML / markdown). + */ + +function render( + props: React.ComponentProps, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +function briefing(overrides: Partial = {}): DailyBriefing { + return { + paragraph: "x", + signalsOfDay: null, + keyFindings: [], + ...overrides, + }; +} + +describe("", () => { + it("renders nothing when no briefing is present", () => { + expect( + render({ + briefing: null, + briefingState: "ready", + briefingStale: false, + }), + ).toBe(""); + }); + + it("renders nothing for a stale briefing", () => { + expect( + render({ + briefing: briefing({ + keyFindings: [ + { + tone: "watch", + headline: "BP creeping up", + detail: "d", + delta: "+6 mmHg", + sourceWindow: "30d", + sourceMetric: "bp", + }, + ], + }), + briefingState: "ready", + briefingStale: true, + }), + ).toBe(""); + }); + + it("renders nothing while the briefing is still preparing", () => { + expect( + render({ + briefing: briefing({ + keyFindings: [ + { + tone: "info", + headline: "h", + detail: "d", + delta: null, + sourceWindow: "30d", + sourceMetric: "weight", + }, + ], + }), + briefingState: "preparing", + briefingStale: false, + }), + ).toBe(""); + }); + + it("surfaces the present-focused signals and links each row to /insights", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "watch", + headline: "Short on sleep this week", + nudge: "Aim for an earlier night", + delta: "-1.2 h", + }, + ], + keyFindings: [ + { + tone: "good", + headline: "should not appear when signals exist", + detail: "d", + delta: null, + sourceWindow: "30d", + sourceMetric: "bp", + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + expect(html).toContain('data-slot="dashboard-briefing-spotlight"'); + expect(html).toContain("Short on sleep this week"); + expect(html).toContain('href="/insights"'); + expect(html).toContain("-1.2 h"); + // Signals win over key findings when present. + expect(html).not.toContain("should not appear when signals exist"); + }); + + it("falls back to key findings when no signals were generated", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [], + keyFindings: [ + { + tone: "watch", + headline: "Blood pressure trending up", + detail: "d", + delta: "+8 mmHg", + sourceWindow: "30d", + sourceMetric: "bp", + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + expect(html).toContain("Blood pressure trending up"); + }); + + it("caps the strip at three rows", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "info", + headline: "one", + nudge: "n", + delta: null, + }, + { + sourceMetric: "weight", + tone: "info", + headline: "two", + nudge: "n", + delta: null, + }, + { + sourceMetric: "mood", + tone: "info", + headline: "three", + nudge: "n", + delta: null, + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g); + expect(rows).toHaveLength(3); + }); +}); diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx new file mode 100644 index 000000000..ae222e0b8 --- /dev/null +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -0,0 +1,219 @@ +"use client"; + +/** + * v1.18.11 — dashboard briefing spotlight. + * + * The daily briefing previously surfaced on the dashboard ONLY as the + * hero's rung-8 verdict — a single sentence that almost always lost the + * verdict slot to a routine state (an upcoming dose, a weight drift, the + * all-quiet fallback). A fresh, model-authored briefing was effectively + * invisible above the fold. + * + * This strip raises the briefing's prominence WITHOUT touching its + * content or generation: it lifts the briefing's own present-focused + * "Signals of the day" (falling back to the longer-horizon key findings) + * straight onto the dashboard hero, so the freshest read is always one + * glance away. The verdict ladder is unchanged — a real BP crisis still + * leads the verdict line; this band sits below it. + * + * Design contract: + * - reuses the established tone-bar + metric-icon row language from the + * `/insights` daily-briefing card so the two surfaces read identically; + * - self-gating — renders nothing unless a FRESH briefing (`ready`, not + * stale) carries at least one signal or finding, so a missing / + * stale / disabled briefing never leaves an empty band; + * - rows are tappable, routing to `/insights` for the full briefing; + * - plain text children only (no HTML / markdown) per the repo-wide + * no-markdown-renderer rule; headlines pass through `stripChartTokens` + * to defend against a stray chart token leaking into the prose. + * + * Pure presentational: the caller resolves the briefing from the + * server-authoritative snapshot and passes the already-validated payload. + */ +import Link from "next/link"; +import { + Activity, + Flame, + Footprints, + Heart, + HeartPulse, + Mountain, + Moon, + Pill, + Route, + Scale, + Smile, + Sparkles, + Thermometer, + Wind, + Zap, +} from "lucide-react"; +import { ListRow } from "@/components/ui/list-row"; +import { useTranslations } from "@/lib/i18n/context"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { cn } from "@/lib/utils"; +import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema"; + +/** + * Max rows the spotlight surfaces. The briefing caps `signalsOfDay` at 3 + * and `keyFindings` at 5; the dashboard strip stays a glance, so it shows + * at most the top 3 — the user opens `/insights` for the full list. + */ +const MAX_SPOTLIGHT_ROWS = 3; + +const METRIC_ICON: Record< + DailyBriefingKeyFinding["sourceMetric"], + React.ComponentType<{ className?: string }> +> = { + bp: Heart, + weight: Scale, + pulse: Activity, + mood: Smile, + compliance: Pill, + hrv: Wind, + sleep: Moon, + resting_hr: HeartPulse, + steps: Footprints, + active_energy: Flame, + flights: Mountain, + distance: Route, + vo2_max: Zap, + body_temp: Thermometer, + glp1_plateau: Pill, + readiness: Sparkles, + recovery: HeartPulse, +}; + +const TONE_BAR_CLASSNAME: Record = { + good: "bg-success", + watch: "bg-warning", + info: "bg-info", +}; + +const TONE_TEXT_CLASSNAME: Record = { + good: "text-success", + watch: "text-warning", + info: "text-info", +}; + +/** One spotlight row, normalised from a signal or a key finding. */ +interface SpotlightRow { + sourceMetric: DailyBriefingKeyFinding["sourceMetric"]; + tone: DailyBriefingKeyFinding["tone"]; + headline: string; + delta: string | null; +} + +/** + * Choose the rows to spotlight. Prefers the present-focused + * `signalsOfDay` (the rebuilt briefing's lead); falls back to the + * longer-horizon `keyFindings` when no signals were generated. Returns an + * empty array when neither carries content (the caller then renders + * nothing). + */ +function selectSpotlightRows(briefing: DailyBriefing): SpotlightRow[] { + const signals = briefing.signalsOfDay ?? []; + if (signals.length > 0) { + return signals.slice(0, MAX_SPOTLIGHT_ROWS).map((s) => ({ + sourceMetric: s.sourceMetric, + tone: s.tone, + headline: s.headline, + delta: s.delta, + })); + } + return briefing.keyFindings.slice(0, MAX_SPOTLIGHT_ROWS).map((f) => ({ + sourceMetric: f.sourceMetric, + tone: f.tone, + headline: f.headline, + delta: f.delta, + })); +} + +export function BriefingSpotlight({ + briefing, + briefingState, + briefingStale, +}: { + briefing: DailyBriefing | null; + briefingState: string; + briefingStale: boolean; +}) { + const { t } = useTranslations(); + + // Fresh-only gate: a ready, non-stale briefing with at least one row. + // A stale / preparing / disabled briefing falls through to nothing so + // the band never sits empty or narrates a days-old read as current. + if (briefingState !== "ready" || briefingStale || briefing === null) { + return null; + } + const rows = selectSpotlightRows(briefing); + if (rows.length === 0) return null; + + return ( +
+
+

+ {t("dashboard.hero.briefing.title")} +

+ + {t("dashboard.hero.briefing.viewAll")} + +
+
+ {rows.map((row, index) => { + const Icon = METRIC_ICON[row.sourceMetric]; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index d13db1963..36863ac82 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -48,6 +48,7 @@ import { Pill } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; +import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight"; import { resolveDashboardVerdict, type DashboardVerdictVariant, @@ -325,6 +326,18 @@ export function DashboardHero({ untinted so it frames the score without alarming. Closes the web↔iOS parity gap: iOS already mirrors `score.restMode`. */} + {/* v1.18.11 — briefing spotlight. Lifts the fresh briefing's own + signals onto the hero so the daily read is always above the + fold, not buried behind the rung-8 verdict. Self-gating: renders + nothing unless a fresh (ready, non-stale) briefing carries + content. The rung-8 `briefing` verdict above still fires when no + higher rung claims the verdict line; this band is additive and + does not change which sentence the verdict shows. */} + );