From 4666359470ac6e313246a415a5fdae68b15c841d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 17:02:42 +0200 Subject: [PATCH] feat(insights): compute a deterministic trend caption when the AI annotation is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Trends-row card carries a one-line caption below its mini-chart. The first choice is the AI advisor's per-metric sentence, but on a cold briefing — a web-only account, a pre-briefing cached payload, or the advisor still generating — that sentence is null and every card fell back to a static "Awaiting more data" hint, even with a full 30-day series painted right above it. Add a deterministic, rule-based descriptor derived from the same series the mini-chart plots, and apply a three-tier caption precedence: 1. advisor annotation present → the AI sentence (unchanged). 2. else, a computable series → the deterministic descriptor: direction (rising / falling / stable) plus the first-vs-last magnitude over the window, e.g. "Rising over 30 days (+8 mmHg)", "Falling over 30 days (−1.4 kg)", "Stable over the last 30 days". Mood reads on a categorical 1–5 scale, so it is phrased in plain terms ("Mood trended slightly higher over 30 days") with no raw point delta. 3. else (genuinely < 2 points) → the real "Awaiting more data" hint. The descriptor is observational and neutral by construction: direction and magnitude only, with no causal, diagnostic, or medical claim and no value judgement — a rising weight and a rising step count read the same way here. A per-metric stability floor (the larger of an absolute and a relative bound) keeps day-to-day noise from reading as a trend. The descriptor logic lives in a pure, unit-tested helper. The caption reads the same series the chart shows: mood reuses the existing mood-analytics cache slot, and numeric metrics take a small dedicated 30-day daily-aggregate read keyed under a new query-key factory entry that joins the measurement-write invalidation set. New descriptor copy ships in all six locales. The pending shimmer while the advisor is in flight is unchanged. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + .../trend-descriptor-caption.test.tsx | 189 ++++++++++++++ src/components/insights/trend-annotation.tsx | 186 ++++++++++++++ src/components/insights/trends-row.tsx | 47 +++- .../__tests__/trend-descriptor.test.ts | 192 ++++++++++++++ src/lib/insights/trend-descriptor.ts | 239 ++++++++++++++++++ src/lib/query-keys.ts | 17 ++ 12 files changed, 912 insertions(+), 6 deletions(-) create mode 100644 src/components/insights/__tests__/trend-descriptor-caption.test.tsx create mode 100644 src/lib/insights/__tests__/trend-descriptor.test.ts create mode 100644 src/lib/insights/trend-descriptor.ts diff --git a/messages/de.json b/messages/de.json index 581ad0cc8..fd04c3845 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2812,6 +2812,14 @@ "veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.", "fired": "Dein Gerät hat dieses Ereignis erkannt." } + }, + "trendDescriptor": { + "rising": "Über 30 Tage steigend ({delta}{unit}).", + "falling": "Über 30 Tage fallend ({delta}{unit}).", + "stable": "Über die letzten 30 Tage stabil.", + "moodImproved": "Stimmung über 30 Tage leicht verbessert.", + "moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.", + "moodStable": "Stimmung über die letzten 30 Tage stabil." } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 79060ac87..57779b507 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2812,6 +2812,14 @@ "veryLow": "Your device flagged very low walking steadiness.", "fired": "Your device flagged this event." } + }, + "trendDescriptor": { + "rising": "Rising over 30 days ({delta}{unit}).", + "falling": "Falling over 30 days ({delta}{unit}).", + "stable": "Stable over the last 30 days.", + "moodImproved": "Mood trended slightly higher over 30 days.", + "moodDeclined": "Mood trended slightly lower over 30 days.", + "moodStable": "Mood held steady over the last 30 days." } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 4320e0efd..0644325b5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2812,6 +2812,14 @@ "veryLow": "Tu dispositivo detectó una estabilidad al caminar muy baja.", "fired": "Tu dispositivo señaló este evento." } + }, + "trendDescriptor": { + "rising": "Al alza en 30 días ({delta}{unit}).", + "falling": "A la baja en 30 días ({delta}{unit}).", + "stable": "Estable en los últimos 30 días.", + "moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.", + "moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.", + "moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días." } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 8371ffcdd..c5ae3f79e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2812,6 +2812,14 @@ "veryLow": "Votre appareil a détecté une très faible stabilité à la marche.", "fired": "Votre appareil a signalé cet événement." } + }, + "trendDescriptor": { + "rising": "En hausse sur 30 jours ({delta}{unit}).", + "falling": "En baisse sur 30 jours ({delta}{unit}).", + "stable": "Stable sur les 30 derniers jours.", + "moodImproved": "Humeur en légère hausse sur 30 jours.", + "moodDeclined": "Humeur en légère baisse sur 30 jours.", + "moodStable": "Humeur stable sur les 30 derniers jours." } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 6cbbd13cd..1355501b6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2812,6 +2812,14 @@ "veryLow": "Il tuo dispositivo ha rilevato una stabilità nella camminata molto bassa.", "fired": "Il tuo dispositivo ha segnalato questo evento." } + }, + "trendDescriptor": { + "rising": "In aumento su 30 giorni ({delta}{unit}).", + "falling": "In calo su 30 giorni ({delta}{unit}).", + "stable": "Stabile negli ultimi 30 giorni.", + "moodImproved": "Umore in lieve miglioramento su 30 giorni.", + "moodDeclined": "Umore in lieve calo su 30 giorni.", + "moodStable": "Umore stabile negli ultimi 30 giorni." } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index bc818c0bc..2c4e83608 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2812,6 +2812,14 @@ "veryLow": "Twoje urządzenie wykryło bardzo niską stabilność chodu.", "fired": "Twoje urządzenie zgłosiło to zdarzenie." } + }, + "trendDescriptor": { + "rising": "Rośnie w ciągu 30 dni ({delta}{unit}).", + "falling": "Spada w ciągu 30 dni ({delta}{unit}).", + "stable": "Stabilnie przez ostatnie 30 dni.", + "moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.", + "moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.", + "moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni." } }, "thresholds": { diff --git a/src/components/insights/__tests__/trend-descriptor-caption.test.tsx b/src/components/insights/__tests__/trend-descriptor-caption.test.tsx new file mode 100644 index 000000000..079b5afe1 --- /dev/null +++ b/src/components/insights/__tests__/trend-descriptor-caption.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.11.4 item J — `` is the deterministic + * fallback the Trends row shows when the AI advisor produced no + * annotation (cold briefing). It reads the SAME series the mini-chart + * plots and renders a neutral direction + magnitude descriptor, or the + * real "Awaiting more data" hint only when the series is genuinely too + * sparse. + * + * The component reads its series through `useQuery`. We pre-seed the + * QueryClient cache (matching the factory keys) with `staleTime: + * Infinity` so the SSR render resolves synchronously off the cache, and + * stub `useAuth` authenticated so the query is enabled. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { TrendDescriptorCaption } from "../trend-annotation"; + +function seededClient( + seed: (client: QueryClient) => void, +): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + seed(client); + return client; +} + +function render(client: QueryClient, node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {node} + , + ); +} + +function numericSeries(values: number[]): { + value: number; + measuredAt: string; +}[] { + return values.map((value, i) => ({ + value, + measuredAt: new Date(Date.UTC(2026, 0, 1 + i, 12)).toISOString(), + })); +} + +describe("", () => { + it("renders a rising numeric descriptor with the signed magnitude + unit", () => { + const client = seededClient((c) => + c.setQueryData(queryKeys.insightsTrendSeries("BLOOD_PRESSURE_SYS"), [ + ...numericSeries([120, 124, 128]).map((r) => ({ + value: r.value, + measuredAt: r.measuredAt, + })), + ]), + ); + const html = render( + client, + , + ); + expect(html).toMatch(/data-slot="trend-annotation-descriptor"/); + expect(html).toMatch(/data-metric="bp"/); + expect(html).toContain("Rising over 30 days"); + expect(html).toContain("+8 mmHg"); + // Observational + neutral — no causal / medical framing leaks in. + expect(html).not.toContain("Awaiting more data"); + }); + + it("renders a falling weight descriptor", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("WEIGHT"), + numericSeries([82.4, 81.5, 81.0]), + ), + ); + const html = render( + client, + , + ); + expect(html).toContain("Falling over 30 days"); + expect(html).toContain("−1.4 kg"); + }); + + it("renders a stable descriptor when the move is within the metric floor", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("WEIGHT"), + numericSeries([81.0, 81.1]), + ), + ); + const html = render( + client, + , + ); + expect(html).toContain("Stable over the last 30 days"); + }); + + it("renders the mood descriptor in plain improved/declined terms (no raw delta)", () => { + // Mood analytics returns the full history; the descriptor filters to + // the trailing 30-day window client-side, so seed two recent days. + const dayKey = (daysAgo: number): string => + new Date(Date.now() - daysAgo * 86_400_000).toISOString().slice(0, 10); + const client = seededClient((c) => + c.setQueryData(queryKeys.moodAnalytics(), { + entries: [ + { date: dayKey(20), score: 3 }, + { date: dayKey(2), score: 4 }, + ], + }), + ); + const html = render( + client, + , + ); + expect(html).toMatch(/data-slot="trend-annotation-descriptor"/); + expect(html).toContain("Mood trended slightly higher over 30 days"); + // Categorical scale — no raw numeric point delta in the copy. + expect(html).not.toMatch(/\+\d/); + }); + + it("falls back to the real empty hint when the series is too sparse", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("WEIGHT"), + numericSeries([81.0]), + ), + ); + const html = render( + client, + , + ); + expect(html).toMatch(/data-slot="trend-annotation-empty"/); + expect(html).toContain("Awaiting more data"); + }); + + it("localises the descriptor (German)", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("BLOOD_PRESSURE_SYS"), + numericSeries([120, 128]), + ), + ); + const html = render( + client, + , + "de", + ); + expect(html).toContain("steigend"); + expect(html).toContain("+8 mmHg"); + }); +}); diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 009528e0a..e30733a20 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -1,9 +1,21 @@ "use client"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + computeTrendDescriptor, + moodDescriptorCopy, + numericDescriptorCopy, + MOOD_DESCRIPTOR_CONFIG, + TREND_SLOT_DESCRIPTOR_META, + type TrendDescriptorPoint, +} from "@/lib/insights/trend-descriptor"; import { cn } from "@/lib/utils"; import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; @@ -188,3 +200,177 @@ export function TrendAnnotation({ ); } + +// ── v1.11.4 item J — deterministic Trends-row caption ────────────────── + +/** Trailing window the descriptor reads. Matches the Trends row's + * "Last 30 days at a glance" subtitle. */ +const DESCRIPTOR_WINDOW_DAYS = 30; + +interface MeasurementApiRow { + value: number; + measuredAt: string; +} + +interface MoodAnalyticsRow { + date: string; + score: number; +} + +function dayKeyToTimestamp(dayKey: string): number { + const [y, m, d] = dayKey.split("-").map(Number); + return Date.UTC(y, m - 1, d, 12, 0, 0); +} + +/** + * Caption that prefers a deterministic, rule-based trend descriptor over + * the static "Awaiting more data" hint. + * + * Precedence (item J): + * 1. `pending` → shimmer (advisor in flight) — handled by the parent, + * which keeps rendering `` so this + * component only mounts on the resolved path. + * 2. advisor annotation present → the parent renders `` + * with the AI sentence; this component is not mounted. + * 3. NO advisor annotation but a computable series → this component + * shows the deterministic descriptor (direction + magnitude over the + * window) derived from the SAME series the mini-chart plots. + * 4. series too sparse (< 2 points) → the real "not enough data yet" + * empty hint, keeping the `trend-annotation-empty` contract so the + * row reads honestly when there genuinely isn't a trend to describe. + * + * Tone is observational + neutral by construction (see + * `trend-descriptor.ts`): direction + magnitude only, no value judgement. + */ +export function TrendDescriptorCaption({ + metric, + emptyMetric, + kind, + types, +}: { + /** Stable slot id (`TrendChartConfig.metric`) — drives the descriptor + * config + the `data-metric` test hook. */ + metric: string; + /** Which empty-state copy to show when the series is too sparse. */ + emptyMetric: TrendAnnotationProps["metric"]; + /** `mood` reads the categorical mood analytics; everything else reads + * the numeric measurement series for its primary type. */ + kind: "mood" | "numeric"; + /** Measurement types for the numeric path. The descriptor reads the + * FIRST type (the chart's primary line, e.g. systolic for BP). */ + types: string[]; +}) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const isMood = kind === "mood"; + const primaryType = types[0] ?? ""; + + // Mood reuses the exact `moodAnalytics()` cache slot the MoodChart + // already populates, so the descriptor reads from the same series with + // zero extra round-trip. Numeric metrics take a small, dedicated + // 30-day daily-aggregate read keyed under `trend-series` (bounded to + // ≤ 30 rollup rows) rather than re-deriving the chart's heavyweight, + // state-dependent `chart-data` key. + const moodQuery = useQuery({ + queryKey: queryKeys.moodAnalytics(), + queryFn: async () => { + const res = await fetch("/api/mood/analytics"); + if (!res.ok) throw new Error("Failed to fetch mood analytics"); + const json = await res.json(); + return json.data as { entries: MoodAnalyticsRow[] }; + }, + enabled: isAuthenticated && isMood, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const numericQuery = useQuery({ + queryKey: queryKeys.insightsTrendSeries(primaryType), + queryFn: async () => { + const to = new Date(); + const from = new Date( + to.getTime() - DESCRIPTOR_WINDOW_DAYS * 86_400_000, + ); + const params = new URLSearchParams({ + type: primaryType, + sortBy: "measuredAt", + sortDir: "asc", + from: from.toISOString(), + to: to.toISOString(), + limit: "5000", + aggregate: "daily", + source: "rollup", + }); + const res = await fetch(`/api/measurements?${params}`); + if (!res.ok) throw new Error("Failed to fetch measurement series"); + const json = await res.json(); + return (json.data?.measurements ?? []) as MeasurementApiRow[]; + }, + enabled: isAuthenticated && !isMood && primaryType.length > 0, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const points = useMemo(() => { + if (isMood) { + // Mood analytics returns the full history; the MoodChart mini + // defaults to its trailing 30-point window, so mirror that here by + // taking the last `DESCRIPTOR_WINDOW_DAYS` daily entries rather than + // a wall-clock date filter (keeps the memo pure — no `Date.now()`). + const entries = moodQuery.data?.entries ?? []; + return entries.slice(-DESCRIPTOR_WINDOW_DAYS).map((row) => ({ + timestamp: dayKeyToTimestamp(row.date), + value: row.score, + })); + } + // The numeric series is already server-bounded to the trailing + // 30-day window by the fetch, so every returned row is in-window. + return (numericQuery.data ?? []).map((row) => ({ + timestamp: new Date(row.measuredAt).getTime(), + value: row.value, + })); + }, [isMood, moodQuery.data, numericQuery.data]); + + const descriptor = useMemo(() => { + const config = isMood + ? MOOD_DESCRIPTOR_CONFIG + : TREND_SLOT_DESCRIPTOR_META[metric]?.config; + return computeTrendDescriptor(points, config); + }, [points, isMood, metric]); + + // Tier 4 — genuinely too few points (also covers the in-flight window + // before the small series lands). Surface the real empty hint so the + // caption reads honestly when there is no trend to describe. The + // advisor-pending shimmer is the parent's job; this fallback path only + // mounts when the advisor produced no annotation, so a brief empty + // hint during the small series fetch is the right transient state. + if (!descriptor) { + return ( +

