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, };