From cbf903841c3e5ad92ccc6c2c9b1862bacbe1b86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 23:56:28 +0200 Subject: [PATCH] feat(insights): add Coach-read strip above metric charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/insights/coach-read/route.ts | 70 +++++++++ src/app/insights/blood-pressure/page.tsx | 8 + src/app/insights/pulse/page.tsx | 8 + src/app/insights/weight/page.tsx | 2 + .../insights/derived/coach-read-strip.tsx | 146 ++++++++++++++++++ .../insights/healthkit-metric-page.tsx | 9 ++ src/components/insights/sub-page-shell.tsx | 15 ++ .../derived/__tests__/coach-read.test.ts | 91 +++++++++++ src/lib/insights/derived/coach-read-shape.ts | 98 ++++++++++++ src/lib/insights/derived/coach-read.ts | 118 ++++++++++++++ src/lib/query-keys/insights.ts | 11 ++ 11 files changed, 576 insertions(+) create mode 100644 src/app/api/insights/coach-read/route.ts create mode 100644 src/components/insights/derived/coach-read-strip.tsx create mode 100644 src/lib/insights/derived/__tests__/coach-read.test.ts create mode 100644 src/lib/insights/derived/coach-read-shape.ts create mode 100644 src/lib/insights/derived/coach-read.ts diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts new file mode 100644 index 000000000..149f0d951 --- /dev/null +++ b/src/app/api/insights/coach-read/route.ts @@ -0,0 +1,70 @@ +/** + * v1.22.0 (A1) — "Coach read" strip route. + * + * `GET /api/insights/coach-read?metric=` 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); +}); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index ea184b33c..09e49a51d 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -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={ + + } statStrip={ // v1.12.7 — blood pressure is two series, but they share ONE card // with the systolic / diastolic columns side by side (stacking only diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index ccb7fdecf..fe9701269 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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={ + + } diversityNudge={ } + coachReadStrip={} diversityNudge={ + apiGet( + `/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 ( +
+
+
+
+ ); +} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 6bec959ef..cb3bc8582 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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={ + + } diversityNudge={ & + Pick, +): 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); + }); +}); diff --git a/src/lib/insights/derived/coach-read-shape.ts b/src/lib/insights/derived/coach-read-shape.ts new file mode 100644 index 000000000..54a01e19c --- /dev/null +++ b/src/lib/insights/derived/coach-read-shape.ts @@ -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, + ); +} diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts new file mode 100644 index 000000000..4c62950bb --- /dev/null +++ b/src/lib/insights/derived/coach-read.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d90fade89..d077b6fc4 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -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=`). 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, };