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 * `` (self-fetching, no measurement-type series); every * other slot resolves to `` 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; /** * i18n key for the slot's standard one-line description * (`insights.trendsRow.caption.*`). Every slot carries one so a card * never paints caption-less. For the legacy triple it is the * fallback the renderer shows when the advisor supplies no * annotation; for additive metrics it is the only caption (those * slots carry no advisor annotation). */ captionKey: string; /** * When set, the slot carries an advisor annotation under the chart * and uses the typed `` empty-state copy. Omitted * for additive metrics, which fall back to {@link captionKey}. */ annotationKey?: TrendAnnotationKey; /** * Routed detail page for the metric (`/insights/`), when one exists. * The Trends-row card links to it so the overview can drill into the metric * — notably the one cross-link to `/insights/steps`, which the overview * otherwise never offered despite the steps page existing and being used. * Omitted for slots with no dedicated page (mood routes through its own * surface; metabolic re-frames live on their composite pages). */ detailHref?: string; } /** * 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/`. */ 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", captionKey: "insights.trendsRow.caption.bp", annotationKey: "bp", detailHref: "/insights/blood-pressure", }, weight: { metric: "weight", kind: "health-chart", types: ["WEIGHT"], colors: ["#bd93f9"], unit: "kg", titleKey: "charts.weight", captionKey: "insights.trendsRow.caption.weight", annotationKey: "weight", detailHref: "/insights/weight", }, pulse: { metric: "pulse", kind: "health-chart", types: ["PULSE"], colors: ["#50fa7b"], unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.pulse", captionKey: "insights.trendsRow.caption.pulse", detailHref: "/insights/pulse", }, mood: { metric: "mood", kind: "mood", types: [], colors: [], titleKey: "charts.mood", captionKey: "insights.trendsRow.caption.mood", annotationKey: "mood", detailHref: "/insights/mood", }, sleep: { metric: "sleep", kind: "health-chart", types: ["SLEEP_DURATION"], colors: ["#8be9fd"], unit: "h", yAxisUnit: "h", titleKey: "charts.sleep", captionKey: "insights.trendsRow.caption.sleep", detailHref: "/insights/sleep", }, steps: { metric: "steps", kind: "health-chart", types: ["ACTIVITY_STEPS"], colors: ["#50fa7b"], unit: "steps", yAxisUnit: "steps", titleKey: "charts.steps", captionKey: "insights.trendsRow.caption.steps", detailHref: "/insights/steps", }, hrv: { metric: "hrv", kind: "health-chart", types: ["HEART_RATE_VARIABILITY"], colors: ["#bd93f9"], unit: "ms", yAxisUnit: "ms", titleKey: "charts.hrv", captionKey: "insights.trendsRow.caption.hrv", detailHref: "/insights/hrv", }, resting_hr: { metric: "resting_hr", kind: "health-chart", types: ["RESTING_HEART_RATE"], colors: ["#ff79c6"], unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.restingHeartRate", captionKey: "insights.trendsRow.caption.resting_hr", detailHref: "/insights/resting-pulse", }, active_energy: { metric: "active_energy", kind: "health-chart", types: ["ACTIVE_ENERGY_BURNED"], colors: ["#ffb86c"], unit: "kcal", yAxisUnit: "kcal", titleKey: "charts.activeEnergy", captionKey: "insights.trendsRow.caption.active_energy", detailHref: "/insights/active-energy", }, flights: { metric: "flights", kind: "health-chart", types: ["FLIGHTS_CLIMBED"], colors: ["#f1fa8c"], unit: "flights", yAxisUnit: "flights", titleKey: "charts.flights", captionKey: "insights.trendsRow.caption.flights", detailHref: "/insights/flights-climbed", }, distance: { metric: "distance", kind: "health-chart", types: ["WALKING_RUNNING_DISTANCE"], colors: ["#8be9fd"], unit: "km", yAxisUnit: "km", titleKey: "charts.distance", captionKey: "insights.trendsRow.caption.distance", detailHref: "/insights/walking-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", captionKey: "insights.trendsRow.caption.vo2_max", detailHref: "/insights/cardio-fitness", }, body_temp: { metric: "body_temp", kind: "health-chart", types: ["BODY_TEMPERATURE"], colors: ["#ff5555"], unit: "°C", yAxisUnit: "°C", titleKey: "charts.bodyTemperature", captionKey: "insights.trendsRow.caption.body_temp", detailHref: "/insights/body-temperature", }, // 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, // ── v1.10.0 derived-wellness additive ── // The wellness scores are 0–100 composites whose deeper view is the // score-anatomy page, not a single plottable trend series in this row. readiness: null, recovery: 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, cap: number, ): TrendChartConfig[] { const seen = new Set(); 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); }