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

+7
View File
@@ -1335,6 +1335,13 @@
"bodyFat": "Körperfett",
"sleep": "Schlaf",
"steps": "Schritte",
"hrv": "HRV",
"restingHeartRate": "Ruhepuls",
"activeEnergy": "Aktive Energie",
"flights": "Treppen",
"distance": "Geh- + Laufstrecke",
"vo2Max": "VO₂max",
"bodyTemperature": "Körpertemperatur",
"noData": "Keine Daten im gewählten Zeitraum",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
+7
View File
@@ -1335,6 +1335,13 @@
"bodyFat": "Body Fat",
"sleep": "Sleep",
"steps": "Steps",
"hrv": "HRV",
"restingHeartRate": "Resting heart rate",
"activeEnergy": "Active energy",
"flights": "Flights climbed",
"distance": "Walking + running distance",
"vo2Max": "VO₂ max",
"bodyTemperature": "Body temperature",
"noData": "No data in selected time range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
+7
View File
@@ -1335,6 +1335,13 @@
"bodyFat": "Grasa corporal",
"sleep": "Sueño",
"steps": "Pasos",
"hrv": "VFC",
"restingHeartRate": "FC en reposo",
"activeEnergy": "Energía activa",
"flights": "Pisos subidos",
"distance": "Distancia a pie + corriendo",
"vo2Max": "VO₂ máx",
"bodyTemperature": "Temperatura corporal",
"noData": "Sin datos en el periodo seleccionado",
"bucketWeekly": "Media semanal",
"bucketMonthly": "Media mensual",
+7
View File
@@ -1335,6 +1335,13 @@
"bodyFat": "Masse grasse",
"sleep": "Sommeil",
"steps": "Pas",
"hrv": "VFC",
"restingHeartRate": "FC au repos",
"activeEnergy": "Énergie active",
"flights": "Étages gravis",
"distance": "Distance marche + course",
"vo2Max": "VO₂ max",
"bodyTemperature": "Température corporelle",
"noData": "Aucune donnée sur la période sélectionnée",
"bucketWeekly": "Moyenne hebdomadaire",
"bucketMonthly": "Moyenne mensuelle",
+7
View File
@@ -1335,6 +1335,13 @@
"bodyFat": "Massa grassa",
"sleep": "Sonno",
"steps": "Passi",
"hrv": "HRV",
"restingHeartRate": "FC a riposo",
"activeEnergy": "Energia attiva",
"flights": "Piani saliti",
"distance": "Distanza camminata + corsa",
"vo2Max": "VO₂ max",
"bodyTemperature": "Temperatura corporea",
"noData": "Nessun dato nel periodo selezionato",
"bucketWeekly": "Media settimanale",
"bucketMonthly": "Media mensile",
+7
View File
@@ -1335,6 +1335,13 @@
"bodyFat": "Tkanka tłuszczowa",
"sleep": "Sen",
"steps": "Kroki",
"hrv": "HRV",
"restingHeartRate": "Tętno spoczynkowe",
"activeEnergy": "Energia czynna",
"flights": "Pokonane piętra",
"distance": "Dystans marsz + bieg",
"vo2Max": "VO₂ max",
"bodyTemperature": "Temperatura ciała",
"noData": "Brak danych w wybranym okresie",
"bucketWeekly": "Średnia tygodniowa",
"bucketMonthly": "Średnia miesięczna",
+1
View File
@@ -225,6 +225,7 @@ export default function InsightsPage() {
)}
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
@@ -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>
);
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import type {
DailyBriefing,
DailyBriefingKeyFinding,
} from "@/lib/ai/schema";
import {
selectTrendCharts,
DEFAULT_TREND_CHART_CAP,
TREND_CHART_CONFIG,
} from "../trend-chart-select";
/**
* v1.8.5 — the Trends row charts the metrics the daily briefing flags,
* in briefing order, deduped, capped, with the legacy BP / weight /
* mood triple as the fallback. These tests pin the selection contract;
* the renderer is a thin consumer.
*/
function finding(
sourceMetric: DailyBriefingKeyFinding["sourceMetric"],
): DailyBriefingKeyFinding {
return {
tone: "watch",
headline: `${sourceMetric} headline`,
detail: `${sourceMetric} detail`,
delta: null,
sourceWindow: "30d",
sourceMetric,
};
}
function briefing(
metrics: DailyBriefingKeyFinding["sourceMetric"][],
): DailyBriefing {
return {
paragraph: "Synthesised briefing paragraph.",
keyFindings: metrics.map(finding),
};
}
describe("selectTrendCharts", () => {
it("falls back to the BP / weight / mood triple when the briefing is null", () => {
const charts = selectTrendCharts(null);
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("falls back to the default triple when the briefing is undefined", () => {
const charts = selectTrendCharts(undefined);
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("falls back when the briefing carries no key findings", () => {
const charts = selectTrendCharts(briefing([]));
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("charts exactly the metrics the briefing flags, in briefing order", () => {
const charts = selectTrendCharts(briefing(["weight", "pulse", "sleep"]));
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse", "sleep"]);
});
it("dedupes repeated metrics, keeping first-seen order", () => {
const charts = selectTrendCharts(
briefing(["bp", "weight", "bp", "pulse"]),
);
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "pulse"]);
});
it("caps at the default of three", () => {
const charts = selectTrendCharts(
briefing(["weight", "pulse", "sleep", "hrv", "steps"]),
);
expect(charts).toHaveLength(DEFAULT_TREND_CHART_CAP);
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse", "sleep"]);
});
it("honours a custom cap", () => {
const charts = selectTrendCharts(briefing(["weight", "pulse", "sleep"]), {
cap: 2,
});
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse"]);
});
it("clamps a zero/negative cap to at least one", () => {
const charts = selectTrendCharts(briefing(["weight", "pulse"]), { cap: 0 });
expect(charts).toHaveLength(1);
expect(charts[0].metric).toBe("weight");
});
it("skips metrics with no standalone trend chart (compliance, glp1_plateau)", () => {
const charts = selectTrendCharts(
briefing(["compliance", "weight", "glp1_plateau", "pulse"]),
);
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse"]);
});
it("falls back to the default triple when every finding is chartless", () => {
const charts = selectTrendCharts(briefing(["compliance", "glp1_plateau"]));
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("resolves additive HealthKit metrics to a chart config", () => {
const charts = selectTrendCharts(briefing(["hrv", "active_energy"]));
expect(charts.map((c) => c.metric)).toEqual(["hrv", "active_energy"]);
expect(charts[0].types).toEqual(["HEART_RATE_VARIABILITY"]);
expect(charts[1].types).toEqual(["ACTIVE_ENERGY_BURNED"]);
});
it("carries an annotation key only for the legacy triple", () => {
const charts = selectTrendCharts(
briefing(["bp", "weight", "mood"]),
);
expect(charts.map((c) => c.annotationKey)).toEqual([
"bp",
"weight",
"mood",
]);
const pulseCharts = selectTrendCharts(briefing(["pulse"]));
expect(pulseCharts[0].annotationKey).toBeUndefined();
});
it("maps the mood slot to the bespoke mood chart kind", () => {
const charts = selectTrendCharts(briefing(["mood"]));
expect(charts[0].kind).toBe("mood");
expect(charts[0].types).toEqual([]);
});
it("every chart-bearing sourceMetric resolves to a non-empty health-chart series", () => {
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
if (!config) continue;
if (config.kind === "health-chart") {
expect(config.types.length, `${metric} types`).toBeGreaterThan(0);
expect(
config.colors.length,
`${metric} colors`,
).toBeGreaterThanOrEqual(config.types.length);
}
}
});
});
+268
View File
@@ -0,0 +1,268 @@
import type {
DailyBriefing,
DailyBriefingKeyFinding,
} from "@/lib/ai/schema";
/**
* v1.8.5 — derive the Insights overview "Trends" chart set from the
* daily briefing.
*
* The Trends row used to be a fixed BP / weight / mood triple. The
* briefing already surfaces the day's load-bearing findings (each one
* tied to a `sourceMetric`), so the row should chart exactly those
* flagged metrics — in the briefing's own priority order, deduped,
* capped, with the legacy triple as the fallback when the briefing is
* empty or unavailable.
*
* This module is the pure mapping layer:
* - `TREND_CHART_CONFIG` maps every briefing `sourceMetric` to a
* concrete chart descriptor (which measurement series to plot, its
* colour + unit + i18n title key, and whether the advisor supplies
* a one-sentence annotation for it).
* - `selectTrendCharts(briefing, opts)` walks the briefing findings,
* resolves each to a chart slot, dedupes, caps, and falls back to
* the default triple when nothing usable surfaces.
*
* Kept free of React/Recharts so the selection logic is unit-testable
* in isolation and the renderer stays a thin consumer.
*/
/**
* The annotation key the advisor populates for a slot, when one exists.
* Only the legacy triple (bp / weight / mood) carries AI annotations in
* `trendAnnotations`; every additive metric renders chart-only.
*/
export type TrendAnnotationKey = "bp" | "weight" | "mood";
/**
* How the slot's chart is rendered. `mood` resolves to the bespoke
* `<MoodChart>` (self-fetching, no measurement-type series); every
* other slot resolves to `<HealthChart>` driven by `types`.
*/
export type TrendChartKind = "health-chart" | "mood";
export interface TrendChartConfig {
/** Stable slot id — used as the `data-metric` attribute + React key. */
metric: string;
/** Which chart component to mount. */
kind: TrendChartKind;
/**
* Measurement series for the `health-chart` kind. Empty for `mood`
* (the mood chart self-fetches its own aggregate).
*/
types: string[];
/** Recharts stroke colours, positionally matched to `types`. */
colors: string[];
/** Display unit (chart legend / tooltip). */
unit?: string;
/** Y-axis unit label when it differs from the value unit. */
yAxisUnit?: string;
/** i18n key for the chart title (`charts.*`). */
titleKey: string;
/**
* When set, the slot carries an advisor annotation under the chart
* and uses the typed `<TrendAnnotation>` empty-state copy. Omitted
* for additive metrics that render chart-only.
*/
annotationKey?: TrendAnnotationKey;
}
/**
* Per-`sourceMetric` chart descriptor. `null` marks a metric that has
* no standalone trend chart in this row (compliance + the GLP-1
* plateau finding are adherence-context observations, not a single
* plottable series), so a finding on that metric is skipped during
* selection and the next finding fills the slot instead.
*
* Colours + units mirror the dedicated sub-pages so a metric looks
* identical whether the user lands on it here or on `/insights/<slug>`.
*/
export const TREND_CHART_CONFIG: Record<
DailyBriefingKeyFinding["sourceMetric"],
TrendChartConfig | null
> = {
bp: {
metric: "bp",
kind: "health-chart",
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
colors: ["#ff79c6", "#8be9fd"],
unit: "mmHg",
yAxisUnit: "mmHg",
titleKey: "charts.bloodPressure",
annotationKey: "bp",
},
weight: {
metric: "weight",
kind: "health-chart",
types: ["WEIGHT"],
colors: ["#bd93f9"],
unit: "kg",
titleKey: "charts.weight",
annotationKey: "weight",
},
pulse: {
metric: "pulse",
kind: "health-chart",
types: ["PULSE"],
colors: ["#50fa7b"],
unit: "bpm",
yAxisUnit: "bpm",
titleKey: "charts.pulse",
},
mood: {
metric: "mood",
kind: "mood",
types: [],
colors: [],
titleKey: "charts.mood",
annotationKey: "mood",
},
sleep: {
metric: "sleep",
kind: "health-chart",
types: ["SLEEP_DURATION"],
colors: ["#8be9fd"],
unit: "h",
yAxisUnit: "h",
titleKey: "charts.sleep",
},
steps: {
metric: "steps",
kind: "health-chart",
types: ["ACTIVITY_STEPS"],
colors: ["#50fa7b"],
unit: "steps",
yAxisUnit: "steps",
titleKey: "charts.steps",
},
hrv: {
metric: "hrv",
kind: "health-chart",
types: ["HEART_RATE_VARIABILITY"],
colors: ["#bd93f9"],
unit: "ms",
yAxisUnit: "ms",
titleKey: "charts.hrv",
},
resting_hr: {
metric: "resting_hr",
kind: "health-chart",
types: ["RESTING_HEART_RATE"],
colors: ["#ff79c6"],
unit: "bpm",
yAxisUnit: "bpm",
titleKey: "charts.restingHeartRate",
},
active_energy: {
metric: "active_energy",
kind: "health-chart",
types: ["ACTIVE_ENERGY_BURNED"],
colors: ["#ffb86c"],
unit: "kcal",
yAxisUnit: "kcal",
titleKey: "charts.activeEnergy",
},
flights: {
metric: "flights",
kind: "health-chart",
types: ["FLIGHTS_CLIMBED"],
colors: ["#f1fa8c"],
unit: "flights",
yAxisUnit: "flights",
titleKey: "charts.flights",
},
distance: {
metric: "distance",
kind: "health-chart",
types: ["WALKING_RUNNING_DISTANCE"],
colors: ["#8be9fd"],
unit: "km",
yAxisUnit: "km",
titleKey: "charts.distance",
},
vo2_max: {
metric: "vo2_max",
kind: "health-chart",
types: ["VO2_MAX"],
colors: ["#50fa7b"],
unit: "mL/kg·min",
yAxisUnit: "mL/kg·min",
titleKey: "charts.vo2Max",
},
body_temp: {
metric: "body_temp",
kind: "health-chart",
types: ["BODY_TEMPERATURE"],
colors: ["#ff5555"],
unit: "°C",
yAxisUnit: "°C",
titleKey: "charts.bodyTemperature",
},
// Adherence-context findings — no single plottable trend series in
// this row. A finding on one of these is skipped during selection so
// the next finding (or the default fallback) takes the slot.
compliance: null,
glp1_plateau: null,
};
/**
* Default chart set when the briefing is empty / unavailable. Mirrors
* the legacy fixed Trends triple so a cold mount, a web-only account
* with no findings, or a pre-briefing payload keeps the familiar
* BP / weight / mood row.
*/
export const DEFAULT_TREND_METRICS: ReadonlyArray<
DailyBriefingKeyFinding["sourceMetric"]
> = ["bp", "weight", "mood"];
/** Max charts the row renders. Three keeps the equal-height 3-up grid. */
export const DEFAULT_TREND_CHART_CAP = 3;
export interface SelectTrendChartsOptions {
/** Max number of charts to return. Defaults to {@link DEFAULT_TREND_CHART_CAP}. */
cap?: number;
}
function configsFor(
metrics: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>,
cap: number,
): TrendChartConfig[] {
const seen = new Set<string>();
const out: TrendChartConfig[] = [];
for (const metric of metrics) {
if (out.length >= cap) break;
const config = TREND_CHART_CONFIG[metric];
if (!config) continue; // metric has no standalone trend chart
if (seen.has(config.metric)) continue; // dedupe
seen.add(config.metric);
out.push(config);
}
return out;
}
/**
* Resolve the Trends-row chart set from a daily briefing.
*
* Order follows the briefing's own `keyFindings` ordering (the model
* emits the most load-bearing finding first). Metrics are deduped on
* their chart slot and the result is capped. When the briefing is
* `null`, carries no findings, or every finding maps to a
* chartless metric, the default BP / weight / mood triple is returned
* so the row never paints empty.
*/
export function selectTrendCharts(
briefing: DailyBriefing | null | undefined,
options: SelectTrendChartsOptions = {},
): TrendChartConfig[] {
const cap = Math.max(1, options.cap ?? DEFAULT_TREND_CHART_CAP);
const findingMetrics =
briefing?.keyFindings?.map((f) => f.sourceMetric) ?? [];
const fromBriefing = configsFor(findingMetrics, cap);
if (fromBriefing.length > 0) {
return fromBriefing;
}
return configsFor(DEFAULT_TREND_METRICS, cap);
}