diff --git a/messages/de.json b/messages/de.json index b77f2b496..427e7d436 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 29888b6f3..f89b859b5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index dfe8993b0..4e03cd711 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 7ae6e73a1..798d3d114 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index d290ac44f..28e18988d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index c511d661f..3b1764904 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a7df175c1..0153fc9d9 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -225,6 +225,7 @@ export default function InsightsPage() { )} diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 7c518b0a2..490189708 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -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("", () => { 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( + , + ); + 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( + , + ); + 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(); + 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(); + 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( ` 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 (`` for every + * measurement series; `` 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`: 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 - * `` 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, ``'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 ; + } + return ( + + ); +} + 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 (
` 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 `` + 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). */}
-
- {/* v1.4.28 R3c-Insights — fixed chart slot. `` - 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. */} -
- -
- -
-
-
- -
- -
-
-
- -
- -
+ {charts.map((config) => { + const title = t(config.titleKey); + return ( +
+ {/* 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. */} +
+ +
+ {config.annotationKey ? ( + + ) : null} +
+ ); + })}
); diff --git a/src/lib/insights/__tests__/trend-chart-select.test.ts b/src/lib/insights/__tests__/trend-chart-select.test.ts new file mode 100644 index 000000000..49f58b2f1 --- /dev/null +++ b/src/lib/insights/__tests__/trend-chart-select.test.ts @@ -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); + } + } + }); +}); diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts new file mode 100644 index 000000000..bfdd9f8d5 --- /dev/null +++ b/src/lib/insights/trend-chart-select.ts @@ -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 + * `` (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; + /** + * When set, the slot carries an advisor annotation under the chart + * and uses the typed `` 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/`. + */ +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, + 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); +}