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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:54:09 +02:00
1 parent 118415da12
commit af86a14824
9 files changed
+436

No files matched your search

+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Schlaf ansehen",
"logMeasurement": "Messung erfassen",
"viewInsights": "Insights öffnen"
},
"briefing": {
"title": "Tagesbriefing",
"viewAll": "Insights öffnen"
}
},
"greetingSalutation": "Hallo, {name}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "View sleep",
"logMeasurement": "Log measurement",
"viewInsights": "Open Insights"
},
"briefing": {
"title": "Today’s briefing",
"viewAll": "Open Insights"
}
},
"greetingSalutation": "Hi, {name}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Vedi sonno",
"logMeasurement": "Registra misurazione",
"viewInsights": "Apri Insights"
},
"briefing": {
"title": "Briefing di oggi",
"viewAll": "Apri Insights"
}
},
"greetingSalutation": "Ciao, {name}",
+4
View File
@@ -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}",
@@ -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";
/**
* `<BriefingSpotlight>` 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<typeof BriefingSpotlight>,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BriefingSpotlight {...props} />
</I18nProvider>,
);
}
function briefing(overrides: Partial<DailyBriefing> = {}): DailyBriefing {
return {
paragraph: "x",
signalsOfDay: null,
keyFindings: [],
...overrides,
};
}
describe("<BriefingSpotlight>", () => {
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);
});
});
@@ -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<DailyBriefingKeyFinding["tone"], string> = {
good: "bg-success",
watch: "bg-warning",
info: "bg-info",
};
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
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 (
<div data-slot="dashboard-briefing-spotlight" className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
{t("dashboard.hero.briefing.title")}
</p>
<Link
href="/insights"
data-slot="dashboard-briefing-spotlight-link"
className="text-muted-foreground hover:text-foreground text-[11px] underline underline-offset-2"
>
{t("dashboard.hero.briefing.viewAll")}
</Link>
</div>
<div className="space-y-2">
{rows.map((row, index) => {
const Icon = METRIC_ICON[row.sourceMetric];
return (
<ListRow
key={`${row.sourceMetric}-${index}`}
asChild
data-slot="dashboard-briefing-spotlight-row"
data-metric={row.sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Link href="/insights">
<span
aria-hidden="true"
className={cn(
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
TONE_BAR_CLASSNAME[row.tone],
)}
/>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
TONE_TEXT_CLASSNAME[row.tone],
)}
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
<p className="text-foreground text-sm leading-snug font-medium">
{stripChartTokens(row.headline)}
</p>
{row.delta ? (
<span
data-slot="dashboard-briefing-spotlight-delta"
className={cn(
"shrink-0 text-xs font-semibold tabular-nums",
TONE_TEXT_CLASSNAME[row.tone],
)}
>
{row.delta}
</span>
) : null}
</div>
</Link>
</ListRow>
);
})}
</div>
</div>
);
}
@@ -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`. */}
<RestModeBanner annotation={snapshot.healthScore?.restMode ?? null} />
{/* 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. */}
<BriefingSpotlight
briefing={snapshot.briefing}
briefingState={snapshot.briefingState}
briefingStale={snapshot.briefingStale}
/>
</div>
</section>
);