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

+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
@@ -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
@@ -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({});
});
});
+239
View File
@@ -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<TrendDescriptorPoint>,
config: Partial<TrendDescriptorConfig> = {},
): 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<string, string | number>;
}
/**
* 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<TrendDescriptorDirection, string> = {
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<TrendDescriptorDirection, string> = {
rising: "insights.trendDescriptor.moodImproved",
falling: "insights.trendDescriptor.moodDeclined",
stable: "insights.trendDescriptor.moodStable",
};
return { key: directionKey[descriptor.direction], params: {} };
}
+17
View File
@@ -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,
];
/**