feat(insights): add Coach-read strip above metric charts

Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.

The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.

Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:56:28 +02:00
1 parent 096a486bcc
commit cbf903841c
11 files changed
+576

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
/**
* v1.22.0 (A1) — "Coach read" strip route.
*
* `GET /api/insights/coach-read?metric=<MeasurementType>` serves the two
* server-authoritative lines a metric sub-page renders above its chart: the
* own-baseline placement (median ± k·MAD, today's reading within / above /
* below) and the single strongest lagged association whose outcome is the
* metric. Pure compute over the baseline + correlation engines — no provider
* call, no cache table — so web and iOS decode the SAME resolved DTO.
*
* Mirrors the `/api/insights/derived` precedent: `apiHandler` wrapper, Zod
* `safeParse` on the query (unknown `metric` → 422 via `returnAllZodIssues`),
* cookie OR Bearer auth, `userId` narrowed from the session (never a query
* field), insights-module gate, and the shared analytics-read budget.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
const coachReadQuerySchema = z.object({
metric: measurementTypeEnum,
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Shared analytics-read budget — generous; caps a runaway navigation loop.
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const parsed = coachReadQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.coach-read.invalid-metric" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const metric = parsed.data.metric as MeasurementType;
const strip = await buildCoachReadStrip(user.id, metric);
annotate({
action: { name: "insights.coach-read" },
meta: {
metric,
has_baseline: strip.baseline !== null,
learning: strip.learning,
has_driver: strip.driver !== null,
},
});
return apiSuccess(strip);
});
+8
View File
@@ -14,6 +14,7 @@ import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -111,6 +112,13 @@ export default function InsightsBlutdruckPage() {
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
coachLaunch
coachReadStrip={
<CoachReadStrip
metricType="BLOOD_PRESSURE_SYS"
unit="mmHg"
fractionDigits={0}
/>
}
statStrip={
// v1.12.7 — blood pressure is two series, but they share ONE card
// with the systolic / diastolic columns side by side (stacking only
+8
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -133,6 +134,13 @@ export default function InsightsPulsPage() {
windowStats={statsByType?.PULSE ?? null}
/>
}
coachReadStrip={
<CoachReadStrip
metricType={hasRestingHr ? "RESTING_HEART_RATE" : "PULSE"}
unit="bpm"
fractionDigits={0}
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="PULSE"
+2
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -88,6 +89,7 @@ export default function InsightsGewichtPage() {
windowStats={statsByType?.WEIGHT ?? null}
/>
}
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit="kg" />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="WEIGHT"
@@ -0,0 +1,146 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
/**
* v1.22.0 (A1) — "Coach read" strip.
*
* A compact two-line read rendered ABOVE the chart on each metric sub-page:
*
* 1. own-baseline — "Your usual range is X–Y; today's Z sits within /
* above / below". Below the engine's 7-day history floor it reads
* "still learning your range" — never a fabricated band.
* 2. one lagged association — the single strongest discovered driver whose
* outcome is this metric, stated in the engine's own never-causal voice.
* Omitted entirely when no driver clears the existing effect-size floor.
*
* Server-authoritative: the component only renders the resolved DTO from
* `/api/insights/coach-read`. It never re-derives a band or a correlation, so
* the web and iOS strips read identical numbers. The strip self-gates: it
* paints nothing until the read lands, and nothing at all when there is no
* baseline AND no driver (a brand-new metric stays clean).
*/
export interface CoachReadStripProps {
/** The MeasurementType the route keys on (e.g. `WEIGHT`, `RESTING_HEART_RATE`). */
metricType: string;
/** Unit suffix rendered next to the band edges + today's value. */
unit: string;
/**
* Decimal precision for the formatted numbers. Defaults to 1 — enough for
* weight (78.4) / HRV (41.5); integer metrics drop the trailing zero via
* `Intl.NumberFormat`. Pages pass 0 for integer-only metrics (BP, steps).
*/
fractionDigits?: number;
/**
* Display-time value scale folded into the band edges + today's value (e.g.
* WALKING_SPEED stores m/s but renders km/h via `valueScale={3.6}`). The
* server computes the placement on unscaled stored values (scale-invariant),
* so scaling here only affects the displayed numbers. Defaults to 1.
*/
valueScale?: number;
}
export function CoachReadStrip({
metricType,
unit,
fractionDigits = 1,
valueScale = 1,
}: CoachReadStripProps) {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const mounted = useMounted();
const { data } = useQuery({
queryKey: queryKeys.insightsCoachRead(metricType),
queryFn: () =>
apiGet<CoachReadStripData>(
`/api/insights/coach-read?metric=${encodeURIComponent(metricType)}`,
),
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
// Match the rest of the query-dependent insights chrome: don't paint a
// branch during SSR / hydration (React #418) and don't paint until the
// read lands.
if (!mounted || !data) return null;
const fmt = (value: number): string =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
}).format(value * valueScale);
const baselineLine = ((): string | null => {
if (data.learning || !data.baseline) {
return t("insights.coach.readStrip.insufficient");
}
const { low, high, latest, placement } = data.baseline;
const key =
placement === "above"
? "insights.coach.readStrip.baselineAbove"
: placement === "below"
? "insights.coach.readStrip.baselineBelow"
: "insights.coach.readStrip.baselineWithin";
return t(key, {
low: fmt(low),
high: fmt(high),
value: fmt(latest),
unit,
});
})();
const driverLine = data.driver
? t("insights.coach.readStrip.driver", { note: data.driver.note })
: null;
// Self-gate: nothing to say when both lines are absent. (The baseline line
// is always non-null — it degrades to the "learning" copy — so this only
// fires defensively.)
if (!baselineLine && !driverLine) return null;
return (
<div
data-slot="coach-read-strip"
className={cn(
"bg-card/60 border-border/60 rounded-xl border px-3.5 py-2.5",
"text-muted-foreground space-y-1 text-sm leading-relaxed",
)}
>
<div className="flex items-start gap-2">
<Sparkles
className="text-primary/70 mt-0.5 size-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<span className="text-foreground/70 sr-only text-xs font-medium">
{t("insights.coach.readStrip.label")}
</span>
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p
data-slot="coach-read-driver"
className="text-muted-foreground/90 text-pretty"
>
{driverLine}
</p>
) : null}
</div>
</div>
</div>
);
}
@@ -23,6 +23,7 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
import { isTrajectoryType } from "@/lib/insights/derived/registry";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -354,6 +355,14 @@ export function HealthKitMetricPage({
medianLabel={statMedianLabel}
/>
}
coachReadStrip={
<CoachReadStrip
metricType={effectiveType}
unit={yAxisUnit ?? unit}
fractionDigits={statFractionDigits}
valueScale={valueScale}
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType={effectiveType}
@@ -96,6 +96,15 @@ export interface SubPageShellProps {
* brand-new-metric paths stay clean. Omitted on the empty-state branch.
*/
statStrip?: ReactNode;
/**
* v1.22.0 (A1) — the "Coach read" strip, mounted between the stat strip and
* the chart. A compact two-line own-baseline + lagged-association read that
* makes the metric feel seen-in-context. Self-gating (renders nothing until
* its server read lands, and nothing at all when there is no baseline and no
* driver), so the spine's rhythm holds on the empty-state and brand-new-
* metric paths. Omitted on the empty / loading / error branches.
*/
coachReadStrip?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge.
*
@@ -144,6 +153,7 @@ export function SubPageShell({
explainerMetric,
focusOnMount = false,
statStrip,
coachReadStrip,
diversityNudge,
// v1.21.0 (C4 H1) — `coachLaunch` no longer renders a control (CCH-04
// stands) but now gates whether the page registers its metric as the
@@ -379,6 +389,11 @@ export function SubPageShell({
strip so the numbers always reflect the range the chart paints. No
drag, no pill — the range tab is the single selector. */}
{statStrip}
{/* v1.22.0 (A1) — the "Coach read" strip sits between the numbers-first
stat strip and the chart, so the spine reads intro → numbers → Coach
read → chart → target → assessment. Self-gating, so the rhythm holds
when the strip has nothing to say. */}
{coachReadStrip}
{/* v1.12.6 — the canonical spine body: intro (header) → stat strip
(above) → chart → target card → assessment. The page renders
chart → target → assessment as `children`. */}
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import {
placeAgainstBand,
pickDriverForMetric,
humaniseType,
} from "@/lib/insights/derived/coach-read-shape";
import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read";
/**
* v1.22.0 (A1) — "Coach read" strip selection logic.
*
* Covers the two decisions the strip turns on:
* 1. band-line placement (within / above / below), incl. the inclusive
* edges that read "within", and the insufficient path (no band).
* 2. driver-line pick + omission — strongest on-metric driver wins, and
* nothing on the metric ⇒ the line is omitted (null).
*
* Pure helpers — no Prisma, no engine round-trip. The MAD band itself + the
* 7-day floor are owned by `baseline.test.ts`; this file pins only the strip's
* own selection seam.
*/
function driver(
overrides: Partial<CoachCorrelationDriver> &
Pick<CoachCorrelationDriver, "outcome" | "r">,
): CoachCorrelationDriver {
return {
behaviour: "sleep duration",
direction: overrides.r >= 0 ? "higher" : "lower",
lagDays: 1,
n: 42,
note: "On days your sleep runs short, your resting pulse tends to rise.",
...overrides,
};
}
describe("placeAgainstBand", () => {
it("reads 'within' when the value sits inside the band", () => {
expect(placeAgainstBand(72, 60, 80)).toBe("within");
});
it("reads 'above' when the value clears the upper edge", () => {
expect(placeAgainstBand(85, 60, 80)).toBe("above");
});
it("reads 'below' when the value sits under the lower edge", () => {
expect(placeAgainstBand(55, 60, 80)).toBe("below");
});
it("treats both edges as inclusive (on-edge reads 'within')", () => {
expect(placeAgainstBand(60, 60, 80)).toBe("within");
expect(placeAgainstBand(80, 60, 80)).toBe("within");
});
});
describe("pickDriverForMetric", () => {
const restingPulse = humaniseType("RESTING_HEART_RATE" as never); // "resting heart rate"
it("omits the line when no driver lands on the metric (returns null)", () => {
const drivers = [
driver({ outcome: "weight", r: 0.6 }),
driver({ outcome: "sleep duration", r: -0.5 }),
];
expect(pickDriverForMetric(drivers, restingPulse)).toBeNull();
});
it("omits the line on an empty driver list", () => {
expect(pickDriverForMetric([], restingPulse)).toBeNull();
});
it("picks the strongest |r| driver whose outcome is the metric", () => {
const weak = driver({ outcome: restingPulse, r: 0.21, n: 30 });
const strong = driver({ outcome: restingPulse, r: -0.48, n: 50 });
const offMetric = driver({ outcome: "weight", r: 0.9 });
const picked = pickDriverForMetric([weak, strong, offMetric], restingPulse);
expect(picked).toBe(strong);
});
it("ignores off-metric drivers even when they are stronger", () => {
const onMetric = driver({ outcome: restingPulse, r: 0.3 });
const strongerElsewhere = driver({ outcome: "weight", r: 0.95 });
const picked = pickDriverForMetric(
[onMetric, strongerElsewhere],
restingPulse,
);
expect(picked).toBe(onMetric);
});
});
@@ -0,0 +1,98 @@
/**
* v1.22.0 (A1) — "Coach read" strip: shared shapes + pure selection logic.
*
* Client-safe (no `server-only`, no Prisma): the DTO shapes the route returns
* and the strip renders, plus the two pure decisions the strip turns on —
* band placement (within / above / below) and the on-metric driver pick. The
* server builder (`coach-read.ts`) composes these over the baseline +
* correlation engines; the unit test pins them directly.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read";
/** Where today's latest reading sits relative to the personal band. */
export type CoachReadBaselinePlacement = "within" | "above" | "below";
/** The resolved own-baseline line (line 1). */
export interface CoachReadBaseline {
/** Robust lower edge of the personal typical range. */
low: number;
/** Robust upper edge of the personal typical range. */
high: number;
/** Today's latest reading (display value, same units as the band). */
latest: number;
/** Where the latest reading sits relative to the band. */
placement: CoachReadBaselinePlacement;
/** Distinct days that fed the band (transparency). */
sampleDays: number;
}
/** The resolved lagged-association line (line 2). */
export interface CoachReadDriver {
/** The engine's conservative, descriptive, never-causal interpretation. */
note: string;
/** Lower-cased behaviour label (e.g. "sleep duration"). */
behaviour: string;
/** Lower-cased outcome label (this metric). */
outcome: string;
}
export interface CoachReadStripData {
/**
* The own-baseline line. `null` when the band has < 7 days of history
* (`learning: true`) or when there is no latest reading to place.
*/
baseline: CoachReadBaseline | null;
/**
* True when a band could not be established yet (history below the
* engine's 7-day floor, or no readings). Drives the "still learning your
* range" copy. Mutually exclusive with a non-null `baseline`.
*/
learning: boolean;
/**
* The single strongest lagged driver whose outcome is this metric, or
* `null` when none clears the existing effect-size floor (line 2 omitted).
*/
driver: CoachReadDriver | null;
}
/**
* `humanise()` in the correlations reader maps a discovery channel key to a
* lower-cased label. We need the SAME mapping to recognise which discovered
* drivers land on this page's metric. Kept byte-identical to the reader's
* private helper for MeasurementType keys.
*/
export function humaniseType(type: MeasurementType): string {
return String(type).replace(/_/g, " ").toLowerCase();
}
/**
* Where today's reading sits relative to the personal band. Pure. The band
* edges are inclusive — a reading exactly on an edge reads "within".
*/
export function placeAgainstBand(
latest: number,
low: number,
high: number,
): CoachReadBaselinePlacement {
if (latest > high) return "above";
if (latest < low) return "below";
return "within";
}
/**
* Pick the strongest driver whose OUTCOME is this metric. The reader already
* floors + tiers + FDR-controls the list; we only choose the page-relevant
* row with the largest |r| so the strip surfaces the single most informative
* link. Returns `null` when no driver lands on the metric.
*/
export function pickDriverForMetric(
drivers: CoachCorrelationDriver[],
outcomeLabel: string,
): CoachCorrelationDriver | null {
const onMetric = drivers.filter((d) => d.outcome === outcomeLabel);
if (onMetric.length === 0) return null;
return onMetric.reduce((best, cur) =>
Math.abs(cur.r) > Math.abs(best.r) ? cur : best,
);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* v1.22.0 (A1) — "Coach read" strip data builder.
*
* Two server-authoritative lines for a single metric sub-page, computed
* here so web and iOS read the SAME resolved DTO (no client re-derivation):
*
* 1. own-baseline — the user's personal typical range (median ± k·MAD)
* from `computeVitalsBaseline`, plus where today's latest reading sits
* relative to it (within / above / below). Below the engine's 7-day
* history floor the band is `insufficient` and the strip says
* "still learning your range" — never a fabricated range.
* 2. one lagged association — the single strongest discovered driver
* whose OUTCOME is this metric, surfaced from `readCoachCorrelations`
* (FDR-controlled, effect-size-floored, confidence-tiered). The line
* carries the engine's own never-causal interpretation verbatim. When
* nothing clears the existing floor the line is omitted entirely.
*
* Both inputs reuse the deterministic engines unchanged — no new statistics,
* no lowered floor. Server-only (Prisma reads). The DTO shapes + the pure
* selection helpers live in the client-safe `coach-read-shape.ts` sibling so
* the strip component and the unit test can share them without pulling Prisma.
*/
import "server-only";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
loadBaselineProfile,
computeVitalsBaseline,
} from "@/lib/insights/derived/baseline";
import { isDerivedOk } from "@/lib/insights/derived";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import {
humaniseType,
placeAgainstBand,
pickDriverForMetric,
type CoachReadBaseline,
type CoachReadDriver,
type CoachReadStripData,
} from "@/lib/insights/derived/coach-read-shape";
export type {
CoachReadBaseline,
CoachReadDriver,
CoachReadStripData,
CoachReadBaselinePlacement,
} from "@/lib/insights/derived/coach-read-shape";
/**
* Read the latest reading for `(userId, type)` — the value the strip places
* against the band. Display-side scaling (e.g. WALKING_SPEED m/s → km/h) is
* the caller's concern; the strip renders unscaled stored values, and the
* band is unscaled too, so the placement is scale-invariant.
*/
async function readLatestValue(
userId: string,
type: MeasurementType,
): Promise<number | null> {
const row = await prisma.measurement.findFirst({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true },
});
return row?.value ?? null;
}
/**
* Build the "Coach read" strip payload for one metric. Pure orchestration
* over the baseline + correlations engines; never throws on a partial read
* (a correlations hiccup degrades line 2 to `null`, the band stands alone).
*/
export async function buildCoachReadStrip(
userId: string,
type: MeasurementType,
): Promise<CoachReadStripData> {
const profile = await loadBaselineProfile(prisma, userId);
const [baselineDerived, latest, correlations] = await Promise.all([
computeVitalsBaseline(userId, profile, { type }),
readLatestValue(userId, type),
// Line 2 is best-effort: a correlation failure must never sink the band.
readCoachCorrelations(userId).catch(() => ({ present: false }) as const),
]);
let baseline: CoachReadBaseline | null = null;
let learning = false;
if (isDerivedOk(baselineDerived) && latest !== null) {
const { low, high, sampleDays } = baselineDerived.value;
baseline = {
low,
high,
latest,
placement: placeAgainstBand(latest, low, high),
sampleDays,
};
} else {
// Band not established (history below the 7-day floor, or no readings) —
// the strip says "still learning your range" rather than inventing one.
learning = true;
}
const driver = ((): CoachReadDriver | null => {
if (!("drivers" in correlations) || !correlations.drivers) return null;
const picked = pickDriverForMetric(
correlations.drivers,
humaniseType(type),
);
if (!picked) return null;
return {
note: picked.note,
behaviour: picked.behaviour,
outcome: picked.outcome,
};
})();
return { baseline, learning, driver };
}
+11
View File
@@ -126,4 +126,15 @@ export const insightsKeys = {
* refreshes the caption in lockstep with the chart.
*/
insightsTrendSeries: (types: string) => ["trend-series", types] as const,
/**
* v1.22.0 (A1) — per-metric "Coach read" strip
* (`/api/insights/coach-read?metric=<MeasurementType>`). One generic route
* backs every metric sub-page, so the key is parameterised by the
* MeasurementType. Pure compute over the baseline + correlation engines —
* the `["insights"]` prefix keeps it in the standard invalidation fan-out
* (a measurement write busts it so the placement refreshes).
*/
insightsCoachRead: (metric: string) =>
["insights", "coach-read", metric] as const,
};