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:
Marc-André Bombeck committed 2026-06-02 21:19:56 +02:00
1 parent 663a3ee09d
commit 95a2a7692f
29 files changed
+3087 -30

No files matched your search

@@ -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;
}
+5
View File
@@ -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>
);
}