mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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: {} };
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user