merge: wave/v1220-strip into release/v1.21.2

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:58:51 +02:00
commit 089a5254aa
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,
};