mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user