mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
6ce09cc76d
commit
5de9ddb0cc
11 files changed
+609
-90
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user