mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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.
This commit is contained in:
1 parent
663a3ee09d
commit
95a2a7692f
29 files changed
+3087
-30
No files matched your search
@@ -5,6 +5,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -31,7 +34,6 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -47,6 +49,16 @@ beforeEach(() => {
|
||||
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
// v1.10.0 — the discovery engine reads the profile timezone + the
|
||||
// measurement / mood series. Default to an empty corpus so the engine
|
||||
// returns no discovered pairs (the n ≥ 20 gate trivially fails).
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -61,21 +73,16 @@ describe("GET /api/insights/correlations", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns an empty array as the placeholder", async () => {
|
||||
it("returns the discovery result shape (no pairs on an empty corpus)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown[] };
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes the empty-state audit log", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq());
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"insights.correlations.empty",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { discovered: unknown[]; pairsTested: number; fdrQ: number };
|
||||
};
|
||||
expect(body.data.discovered).toEqual([]);
|
||||
expect(body.data.pairsTested).toBe(0);
|
||||
expect(body.data.fdrQ).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns 403 + errorCode when the correlations flag is off", async () => {
|
||||
|
||||
@@ -1,29 +1,142 @@
|
||||
/**
|
||||
* GET /api/insights/correlations — placeholder iOS endpoint.
|
||||
* GET /api/insights/correlations — FDR-controlled correlation discovery.
|
||||
*
|
||||
* The full correlations engine is part of `/api/insights/comprehensive`
|
||||
* and is intentionally not re-exposed yet — iOS only needs a stable
|
||||
* shape to render the empty state. We return `[]` and emit an audit log
|
||||
* so the gap is visible until Phase 7 fills it in.
|
||||
* v1.10.0 — promotes the former placeholder to the real discovery engine.
|
||||
* Scans a curated behaviour × outcome matrix (daylight / mood / glucose /
|
||||
* BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour
|
||||
* day to the NEXT day's outcome, runs Pearson with the exact Student-t
|
||||
* p-value, and applies Benjamini-Hochberg FDR control across every tested
|
||||
* pair so only statistically-defensible patterns surface. Every surfaced
|
||||
* pair carries n, r, p, and the BH-adjusted q, framed descriptive — never
|
||||
* causal.
|
||||
*
|
||||
* Reads daily series bounded to a trailing window, day-keyed in the user's
|
||||
* display timezone (late-night readings mis-bucket under UTC). Pure compute
|
||||
* lives in `src/lib/insights/correlation-discovery.ts`; this route only
|
||||
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Trailing window for the discovery scan (days). */
|
||||
const WINDOW_DAYS = 180;
|
||||
|
||||
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
|
||||
function tzDayKey(at: Date, tz: string): string {
|
||||
const { year, month, day } = getLocalDateParts(at, tz);
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** Collapse rows to per-day means keyed in the user's tz. */
|
||||
function toDailyMeans(
|
||||
rows: Array<{ value: number; at: Date }>,
|
||||
tz: string,
|
||||
): DailySeriesPoint[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.value)) continue;
|
||||
const day = tzDayKey(r.at, tz);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += r.value;
|
||||
acc.count += 1;
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : 1));
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
// v1.4.31 — operator can hide the correlation narration tile.
|
||||
// Operator can hide the correlation surface entirely.
|
||||
await requireAssistantSurface("correlations");
|
||||
annotate({ action: { name: "insights.correlations" } });
|
||||
|
||||
await auditLog("insights.correlations.empty", {
|
||||
userId: user.id,
|
||||
details: { reason: "phase7_pending" },
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
// Behaviour channels are measurement-backed except MOOD (mood entries).
|
||||
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
|
||||
(k) => k !== "MOOD",
|
||||
) as MeasurementType[];
|
||||
const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[];
|
||||
|
||||
const [measurements, moodEntries] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
type: { in: [...behaviourTypes, ...outcomeTypes] },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: 20000,
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
take: 5000,
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const measurementsByType = new Map<string, Array<{ value: number; at: Date }>>();
|
||||
for (const m of measurements) {
|
||||
const list = measurementsByType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
measurementsByType.set(m.type, list);
|
||||
}
|
||||
|
||||
const series: NamedSeries[] = [];
|
||||
for (const key of DISCOVERY_BEHAVIOURS) {
|
||||
const points =
|
||||
key === "MOOD"
|
||||
? toDailyMeans(
|
||||
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
|
||||
tz,
|
||||
)
|
||||
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
|
||||
series.push({ key, role: "behaviour", points });
|
||||
}
|
||||
for (const key of DISCOVERY_OUTCOMES) {
|
||||
series.push({
|
||||
key,
|
||||
role: "outcome",
|
||||
points: toDailyMeans(measurementsByType.get(key) ?? [], tz),
|
||||
});
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.correlations.discover" },
|
||||
meta: {
|
||||
pairs_tested: result.pairsTested,
|
||||
discovered: result.discovered.length,
|
||||
fdr_q: result.fdrQ,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess([]);
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
type CompositeMetricId,
|
||||
} from "@/components/insights/derived/composite-score-anatomy";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/scores/<metric>` — the composite score-anatomy
|
||||
* detail surface.
|
||||
*
|
||||
* Reached on tap from a composite's dashboard / insights entry. Renders the
|
||||
* Oura-style anatomy view (big ring + ranked contributor rows + cited
|
||||
* standard) for one composite. The `[metric]` param is validated against the
|
||||
* two W3 composites — an unknown slug falls back to the readiness view's
|
||||
* insufficient state rather than 404-ing, so a stale link never dead-ends.
|
||||
*/
|
||||
|
||||
const SLUG_TO_METRIC: Record<string, CompositeMetricId> = {
|
||||
sleep: "SLEEP_SCORE",
|
||||
readiness: "READINESS",
|
||||
};
|
||||
|
||||
export default function CompositeScorePage() {
|
||||
const params = useParams<{ metric: string }>();
|
||||
const { t } = useTranslations();
|
||||
const slug = typeof params.metric === "string" ? params.metric : "";
|
||||
const metric: CompositeMetricId = SLUG_TO_METRIC[slug] ?? "READINESS";
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t(`insights.derived.composite.${metric}.title`)}
|
||||
description={t(`insights.derived.composite.${metric}.description`)}
|
||||
coachLaunch
|
||||
>
|
||||
<CompositeScoreAnatomy metric={metric} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const COVERAGE = {
|
||||
requiredInputs: 5,
|
||||
presentInputs: 4,
|
||||
historyDays: 21,
|
||||
missing: ["respiratory"],
|
||||
};
|
||||
const CONFIDENCE = { score: 72, band: "medium" as const };
|
||||
const PROVENANCE = {
|
||||
inputs: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY"],
|
||||
source: "DAY" as const,
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
};
|
||||
const CONTRIBUTORS: AnatomyContributor[] = [
|
||||
{ key: "rhr", label: "Resting heart rate", value: 80, weight: 0.3 },
|
||||
{ key: "hrv", label: "HRV (SDNN)", value: 60, weight: 0.3 },
|
||||
{ key: "respiratory", label: "Respiratory rate", value: null, weight: 0 },
|
||||
];
|
||||
|
||||
describe("<ScoreAnatomyView>", () => {
|
||||
it("renders the ok state: ring + contributor rows + provenance", () => {
|
||||
const html = render(
|
||||
<ScoreAnatomyView
|
||||
title="Readiness"
|
||||
score={74}
|
||||
contributors={CONTRIBUTORS}
|
||||
coverage={COVERAGE}
|
||||
confidence={CONFIDENCE}
|
||||
provenance={PROVENANCE}
|
||||
method="A blend of deviation components."
|
||||
standard={{ name: "Plews 2013", url: "https://example.org" }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="score-anatomy-view"');
|
||||
expect(html).toContain('data-status="ok"');
|
||||
expect(html).toContain('data-slot="score-ring"');
|
||||
// Each contributor renders a row with its stable key attr.
|
||||
expect(html).toContain('data-contributor="rhr"');
|
||||
expect(html).toContain('data-contributor="hrv"');
|
||||
// The missing contributor renders present=false (dropped, not blank).
|
||||
expect(html).toContain('data-contributor="respiratory"');
|
||||
expect(html).toContain('data-present="false"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="score-anatomy-disclaimer"');
|
||||
});
|
||||
|
||||
it("renders the insufficient state with the provisional ring + nudge", () => {
|
||||
const html = render(
|
||||
<ScoreAnatomyView
|
||||
title="Sleep score"
|
||||
score={null}
|
||||
contributors={[]}
|
||||
coverage={{
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
}}
|
||||
confidence={null}
|
||||
provenance={{
|
||||
inputs: [],
|
||||
source: "none",
|
||||
windowDays: 0,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
}}
|
||||
method="A transparent blend."
|
||||
insufficient
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-status="insufficient"');
|
||||
expect(html).toContain('data-provisional="true"'); // the ring's empty state
|
||||
expect(html).toContain('data-slot="score-anatomy-insufficient"');
|
||||
// No contributor rows in the insufficient state.
|
||||
expect(html).not.toContain('data-slot="anatomy-contributor-row"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDerivedMetric } from "@/hooks/use-derived-metric";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
ReadinessValue,
|
||||
SleepScoreValue,
|
||||
} from "@/lib/insights/derived";
|
||||
import {
|
||||
ScoreAnatomyView,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
import type { ProvenanceStandard } from "./provenance-explainer";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the data-bound wrapper that fetches a composite derived metric
|
||||
* and renders the reusable `ScoreAnatomyView`. It owns the per-metric
|
||||
* presentation map (title, contributor labels, the cited standard, the
|
||||
* plain-language method) so the anatomy view itself stays metric-agnostic.
|
||||
*
|
||||
* Supports the W3 composites: `SLEEP_SCORE` and `READINESS`. Each maps its
|
||||
* `value` (sub-scores / components) onto the normalised `AnatomyContributor`
|
||||
* list and supplies its cited standard + method copy from the i18n bundle.
|
||||
*
|
||||
* Client-only: `import type` for the value shapes (no server graph leaks);
|
||||
* the fetch rides the same `/api/insights/derived` route + 8 s client
|
||||
* ceiling as the rest of the surface.
|
||||
*/
|
||||
|
||||
export type CompositeMetricId = "SLEEP_SCORE" | "READINESS";
|
||||
|
||||
/** The cited standard per composite (the explainer's external link). */
|
||||
const STANDARDS: Record<CompositeMetricId, ProvenanceStandard> = {
|
||||
SLEEP_SCORE: {
|
||||
name: "Hirshkowitz et al. 2015, Sleep Health",
|
||||
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
|
||||
},
|
||||
READINESS: {
|
||||
name: "Plews et al. 2013, Sports Medicine",
|
||||
url: "https://doi.org/10.1007/s40279-013-0071-8",
|
||||
},
|
||||
};
|
||||
|
||||
export interface CompositeScoreAnatomyProps {
|
||||
metric: CompositeMetricId;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompositeScoreAnatomy({
|
||||
metric,
|
||||
className,
|
||||
}: CompositeScoreAnatomyProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const sleep = useDerivedMetric<SleepScoreValue>("SLEEP_SCORE", {
|
||||
enabled: metric === "SLEEP_SCORE",
|
||||
});
|
||||
const readiness = useDerivedMetric<ReadinessValue>("READINESS", {
|
||||
enabled: metric === "READINESS",
|
||||
});
|
||||
|
||||
const query = metric === "SLEEP_SCORE" ? sleep : readiness;
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div data-slot="composite-anatomy-loading" className={className}>
|
||||
<Skeleton className="h-[28rem] w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
const title = t(`insights.derived.composite.${metric}.title`);
|
||||
const standard = STANDARDS[metric];
|
||||
|
||||
if (!data) {
|
||||
// Network/abort fallback — render the insufficient state honestly.
|
||||
return (
|
||||
<ScoreAnatomyView
|
||||
title={title}
|
||||
score={null}
|
||||
contributors={[]}
|
||||
coverage={{
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
}}
|
||||
confidence={null}
|
||||
provenance={{
|
||||
inputs: [],
|
||||
source: "none",
|
||||
windowDays: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
}}
|
||||
method={t(`insights.derived.composite.${metric}.method`)}
|
||||
standard={standard}
|
||||
insufficient
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const insufficient = data.status !== "ok";
|
||||
let score: number | null = null;
|
||||
let contributors: AnatomyContributor[] = [];
|
||||
let caption: string | undefined;
|
||||
|
||||
if (data.status === "ok" && data.value) {
|
||||
if (metric === "SLEEP_SCORE") {
|
||||
const v = data.value as SleepScoreValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
contributors = v.subScores
|
||||
.map((s) => ({
|
||||
key: s.key,
|
||||
label: t(`insights.derived.composite.SLEEP_SCORE.sub.${s.key}`),
|
||||
value: s.value,
|
||||
weight: s.weight,
|
||||
}))
|
||||
.sort(rankByImpact);
|
||||
} else {
|
||||
const v = data.value as ReadinessValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
contributors = v.components
|
||||
.map((c) => ({
|
||||
key: c.key,
|
||||
label: t(`insights.derived.composite.READINESS.component.${c.key}`),
|
||||
value: c.value,
|
||||
weight: c.weight,
|
||||
}))
|
||||
.sort(rankByImpact);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScoreAnatomyView
|
||||
title={title}
|
||||
score={score}
|
||||
caption={caption}
|
||||
contributors={contributors}
|
||||
coverage={data.coverage}
|
||||
confidence={data.confidence}
|
||||
provenance={data.provenance}
|
||||
method={t(`insights.derived.composite.${metric}.method`)}
|
||||
standard={standard}
|
||||
insufficient={insufficient}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rank contributors by impact: present-and-higher-weight first. */
|
||||
function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number {
|
||||
const aPresent = a.value != null ? 1 : 0;
|
||||
const bPresent = b.value != null ? 1 : 0;
|
||||
if (aPresent !== bPresent) return bPresent - aPresent;
|
||||
return b.weight - a.weight;
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export {
|
||||
type ProvenanceExplainerProps,
|
||||
type ProvenanceStandard,
|
||||
} from "./provenance-explainer";
|
||||
export {
|
||||
ScoreAnatomyView,
|
||||
type ScoreAnatomyViewProps,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
export {
|
||||
bandForScore,
|
||||
clampScore,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DerivedConfidence,
|
||||
DerivedCoverage,
|
||||
DerivedProvenance,
|
||||
} from "@/lib/insights/derived/types";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import {
|
||||
ProvenanceExplainer,
|
||||
type ProvenanceStandard,
|
||||
} from "./provenance-explainer";
|
||||
import {
|
||||
bandForScore,
|
||||
BAND_PROGRESS_CLASS,
|
||||
clampScore,
|
||||
type ScoreBand,
|
||||
} from "./band-tokens";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Oura-style score-anatomy detail view (the Hybrid
|
||||
* direction's "reinhauen" surface).
|
||||
*
|
||||
* A full-bleed `ScoreRing` + ranked contributor rows + a cited-standard
|
||||
* `ProvenanceExplainer`. Each contributor row is one input's push/pull on
|
||||
* the score: a label, an impact bar (band-coloured, width = the
|
||||
* contributor's 0..100 value), and the contributor's own mini coverage
|
||||
* (its effective weight share). Reused by every composite (sleep score,
|
||||
* readiness, …) — the caller normalises its `value` into the
|
||||
* `contributors` list, so this component stays metric-agnostic.
|
||||
*
|
||||
* Renders the same three states the rest of the design system speaks:
|
||||
* - ok → ring + contributors + coverage + provenance
|
||||
* - insufficient → the ring's provisional state + the coverage "track N"
|
||||
* nudge (never a blank, never a fabricated number)
|
||||
*
|
||||
* Mobile-first (the ring is large where there is room), dark-first, a11y:
|
||||
* the ring restates the number in its aria-label; contributor bars carry a
|
||||
* text value (never colour-only); the provenance explainer threads
|
||||
* aria-describedby. Plain React text children only — no markdown library.
|
||||
*/
|
||||
|
||||
/** One normalised contributor the view ranks by impact. */
|
||||
export interface AnatomyContributor {
|
||||
/** Stable key (drives the data-attr + the localised label lookup by caller). */
|
||||
key: string;
|
||||
/** Localised display label. */
|
||||
label: ReactNode;
|
||||
/** 0..100 contribution score, or null when the input was missing (dropped). */
|
||||
value: number | null;
|
||||
/** Effective weight share after null-redistribution, 0..1. */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ScoreAnatomyViewProps {
|
||||
/** Localised metric title (e.g. "Readiness", "Sleep score"). */
|
||||
title: ReactNode;
|
||||
/** The composite 0..100 score, or null for the insufficient/empty state. */
|
||||
score: number | null;
|
||||
/** Band override; derived from the score when omitted. */
|
||||
band?: ScoreBand;
|
||||
/** Short caption under the ring (e.g. a band word or a one-line summary). */
|
||||
caption?: ReactNode;
|
||||
/** Ranked contributors (the caller pre-ranks; the view renders in order). */
|
||||
contributors: AnatomyContributor[];
|
||||
coverage: DerivedCoverage;
|
||||
confidence?: DerivedConfidence | null;
|
||||
provenance: DerivedProvenance;
|
||||
/** Plain-language method shown in the provenance explainer. */
|
||||
method: ReactNode;
|
||||
/** Cited standard rendered as an external link in the explainer footer. */
|
||||
standard?: ProvenanceStandard;
|
||||
/** When true the score is unavailable — render the provisional state. */
|
||||
insufficient?: boolean;
|
||||
/** Localised "not enough data" nudge for the insufficient state. */
|
||||
insufficientNote?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreAnatomyView({
|
||||
title,
|
||||
score,
|
||||
band,
|
||||
caption,
|
||||
contributors,
|
||||
coverage,
|
||||
confidence,
|
||||
provenance,
|
||||
method,
|
||||
standard,
|
||||
insufficient = false,
|
||||
insufficientNote,
|
||||
className,
|
||||
}: ScoreAnatomyViewProps) {
|
||||
const { t } = useTranslations();
|
||||
const effectiveScore = insufficient ? null : score;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="score-anatomy-view"
|
||||
data-status={insufficient ? "insufficient" : "ok"}
|
||||
className={cn(
|
||||
"border-border bg-card flex flex-col gap-5 rounded-xl border p-5",
|
||||
className,
|
||||
)}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
>
|
||||
{/* Hero: title + ring + caption */}
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<h2
|
||||
data-slot="score-anatomy-title"
|
||||
className="text-foreground text-sm font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<ScoreRing
|
||||
score={effectiveScore}
|
||||
band={band}
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
size="lg"
|
||||
/>
|
||||
{caption && !insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-caption"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
<CoverageMeter
|
||||
coverage={coverage}
|
||||
confidence={confidence ?? undefined}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-insufficient"
|
||||
className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-center text-xs"
|
||||
>
|
||||
{insufficientNote ??
|
||||
t("insights.derived.anatomy.insufficient")}
|
||||
</p>
|
||||
) : (
|
||||
<div data-slot="score-anatomy-contributors" className="space-y-3">
|
||||
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
|
||||
{t("insights.derived.anatomy.contributorsLabel")}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{contributors.map((c) => (
|
||||
<ContributorRow key={c.key} contributor={c} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provenance + cited standard + the non-clinical disclaimer */}
|
||||
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
||||
<p
|
||||
data-slot="score-anatomy-disclaimer"
|
||||
className="text-muted-foreground text-[10px] leading-snug"
|
||||
>
|
||||
{t("insights.derived.anatomy.disclaimer")}
|
||||
</p>
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={standard}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ranked contributor row — label + impact bar + value + weight share. */
|
||||
function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
|
||||
const { t } = useTranslations();
|
||||
const present = contributor.value != null && Number.isFinite(contributor.value);
|
||||
const clamped = present ? clampScore(contributor.value as number) : 0;
|
||||
const rowBand: ScoreBand = bandForScore(clamped);
|
||||
const weightPct = Math.round(contributor.weight * 100);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="anatomy-contributor-row"
|
||||
data-contributor={contributor.key}
|
||||
data-present={present ? "true" : "false"}
|
||||
className="space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
{contributor.label}
|
||||
</span>
|
||||
<span
|
||||
data-slot="anatomy-contributor-value"
|
||||
className="text-muted-foreground text-[11px] tabular-nums"
|
||||
>
|
||||
{present
|
||||
? t("insights.derived.anatomy.contributorValue", {
|
||||
value: Math.round(clamped),
|
||||
weight: weightPct,
|
||||
})
|
||||
: t("insights.derived.anatomy.contributorMissing")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
data-slot="anatomy-contributor-bar"
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
present ? BAND_PROGRESS_CLASS[rowBand] : "bg-muted",
|
||||
)}
|
||||
style={{ width: present ? `${clamped}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DerivedConfidence,
|
||||
DerivedCoverage,
|
||||
DerivedProvenance,
|
||||
} from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.0 — client loader for one derived wellness metric.
|
||||
*
|
||||
* Reads the flat `Derived<T>` envelope off `/api/insights/derived?metric=…`
|
||||
* (the same shape the score-anatomy view + iOS decode). Pure compute on the
|
||||
* server — sub-second on a warm rollup tenant — so the hook bounds the fetch
|
||||
* on an 8 s client ceiling (a slow response means a degraded network, not a
|
||||
* provider call), keeps a 60 s `staleTime`, and never inline-retries.
|
||||
*
|
||||
* `T` is the metric-specific value shape (e.g. `ReadinessValue`,
|
||||
* `SleepScoreValue`); the caller supplies it so the anatomy view stays
|
||||
* strongly typed without a second cast.
|
||||
*/
|
||||
|
||||
const DERIVED_TIMEOUT_MS = 8_000;
|
||||
|
||||
/** The flat wire shape the derived route returns inside `{ data }`. */
|
||||
export interface DerivedMetricEnvelope<T> {
|
||||
metric: string;
|
||||
status: "ok" | "insufficient";
|
||||
value: T | null;
|
||||
coverage: DerivedCoverage;
|
||||
confidence: DerivedConfidence | null;
|
||||
provenance: DerivedProvenance;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export function useDerivedMetric<T>(
|
||||
metric: string,
|
||||
options: { type?: string; enabled?: boolean } = {},
|
||||
) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const enabled = (options.enabled ?? true) && isAuthenticated && metric !== "";
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insightsDerivedMetric(metric, options.type),
|
||||
queryFn: async (): Promise<DerivedMetricEnvelope<T>> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
DERIVED_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const params = new URLSearchParams({ metric });
|
||||
if (options.type) params.set("type", options.type);
|
||||
const res = await fetch(`/api/insights/derived?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: DerivedMetricEnvelope<T> };
|
||||
return json.data;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
discoverCorrelations,
|
||||
lagJoin,
|
||||
benjaminiHochberg,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "../correlation-discovery";
|
||||
|
||||
/** Build a contiguous daily series from day-1 of a month. */
|
||||
function series(values: number[], startDay = 1): DailySeriesPoint[] {
|
||||
return values.map((value, i) => ({
|
||||
day: `2026-03-${String(startDay + i).padStart(2, "0")}`,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("lagJoin", () => {
|
||||
it("pairs a behaviour day with the next day's outcome", () => {
|
||||
const behaviour = series([1, 2, 3]); // Mar 1,2,3
|
||||
const outcome = series([10, 20, 30]); // Mar 1,2,3
|
||||
const { xs, ys } = lagJoin(behaviour, outcome, 1);
|
||||
// Mar1→Mar2 (1,20), Mar2→Mar3 (2,30); Mar3→Mar4 has no outcome.
|
||||
expect(xs).toEqual([1, 2]);
|
||||
expect(ys).toEqual([20, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("benjaminiHochberg", () => {
|
||||
it("adjusts p-values monotonically and never exceeds 1", () => {
|
||||
const q = benjaminiHochberg([0.01, 0.04, 0.5]);
|
||||
expect(q.every((v) => v <= 1 && v >= 0)).toBe(true);
|
||||
// Smallest p gets the smallest q under the monotone step-up.
|
||||
expect(q[0]).toBeLessThanOrEqual(q[1]);
|
||||
});
|
||||
|
||||
it("controls discovery — a single tiny p stays significant, noise inflates", () => {
|
||||
// 1 true (p=0.001) + 19 noise (p≈0.5): BH keeps the true one well under q.
|
||||
const ps = [0.001, ...Array.from({ length: 19 }, () => 0.5)];
|
||||
const q = benjaminiHochberg(ps);
|
||||
expect(q[0]).toBeLessThan(0.05);
|
||||
expect(q[1]).toBeGreaterThan(0.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverCorrelations", () => {
|
||||
it("returns none when no pair clears the n ≥ 20 gate", () => {
|
||||
const behaviours: NamedSeries[] = [
|
||||
{ key: "MOOD", role: "behaviour", points: series([3, 4, 5]) },
|
||||
];
|
||||
const outcomes: NamedSeries[] = [
|
||||
{ key: "SLEEP_DURATION", role: "outcome", points: series([400, 410, 420]) },
|
||||
];
|
||||
const result = discoverCorrelations([...behaviours, ...outcomes]);
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
expect(result.pairsTested).toBe(0);
|
||||
});
|
||||
|
||||
it("surfaces a strong lagged pair and tags n, r, p, q", () => {
|
||||
// 30 days: outcome[d+1] tracks behaviour[d] linearly → strong r.
|
||||
const n = 30;
|
||||
const behaviourVals = Array.from({ length: n }, (_, i) => i + (i % 3));
|
||||
// Outcome on day d+1 mirrors behaviour on day d (shift by one).
|
||||
const outcomeVals = [0, ...behaviourVals.slice(0, n - 1).map((v) => v * 2 + 5)];
|
||||
const result = discoverCorrelations(
|
||||
[
|
||||
{ key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(behaviourVals) },
|
||||
{ key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) },
|
||||
],
|
||||
{ fdrQ: 0.1 },
|
||||
);
|
||||
expect(result.pairsTested).toBe(1);
|
||||
expect(result.discovered).toHaveLength(1);
|
||||
const pair = result.discovered[0];
|
||||
expect(pair.behaviour).toBe("TIME_IN_DAYLIGHT");
|
||||
expect(pair.outcome).toBe("SLEEP_DURATION");
|
||||
expect(pair.n).toBeGreaterThanOrEqual(20);
|
||||
expect(pair.pValue).toBeLessThan(0.05);
|
||||
expect(pair.qValue).toBeLessThanOrEqual(0.1);
|
||||
expect(pair.interpretation).toMatch(/not a cause/);
|
||||
expect(pair.lagDays).toBe(1);
|
||||
});
|
||||
|
||||
it("drops a pure-noise pair under FDR control", () => {
|
||||
// 30 days of independent-ish noise → no defensible correlation.
|
||||
const noiseA = Array.from({ length: 30 }, (_, i) => Math.sin(i) * 10 + 50);
|
||||
const noiseB = Array.from({ length: 30 }, (_, i) => Math.cos(i * 1.7) * 10 + 50);
|
||||
const result = discoverCorrelations([
|
||||
{ key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) },
|
||||
{ key: "WEIGHT", role: "outcome", points: series(noiseB) },
|
||||
]);
|
||||
// It is tested (n ≥ 20) but should not survive p < 0.05 + FDR.
|
||||
expect(result.pairsTested).toBe(1);
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* v1.10.0 — FDR-controlled correlation discovery engine.
|
||||
*
|
||||
* Promotes the legacy three-fixed-hypothesis `correlations.ts` to a bounded
|
||||
* ALL-PAIRS discovery over a curated behaviour × outcome matrix, lagged
|
||||
* (a behaviour on day D vs an outcome on day D+1), Benjamini-Hochberg
|
||||
* FDR-controlled on top of the existing exact Student-t / n ≥ 20 / p < 0.05
|
||||
* gates. Only statistically-defensible pairs surface, each with an honest
|
||||
* "descriptive, not causal" caveat.
|
||||
*
|
||||
* Why FDR: scanning many pairs inflates the family-wise false-discovery
|
||||
* rate — at p < 0.05 alone, 1-in-20 noise pairs surface as "significant".
|
||||
* Benjamini & Hochberg (1995), J. R. Stat. Soc. B 57(1):289–300 controls
|
||||
* the expected proportion of false discoveries among the surfaced pairs to
|
||||
* `FDR_Q` (default 0.10). A pair surfaces only when its rank-adjusted q
|
||||
* clears the threshold — so the card count stays honest as the matrix grows.
|
||||
*
|
||||
* Lagging: a behaviour (today's daylight, mood, glucose, BP) is paired with
|
||||
* the NEXT day's outcome (sleep, HRV, resting HR, weight). The lag encodes
|
||||
* the plausible direction ("more daylight today → better sleep tonight")
|
||||
* without claiming causation — the caveat is displayed verbatim.
|
||||
*
|
||||
* Pearson + the exact incomplete-beta p-value are reused from
|
||||
* `src/lib/insights/correlations.ts` (its docstring explicitly anticipates
|
||||
* this auto-discovery widening the pair grid). This module is PURE over
|
||||
* already-fetched daily series — the DB read lives in the route.
|
||||
*
|
||||
* Framing discipline: descriptive, never causal; every pair carries n, r,
|
||||
* p, and the BH-adjusted q; medication compliance is NOT yet a behaviour
|
||||
* channel (the cadence-aware per-day rate needs the compliance engine — a
|
||||
* later wave), documented here so the omission is intentional, not a gap.
|
||||
*/
|
||||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
|
||||
/** Default Benjamini-Hochberg target false-discovery rate. */
|
||||
export const FDR_Q = 0.1;
|
||||
|
||||
/** A single day's value for one metric (daily mean / sum). */
|
||||
export interface DailySeriesPoint {
|
||||
/** Day key YYYY-MM-DD. */
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** A named daily series feeding the discovery matrix. */
|
||||
export interface NamedSeries {
|
||||
/** Stable channel key (e.g. "TIME_IN_DAYLIGHT", "MOOD"). */
|
||||
key: string;
|
||||
/** Whether the channel is a behaviour (lag source) or an outcome (lag target). */
|
||||
role: "behaviour" | "outcome";
|
||||
points: DailySeriesPoint[];
|
||||
}
|
||||
|
||||
/** One discovered, FDR-surviving correlation pair. */
|
||||
export interface DiscoveredCorrelation {
|
||||
behaviour: string;
|
||||
outcome: string;
|
||||
/** Paired-day count after the day+1 lag join. */
|
||||
n: number;
|
||||
/** Pearson r (lag-joined). */
|
||||
r: number;
|
||||
/** Two-sided exact Student-t p-value. */
|
||||
pValue: number;
|
||||
/** Benjamini-Hochberg adjusted q-value. */
|
||||
qValue: number;
|
||||
/** Conservative, descriptive interpretation — never causal. */
|
||||
interpretation: string;
|
||||
/** Lag in days applied (always 1 here). */
|
||||
lagDays: number;
|
||||
}
|
||||
|
||||
export interface CorrelationDiscoveryResult {
|
||||
/** Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. */
|
||||
discovered: DiscoveredCorrelation[];
|
||||
/** How many behaviour × outcome pairs were tested (for the honest footer). */
|
||||
pairsTested: number;
|
||||
/** The FDR target the surface used. */
|
||||
fdrQ: number;
|
||||
/** Minimum paired-day count enforced per pair. */
|
||||
minPairs: number;
|
||||
}
|
||||
|
||||
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
|
||||
function shiftDay(day: string, lagDays: number): string {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + lagDays);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lag-join a behaviour series (day D) against an outcome series (day D+lag).
|
||||
* Returns the paired (behaviour, outcome) value arrays. Pure.
|
||||
*/
|
||||
export function lagJoin(
|
||||
behaviour: DailySeriesPoint[],
|
||||
outcome: DailySeriesPoint[],
|
||||
lagDays: number,
|
||||
): { xs: number[]; ys: number[] } {
|
||||
const outcomeByDay = new Map(outcome.map((p) => [p.day, p.value]));
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
for (const b of behaviour) {
|
||||
const target = outcomeByDay.get(shiftDay(b.day, lagDays));
|
||||
if (target != null && Number.isFinite(b.value) && Number.isFinite(target)) {
|
||||
xs.push(b.value);
|
||||
ys.push(target);
|
||||
}
|
||||
}
|
||||
return { xs, ys };
|
||||
}
|
||||
|
||||
interface RawPair {
|
||||
behaviour: string;
|
||||
outcome: string;
|
||||
n: number;
|
||||
r: number;
|
||||
pValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the Benjamini-Hochberg step-up procedure to a set of p-values.
|
||||
* Returns the BH-adjusted q-value per input index (monotone-enforced from
|
||||
* the largest rank down), so a caller can surface pairs whose q ≤ `fdrQ`.
|
||||
* Pure. `m` is the number of tests (the full family), which can exceed
|
||||
* `pValues.length` if some pairs were dropped before ranking — but here we
|
||||
* pass every tested pair, so `m === pValues.length`.
|
||||
*/
|
||||
export function benjaminiHochberg(
|
||||
pValues: number[],
|
||||
m: number = pValues.length,
|
||||
): number[] {
|
||||
const indexed = pValues.map((p, i) => ({ p, i }));
|
||||
indexed.sort((a, b) => a.p - b.p);
|
||||
const q = new Array<number>(pValues.length);
|
||||
// Walk from the largest p (rank m) down, enforcing monotonicity so a
|
||||
// smaller p never gets a larger q than a larger p.
|
||||
let prev = 1;
|
||||
for (let rank = indexed.length; rank >= 1; rank--) {
|
||||
const { p, i } = indexed[rank - 1];
|
||||
const raw = (p * m) / rank;
|
||||
prev = Math.min(prev, raw);
|
||||
q[i] = Math.min(1, prev);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a conservative, descriptive interpretation for a surviving pair.
|
||||
* Sign of `r` flips the phrasing; never claims causation.
|
||||
*/
|
||||
function interpret(behaviour: string, outcome: string, r: number): string {
|
||||
const b = humanise(behaviour);
|
||||
const o = humanise(outcome);
|
||||
if (r <= -0.1) {
|
||||
return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`;
|
||||
}
|
||||
if (r >= 0.1) {
|
||||
return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`;
|
||||
}
|
||||
return `${b} and next-day ${o} move together only weakly in this window.`;
|
||||
}
|
||||
|
||||
/** Lower-case, space-separated label from a channel key. */
|
||||
function humanise(key: string): string {
|
||||
return key.replace(/_/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the FDR-controlled discovery over the behaviour × outcome matrix.
|
||||
*
|
||||
* 1. For every (behaviour, outcome) pair, lag-join (D → D+1) and run
|
||||
* Pearson with the exact p-value. Pairs below `minPairs` paired days
|
||||
* are dropped (not tested — they cannot be defensibly assessed).
|
||||
* 2. Across the tested pairs, compute BH q-values.
|
||||
* 3. Surface pairs with p < 0.05 AND q ≤ `fdrQ`, ranked by q then |r|.
|
||||
*
|
||||
* Pure — the caller fetches the daily series.
|
||||
*/
|
||||
export function discoverCorrelations(
|
||||
series: NamedSeries[],
|
||||
opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {},
|
||||
): CorrelationDiscoveryResult {
|
||||
const lagDays = opts.lagDays ?? 1;
|
||||
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
|
||||
const fdrQ = opts.fdrQ ?? FDR_Q;
|
||||
|
||||
const behaviours = series.filter((s) => s.role === "behaviour");
|
||||
const outcomes = series.filter((s) => s.role === "outcome");
|
||||
|
||||
const tested: RawPair[] = [];
|
||||
for (const b of behaviours) {
|
||||
for (const o of outcomes) {
|
||||
if (b.key === o.key) continue;
|
||||
const { xs, ys } = lagJoin(b.points, o.points, lagDays);
|
||||
if (xs.length < minPairs) continue;
|
||||
const result = pearson({ xs, ys, minPairs });
|
||||
if (result.status !== "ok") continue;
|
||||
tested.push({
|
||||
behaviour: b.key,
|
||||
outcome: o.key,
|
||||
n: result.n,
|
||||
r: result.r,
|
||||
pValue: result.pValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pairsTested = tested.length;
|
||||
if (pairsTested === 0) {
|
||||
return { discovered: [], pairsTested: 0, fdrQ, minPairs };
|
||||
}
|
||||
|
||||
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
|
||||
const discovered: DiscoveredCorrelation[] = tested
|
||||
.map((t, i) => ({ ...t, qValue: qValues[i] }))
|
||||
.filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ)
|
||||
.map((t) => ({
|
||||
behaviour: t.behaviour,
|
||||
outcome: t.outcome,
|
||||
n: t.n,
|
||||
r: t.r,
|
||||
pValue: t.pValue,
|
||||
qValue: Math.round(t.qValue * 1000) / 1000,
|
||||
interpretation: interpret(t.behaviour, t.outcome, t.r),
|
||||
lagDays,
|
||||
}))
|
||||
.sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r));
|
||||
|
||||
return { discovered, pairsTested, fdrQ, minPairs };
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated discovery matrix — the channels the engine pairs. Behaviours
|
||||
* (lag sources) on the left, outcomes (lag targets) on the right. Medication
|
||||
* compliance is deliberately NOT yet a behaviour channel — its cadence-aware
|
||||
* per-day rate needs the compliance engine; folding it in is a later wave.
|
||||
*/
|
||||
export const DISCOVERY_BEHAVIOURS = [
|
||||
"TIME_IN_DAYLIGHT",
|
||||
"MOOD",
|
||||
"BLOOD_GLUCOSE",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"ACTIVITY_STEPS",
|
||||
] as const;
|
||||
|
||||
export const DISCOVERY_OUTCOMES = [
|
||||
"SLEEP_DURATION",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESTING_HEART_RATE",
|
||||
"WEIGHT",
|
||||
] as const;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeCoincidentDeviation,
|
||||
classifyDeviation,
|
||||
COINCIDENT_FIRE_THRESHOLD,
|
||||
} from "../coincident-deviation";
|
||||
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const };
|
||||
const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("classifyDeviation", () => {
|
||||
it("flags above / below / in-band", () => {
|
||||
expect(classifyDeviation("PULSE", 90, 60, 80, 70).direction).toBe("above");
|
||||
expect(classifyDeviation("PULSE", 50, 60, 80, 70).direction).toBe("below");
|
||||
expect(classifyDeviation("PULSE", 70, 60, 80, 70).direction).toBe("in");
|
||||
expect(classifyDeviation("PULSE", 70, 60, 80, 70).outside).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCoincidentDeviation", () => {
|
||||
it("returns insufficient with fewer than two banded vitals", async () => {
|
||||
// Only one vital has a usable band.
|
||||
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "RESTING_HEART_RATE") {
|
||||
return Array.from({ length: 10 }, (_, i) => ({
|
||||
value: 58,
|
||||
measuredAt: new Date(
|
||||
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("insufficient");
|
||||
if (result.status === "insufficient") {
|
||||
expect(result.reason).toBe("too_few_banded_vitals");
|
||||
}
|
||||
});
|
||||
|
||||
it("fires when ≥ 2 vitals sit outside their band on the latest day", async () => {
|
||||
// RHR + HRV each have a flat band, then a final outlier day that
|
||||
// pushes both outside the band.
|
||||
const baseline = (
|
||||
base: number,
|
||||
outlier: number,
|
||||
): Array<{ value: number; measuredAt: Date }> => [
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
value: base,
|
||||
measuredAt: new Date(
|
||||
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
||||
),
|
||||
})),
|
||||
{ value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") },
|
||||
];
|
||||
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95);
|
||||
if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10);
|
||||
return [];
|
||||
});
|
||||
const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.value.contributing.length).toBeGreaterThanOrEqual(
|
||||
COINCIDENT_FIRE_THRESHOLD,
|
||||
);
|
||||
expect(result.value.fired).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not fire when vitals stay inside their bands", async () => {
|
||||
const flat = (base: number): Array<{ value: number; measuredAt: Date }> =>
|
||||
Array.from({ length: 10 }, (_, i) => ({
|
||||
value: base,
|
||||
measuredAt: new Date(
|
||||
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
||||
),
|
||||
}));
|
||||
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "RESTING_HEART_RATE") return flat(58);
|
||||
if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60);
|
||||
return [];
|
||||
});
|
||||
const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.value.fired).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) } },
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
|
||||
@@ -33,6 +36,13 @@ describe("registry", () => {
|
||||
// Composites still land in W3.
|
||||
expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false);
|
||||
expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(false);
|
||||
it("exposes the flagship + W3 composites as implemented; the passthroughs stay stubs", () => {
|
||||
expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true);
|
||||
expect(getDerivedMetricMeta("SLEEP_SCORE")?.implemented).toBe(true);
|
||||
expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(true);
|
||||
expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(true);
|
||||
// FITNESS_AGE + VASCULAR_AGE_DELTA land in a sibling wave.
|
||||
expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false);
|
||||
});
|
||||
|
||||
it("isDerivedMetricId rejects unknown ids", () => {
|
||||
@@ -68,7 +78,7 @@ describe("computeDerivedMetric dispatch", () => {
|
||||
|
||||
it("returns not_implemented for a stubbed metric (never a 500)", async () => {
|
||||
const result = await computeDerivedMetric({
|
||||
metric: "READINESS",
|
||||
metric: "FITNESS_AGE",
|
||||
userId: "u1",
|
||||
profile: PROFILE,
|
||||
now: NOW,
|
||||
@@ -79,6 +89,45 @@ describe("computeDerivedMetric dispatch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("routes READINESS to the engine (no data → insufficient, not not_implemented)", async () => {
|
||||
const result = await computeDerivedMetric({
|
||||
metric: "READINESS",
|
||||
userId: "u1",
|
||||
profile: PROFILE,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.status).toBe("insufficient");
|
||||
if (result.status === "insufficient") {
|
||||
expect(result.reason).toBe("insufficient_components");
|
||||
}
|
||||
});
|
||||
|
||||
it("routes SLEEP_SCORE to the engine (no sleep → insufficient)", async () => {
|
||||
const result = await computeDerivedMetric({
|
||||
metric: "SLEEP_SCORE",
|
||||
userId: "u1",
|
||||
profile: PROFILE,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.status).toBe("insufficient");
|
||||
if (result.status === "insufficient") {
|
||||
expect(result.reason).toBe("no_sleep_in_window");
|
||||
}
|
||||
});
|
||||
|
||||
it("routes COINCIDENT_DEVIATION to the engine (no bands → insufficient)", async () => {
|
||||
const result = await computeDerivedMetric({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
userId: "u1",
|
||||
profile: PROFILE,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.status).toBe("insufficient");
|
||||
if (result.status === "insufficient") {
|
||||
expect(result.reason).toBe("too_few_banded_vitals");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => {
|
||||
const result = await computeDerivedMetric({
|
||||
metric: "VITALS_BASELINE",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeReadiness,
|
||||
blendReadinessComponents,
|
||||
scoreDeviation,
|
||||
READINESS_MIN_COMPONENTS,
|
||||
type ReadinessComponentKey,
|
||||
} from "../readiness";
|
||||
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const };
|
||||
const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
const measurementFindMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
const moodFindMany = prisma.moodEntry.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
measurementFindMany.mockResolvedValue([]);
|
||||
moodFindMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("scoreDeviation", () => {
|
||||
it("scores 100 on or in the good direction (lower-better)", () => {
|
||||
expect(scoreDeviation(50, 60, 5, "lower-better")).toBe(100);
|
||||
expect(scoreDeviation(60, 60, 5, "lower-better")).toBe(100);
|
||||
});
|
||||
|
||||
it("suppresses the score one spread worse (lower-better RHR elevated)", () => {
|
||||
expect(scoreDeviation(65, 60, 5, "lower-better")).toBe(50);
|
||||
expect(scoreDeviation(70, 60, 5, "lower-better")).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses on an HRV drop (higher-better)", () => {
|
||||
expect(scoreDeviation(55, 60, 5, "higher-better")).toBe(50);
|
||||
expect(scoreDeviation(65, 60, 5, "higher-better")).toBe(100);
|
||||
});
|
||||
|
||||
it("degrades gracefully when the spread is zero", () => {
|
||||
expect(scoreDeviation(61, 60, 0, "lower-better")).toBe(50);
|
||||
expect(scoreDeviation(59, 60, 0, "lower-better")).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blendReadinessComponents reweighting", () => {
|
||||
it("renormalises over present components and drops missing ones", () => {
|
||||
const raw: Record<ReadinessComponentKey, number | null> = {
|
||||
rhr: 80,
|
||||
hrv: 70,
|
||||
sleep: null,
|
||||
respiratory: null,
|
||||
mood: 60,
|
||||
};
|
||||
const { score, components } = blendReadinessComponents(raw);
|
||||
const sleep = components.find((c) => c.key === "sleep")!;
|
||||
expect(sleep.weight).toBe(0);
|
||||
const presentSum = components
|
||||
.filter((c) => c.value !== null)
|
||||
.reduce((s, c) => s + c.weight, 0);
|
||||
expect(presentSum).toBeCloseTo(1, 5);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeReadiness gating", () => {
|
||||
it("returns insufficient below the minimum-components floor (no 1-of-N headline)", async () => {
|
||||
// No data at all → 0 components present.
|
||||
const result = await computeReadiness("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("insufficient");
|
||||
if (result.status === "insufficient") {
|
||||
expect(result.reason).toBe("insufficient_components");
|
||||
expect(result.coverage.presentInputs).toBeLessThan(
|
||||
READINESS_MIN_COMPONENTS,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces a headline once ≥ 2 deviation components are present", async () => {
|
||||
// RHR + HRV baselines: ≥ 7 distinct days each, plus a latest reading.
|
||||
const days = (type: string, base: number) =>
|
||||
Array.from({ length: 10 }, (_, i) => ({
|
||||
value: base,
|
||||
// 10 distinct days within May (15..24) — all valid calendar days.
|
||||
measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`),
|
||||
type,
|
||||
}));
|
||||
measurementFindMany.mockImplementation(
|
||||
async (args: { where: { type: string } }) => {
|
||||
const type = args.where.type;
|
||||
if (type === "RESTING_HEART_RATE") return days(type, 58);
|
||||
if (type === "HEART_RATE_VARIABILITY") return days(type, 65);
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const result = await computeReadiness("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
const present = result.value.components.filter((c) => c.value !== null);
|
||||
expect(present.length).toBeGreaterThanOrEqual(READINESS_MIN_COMPONENTS);
|
||||
expect(result.value.score).toBeGreaterThanOrEqual(0);
|
||||
expect(result.value.score).toBeLessThanOrEqual(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeSleepScore,
|
||||
blendSleepSubScores,
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
scoreSufficiency,
|
||||
scoreEfficiency,
|
||||
scoreComposition,
|
||||
scoreConsistency,
|
||||
scoreTiming,
|
||||
type SleepSubScoreKey,
|
||||
} from "../sleep-score";
|
||||
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const };
|
||||
const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/** Build per-stage rows for one night keyed on a wake-day. */
|
||||
function night(day: string, stages: Array<[string | null, number, string]>) {
|
||||
return stages.map(([sleepStage, value, hhmm]) => ({
|
||||
value,
|
||||
measuredAt: new Date(`${day}T${hhmm}:00Z`),
|
||||
sleepStage: sleepStage as never,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("pure sleep scorers", () => {
|
||||
it("sleepNeedMinutes is age-banded with an adult default", () => {
|
||||
expect(sleepNeedMinutes(40)).toBe(7 * 60);
|
||||
expect(sleepNeedMinutes(10)).toBe(10 * 60);
|
||||
expect(sleepNeedMinutes(null)).toBe(7 * 60);
|
||||
});
|
||||
|
||||
it("sufficiency caps at 100 and never penalises oversleep", () => {
|
||||
expect(scoreSufficiency(7 * 60, 7 * 60)).toBe(100);
|
||||
expect(scoreSufficiency(10 * 60, 7 * 60)).toBe(100);
|
||||
expect(scoreSufficiency(3.5 * 60, 7 * 60)).toBe(50);
|
||||
});
|
||||
|
||||
it("efficiency is null without in-bed minutes, else the asleep ratio", () => {
|
||||
expect(scoreEfficiency(420, null)).toBeNull();
|
||||
expect(scoreEfficiency(420, 480)).toBe(88);
|
||||
});
|
||||
|
||||
it("composition is null without a stage breakdown, 100 inside the band", () => {
|
||||
expect(scoreComposition(0, 0, 420, false)).toBeNull();
|
||||
// REM 90 + Deep 60 = 150 of 420 ≈ 0.357 — inside [0.33, 0.48].
|
||||
expect(scoreComposition(90, 60, 420, true)).toBe(100);
|
||||
});
|
||||
|
||||
it("consistency is null below 3 nights, 100 for a flat midpoint", () => {
|
||||
expect(scoreConsistency([100, 100])).toBeNull();
|
||||
expect(scoreConsistency([180, 180, 180])).toBe(100);
|
||||
});
|
||||
|
||||
it("timing is null without a habitual window", () => {
|
||||
expect(scoreTiming(180, null, 1)).toBeNull();
|
||||
expect(scoreTiming(180, 180, 5)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blendSleepSubScores reweighting", () => {
|
||||
it("renormalises weights over the present sub-scores (drops a missing one)", () => {
|
||||
const raw: Record<SleepSubScoreKey, number | null> = {
|
||||
sufficiency: 80,
|
||||
efficiency: 90,
|
||||
consistency: 70,
|
||||
timing: 60,
|
||||
composition: null, // legacy ASLEEP-only night
|
||||
};
|
||||
const { score, subScores } = blendSleepSubScores(raw);
|
||||
const composition = subScores.find((s) => s.key === "composition")!;
|
||||
expect(composition.weight).toBe(0);
|
||||
// Present weights sum to 1.
|
||||
const sum = subScores.reduce((s, c) => s + c.weight, 0);
|
||||
expect(sum).toBeCloseTo(1, 5);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
expect(score).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconstructNights", () => {
|
||||
it("sums asleep stages, derives in-bed, flags the stage breakdown", () => {
|
||||
const rows = night("2026-06-02", [
|
||||
["IN_BED", 480, "06:00"],
|
||||
["REM", 90, "02:00"],
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
["AWAKE", 30, "05:00"],
|
||||
]);
|
||||
const nights = reconstructNights(rows);
|
||||
expect(nights).toHaveLength(1);
|
||||
expect(nights[0].asleepMinutes).toBe(390);
|
||||
expect(nights[0].inBedMinutes).toBe(480);
|
||||
expect(nights[0].hasStageBreakdown).toBe(true);
|
||||
expect(nights[0].remMinutes).toBe(90);
|
||||
expect(nights[0].deepMinutes).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSleepScore", () => {
|
||||
it("returns insufficient when no sleep rows exist", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("insufficient");
|
||||
if (result.status === "insufficient") {
|
||||
expect(result.reason).toBe("no_sleep_in_window");
|
||||
}
|
||||
});
|
||||
|
||||
it("scores a multi-stage night and shows the present sub-scores", async () => {
|
||||
// Three nights so consistency/timing can compute.
|
||||
const rows = [
|
||||
...night("2026-05-31", [
|
||||
["IN_BED", 480, "06:00"],
|
||||
["REM", 90, "02:00"],
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
]),
|
||||
...night("2026-06-01", [
|
||||
["IN_BED", 470, "06:10"],
|
||||
["REM", 85, "02:10"],
|
||||
["CORE", 250, "03:10"],
|
||||
["DEEP", 55, "04:10"],
|
||||
]),
|
||||
...night("2026-06-02", [
|
||||
["IN_BED", 480, "06:05"],
|
||||
["REM", 95, "02:05"],
|
||||
["CORE", 235, "03:05"],
|
||||
["DEEP", 65, "04:05"],
|
||||
]),
|
||||
];
|
||||
findMany.mockResolvedValue(rows);
|
||||
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.value.score).toBeGreaterThan(0);
|
||||
expect(result.value.windowNights).toBe(3);
|
||||
// All five sub-scores present on a full-stage multi-night history.
|
||||
const present = result.value.subScores.filter((s) => s.value !== null);
|
||||
expect(present.length).toBe(5);
|
||||
expect(result.coverage.presentInputs).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
it("reweights around composition on a legacy ASLEEP-only night", async () => {
|
||||
const rows = [
|
||||
...night("2026-05-31", [["ASLEEP", 420, "06:00"]]),
|
||||
...night("2026-06-01", [["ASLEEP", 430, "06:10"]]),
|
||||
...night("2026-06-02", [["ASLEEP", 410, "06:05"]]),
|
||||
];
|
||||
findMany.mockResolvedValue(rows);
|
||||
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
const composition = result.value.subScores.find(
|
||||
(s) => s.key === "composition",
|
||||
)!;
|
||||
expect(composition.value).toBeNull();
|
||||
expect(composition.weight).toBe(0);
|
||||
expect(result.coverage.missing).toContain("composition");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* v1.10.0 — Coincident-deviation flag (catalogue metric #2, COMPOSITE).
|
||||
*
|
||||
* "2 of your vitals are outside their usual range this morning — possible
|
||||
* factors: illness, alcohol, altitude, a hard workout." For each vital
|
||||
* with an established personal band (#1 `VITALS_BASELINE`), check whether
|
||||
* the latest reading falls outside the band; **≥ 2 outside on the same day
|
||||
* → fire** a flag listing the contributing vitals. No black-box score — it
|
||||
* is a transparent COUNT of deviations, mirroring Apple Vitals' ≥2-metric
|
||||
* next-morning notification.
|
||||
*
|
||||
* Coverage gate: ≥ 2 vitals each with an established band. Below that the
|
||||
* flag is `insufficient` (it cannot coincide). The minimum-inputs floor is
|
||||
* the composite contract — it never emits a single composite number, and
|
||||
* never labels a cause ("illness"); it lists the contributing vitals and
|
||||
* frames them as "possible factors" only.
|
||||
*
|
||||
* Standard: the same MAD / personal-baseline basis as #1 (Hampel 1974;
|
||||
* Leys et al. 2013). Multi-signal coincidence is descriptive, NOT a
|
||||
* diagnosis. Frame: Apple Vitals / WHOOP Health Monitor / Fitbit Health
|
||||
* Metrics / Oura Symptom Radar — all descriptive, none continuous.
|
||||
*
|
||||
* Server-only — fans the baseline engine across the supported vitals with
|
||||
* one shared coverage probe (the pool-contention mitigation). The pure
|
||||
* classifier is exported for tests.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import {
|
||||
buildInsufficient,
|
||||
buildOk,
|
||||
deriveCoverage,
|
||||
nowProvenanceTimestamp,
|
||||
} from "./coverage";
|
||||
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
|
||||
import { VITALS_BASELINE_TYPES } from "./registry";
|
||||
import type { Derived, DerivedProvenanceSource } from "./types";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_WINDOW_DAYS = 30;
|
||||
/** ≥ this many out-of-band vitals on a day fires the flag. */
|
||||
export const COINCIDENT_FIRE_THRESHOLD = 2;
|
||||
/** Need ≥ this many banded vitals before the flag can even coincide. */
|
||||
export const COINCIDENT_MIN_BANDS = 2;
|
||||
|
||||
/** One vital's standing against its personal band today. */
|
||||
export interface VitalDeviation {
|
||||
type: MeasurementType;
|
||||
/** Today's value. */
|
||||
value: number;
|
||||
/** Band center (median). */
|
||||
center: number;
|
||||
low: number;
|
||||
high: number;
|
||||
/** True when today's value falls outside [low, high]. */
|
||||
outside: boolean;
|
||||
/** "above" / "below" the band, or "in" when inside. */
|
||||
direction: "above" | "below" | "in";
|
||||
}
|
||||
|
||||
export interface CoincidentDeviationValue {
|
||||
/** True when ≥ COINCIDENT_FIRE_THRESHOLD vitals are outside their band. */
|
||||
fired: boolean;
|
||||
/** All banded vitals checked today (the anatomy view lists them). */
|
||||
vitals: VitalDeviation[];
|
||||
/** Just the out-of-band vitals (the contributing factors). */
|
||||
contributing: VitalDeviation[];
|
||||
/** The day the flag was evaluated (YYYY-MM-DD). */
|
||||
day: string;
|
||||
}
|
||||
|
||||
// ── pure classifier (exported for tests) ───────────────────────────────
|
||||
|
||||
/** Classify one vital's latest value against its band. Pure. */
|
||||
export function classifyDeviation(
|
||||
type: MeasurementType,
|
||||
value: number,
|
||||
low: number,
|
||||
high: number,
|
||||
center: number,
|
||||
): VitalDeviation {
|
||||
const above = value > high;
|
||||
const below = value < low;
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
center,
|
||||
low,
|
||||
high,
|
||||
outside: above || below,
|
||||
direction: above ? "above" : below ? "below" : "in",
|
||||
};
|
||||
}
|
||||
|
||||
// ── compute ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CoincidentDeviationOpts {
|
||||
windowDays?: number;
|
||||
now?: Date;
|
||||
coverage?: RollupCoverageMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent DAY mean for a type within the window, plus its day key.
|
||||
* Bounded raw read; null when no reading in the window.
|
||||
*/
|
||||
async function readLatestDayMean(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
windowDays: number,
|
||||
now: Date,
|
||||
): Promise<{ value: number; day: string } | null> {
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 50,
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
// Derive the most-recent day defensively (do not assume the DB ordering)
|
||||
// so the "today" reading is always the genuine latest, then mean its rows.
|
||||
let day = "";
|
||||
for (const r of rows) {
|
||||
const d = r.measuredAt.toISOString().slice(0, 10);
|
||||
if (d > day) day = d;
|
||||
}
|
||||
const sameDay = rows.filter(
|
||||
(r) => r.measuredAt.toISOString().slice(0, 10) === day,
|
||||
);
|
||||
return { value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length, day };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the coincident-deviation flag across the supported vitals. Below
|
||||
* `COINCIDENT_MIN_BANDS` banded vitals it returns `insufficient`.
|
||||
*/
|
||||
export async function computeCoincidentDeviation(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
opts: CoincidentDeviationOpts = {},
|
||||
): Promise<Derived<CoincidentDeviationValue>> {
|
||||
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
||||
const now = opts.now ?? new Date();
|
||||
const computedAt = nowProvenanceTimestamp(now);
|
||||
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
|
||||
|
||||
const vitals: VitalDeviation[] = [];
|
||||
let latestDay = "";
|
||||
let anyDaySource = false;
|
||||
|
||||
// Fan the baseline engine across the supported vitals. Each baseline read
|
||||
// shares the one coverage probe (no per-vital re-probe).
|
||||
for (const type of VITALS_BASELINE_TYPES) {
|
||||
const baseline = await computeVitalsBaseline(userId, profile, {
|
||||
type,
|
||||
windowDays,
|
||||
now,
|
||||
coverage,
|
||||
});
|
||||
if (baseline.status !== "ok") continue;
|
||||
if (baseline.provenance.source === "DAY") anyDaySource = true;
|
||||
const latest = await readLatestDayMean(userId, type, windowDays, now);
|
||||
if (!latest) continue;
|
||||
if (latest.day > latestDay) latestDay = latest.day;
|
||||
vitals.push(
|
||||
classifyDeviation(
|
||||
type,
|
||||
latest.value,
|
||||
baseline.value.low,
|
||||
baseline.value.high,
|
||||
baseline.value.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const inputs = vitals.map((v) => String(v.type));
|
||||
const source: DerivedProvenanceSource =
|
||||
vitals.length === 0 ? "none" : anyDaySource ? "DAY" : "live";
|
||||
|
||||
if (vitals.length < COINCIDENT_MIN_BANDS) {
|
||||
const { coverage: cov } = deriveCoverage({
|
||||
requiredInputs: COINCIDENT_MIN_BANDS,
|
||||
presentInputs: vitals.length,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
return buildInsufficient<CoincidentDeviationValue>({
|
||||
coverage: cov,
|
||||
provenance: {
|
||||
inputs: inputs.length > 0 ? inputs : VITALS_BASELINE_TYPES.map(String),
|
||||
source,
|
||||
windowDays,
|
||||
computedAt,
|
||||
},
|
||||
reason: "too_few_banded_vitals",
|
||||
});
|
||||
}
|
||||
|
||||
const contributing = vitals.filter((v) => v.outside);
|
||||
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
|
||||
|
||||
const { coverage: cov, confidence } = deriveCoverage({
|
||||
// The coverage axis is "how many vitals could have coincided".
|
||||
requiredInputs: vitals.length,
|
||||
presentInputs: vitals.length,
|
||||
historyDays: windowDays,
|
||||
missing: [],
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
|
||||
return buildOk<CoincidentDeviationValue>({
|
||||
value: {
|
||||
fired,
|
||||
vitals,
|
||||
contributing,
|
||||
day: latestDay,
|
||||
},
|
||||
coverage: cov,
|
||||
confidence,
|
||||
provenance: { inputs, source, windowDays, computedAt },
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,9 @@ 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 {
|
||||
@@ -121,6 +124,18 @@ export async function computeDerivedMetric(
|
||||
}) 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>>;
|
||||
|
||||
@@ -84,3 +84,49 @@ export type { HrvBalanceValue, HrvBalanceBand } from "./hrv-balance";
|
||||
|
||||
export { computeBmi, classifyBmi } from "./bmi";
|
||||
export type { BmiValue, BmiBand, BmiCategory } from "./bmi";
|
||||
// ── W3 composites (server-only compute engines) ──────────────────────
|
||||
export {
|
||||
computeSleepScore,
|
||||
blendSleepSubScores,
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
scoreSufficiency,
|
||||
scoreEfficiency,
|
||||
scoreComposition,
|
||||
scoreConsistency,
|
||||
scoreTiming,
|
||||
SLEEP_SUBSCORE_WEIGHTS,
|
||||
} from "./sleep-score";
|
||||
export type {
|
||||
SleepScoreValue,
|
||||
SleepScoreOpts,
|
||||
SleepSubScore,
|
||||
SleepSubScoreKey,
|
||||
NightSummary,
|
||||
} from "./sleep-score";
|
||||
|
||||
export {
|
||||
computeReadiness,
|
||||
blendReadinessComponents,
|
||||
scoreDeviation,
|
||||
READINESS_WEIGHTS,
|
||||
READINESS_MIN_COMPONENTS,
|
||||
} from "./readiness";
|
||||
export type {
|
||||
ReadinessValue,
|
||||
ReadinessOpts,
|
||||
ReadinessComponent,
|
||||
ReadinessComponentKey,
|
||||
} from "./readiness";
|
||||
|
||||
export {
|
||||
computeCoincidentDeviation,
|
||||
classifyDeviation,
|
||||
COINCIDENT_FIRE_THRESHOLD,
|
||||
COINCIDENT_MIN_BANDS,
|
||||
} from "./coincident-deviation";
|
||||
export type {
|
||||
CoincidentDeviationValue,
|
||||
CoincidentDeviationOpts,
|
||||
VitalDeviation,
|
||||
} from "./coincident-deviation";
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* v1.10.0 — Wellness / readiness index (catalogue metric #7, COMPOSITE).
|
||||
*
|
||||
* A daily "how recovered are you" proxy, framed as a wellness index, with
|
||||
* every contributing component SHOWN. It extends the existing
|
||||
* `health-score.ts` weighted-blend + null-redistribution pattern with
|
||||
* deviation-from-baseline components:
|
||||
*
|
||||
* - **RHR deviation** — resting HR below/at baseline scores high;
|
||||
* an elevation suppresses the score.
|
||||
* - **HRV deviation** — SDNN at/above baseline scores high; a drop
|
||||
* suppresses it. (Surfaced as "HRV (SDNN)" — it
|
||||
* is NEVER relabelled RMSSD, and this is never
|
||||
* called "Recovery".)
|
||||
* - **Sleep adequacy** — the Sleep Score (#6) folded in as a 0..100
|
||||
* component (stubbed behind coverage so this
|
||||
* builds independently and lights up when sleep
|
||||
* data exists).
|
||||
* - **Respiratory-rate stability** — closeness to the personal baseline.
|
||||
* - **Mood stability** — the existing mood-aggregate stability score.
|
||||
*
|
||||
* Each component is 0..100; weights renormalise over the PRESENT components
|
||||
* (exactly as the health score redistributes a null pillar). Below the
|
||||
* minimum-inputs floor (`minInputs = 2`) it returns `insufficient` — never
|
||||
* a headline from 1-of-N. The overnight-HR-return contributor is dropped
|
||||
* (needs intra-night HR — absent from the schema).
|
||||
*
|
||||
* Standard: RHR elevation + HRV suppression as recovery/strain markers —
|
||||
* Plews et al. 2013, Sports Medicine 43(9):773–781; Buchheit 2014,
|
||||
* Frontiers in Physiology 5:73. Framing: our own heuristic — a daily
|
||||
* wellness proxy, NOT a training-recovery score, NOT clinical.
|
||||
*
|
||||
* Server-only — reads the rollup tier (via the baseline engine) + the
|
||||
* sleep score + mood aggregates. The pure blend is exported for tests.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import { computeMoodStability } from "@/lib/insights/mood-aggregates";
|
||||
import {
|
||||
buildInsufficient,
|
||||
buildOk,
|
||||
deriveCoverage,
|
||||
nowProvenanceTimestamp,
|
||||
} from "./coverage";
|
||||
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
|
||||
import { computeSleepScore } from "./sleep-score";
|
||||
import type { Derived, DerivedProvenanceSource } from "./types";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_WINDOW_DAYS = 30;
|
||||
/** Minimum present components before a headline is produced (no 1-of-N). */
|
||||
export const READINESS_MIN_COMPONENTS = 2;
|
||||
|
||||
/** Transparent component weights (renormalised over present components). */
|
||||
export const READINESS_WEIGHTS = {
|
||||
rhr: 0.25,
|
||||
hrv: 0.25,
|
||||
sleep: 0.25,
|
||||
respiratory: 0.1,
|
||||
mood: 0.15,
|
||||
} as const;
|
||||
|
||||
export type ReadinessComponentKey = keyof typeof READINESS_WEIGHTS;
|
||||
|
||||
/** One readiness contributor the anatomy view ranks by impact. */
|
||||
export interface ReadinessComponent {
|
||||
key: ReadinessComponentKey;
|
||||
/** 0..100, or null when the input is missing (drops from the blend). */
|
||||
value: number | null;
|
||||
/** Effective weight after null-redistribution, 0..1. */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ReadinessValue {
|
||||
score: number;
|
||||
band: "green" | "yellow" | "red";
|
||||
components: ReadinessComponent[];
|
||||
}
|
||||
|
||||
// ── pure deviation scorers (exported for tests) ────────────────────────
|
||||
|
||||
function clamp100(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(v)));
|
||||
}
|
||||
|
||||
function bandForScore(score: number): "green" | "yellow" | "red" {
|
||||
if (score >= 70) return "green";
|
||||
if (score >= 40) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a today-vs-baseline deviation to a 0..100 component score. `spread`
|
||||
* is the band half-width (k·MAD·σ-scale from the baseline engine); a value
|
||||
* one spread in the "bad" direction lands near 50, two spreads near 0.
|
||||
*
|
||||
* `direction = "lower-better"` (RHR, respiratory rate): a value above
|
||||
* baseline suppresses the score. `direction = "higher-better"` (HRV): a
|
||||
* value below baseline suppresses it. On-baseline (or in the good
|
||||
* direction) scores 100.
|
||||
*/
|
||||
export function scoreDeviation(
|
||||
today: number,
|
||||
center: number,
|
||||
spread: number,
|
||||
direction: "lower-better" | "higher-better",
|
||||
): number {
|
||||
if (!Number.isFinite(spread) || spread <= 0) {
|
||||
// No usable spread → degrade to a coarse same/above/below.
|
||||
if (today === center) return 100;
|
||||
const worse =
|
||||
direction === "lower-better" ? today > center : today < center;
|
||||
return worse ? 50 : 100;
|
||||
}
|
||||
const worseDelta =
|
||||
direction === "lower-better" ? today - center : center - today;
|
||||
if (worseDelta <= 0) return 100; // at or in the good direction.
|
||||
// 1 spread worse → 50, 2 spreads → 0 (linear).
|
||||
return clamp100(100 - (worseDelta / spread) * 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend the present components with null-redistribution (the
|
||||
* `health-score.ts` pattern). Returns the composite + per-component
|
||||
* effective weights for the anatomy view. Pure.
|
||||
*/
|
||||
export function blendReadinessComponents(
|
||||
raw: Record<ReadinessComponentKey, number | null>,
|
||||
): { score: number; components: ReadinessComponent[] } {
|
||||
const keys = Object.keys(READINESS_WEIGHTS) as ReadinessComponentKey[];
|
||||
const present = keys.filter((k) => raw[k] !== null);
|
||||
const totalBaseWeight = present.reduce((s, k) => s + READINESS_WEIGHTS[k], 0);
|
||||
const components: ReadinessComponent[] = keys.map((k) => ({
|
||||
key: k,
|
||||
value: raw[k],
|
||||
weight:
|
||||
raw[k] === null || totalBaseWeight === 0
|
||||
? 0
|
||||
: READINESS_WEIGHTS[k] / totalBaseWeight,
|
||||
}));
|
||||
let composite = 0;
|
||||
for (const c of components) {
|
||||
if (c.value !== null) composite += c.value * c.weight;
|
||||
}
|
||||
return { score: clamp100(composite), components };
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The most recent DAY mean for a type within the window — the "today"
|
||||
* value the deviation is measured against. Bounded raw read; null when no
|
||||
* reading in the window.
|
||||
*/
|
||||
async function readLatestDayMean(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
windowDays: number,
|
||||
now: Date,
|
||||
): Promise<number | null> {
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 50,
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
// Derive the most-recent day defensively (do not assume the DB ordering).
|
||||
let latestDay = "";
|
||||
for (const r of rows) {
|
||||
const d = r.measuredAt.toISOString().slice(0, 10);
|
||||
if (d > latestDay) latestDay = d;
|
||||
}
|
||||
const sameDay = rows.filter(
|
||||
(r) => r.measuredAt.toISOString().slice(0, 10) === latestDay,
|
||||
);
|
||||
return sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score one baseline-deviation vital component (RHR / HRV / respiratory).
|
||||
* Returns null when the baseline is insufficient or there is no recent
|
||||
* reading — the component then drops from the blend.
|
||||
*/
|
||||
async function scoreVitalDeviation(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
type: MeasurementType,
|
||||
direction: "lower-better" | "higher-better",
|
||||
windowDays: number,
|
||||
now: Date,
|
||||
coverage: RollupCoverageMap,
|
||||
): Promise<{ value: number | null; source: DerivedProvenanceSource }> {
|
||||
const baseline = await computeVitalsBaseline(userId, profile, {
|
||||
type,
|
||||
windowDays,
|
||||
now,
|
||||
coverage,
|
||||
});
|
||||
if (baseline.status !== "ok") {
|
||||
return { value: null, source: baseline.provenance.source };
|
||||
}
|
||||
const today = await readLatestDayMean(userId, type, windowDays, now);
|
||||
if (today == null) {
|
||||
return { value: null, source: baseline.provenance.source };
|
||||
}
|
||||
const value = scoreDeviation(
|
||||
today,
|
||||
baseline.value.center,
|
||||
baseline.value.spread,
|
||||
direction,
|
||||
);
|
||||
return { value, source: baseline.provenance.source };
|
||||
}
|
||||
|
||||
// ── compute ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReadinessOpts {
|
||||
windowDays?: number;
|
||||
now?: Date;
|
||||
/** Pre-probed coverage (shared across metrics in one request). */
|
||||
coverage?: RollupCoverageMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the wellness/readiness index. Below `READINESS_MIN_COMPONENTS`
|
||||
* present components it returns `insufficient` (no 1-of-N headline).
|
||||
*/
|
||||
export async function computeReadiness(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
opts: ReadinessOpts = {},
|
||||
): Promise<Derived<ReadinessValue>> {
|
||||
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
||||
const now = opts.now ?? new Date();
|
||||
const computedAt = nowProvenanceTimestamp(now);
|
||||
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
|
||||
|
||||
const inputNames = [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"SLEEP_DURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
"MOOD",
|
||||
];
|
||||
|
||||
// Vital deviations (shared coverage map → one probe per request).
|
||||
const [rhr, hrv, resp] = await Promise.all([
|
||||
scoreVitalDeviation(
|
||||
userId,
|
||||
profile,
|
||||
"RESTING_HEART_RATE",
|
||||
"lower-better",
|
||||
windowDays,
|
||||
now,
|
||||
coverage,
|
||||
),
|
||||
scoreVitalDeviation(
|
||||
userId,
|
||||
profile,
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"higher-better",
|
||||
windowDays,
|
||||
now,
|
||||
coverage,
|
||||
),
|
||||
scoreVitalDeviation(
|
||||
userId,
|
||||
profile,
|
||||
"RESPIRATORY_RATE",
|
||||
"lower-better",
|
||||
windowDays,
|
||||
now,
|
||||
coverage,
|
||||
),
|
||||
]);
|
||||
|
||||
// Sleep adequacy — folds the Sleep Score in (stubbed behind coverage:
|
||||
// null when there is no scorable night, dropping the component).
|
||||
const sleep = await computeSleepScore(userId, profile, { windowDays, now });
|
||||
const sleepValue = sleep.status === "ok" ? sleep.value.score : null;
|
||||
|
||||
// Mood stability — reuse the shipped mood-aggregate stability score over
|
||||
// the window's daily means.
|
||||
const moodValue = await readMoodStability(userId, windowDays, now);
|
||||
|
||||
const raw: Record<ReadinessComponentKey, number | null> = {
|
||||
rhr: rhr.value,
|
||||
hrv: hrv.value,
|
||||
sleep: sleepValue,
|
||||
respiratory: resp.value,
|
||||
mood: moodValue,
|
||||
};
|
||||
|
||||
const { score, components } = blendReadinessComponents(raw);
|
||||
const presentCount = components.filter((c) => c.value !== null).length;
|
||||
const missing = components
|
||||
.filter((c) => c.value === null)
|
||||
.map((c) => c.key);
|
||||
|
||||
// Provenance source: DAY when any vital read the rollup tier, else live.
|
||||
const anyDay = [rhr.source, hrv.source, resp.source].includes("DAY");
|
||||
const source: DerivedProvenanceSource =
|
||||
presentCount === 0 ? "none" : anyDay ? "DAY" : "live";
|
||||
|
||||
if (presentCount < READINESS_MIN_COMPONENTS) {
|
||||
const { coverage: cov } = deriveCoverage({
|
||||
requiredInputs: components.length,
|
||||
presentInputs: presentCount,
|
||||
historyDays: 0,
|
||||
missing,
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
return buildInsufficient<ReadinessValue>({
|
||||
coverage: cov,
|
||||
provenance: { inputs: inputNames, source, windowDays, computedAt },
|
||||
reason: "insufficient_components",
|
||||
});
|
||||
}
|
||||
|
||||
const { coverage: cov, confidence } = deriveCoverage({
|
||||
requiredInputs: components.length,
|
||||
presentInputs: presentCount,
|
||||
historyDays: windowDays,
|
||||
missing,
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
|
||||
const presentInputNames = inputNames.filter((_, i) => {
|
||||
const key = (
|
||||
["rhr", "hrv", "sleep", "respiratory", "mood"] as ReadinessComponentKey[]
|
||||
)[i];
|
||||
return raw[key] !== null;
|
||||
});
|
||||
|
||||
return buildOk<ReadinessValue>({
|
||||
value: { score, band: bandForScore(score), components },
|
||||
coverage: cov,
|
||||
confidence,
|
||||
provenance: {
|
||||
inputs: presentInputNames.length > 0 ? presentInputNames : inputNames,
|
||||
source,
|
||||
windowDays,
|
||||
computedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood stability over the window — reads daily-mean mood and feeds the
|
||||
* shipped `computeMoodStability` (0..100, higher = steadier). Null below
|
||||
* the stability floor.
|
||||
*/
|
||||
async function readMoodStability(
|
||||
userId: string,
|
||||
windowDays: number,
|
||||
now: Date,
|
||||
): Promise<number | null> {
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
take: 2000,
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
// Collapse to daily means keyed by dayOffset (newest = 0), matching the
|
||||
// mood-aggregate DailyPoint shape.
|
||||
const byDay = new Map<number, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const offset = Math.floor(
|
||||
(now.getTime() - r.moodLoggedAt.getTime()) / MS_PER_DAY,
|
||||
);
|
||||
const acc = byDay.get(offset) ?? { sum: 0, count: 0 };
|
||||
acc.sum += r.score;
|
||||
acc.count += 1;
|
||||
byDay.set(offset, acc);
|
||||
}
|
||||
const daily = [...byDay.entries()].map(([dayOffset, acc]) => ({
|
||||
dayOffset,
|
||||
value: acc.sum / acc.count,
|
||||
}));
|
||||
const stability = computeMoodStability(daily);
|
||||
return stability ? stability.score : null;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
|
||||
inputs: ["SLEEP_DURATION"],
|
||||
minHistoryDays: 1,
|
||||
minInputs: 1,
|
||||
implemented: false,
|
||||
implemented: true,
|
||||
},
|
||||
READINESS: {
|
||||
id: "READINESS",
|
||||
@@ -148,7 +148,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
|
||||
],
|
||||
minHistoryDays: 7,
|
||||
minInputs: 2,
|
||||
implemented: false,
|
||||
implemented: true,
|
||||
},
|
||||
COINCIDENT_DEVIATION: {
|
||||
id: "COINCIDENT_DEVIATION",
|
||||
@@ -157,7 +157,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
|
||||
inputs: VITALS_BASELINE_TYPES,
|
||||
minHistoryDays: 7,
|
||||
minInputs: 2,
|
||||
implemented: false,
|
||||
implemented: true,
|
||||
},
|
||||
HRV_BALANCE: {
|
||||
id: "HRV_BALANCE",
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* v1.10.0 — transparent Sleep Score (catalogue metric #6, COMPOSITE).
|
||||
*
|
||||
* A 0–100 sleep-quality composite whose sub-scores are SHOWN, not a black
|
||||
* box. Built from the per-stage `SLEEP_DURATION` rows HealthKit + Withings
|
||||
* write (one row per stage per night, `value` in minutes, `sleepStage` ∈
|
||||
* { IN_BED, AWAKE, ASLEEP, REM, CORE, DEEP }). Combines:
|
||||
*
|
||||
* - **Sufficiency** = total asleep minutes vs an age-based need target
|
||||
* (Hirshkowitz et al. 2015 National Sleep Foundation recommendations).
|
||||
* - **Efficiency** = asleep ÷ in-bed minutes (AASM scoring convention;
|
||||
* ≥ 85 % is the clinical "good" floor).
|
||||
* - **Consistency** = SD of the sleep midpoint across the window (lower
|
||||
* is better) — a regularity proxy.
|
||||
* - **Composition** = REM% + Deep% of total asleep vs Ohayon et al. 2017
|
||||
* age norms.
|
||||
*
|
||||
* Each sub-score is 0..100 with its own transparent weight; the composite
|
||||
* **reweights around missing sub-scores** exactly as `health-score.ts`
|
||||
* null-redistributes (a legacy `ASLEEP`-only night yields Sufficiency +
|
||||
* Efficiency + Consistency + Timing, dropping Composition gracefully — it
|
||||
* is never fabricated). Restfulness / Restoration are NOT derivable (no
|
||||
* per-stage HR, no WASO timestamps) and are omitted, not faked.
|
||||
*
|
||||
* Standard: AASM scoring conventions (efficiency); Hirshkowitz et al. 2015,
|
||||
* Sleep Health 1(1):40–43 (age-based duration need); Ohayon et al. 2017,
|
||||
* Sleep Health 3(1):6–19 (stage-percentage norms by age). Framing
|
||||
* discipline: consumer sleep-staging vs PSG is only moderately accurate —
|
||||
* the copy stays directional, never clinical.
|
||||
*
|
||||
* Server-only — reads raw `SLEEP_DURATION` rows via Prisma (per-stage rows
|
||||
* are not rolled up by stage, so the night reconstruction reads raw, but
|
||||
* bounded to the window). The pure scorers are exported for unit tests.
|
||||
*/
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildInsufficient,
|
||||
buildOk,
|
||||
deriveCoverage,
|
||||
nowProvenanceTimestamp,
|
||||
} from "./coverage";
|
||||
import type { BaselineProfile } from "./baseline";
|
||||
import type { Derived } from "./types";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Default trailing window for the consistency/timing baseline (days). */
|
||||
const DEFAULT_WINDOW_DAYS = 30;
|
||||
/** A night needs ≥ this many stage rows to score at all. */
|
||||
const MIN_STAGE_ROWS_PER_NIGHT = 1;
|
||||
|
||||
/** Transparent sub-score weights (shown to the user, sum to 1 over present). */
|
||||
export const SLEEP_SUBSCORE_WEIGHTS = {
|
||||
sufficiency: 0.3,
|
||||
efficiency: 0.25,
|
||||
consistency: 0.2,
|
||||
timing: 0.1,
|
||||
composition: 0.15,
|
||||
} as const;
|
||||
|
||||
export type SleepSubScoreKey = keyof typeof SLEEP_SUBSCORE_WEIGHTS;
|
||||
|
||||
/** One sub-score row the anatomy view renders as a contributor. */
|
||||
export interface SleepSubScore {
|
||||
key: SleepSubScoreKey;
|
||||
/** 0..100, or null when the night lacks the inputs (drops from the blend). */
|
||||
value: number | null;
|
||||
/** Effective weight after null-redistribution, 0..1. */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface SleepScoreValue {
|
||||
/** The composite 0..100. */
|
||||
score: number;
|
||||
/** Band derived from the score (green/yellow/red). */
|
||||
band: "green" | "yellow" | "red";
|
||||
/** The night this score describes (YYYY-MM-DD of the wake day). */
|
||||
night: string;
|
||||
/** Total asleep minutes that night. */
|
||||
asleepMinutes: number;
|
||||
/** Total in-bed minutes that night (asleep + awake-in-bed), when known. */
|
||||
inBedMinutes: number | null;
|
||||
/** The transparent sub-scores (present + dropped). */
|
||||
subScores: SleepSubScore[];
|
||||
/** Nights in the window that backed the consistency/timing baseline. */
|
||||
windowNights: number;
|
||||
}
|
||||
|
||||
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
|
||||
"ASLEEP",
|
||||
"REM",
|
||||
"CORE",
|
||||
"DEEP",
|
||||
]);
|
||||
|
||||
// ── pure scorers (exported for tests) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Age-based sleep-need target in minutes (National Sleep Foundation,
|
||||
* Hirshkowitz et al. 2015). Adult default 7–9 h; we anchor the target at
|
||||
* the lower-recommended bound so hitting it scores 100.
|
||||
*/
|
||||
export function sleepNeedMinutes(ageYears: number | null): number {
|
||||
if (ageYears == null || !Number.isFinite(ageYears)) return 7 * 60; // adult default
|
||||
if (ageYears < 1) return 14 * 60;
|
||||
if (ageYears < 3) return 12 * 60;
|
||||
if (ageYears < 6) return 11 * 60;
|
||||
if (ageYears < 14) return 10 * 60;
|
||||
if (ageYears < 18) return 9 * 60;
|
||||
if (ageYears < 65) return 7 * 60;
|
||||
return 7 * 60; // 65+ NSF recommends 7–8 h
|
||||
}
|
||||
|
||||
/** Sufficiency: asleep vs need, capped at 100 (oversleep is not penalised). */
|
||||
export function scoreSufficiency(
|
||||
asleepMinutes: number,
|
||||
needMinutes: number,
|
||||
): number | null {
|
||||
if (needMinutes <= 0) return null;
|
||||
return clamp100((asleepMinutes / needMinutes) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiency: asleep ÷ in-bed, rescaled so the AASM 85 % "good" floor
|
||||
* maps to ~85 and 100 % maps to 100. Null when in-bed is unknown (legacy
|
||||
* nights with only asleep stages and no IN_BED row).
|
||||
*/
|
||||
export function scoreEfficiency(
|
||||
asleepMinutes: number,
|
||||
inBedMinutes: number | null,
|
||||
): number | null {
|
||||
if (inBedMinutes == null || inBedMinutes <= 0) return null;
|
||||
const pct = (asleepMinutes / inBedMinutes) * 100;
|
||||
return clamp100(pct);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition: how close REM% + Deep% sit to the age-typical band
|
||||
* (Ohayon et al. 2017). Adults: REM ≈ 20–25 %, Deep ≈ 13–23 % of total
|
||||
* asleep. We score the combined REM+Deep fraction against a 33–48 % target
|
||||
* window — inside → 100, falling off linearly outside. Null when no stage
|
||||
* breakdown exists (a legacy `ASLEEP`-only night).
|
||||
*/
|
||||
export function scoreComposition(
|
||||
remMinutes: number,
|
||||
deepMinutes: number,
|
||||
asleepMinutes: number,
|
||||
hasStageBreakdown: boolean,
|
||||
): number | null {
|
||||
if (!hasStageBreakdown || asleepMinutes <= 0) return null;
|
||||
const fraction = (remMinutes + deepMinutes) / asleepMinutes;
|
||||
const lo = 0.33;
|
||||
const hi = 0.48;
|
||||
if (fraction >= lo && fraction <= hi) return 100;
|
||||
// Linear fall-off: half a window-width away from the band → 0.
|
||||
const halfWidth = (hi - lo) / 2 + 0.15;
|
||||
const dist = fraction < lo ? lo - fraction : fraction - hi;
|
||||
return clamp100(100 * (1 - dist / halfWidth));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistency: lower SD of the sleep midpoint (minutes-of-day) across the
|
||||
* window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. Null
|
||||
* below 3 nights (no variance signal).
|
||||
*/
|
||||
export function scoreConsistency(midpointMinutes: number[]): number | null {
|
||||
if (midpointMinutes.length < 3) return null;
|
||||
const mean =
|
||||
midpointMinutes.reduce((s, v) => s + v, 0) / midpointMinutes.length;
|
||||
const variance =
|
||||
midpointMinutes.reduce((s, v) => s + (v - mean) ** 2, 0) /
|
||||
midpointMinutes.length;
|
||||
const sd = Math.sqrt(variance);
|
||||
const FULL_SCALE = 90; // minutes — beyond this the rhythm reads erratic.
|
||||
return clamp100(100 * (1 - Math.min(sd, FULL_SCALE) / FULL_SCALE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing: this night's midpoint vs the user's habitual midpoint (the
|
||||
* window mean). On-target → 100; 90 min off → 0. Null when the habitual
|
||||
* window is not yet established (< 3 nights).
|
||||
*/
|
||||
export function scoreTiming(
|
||||
nightMidpoint: number | null,
|
||||
habitualMidpoint: number | null,
|
||||
windowNights: number,
|
||||
): number | null {
|
||||
if (nightMidpoint == null || habitualMidpoint == null || windowNights < 3) {
|
||||
return null;
|
||||
}
|
||||
const FULL_SCALE = 90;
|
||||
const dist = Math.abs(nightMidpoint - habitualMidpoint);
|
||||
return clamp100(100 * (1 - Math.min(dist, FULL_SCALE) / FULL_SCALE));
|
||||
}
|
||||
|
||||
function clamp100(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(v)));
|
||||
}
|
||||
|
||||
function bandForScore(score: number): "green" | "yellow" | "red" {
|
||||
if (score >= 70) return "green";
|
||||
if (score >= 40) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend the present sub-scores with null-redistribution (the
|
||||
* `health-score.ts` pattern). Returns the composite + the per-sub-score
|
||||
* effective weights for the anatomy view. Pure.
|
||||
*/
|
||||
export function blendSleepSubScores(
|
||||
raw: Record<SleepSubScoreKey, number | null>,
|
||||
): { score: number; subScores: SleepSubScore[] } {
|
||||
const keys = Object.keys(SLEEP_SUBSCORE_WEIGHTS) as SleepSubScoreKey[];
|
||||
const present = keys.filter((k) => raw[k] !== null);
|
||||
const totalBaseWeight = present.reduce(
|
||||
(s, k) => s + SLEEP_SUBSCORE_WEIGHTS[k],
|
||||
0,
|
||||
);
|
||||
const subScores: SleepSubScore[] = keys.map((k) => ({
|
||||
key: k,
|
||||
value: raw[k],
|
||||
weight:
|
||||
raw[k] === null || totalBaseWeight === 0
|
||||
? 0
|
||||
: SLEEP_SUBSCORE_WEIGHTS[k] / totalBaseWeight,
|
||||
}));
|
||||
let composite = 0;
|
||||
for (const s of subScores) {
|
||||
if (s.value !== null) composite += s.value * s.weight;
|
||||
}
|
||||
return { score: clamp100(composite), subScores };
|
||||
}
|
||||
|
||||
// ── night reconstruction ───────────────────────────────────────────────
|
||||
|
||||
interface SleepRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: SleepStage | null;
|
||||
}
|
||||
|
||||
export interface NightSummary {
|
||||
/** Wake-day key (YYYY-MM-DD) the night is filed under. */
|
||||
night: string;
|
||||
asleepMinutes: number;
|
||||
awakeMinutes: number;
|
||||
remMinutes: number;
|
||||
deepMinutes: number;
|
||||
inBedMinutes: number | null;
|
||||
hasStageBreakdown: boolean;
|
||||
/** Midpoint as minutes-of-day (0..1439), or null when timestamps collapse. */
|
||||
midpoint: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw stage rows into per-night summaries. A "night" is keyed by the
|
||||
* latest stage row's calendar day (the wake day). Pure — the caller does
|
||||
* the bounded DB read and passes rows in.
|
||||
*/
|
||||
export function reconstructNights(rows: SleepRow[]): NightSummary[] {
|
||||
// Bucket rows by the wake-day key (UTC day of the row's measuredAt).
|
||||
const byNight = new Map<string, SleepRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.measuredAt.toISOString().slice(0, 10);
|
||||
const list = byNight.get(key) ?? [];
|
||||
list.push(row);
|
||||
byNight.set(key, list);
|
||||
}
|
||||
const nights: NightSummary[] = [];
|
||||
for (const [night, nightRows] of byNight) {
|
||||
let asleep = 0;
|
||||
let awake = 0;
|
||||
let rem = 0;
|
||||
let deep = 0;
|
||||
let inBed = 0;
|
||||
let sawInBed = false;
|
||||
let sawStageBreakdown = false;
|
||||
let earliest = Infinity;
|
||||
let latest = -Infinity;
|
||||
for (const r of nightRows) {
|
||||
const stage = r.sleepStage;
|
||||
const minutes = Number.isFinite(r.value) ? r.value : 0;
|
||||
const t = r.measuredAt.getTime();
|
||||
if (t < earliest) earliest = t;
|
||||
if (t > latest) latest = t;
|
||||
if (stage === "IN_BED") {
|
||||
inBed += minutes;
|
||||
sawInBed = true;
|
||||
continue;
|
||||
}
|
||||
if (stage === "AWAKE") {
|
||||
awake += minutes;
|
||||
continue;
|
||||
}
|
||||
if (stage && ASLEEP_STAGES.has(stage)) {
|
||||
asleep += minutes;
|
||||
if (stage === "REM") rem += minutes;
|
||||
if (stage === "DEEP") deep += minutes;
|
||||
if (stage === "REM" || stage === "CORE" || stage === "DEEP") {
|
||||
sawStageBreakdown = true;
|
||||
}
|
||||
} else if (stage == null) {
|
||||
// A bare SLEEP_DURATION row (no stage) is the night's total asleep.
|
||||
asleep += minutes;
|
||||
}
|
||||
}
|
||||
// In-bed = explicit IN_BED rows when present, else asleep + awake.
|
||||
const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null;
|
||||
const midpoint =
|
||||
Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest
|
||||
? minutesOfDay(new Date((earliest + latest) / 2))
|
||||
: null;
|
||||
nights.push({
|
||||
night,
|
||||
asleepMinutes: asleep,
|
||||
awakeMinutes: awake,
|
||||
remMinutes: rem,
|
||||
deepMinutes: deep,
|
||||
inBedMinutes,
|
||||
hasStageBreakdown: sawStageBreakdown,
|
||||
midpoint,
|
||||
});
|
||||
}
|
||||
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
|
||||
}
|
||||
|
||||
function minutesOfDay(d: Date): number {
|
||||
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
||||
}
|
||||
|
||||
// ── compute ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SleepScoreOpts {
|
||||
windowDays?: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Sleep Score for the most recent night, using the trailing
|
||||
* window for the consistency/timing baseline. Returns `insufficient` when
|
||||
* no scorable night exists in the window.
|
||||
*/
|
||||
export async function computeSleepScore(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
opts: SleepScoreOpts = {},
|
||||
): Promise<Derived<SleepScoreValue>> {
|
||||
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
||||
const now = opts.now ?? new Date();
|
||||
const computedAt = nowProvenanceTimestamp(now);
|
||||
const inputs = ["SLEEP_DURATION"];
|
||||
const required = 1;
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
|
||||
const rows = (await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION" satisfies MeasurementType,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, sleepStage: true },
|
||||
})) as SleepRow[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
const { coverage } = deriveCoverage({
|
||||
requiredInputs: required,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: inputs,
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
return buildInsufficient<SleepScoreValue>({
|
||||
coverage,
|
||||
provenance: { inputs, source: "none", windowDays, computedAt },
|
||||
reason: "no_sleep_in_window",
|
||||
});
|
||||
}
|
||||
|
||||
const nights = reconstructNights(rows).filter(
|
||||
(n) => n.asleepMinutes > 0,
|
||||
);
|
||||
const scorableNights = nights.filter(
|
||||
(n) => countNightRows(rows, n.night) >= MIN_STAGE_ROWS_PER_NIGHT,
|
||||
);
|
||||
|
||||
if (scorableNights.length === 0) {
|
||||
const { coverage } = deriveCoverage({
|
||||
requiredInputs: required,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: inputs,
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
return buildInsufficient<SleepScoreValue>({
|
||||
coverage,
|
||||
provenance: { inputs, source: "live", windowDays, computedAt },
|
||||
reason: "no_scorable_night",
|
||||
});
|
||||
}
|
||||
|
||||
const latest = scorableNights[scorableNights.length - 1];
|
||||
const midpoints = scorableNights
|
||||
.map((n) => n.midpoint)
|
||||
.filter((m): m is number => m != null);
|
||||
const habitualMidpoint =
|
||||
midpoints.length > 0
|
||||
? midpoints.reduce((s, v) => s + v, 0) / midpoints.length
|
||||
: null;
|
||||
|
||||
const needMinutes = sleepNeedMinutes(profile.ageYears);
|
||||
|
||||
const raw: Record<SleepSubScoreKey, number | null> = {
|
||||
sufficiency: scoreSufficiency(latest.asleepMinutes, needMinutes),
|
||||
efficiency: scoreEfficiency(latest.asleepMinutes, latest.inBedMinutes),
|
||||
consistency: scoreConsistency(midpoints),
|
||||
timing: scoreTiming(latest.midpoint, habitualMidpoint, midpoints.length),
|
||||
composition: scoreComposition(
|
||||
latest.remMinutes,
|
||||
latest.deepMinutes,
|
||||
latest.asleepMinutes,
|
||||
latest.hasStageBreakdown,
|
||||
),
|
||||
};
|
||||
|
||||
const { score, subScores } = blendSleepSubScores(raw);
|
||||
const presentCount = subScores.filter((s) => s.value !== null).length;
|
||||
const missing = subScores
|
||||
.filter((s) => s.value === null)
|
||||
.map((s) => s.key);
|
||||
|
||||
const { coverage, confidence } = deriveCoverage({
|
||||
// Coverage here is "sub-scores present / total possible" — the
|
||||
// composite's input axis is its five sub-scores, not the single
|
||||
// SLEEP_DURATION stream.
|
||||
requiredInputs: subScores.length,
|
||||
presentInputs: presentCount,
|
||||
historyDays: scorableNights.length,
|
||||
missing,
|
||||
fullHistoryDays: windowDays,
|
||||
});
|
||||
|
||||
return buildOk<SleepScoreValue>({
|
||||
value: {
|
||||
score,
|
||||
band: bandForScore(score),
|
||||
night: latest.night,
|
||||
asleepMinutes: latest.asleepMinutes,
|
||||
inBedMinutes: latest.inBedMinutes,
|
||||
subScores,
|
||||
windowNights: scorableNights.length,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
provenance: {
|
||||
inputs,
|
||||
source: "live",
|
||||
windowDays,
|
||||
computedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function countNightRows(rows: SleepRow[], night: string): number {
|
||||
let c = 0;
|
||||
for (const r of rows) {
|
||||
if (r.measuredAt.toISOString().slice(0, 10) === night) c += 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -1385,6 +1385,47 @@ const derivedMetricResponse = z
|
||||
"Flat `Derived<T>` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.",
|
||||
});
|
||||
|
||||
// v1.10.0 — FDR-controlled correlation discovery result. One discovered,
|
||||
// statistically-defensible behaviour → next-day-outcome pair.
|
||||
const discoveredCorrelation = z
|
||||
.object({
|
||||
behaviour: z
|
||||
.string()
|
||||
.describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."),
|
||||
outcome: z
|
||||
.string()
|
||||
.describe("Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY."),
|
||||
n: z.number().int().describe("Paired-day count after the day+1 lag join (≥ 20)."),
|
||||
r: z.number().describe("Pearson r over the lag-joined daily series."),
|
||||
pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."),
|
||||
qValue: z
|
||||
.number()
|
||||
.describe("Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold)."),
|
||||
interpretation: z
|
||||
.string()
|
||||
.describe("Conservative, descriptive interpretation — never causal."),
|
||||
lagDays: z.number().int().describe("Lag in days applied (1)."),
|
||||
})
|
||||
.meta({ id: "DiscoveredCorrelation" });
|
||||
|
||||
const correlationDiscoveryResponse = z
|
||||
.object({
|
||||
discovered: z
|
||||
.array(discoveredCorrelation)
|
||||
.describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."),
|
||||
pairsTested: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Behaviour × outcome pairs assessed (for the honest footer)."),
|
||||
fdrQ: z.number().describe("The FDR target the surface used."),
|
||||
minPairs: z.number().int().describe("Minimum paired-day count enforced per pair."),
|
||||
})
|
||||
.meta({
|
||||
id: "CorrelationDiscoveryResponse",
|
||||
description:
|
||||
"v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.",
|
||||
});
|
||||
|
||||
// The seven specialised `*-status` routes accept an optional locale
|
||||
// override (the metric is fixed by the route path, unlike the generic
|
||||
// metric-status route which carries it as a query field).
|
||||
@@ -3128,6 +3169,28 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/correlations": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Correlation discovery (FDR-controlled)",
|
||||
description:
|
||||
"v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The discovered correlations + the tested-pair count.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
correlationDiscoveryResponse,
|
||||
"CorrelationDiscoveryResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -143,6 +143,24 @@ export const queryKeys = {
|
||||
*/
|
||||
insightsDerived: (metric: string, type?: string | null) =>
|
||||
["insights", "derived", metric, type ?? null] as const,
|
||||
* v1.10.0 — a derived wellness metric (`/api/insights/derived?metric=…`).
|
||||
* One generic route backs every derived composite (sleep score,
|
||||
* readiness, coincident-deviation, vitals baseline, …) so the key is
|
||||
* parameterised by the metric id alongside the optional sub-type. Pure
|
||||
* compute over the rollup tier — a measurement write keeps it fresh by
|
||||
* construction (next read sees new buckets), so the `["insights"]` prefix
|
||||
* is enough for invalidation fan-out.
|
||||
*/
|
||||
insightsDerivedMetric: (metric: string, type?: string) =>
|
||||
type
|
||||
? (["insights", "derived", metric, type] as const)
|
||||
: (["insights", "derived", metric] as const),
|
||||
/**
|
||||
* v1.10.0 — FDR-controlled correlation discovery
|
||||
* (`/api/insights/correlations`). Read-only descriptive surface; the
|
||||
* `["insights"]` prefix keeps it in the existing invalidation fan-out.
|
||||
*/
|
||||
insightsCorrelations: () => ["insights", "correlations"] as const,
|
||||
|
||||
medications: () => ["medications"] as const,
|
||||
medicationDetail: (id: string) => ["medications", id] as const,
|
||||
|
||||
Reference in new issue
Block a user