feat(insights): drive the Trends charts from the daily briefing

The overview Trends row used to render a fixed BP / weight / mood
triple regardless of what the day's data surfaced. It now mirrors the
daily briefing: the row charts exactly the metrics the briefing flags,
in the briefing's priority order, deduped and capped at three. When no
briefing is available — cold mount, a web-only account with no
findings, or a pre-briefing cached payload — selection falls back to
the legacy BP / weight / mood triple so the row never paints empty.

Selection lives in `trend-chart-select.ts` as a pure, unit-tested
mapping from each briefing `sourceMetric` to a chart descriptor
(series, colour, unit, title key). Adherence-context findings
(compliance, GLP-1 plateau) have no standalone trend series and are
skipped so the next finding fills the slot. Annotations stay on the
legacy triple; additive HealthKit metrics render chart-only. The chart
set is derived from the briefing payload already on the page, so there
is no new blocking call.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 20:26:31 +02:00
1 parent 6ce09cc76d
commit 5de9ddb0cc
11 files changed
+609 -90

No files matched your search

@@ -30,6 +30,23 @@ vi.mock("next/dynamic", () => ({
}));
import { TrendsRow } from "../trends-row";
import type { DailyBriefing } from "@/lib/ai/schema";
function briefing(
metrics: DailyBriefing["keyFindings"][number]["sourceMetric"][],
): DailyBriefing {
return {
paragraph: "Synthesised briefing paragraph.",
keyFindings: metrics.map((sourceMetric) => ({
tone: "watch" as const,
headline: `${sourceMetric} headline`,
detail: `${sourceMetric} detail`,
delta: null,
sourceWindow: "30d" as const,
sourceMetric,
})),
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
@@ -165,6 +182,44 @@ describe("<TrendsRow>", () => {
expect(pending.length).toBe(3);
});
// ── v1.8.5 — dynamic chart set driven by the daily briefing ───────
it("charts the metrics the briefing flags, in briefing order", () => {
const html = render(
<TrendsRow briefing={briefing(["weight", "pulse", "sleep"])} />,
);
expect(html).toMatch(/data-metric="weight"/);
expect(html).toMatch(/data-metric="pulse"/);
expect(html).toMatch(/data-metric="sleep"/);
// The default BP / mood tiles drop out when the briefing flags
// other metrics.
expect(html).not.toMatch(/data-metric="bp"/);
expect(html).not.toMatch(/data-metric="mood"/);
});
it("caps the briefing-driven set at three tiles", () => {
const html = render(
<TrendsRow
briefing={briefing(["weight", "pulse", "sleep", "hrv", "steps"])}
/>,
);
const cards = html.match(/data-slot="trends-row-card"/g) ?? [];
expect(cards.length).toBe(3);
});
it("falls back to BP / weight / mood when the briefing is empty", () => {
const html = render(<TrendsRow briefing={briefing([])} />);
expect(html).toMatch(/data-metric="bp"/);
expect(html).toMatch(/data-metric="weight"/);
expect(html).toMatch(/data-metric="mood"/);
});
it("renders additive metrics chart-only (no annotation slot)", () => {
const html = render(<TrendsRow briefing={briefing(["pulse"])} />);
expect(html).toMatch(/data-metric="pulse"/);
// No TrendAnnotation empty/pending slot for a chart-only tile.
expect(html).not.toMatch(/data-slot="trend-annotation-empty"/);
});
it("propagates per-metric confidence chips", () => {
const html = render(
<TrendsRow
+102 -90
View File
@@ -5,6 +5,12 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import type { DailyBriefing } from "@/lib/ai/schema";
import {
selectTrendCharts,
type TrendAnnotationKey,
type TrendChartConfig,
} from "@/lib/insights/trend-chart-select";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
@@ -12,20 +18,30 @@ import {
} from "./trend-annotation";
/**
* Trends row — three small charts (BP / Weight / Mood) above a
* one-sentence assessment each. Recharts is ~108 KiB Brotli, so the
* three charts ride the shared lazy-import boundary
* (`<HealthChartDynamicDynamic>` for BP + weight; MoodChart still resolves
* locally because no shared re-export exists yet for it).
* Trends row — a small chart per metric above a one-sentence
* assessment. Recharts is ~108 KiB Brotli, so the charts ride the
* shared lazy-import boundary (`<HealthChartDynamicMini>` for every
* measurement series; `<MoodChart>` resolves locally because no shared
* re-export exists for it yet).
*
* v1.8.5 — the chart set is DYNAMIC. It mirrors the daily briefing:
* the row charts exactly the metrics the briefing flags, in the
* briefing's priority order, deduped and capped (top three). When no
* briefing is available — cold mount, web-only account with no
* findings, or a pre-briefing cached payload — `selectTrendCharts`
* falls back to the legacy BP / weight / mood triple so the row never
* paints empty. Selection lives in `@/lib/insights/trend-chart-select`
* (pure + unit-tested); this component is the renderer.
*
* Layout:
* - `<md`: single column, full width
* - `>=md`: 3-up grid, equal column tracks
*
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the
* Insights payload. When a metric's annotation is null, the
* `<TrendAnnotation>` empty state hints at the gap without breaking
* the row's visual rhythm.
* Insights payload and only attach to those three slots (the advisor
* authors annotations for the legacy triple). Additive metrics render
* chart-only. When a slot's annotation is null, `<TrendAnnotation>`'s
* empty state hints at the gap without breaking the row's rhythm.
*/
const MoodChart = dynamic(
@@ -38,9 +54,16 @@ const MoodChart = dynamic(
interface TrendsRowProps {
/**
* Assistant-authored one-sentence annotations, keyed by metric. Optional —
* legacy advisor payloads (pre-PROMPT_VERSION 4.20.1) won't carry
* the field.
* Daily briefing payload. Drives the chart set: the row charts the
* metrics the briefing flags, in order, deduped + capped. `null` /
* omitted → the default BP / weight / mood triple.
*/
briefing?: DailyBriefing | null;
/**
* Assistant-authored one-sentence annotations, keyed by metric. Only
* the legacy triple (bp / weight / mood) carries annotations.
* Optional — legacy advisor payloads (pre-PROMPT_VERSION 4.20.1)
* won't carry the field.
*/
annotations?: {
bp?: string | null;
@@ -67,17 +90,38 @@ interface TrendsRowProps {
loading?: boolean;
}
function MetricChart({ config, title }: { config: TrendChartConfig; title: string }) {
const { user } = useAuth();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini userTimezone={userTimezone} />;
}
return (
<HealthChartDynamicMini
types={config.types}
title={title}
colors={config.colors}
unit={config.unit}
yAxisUnit={config.yAxisUnit}
mini
userTimezone={userTimezone}
/>
);
}
export function TrendsRow({
briefing,
annotations,
confidence,
loading = false,
}: TrendsRowProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTimezone = user?.timezone;
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
// v1.8.5 — derive the chart set from the briefing. No new fetch: the
// briefing payload is already on the page (advisor cache), so this is
// a pure read that respects the v1.8.3 anti-freeze contract.
const charts = selectTrendCharts(briefing);
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
// over needs_data so a mid-generation regenerate doesn't flash the
@@ -87,6 +131,9 @@ export function TrendsRow({
return annotation ? "generated" : "needs_data";
};
const annotationFor = (key: TrendAnnotationKey): string | null =>
annotations?.[key] ?? null;
return (
<section
data-slot="trends-row"
@@ -122,81 +169,46 @@ export function TrendsRow({
mobile single-column path picks up the same row contract
when the user expands the strip side-by-side via the
orientation-change. The chart slot is now wrapped in a
fixed-height shell so BP / weight / mood all paint the
series at the same vertical position; the annotation slot
clamps via `<TrendAnnotation>` so long captions can't pull
the row taller than the design constant. */}
fixed-height shell so the series paint at the same vertical
position; the annotation slot clamps via `<TrendAnnotation>`
so long captions can't pull the row taller than the design
constant.
v1.8.5 — the cards are now rendered from the dynamic chart
set instead of three hard-coded tiles. The grid + slot
contract is unchanged; the card count tracks the briefing
(capped at three so the 3-up template still holds). */}
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-3 md:items-stretch">
<div
data-slot="trends-row-card"
data-metric="bp"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
{/* v1.4.28 R3c-Insights — fixed chart slot. `<HealthChartDynamic>`
mini paints its own 140 px chart band; this wrapper pins
the total chart-envelope height so the mood tile's Card
wrapper (which carries a heavier shell on a default
shadcn Card) lines up with the BP/weight tiles' lighter
shell. Both chart types ship the same data-slot now. */}
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
<HealthChartDynamicMini
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="mmHg"
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="bp"
annotation={bpAnnotation}
confidence={confidence?.bp}
status={statusFor(bpAnnotation)}
/>
</div>
<div
data-slot="trends-row-card"
data-metric="weight"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
<HealthChartDynamicMini
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="weight"
annotation={weightAnnotation}
confidence={confidence?.weight}
status={statusFor(weightAnnotation)}
/>
</div>
<div
data-slot="trends-row-card"
data-metric="mood"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
<MoodChart
title={t("charts.mood")}
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="mood"
annotation={moodAnnotation}
confidence={confidence?.mood}
status={statusFor(moodAnnotation)}
/>
</div>
{charts.map((config) => {
const title = t(config.titleKey);
return (
<div
key={config.metric}
data-slot="trends-row-card"
data-metric={config.metric}
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
{/* v1.4.28 R3c-Insights — fixed chart slot. The mini
chart paints its own band; this wrapper pins the
total chart-envelope height so every tile lines up on
a single baseline regardless of the chart kind. */}
<div
data-slot="trends-row-chart-slot"
className="h-[180px] shrink-0"
>
<MetricChart config={config} title={title} />
</div>
{config.annotationKey ? (
<TrendAnnotation
metric={config.annotationKey}
annotation={annotationFor(config.annotationKey)}
confidence={confidence?.[config.annotationKey]}
status={statusFor(annotationFor(config.annotationKey))}
/>
) : null}
</div>
);
})}
</div>
</section>
);