mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user