Files
HealthLog/src/lib/insights/derived/dispatch.ts
T
Marc-André Bombeck 95a2a7692f feat(insights): add composite scores, score-anatomy view, and correlation discovery
Build the composite tier of the derived-metrics layer on the shared seam:

- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
  consistency, timing and composition sub-scores over the per-stage sleep
  rows, reweighting around any sub-score the data cannot support (a legacy
  asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
  blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
  deviation-from-baseline components; returns insufficient below two present
  components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
  the latest day and fires when two or more coincide, listing the
  contributing vitals as possible factors, never a cause or a diagnosis.

Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.

Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.

Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.

Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
2026-06-02 21:19:56 +02:00

147 lines
5.0 KiB
TypeScript

/**
* v1.10.0 — the route-side dispatcher: `DerivedMetricId` → its pure
* compute function → a wire-flat `Derived<unknown>`.
*
* The generic `/api/insights/derived` route validates `metric` against
* the closed registry enum, then calls `computeDerivedMetric`. Wave 1
* implements `VITALS_BASELINE` end-to-end; every other registered id is
* a metadata stub whose compute lands in W2/W3. A stub routed before its
* compute lands returns `insufficient` with `reason: "not_implemented"`
* — never a fabricated value, never a 500.
*
* Server-only — imports the server-only compute engines.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { buildInsufficient, nowProvenanceTimestamp } from "./coverage";
import {
getDerivedMetricMeta,
isVitalsBaselineType,
type DerivedMetricId,
} from "./registry";
import {
computeVitalsBaseline,
type BaselineProfile,
} from "./baseline";
import { computeFitnessAge } from "./fitness-age";
import { computeVascularAgeDelta } from "./vascular-age";
import { computeHrvBalance } from "./hrv-balance";
import { computeBmi } from "./bmi";
import { computeSleepScore } from "./sleep-score";
import { computeReadiness } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
import type { Derived } from "./types";
export interface DerivedComputeArgs {
metric: DerivedMetricId;
userId: string;
profile: BaselineProfile;
/**
* Optional sub-target for metrics that baseline a single chosen type
* (`VITALS_BASELINE`). Validated against the engine's supported set.
*/
type?: string | null;
/** Trailing window override (days). */
windowDays?: number;
now?: Date;
}
/** A `not_implemented` insufficient — the stub guard for W2/W3 metrics. */
function notImplemented(
inputs: string[],
now: Date,
): Derived<unknown> {
const computedAt = nowProvenanceTimestamp(now);
return buildInsufficient<unknown>({
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: inputs },
provenance: { inputs, source: "none", windowDays: 0, computedAt },
reason: "not_implemented",
});
}
/**
* Dispatch a derived-metric id to its compute function. Returns the flat
* `Derived<unknown>` the route wraps in the standard envelope. The caller
* has already Zod-validated `metric` against the registry enum, so an
* unknown id never reaches here.
*/
export async function computeDerivedMetric(
args: DerivedComputeArgs,
): Promise<Derived<unknown>> {
const now = args.now ?? new Date();
const meta = getDerivedMetricMeta(args.metric);
if (!meta) {
// Defensive — the route validates against the enum first.
return notImplemented([], now);
}
if (!meta.implemented) {
return notImplemented(meta.inputs.map(String), now);
}
switch (args.metric) {
case "VITALS_BASELINE": {
// The baseline engine needs a single chosen vital; default to
// RESTING_HEART_RATE when the caller omits one (the catalogue's
// canonical example).
const requested = args.type ?? "RESTING_HEART_RATE";
if (!isVitalsBaselineType(requested)) {
return buildInsufficient<unknown>({
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [requested],
},
provenance: {
inputs: [requested],
source: "none",
windowDays: 0,
computedAt: nowProvenanceTimestamp(now),
},
reason: "unsupported_baseline_type",
});
}
return computeVitalsBaseline(args.userId, args.profile, {
type: requested as MeasurementType,
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
}
case "FITNESS_AGE":
return computeFitnessAge(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "VASCULAR_AGE_DELTA":
return computeVascularAgeDelta(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "HRV_BALANCE":
return computeHrvBalance(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "BMI":
return computeBmi(args.userId, args.profile, {
case "SLEEP_SCORE":
return computeSleepScore(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "READINESS":
return computeReadiness(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "COINCIDENT_DEVIATION":
return computeCoincidentDeviation(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
default:
// Registered + implemented but no dispatch arm — treat as a stub.
return notImplemented(meta.inputs.map(String), now);
}
}