mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
118415da12
commit
af86a14824
9 files changed
+436
No files matched your search
@@ -301,6 +301,10 @@
|
||||
"viewSleep": "Schlaf ansehen",
|
||||
"logMeasurement": "Messung erfassen",
|
||||
"viewInsights": "Insights öffnen"
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Tagesbriefing",
|
||||
"viewAll": "Insights öffnen"
|
||||
}
|
||||
},
|
||||
"greetingSalutation": "Hallo, {name}",
|
||||
|
||||
@@ -301,6 +301,10 @@
|
||||
"viewSleep": "View sleep",
|
||||
"logMeasurement": "Log measurement",
|
||||
"viewInsights": "Open Insights"
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Today’s briefing",
|
||||
"viewAll": "Open Insights"
|
||||
}
|
||||
},
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -301,6 +301,10 @@
|
||||
"viewSleep": "Vedi sonno",
|
||||
"logMeasurement": "Registra misurazione",
|
||||
"viewInsights": "Apri Insights"
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Briefing di oggi",
|
||||
"viewAll": "Apri Insights"
|
||||
}
|
||||
},
|
||||
"greetingSalutation": "Ciao, {name}",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user