mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user