feat(insights): compute a deterministic trend caption when the AI annotation is absent

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 17:05:35 +02:00
1 parent a2dff85fb8
commit 4666359470
12 files changed
+912 -6

No files matched your search

@@ -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 — `<TrendDescriptorCaption>` 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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
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("<TrendDescriptorCaption>", () => {
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,
<TrendDescriptorCaption
metric="bp"
emptyMetric="bp"
kind="numeric"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
/>,
);
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,
<TrendDescriptorCaption
metric="weight"
emptyMetric="weight"
kind="numeric"
types={["WEIGHT"]}
/>,
);
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,
<TrendDescriptorCaption
metric="weight"
emptyMetric="weight"
kind="numeric"
types={["WEIGHT"]}
/>,
);
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,
<TrendDescriptorCaption
metric="mood"
emptyMetric="mood"
kind="mood"
types={[]}
/>,
);
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,
<TrendDescriptorCaption
metric="weight"
emptyMetric="weight"
kind="numeric"
types={["WEIGHT"]}
/>,
);
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,
<TrendDescriptorCaption
metric="bp"
emptyMetric="bp"
kind="numeric"
types={["BLOOD_PRESSURE_SYS"]}
/>,
"de",
);
expect(html).toContain("steigend");
expect(html).toContain("+8 mmHg");
});
});
@@ -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({
</TrendCaptionCard>
);
}
// ── 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 `<TrendAnnotation status="pending">` so this
* component only mounts on the resolved path.
* 2. advisor annotation present → the parent renders `<TrendAnnotation>`
* 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<TrendDescriptorPoint[]>(() => {
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 (
<p
data-slot="trend-annotation-empty"
data-metric={metric}
className="text-muted-foreground line-clamp-3 text-xs italic"
>
{t(EMPTY_KEY[emptyMetric])}
</p>
);
}
// 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 (
<TrendCaptionCard
slot="trend-annotation-descriptor"
metric={metric}
text={t(copy.key, copy.params)}
/>
);
}
+41 -6
View File
@@ -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 → `<TrendAnnotation status="pending">`
// 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
// (`<TrendDescriptorCaption>`), 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 (
<TrendAnnotation
metric={annotationKey}
annotation={annotation}
confidence={confidence?.[annotationKey]}
status={status}
/>
);
}
// Tier 3 — deterministic descriptor (falls through to the real empty
// hint internally when the series is too sparse).
return (
<TrendDescriptorCaption
metric={config.metric}
emptyMetric={annotationKey}
kind={config.kind === "mood" ? "mood" : "numeric"}
types={config.types}
/>
);
};
return (
<section
data-slot="trends-row"
@@ -227,12 +267,7 @@ export function TrendsRow({
<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))}
/>
renderLegacyCaption(config)
) : (
// v1.8.6 W8 — additive metrics carry no advisor
// annotation. Paint the metric's standard one-line