+ {t(EMPTY_KEY[emptyMetric])} +

+ ); + } + + // Tier 3 — deterministic descriptor. Mood uses the categorical + // "improved / declined / stable" copy; every numeric metric uses the + // "{delta}{unit}" template. `numericDescriptorCopy` returns null for a + // slot with no numeric meta, in which case we fall back to mood-style + // copy defensively (should not happen for the legacy triple). + const copy = isMood + ? moodDescriptorCopy(descriptor) + : (numericDescriptorCopy(metric, descriptor) ?? moodDescriptorCopy(descriptor)); + + return ( + + ); +} diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index c08d5f3bd..17169b1aa 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -14,6 +14,7 @@ import { import { TrendAnnotation, TrendCaptionCard, + TrendDescriptorCaption, type TrendAnnotationConfidenceBand, type TrendAnnotationStatus, } from "./trend-annotation"; @@ -142,6 +143,45 @@ export function TrendsRow({ const annotationFor = (key: TrendAnnotationKey): string | null => annotations?.[key] ?? null; + // v1.11.4 item J — three-tier caption precedence for the legacy triple + // (the only slots that carry an advisor annotation): + // 1. advisor in flight → `` + // shimmer (loading flag). + // 2. advisor annotation present → the AI sentence. + // 3. NO annotation (cold briefing) → a deterministic, rule-based + // descriptor computed from the SAME series the mini-chart plots + // (``), instead of the old static + // "Awaiting more data" hint. That component itself falls back to + // the real empty hint only when the series is genuinely too sparse. + const renderLegacyCaption = (config: TrendChartConfig) => { + const annotationKey = config.annotationKey as TrendAnnotationKey; + const annotation = annotationFor(annotationKey); + const status = statusFor(annotation); + + // Tiers 1 + 2 — keep the existing pending shimmer / AI prose path. + if (status === "pending" || status === "generated") { + return ( + + ); + } + + // Tier 3 — deterministic descriptor (falls through to the real empty + // hint internally when the series is too sparse). + return ( + + ); + }; + return (
{config.annotationKey ? ( - + renderLegacyCaption(config) ) : ( // v1.8.6 W8 — additive metrics carry no advisor // annotation. Paint the metric's standard one-line diff --git a/src/lib/insights/__tests__/trend-descriptor.test.ts b/src/lib/insights/__tests__/trend-descriptor.test.ts new file mode 100644 index 000000000..da987aab3 --- /dev/null +++ b/src/lib/insights/__tests__/trend-descriptor.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from "vitest"; +import { + computeTrendDescriptor, + numericDescriptorCopy, + moodDescriptorCopy, + MOOD_DESCRIPTOR_CONFIG, + TREND_SLOT_DESCRIPTOR_META, +} from "../trend-descriptor"; + +/** + * v1.11.4 item J — deterministic Trends-row caption descriptor. + * + * The helper turns the same series the mini-chart plots into a neutral, + * observational direction + magnitude. These tests pin the direction + * classification, the stability floor, the first-vs-last delta, and the + * per-metric copy resolution (numeric template vs categorical mood copy). + */ + +function series(values: number[]): { timestamp: number; value: number }[] { + // One point per day, ascending; timestamps don't affect direction + // (first-vs-last) but anchor the chronological sort. + return values.map((value, i) => ({ + timestamp: Date.UTC(2026, 0, 1 + i, 12), + value, + })); +} + +describe("computeTrendDescriptor", () => { + it("returns null for fewer than two points", () => { + expect(computeTrendDescriptor([])).toBeNull(); + expect(computeTrendDescriptor(series([120]))).toBeNull(); + }); + + it("classifies a clear upward move as rising with a signed delta", () => { + const d = computeTrendDescriptor(series([120, 124, 128]), { + absoluteFloor: 2, + relativeFloor: 0.02, + decimals: 0, + }); + expect(d).not.toBeNull(); + expect(d?.direction).toBe("rising"); + expect(d?.delta).toBe(8); + expect(d?.magnitude).toBe(8); + expect(d?.pointCount).toBe(3); + }); + + it("classifies a clear downward move as falling with a negative delta", () => { + const d = computeTrendDescriptor(series([82.4, 81.5, 81.0]), { + absoluteFloor: 0.3, + relativeFloor: 0.01, + decimals: 1, + }); + expect(d?.direction).toBe("falling"); + expect(d?.delta).toBe(-1.4); + expect(d?.magnitude).toBe(1.4); + }); + + it("classifies a within-floor move as stable", () => { + // +1 mmHg over the window is inside the 2-mmHg absolute floor. + const d = computeTrendDescriptor(series([120, 121]), { + absoluteFloor: 2, + relativeFloor: 0.02, + decimals: 0, + }); + expect(d?.direction).toBe("stable"); + expect(d?.delta).toBe(1); + }); + + it("uses the relative floor when it exceeds the absolute floor", () => { + // 2 % of 10000 steps = 200; a +150 move reads stable. + const d = computeTrendDescriptor(series([10000, 10150]), { + absoluteFloor: 0, + relativeFloor: 0.02, + decimals: 0, + }); + expect(d?.direction).toBe("stable"); + }); + + it("sorts unordered points chronologically before first-vs-last", () => { + const unordered = [ + { timestamp: Date.UTC(2026, 0, 3, 12), value: 128 }, + { timestamp: Date.UTC(2026, 0, 1, 12), value: 120 }, + { timestamp: Date.UTC(2026, 0, 2, 12), value: 124 }, + ]; + const d = computeTrendDescriptor(unordered, { + absoluteFloor: 2, + relativeFloor: 0.02, + decimals: 0, + }); + expect(d?.direction).toBe("rising"); + expect(d?.delta).toBe(8); + }); + + it("drops non-finite points", () => { + const d = computeTrendDescriptor( + [ + { timestamp: Date.UTC(2026, 0, 1, 12), value: 120 }, + { timestamp: Date.UTC(2026, 0, 2, 12), value: Number.NaN }, + { timestamp: Date.UTC(2026, 0, 3, 12), value: 128 }, + ], + { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 }, + ); + expect(d?.pointCount).toBe(2); + expect(d?.direction).toBe("rising"); + }); +}); + +describe("numericDescriptorCopy", () => { + it("resolves the rising template with a signed delta and spaced unit", () => { + const d = computeTrendDescriptor(series([120, 128]), { + absoluteFloor: 2, + relativeFloor: 0.02, + decimals: 0, + })!; + const copy = numericDescriptorCopy("bp", d); + expect(copy?.key).toBe("insights.trendDescriptor.rising"); + expect(copy?.params.delta).toBe("+8"); + expect(copy?.params.unit).toBe(" mmHg"); + }); + + it("renders a unit-less metric without a dangling space", () => { + const d = computeTrendDescriptor(series([8000, 9500]), { + absoluteFloor: 300, + relativeFloor: 0.05, + decimals: 0, + })!; + const copy = numericDescriptorCopy("steps", d); + expect(copy?.key).toBe("insights.trendDescriptor.rising"); + expect(copy?.params.unit).toBe(""); + }); + + it("uses a minus sign glyph for a falling delta", () => { + const d = computeTrendDescriptor(series([82.4, 81.0]), { + absoluteFloor: 0.3, + relativeFloor: 0.01, + decimals: 1, + })!; + const copy = numericDescriptorCopy("weight", d); + expect(copy?.key).toBe("insights.trendDescriptor.falling"); + expect(copy?.params.delta).toBe("−1.4"); + }); + + it("returns null for a slot with no numeric meta (mood)", () => { + const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!; + expect(numericDescriptorCopy("mood", d)).toBeNull(); + }); + + it("covers every numeric slot in the descriptor meta", () => { + // Each meta-backed slot resolves a copy key — guards against a slot + // added to the chart selector without a descriptor config. + for (const metric of Object.keys(TREND_SLOT_DESCRIPTOR_META)) { + const d = computeTrendDescriptor(series([1, 100]), { + absoluteFloor: 0, + relativeFloor: 0, + decimals: 1, + })!; + expect(numericDescriptorCopy(metric, d)).not.toBeNull(); + } + }); +}); + +describe("moodDescriptorCopy", () => { + it("maps rising mood to the improved copy", () => { + const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!; + expect(d.direction).toBe("rising"); + expect(moodDescriptorCopy(d).key).toBe( + "insights.trendDescriptor.moodImproved", + ); + }); + + it("maps falling mood to the declined copy", () => { + const d = computeTrendDescriptor(series([4, 3]), MOOD_DESCRIPTOR_CONFIG)!; + expect(d.direction).toBe("falling"); + expect(moodDescriptorCopy(d).key).toBe( + "insights.trendDescriptor.moodDeclined", + ); + }); + + it("maps a within-floor mood move to the stable copy", () => { + // 0.2 points is inside the 0.3 mood floor. + const d = computeTrendDescriptor(series([3.5, 3.7]), MOOD_DESCRIPTOR_CONFIG)!; + expect(d.direction).toBe("stable"); + expect(moodDescriptorCopy(d).key).toBe( + "insights.trendDescriptor.moodStable", + ); + }); + + it("carries no interpolation params (no raw point delta in copy)", () => { + const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!; + expect(moodDescriptorCopy(d).params).toEqual({}); + }); +}); diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts new file mode 100644 index 000000000..26d79d761 --- /dev/null +++ b/src/lib/insights/trend-descriptor.ts @@ -0,0 +1,239 @@ +/** + * v1.11.4 item J — deterministic, rule-based trend descriptor for the + * Insights "Trends" row captions. + * + * Background. Each Trends-row card carries a one-line caption below its + * mini-chart. The first choice is the AI advisor's `trendAnnotations` + * sentence; but on a cold briefing (web-only account, pre-briefing + * cached payload, or the advisor still generating) that annotation is + * null and every card fell back to "Awaiting more data" — static and + * meaningless even when the user has a full 30-day series painted right + * above the caption. + * + * This module computes a FACTUAL descriptor from the same series the + * mini-chart already renders: direction (rising / falling / stable) plus + * the first-vs-last magnitude over the window. It is the second tier in + * the caption precedence the renderer applies: + * + * 1. advisor annotation present → show the AI sentence. + * 2. else, descriptor computable → show this deterministic descriptor. + * 3. else (truly too few points) → show the "not enough data yet" hint. + * + * Tone is hard-constrained: OBSERVATIONAL and NEUTRAL — direction + + * magnitude only. No causal, diagnostic, or medical claim; no value + * judgement (a rising weight and a rising step count read identically + * here — the row is descriptive, the colour-sentiment lives elsewhere on + * the dedicated metric pages). Mirrors the disclaimer ethos. + * + * Pure + deterministic so the logic is unit-testable in isolation and + * the client component stays a thin consumer. + */ + +/** + * A single chronological observation feeding the descriptor. `value` is + * the metric's display-scaled numeric value (the same value the chart + * plots); ordering is by `timestamp` ascending. + */ +export interface TrendDescriptorPoint { + timestamp: number; + value: number; +} + +export type TrendDescriptorDirection = "rising" | "falling" | "stable"; + +export interface TrendDescriptor { + direction: TrendDescriptorDirection; + /** + * Signed first-vs-last delta over the window, in the metric's display + * unit. Always finite. `stable` carries the (small) delta too so the + * caller can still surface a magnitude when it wants to. + */ + delta: number; + /** Absolute delta, convenience for template interpolation. */ + magnitude: number; + /** Number of points the descriptor was computed from (>= 2). */ + pointCount: number; +} + +/** + * How a metric decides "stable" vs a real move, and how its delta is + * rounded for display. The stability floor is the larger of an absolute + * floor and a fraction of the starting value, so a metric reads "stable" + * when the window move is within its day-to-day noise band rather than + * flagging every wobble as a trend. + */ +export interface TrendDescriptorConfig { + /** Absolute noise floor in the metric's unit (e.g. 2 mmHg for BP). */ + absoluteFloor: number; + /** Relative noise floor as a fraction of |first value| (e.g. 0.02 = 2 %). */ + relativeFloor: number; + /** Decimal places to round the displayed delta to. */ + decimals: number; +} + +const DEFAULT_CONFIG: TrendDescriptorConfig = { + absoluteFloor: 0, + relativeFloor: 0.03, + decimals: 1, +}; + +function roundTo(value: number, decimals: number): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +/** + * Compute a deterministic trend descriptor from a chronological series. + * + * Returns `null` when the series carries fewer than two finite points — + * the genuine "not enough data yet" case the renderer surfaces as a + * real empty hint. Otherwise resolves the first-vs-last delta and + * classifies direction against the metric's stability floor. + */ +export function computeTrendDescriptor( + points: ReadonlyArray, + config: Partial = {}, +): TrendDescriptor | null { + const cfg = { ...DEFAULT_CONFIG, ...config }; + + const finite = points + .filter((p) => Number.isFinite(p.value) && Number.isFinite(p.timestamp)) + .slice() + .sort((a, b) => a.timestamp - b.timestamp); + + if (finite.length < 2) return null; + + const first = finite[0].value; + const last = finite[finite.length - 1].value; + const delta = roundTo(last - first, cfg.decimals); + + // Stability floor: the larger of the absolute floor and a fraction of + // the starting magnitude. A move inside the floor reads as "stable". + const floor = Math.max(cfg.absoluteFloor, Math.abs(first) * cfg.relativeFloor); + + let direction: TrendDescriptorDirection; + if (Math.abs(last - first) <= floor) { + direction = "stable"; + } else { + direction = last > first ? "rising" : "falling"; + } + + return { + direction, + delta, + magnitude: Math.abs(delta), + pointCount: finite.length, + }; +} + +/** + * Per-Trends-row-slot descriptor configuration + the display unit the + * caption interpolates. Keyed by `TrendChartConfig.metric` (the stable + * slot id), so a slot the row can chart always resolves a config here. + * + * `mood` is intentionally ABSENT: mood rides a categorical 1–5 scale, so + * its descriptor is phrased in plain "improved / declined / stable" + * terms (no raw unit), handled by {@link MOOD_DESCRIPTOR_CONFIG} + + * dedicated copy rather than the numeric `{delta} {unit}` template. + */ +export interface TrendSlotDescriptorMeta { + /** Display unit interpolated into the numeric descriptor template. */ + unit: string; + config: TrendDescriptorConfig; +} + +export const TREND_SLOT_DESCRIPTOR_META: Record< + string, + TrendSlotDescriptorMeta +> = { + bp: { unit: "mmHg", config: { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 } }, + weight: { unit: "kg", config: { absoluteFloor: 0.3, relativeFloor: 0.01, decimals: 1 } }, + pulse: { unit: "bpm", config: { absoluteFloor: 2, relativeFloor: 0.03, decimals: 0 } }, + sleep: { unit: "h", config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 } }, + steps: { unit: "", config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 0 } }, + hrv: { unit: "ms", config: { absoluteFloor: 3, relativeFloor: 0.05, decimals: 0 } }, + resting_hr: { unit: "bpm", config: { absoluteFloor: 2, relativeFloor: 0.03, decimals: 0 } }, + active_energy: { unit: "kcal", config: { absoluteFloor: 50, relativeFloor: 0.05, decimals: 0 } }, + flights: { unit: "", config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 } }, + distance: { unit: "km", config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 } }, + vo2_max: { unit: "", config: { absoluteFloor: 0.5, relativeFloor: 0.02, decimals: 1 } }, + body_temp: { unit: "°C", config: { absoluteFloor: 0.2, relativeFloor: 0.005, decimals: 1 } }, +}; + +/** + * Mood descriptor config. Mood is a 1–5 categorical score; a window move + * of ≥ 0.3 points reads as a real shift in plain terms. + */ +export const MOOD_DESCRIPTOR_CONFIG: TrendDescriptorConfig = { + absoluteFloor: 0.3, + relativeFloor: 0, + decimals: 1, +}; + +/** + * The i18n template key + interpolation params for a resolved numeric + * descriptor. The renderer calls `t(key, params)` to produce the + * caption sentence. The metric name itself is NOT interpolated — the + * chart title above the caption already names the metric, so the + * sentence stays "Rising over 30 days (+8 mmHg)" rather than repeating + * the label. + */ +export interface TrendDescriptorCopy { + key: string; + params: Record; +} + +/** + * Resolve the copy for a NUMERIC metric slot. Returns `null` when the + * slot has no numeric descriptor meta (e.g. `mood`, which uses + * {@link moodDescriptorCopy} instead) — the caller then falls through to + * its own path. + */ +export function numericDescriptorCopy( + metric: string, + descriptor: TrendDescriptor, +): TrendDescriptorCopy | null { + const meta = TREND_SLOT_DESCRIPTOR_META[metric]; + if (!meta) return null; + + // Signed, formatted magnitude (e.g. "+8", "-1.4"). `stable` still + // carries its small delta so the caption can read "stable (±0 …)" if a + // future copy wants it; today the stable template omits the magnitude. + const sign = descriptor.delta > 0 ? "+" : descriptor.delta < 0 ? "−" : ""; + const formatted = `${sign}${descriptor.magnitude}`; + + const directionKey: Record = { + rising: "insights.trendDescriptor.rising", + falling: "insights.trendDescriptor.falling", + stable: "insights.trendDescriptor.stable", + }; + + return { + key: directionKey[descriptor.direction], + params: { + delta: formatted, + // Unit carries its OWN leading space when present so the template + // reads "({delta}{unit})" → "(+8 mmHg)" with a unit, "(+1200)" + // without one — no dangling space inside the parens for unit-less + // metrics (steps, flights). + unit: meta.unit ? ` ${meta.unit}` : "", + }, + }; +} + +/** + * Resolve the copy for the MOOD slot. Mood reads on a 1–5 categorical + * scale, so the descriptor is phrased as "slightly improved / declined / + * stable" rather than a raw point delta — observational and neutral, no + * value judgement beyond the plain direction word. + */ +export function moodDescriptorCopy( + descriptor: TrendDescriptor, +): TrendDescriptorCopy { + const directionKey: Record = { + rising: "insights.trendDescriptor.moodImproved", + falling: "insights.trendDescriptor.moodDeclined", + stable: "insights.trendDescriptor.moodStable", + }; + return { key: directionKey[descriptor.direction], params: {} }; +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index eae5f478e..3977bc0f4 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -471,6 +471,20 @@ export const queryKeys = { */ measurementDiversity: (type: string) => ["measurement-diversity", type] as const, + + /** + * v1.11.4 item J — bounded 30-day daily-aggregate read backing the + * Insights Trends-row deterministic caption (`trend-descriptor.ts`). + * Keyed by the comma-joined measurement type set so each card's slot + * caches its own small window (≤ 30 rollup rows). Distinct from + * `chartData(...)` so the caption never re-keys when the chart's + * internal value-mode / scale / range state changes; it only needs the + * raw 30-day series to derive direction + magnitude. Shares the + * `["trend-series"]` invalidation prefix below so a measurement write + * refreshes the caption in lockstep with the chart. + */ + insightsTrendSeries: (types: string) => + ["trend-series", types] as const, }; /** @@ -493,6 +507,9 @@ export const measurementDependentKeys = [ ["chart-data"] as const, // v1.8.5 — re-run the diversity-nudge clustering when readings change. ["measurement-diversity"] as const, + // v1.11.4 item J — refresh the Trends-row deterministic caption series + // when a reading changes, in lockstep with the chart row above it. + ["trend-series"] as const, ]; /**