mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: wave/v1220-strip into release/v1.21.2
This commit is contained in:
commit
089a5254aa
11 files changed
+576
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user