mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(insights): surface inputs, method and a cited standard for all 11 derived metrics
Only Sleep and Readiness reached a provenance surface; the other nine shipped a number with the cited standard buried in a code comment. Give every derived metric a path to its inputs + method + cited standard: - Recovery, Stress and Strain get a ScoreAnatomyView detail page and a tappable ring (they were read-only dead-ends). Stress surfaces its 'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain cites the TRIMP load model; Recovery the HRV-guided recovery literature. - The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age) gain an info explainer affordance carrying the method + standard, since they render as tiles with no detail page. - The coincident-deviation flag gains a dashboard tile (shown only when it fired) with the same explainer (median ± k·MAD basis). - A single standards map (src/components/insights/derived/standards.ts) pins each metric's method copy key, standard name and a real linkable URL (DOI / WHO / Wikipedia) so the citation a metric exposes stays one source. Add the per-metric method/caveat/description copy across all six locales. Map storage units to display symbols so a baseline tile reads '36.6 °C', not '36.6 celsius'. Show the band word under the strip rings so a value reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline placeholder when no trailing series exists rather than reserving dead space, and give the tile a series + provenance slot for when one does.
This commit is contained in:
1 parent
b92d5cec82
commit
551d352581
15 files changed
+960
-250
No files matched your search
@@ -6,35 +6,68 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
type CompositeMetricId,
|
||||
type AnatomyMetricId,
|
||||
} from "@/components/insights/derived/composite-score-anatomy";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/scores/<metric>` — the composite score-anatomy
|
||||
* detail surface.
|
||||
* v1.10.0 — `/insights/scores/<metric>` — the 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.
|
||||
* Reached on tap from a score's dashboard / insights entry. Renders the
|
||||
* Oura-style anatomy view (big ring + ranked contributor rows where the
|
||||
* score decomposes + cited standard + method + honesty caveat) for one
|
||||
* derived score — the two composites (sleep, readiness) AND the three
|
||||
* persisted nightly scores (recovery, stress, strain), which previously had
|
||||
* no detail page. The `[metric]` slug is validated against the known set; 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> = {
|
||||
const SLUG_TO_METRIC: Record<string, AnatomyMetricId> = {
|
||||
sleep: "SLEEP_SCORE",
|
||||
readiness: "READINESS",
|
||||
recovery: "RECOVERY_SCORE",
|
||||
stress: "STRESS_SCORE",
|
||||
strain: "STRAIN_SCORE",
|
||||
};
|
||||
|
||||
/** Localised page header per metric (title + one-line description). */
|
||||
const HEADER_KEYS: Record<
|
||||
AnatomyMetricId,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
SLEEP_SCORE: {
|
||||
title: "insights.derived.composite.SLEEP_SCORE.title",
|
||||
description: "insights.derived.composite.SLEEP_SCORE.description",
|
||||
},
|
||||
READINESS: {
|
||||
title: "insights.derived.composite.READINESS.title",
|
||||
description: "insights.derived.composite.READINESS.description",
|
||||
},
|
||||
RECOVERY_SCORE: {
|
||||
title: "insights.derived.scores.recovery",
|
||||
description: "insights.derived.composite.RECOVERY_SCORE.description",
|
||||
},
|
||||
STRESS_SCORE: {
|
||||
title: "insights.derived.scores.stress",
|
||||
description: "insights.derived.composite.STRESS_SCORE.description",
|
||||
},
|
||||
STRAIN_SCORE: {
|
||||
title: "insights.derived.scores.strain",
|
||||
description: "insights.derived.composite.STRAIN_SCORE.description",
|
||||
},
|
||||
};
|
||||
|
||||
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";
|
||||
const metric: AnatomyMetricId = SLUG_TO_METRIC[slug] ?? "READINESS";
|
||||
const header = HEADER_KEYS[metric];
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t(`insights.derived.composite.${metric}.title`)}
|
||||
description={t(`insights.derived.composite.${metric}.description`)}
|
||||
title={t(header.title)}
|
||||
description={t(header.description)}
|
||||
coachLaunch
|
||||
>
|
||||
<CompositeScoreAnatomy metric={metric} />
|
||||
|
||||
@@ -48,7 +48,8 @@ describe("<CoverageMeter>", () => {
|
||||
const html = render(<CoverageMeter coverage={FULL} confidence={HIGH} />);
|
||||
const lit = (html.match(/data-dot-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBe(5);
|
||||
expect(html).toContain("bg-dracula-green");
|
||||
// Semantic --success token — AA-safe on the white card in light mode.
|
||||
expect(html).toContain("bg-success");
|
||||
});
|
||||
|
||||
it("lights a partial number of dots and tints by band (provisional)", () => {
|
||||
@@ -56,7 +57,10 @@ describe("<CoverageMeter>", () => {
|
||||
const lit = (html.match(/data-dot-state="lit"/g) ?? []).length;
|
||||
expect(lit).toBeGreaterThan(0);
|
||||
expect(lit).toBeLessThan(5);
|
||||
expect(html).toContain("bg-dracula-orange");
|
||||
// The four confidence bands collapse onto three semantic severities;
|
||||
// low rides the caution --warning (medium↔low told apart by lit-dot
|
||||
// count + percent, never colour alone).
|
||||
expect(html).toContain("bg-warning");
|
||||
expect(html).toContain('data-percent="35"');
|
||||
});
|
||||
|
||||
|
||||
@@ -47,23 +47,44 @@ describe("<SparklineDeltaTile>", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-delta"');
|
||||
expect(html).toContain("text-dracula-green");
|
||||
// Semantic --success token (AA-safe in light mode, identical in dark).
|
||||
expect(html).toContain("text-success");
|
||||
});
|
||||
|
||||
it("renders the empty state for a null value (no value, dashed sparkline slot)", () => {
|
||||
it("renders the empty state for a null value with no sparkline slot", () => {
|
||||
const html = render(<SparklineDeltaTile {...base} value={null} />);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-value"');
|
||||
expect(html).toContain("—");
|
||||
// No series → dashed placeholder, not an SVG.
|
||||
expect(html).toContain("border-dashed");
|
||||
// No series → the sparkline row collapses entirely (no reserved empty
|
||||
// dashed placeholder, which read as visual dead space across the grid).
|
||||
expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"');
|
||||
expect(html).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("reserves the sparkline slot but omits the chart when series < 2 points", () => {
|
||||
it("omits the sparkline row when series < 2 points", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} series={[58]} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"');
|
||||
});
|
||||
|
||||
it("renders the sparkline chart when a series of >= 2 points is passed", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} series={[60, 59, 58]} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-spark"');
|
||||
expect(html).toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("renders the provenance affordance in the label row when passed", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
provenance={<button data-slot="test-prov">i</button>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-provenance"');
|
||||
expect(html).toContain('data-slot="test-prov"');
|
||||
});
|
||||
|
||||
it("surfaces the stale caption when staleDays > 7", () => {
|
||||
|
||||
@@ -2,13 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DerivedMetricResponse } from "../use-derived-metric";
|
||||
import type {
|
||||
DerivedMetricResponse,
|
||||
DerivedBatchToken,
|
||||
} from "../use-derived-metric";
|
||||
|
||||
// Drive the grid by stubbing the per-tile query hook; each tile renders the
|
||||
// state the mock returns for its (metric, type) pair.
|
||||
const useDerivedMetric = vi.fn();
|
||||
// The dashboard now reads the whole grid through ONE batched query. Drive it
|
||||
// by stubbing `useDerivedBatch` and returning a `read(token)` selector that
|
||||
// resolves each (metric, type) pair from a scenario map.
|
||||
const useDerivedBatch = vi.fn();
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a),
|
||||
useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a),
|
||||
}));
|
||||
// The auth hook gates the (mocked-away) query — stub it authenticated.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
import { VitalsDashboard } from "../vitals-dashboard";
|
||||
@@ -21,41 +29,55 @@ function render(node: React.ReactNode) {
|
||||
|
||||
type Resp = DerivedMetricResponse<Record<string, unknown>>;
|
||||
|
||||
function ok(value: Record<string, unknown>): { data: Resp; isLoading: false } {
|
||||
function ok(value: Record<string, unknown>): Resp {
|
||||
return {
|
||||
isLoading: false,
|
||||
data: {
|
||||
metric: "X",
|
||||
status: "ok",
|
||||
value,
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 90, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
|
||||
reason: null,
|
||||
},
|
||||
metric: "X",
|
||||
status: "ok",
|
||||
value,
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 90, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
function insufficient(reason: string, historyDays = 0): { data: Resp; isLoading: false } {
|
||||
function insufficient(reason: string, historyDays = 0): Resp {
|
||||
return {
|
||||
isLoading: false,
|
||||
data: {
|
||||
metric: "X",
|
||||
status: "insufficient",
|
||||
value: null,
|
||||
coverage: { requiredInputs: 1, presentInputs: reason === "no_readings_in_window" ? 0 : 1, historyDays, missing: [] },
|
||||
confidence: null,
|
||||
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" },
|
||||
reason,
|
||||
metric: "X",
|
||||
status: "insufficient",
|
||||
value: null,
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: reason === "no_readings_in_window" ? 0 : 1,
|
||||
historyDays,
|
||||
missing: [],
|
||||
},
|
||||
confidence: null,
|
||||
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" },
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a batch mock that resolves each requested token from `resolve`,
|
||||
* defaulting unmatched tokens to the absent state so a scenario only has to
|
||||
* name the tiles it cares about.
|
||||
*/
|
||||
function mockBatch(
|
||||
resolve: (token: DerivedBatchToken) => Resp,
|
||||
isLoading = false,
|
||||
) {
|
||||
useDerivedBatch.mockReturnValue({
|
||||
isLoading,
|
||||
read: <T,>(token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse<T>,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("<VitalsDashboard>", () => {
|
||||
it("renders an available baseline vital as an ok tile", () => {
|
||||
useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => {
|
||||
if (metric === "VITALS_BASELINE" && opts?.type === "RESTING_HEART_RATE") {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") {
|
||||
return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
@@ -69,7 +91,7 @@ describe("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
it("hides an absent vital entirely (no tile)", () => {
|
||||
useDerivedMetric.mockReturnValue(insufficient("no_readings_in_window"));
|
||||
mockBatch(() => insufficient("no_readings_in_window"));
|
||||
const html = render(<VitalsDashboard />);
|
||||
// The grid + heading still render, but no value tile.
|
||||
expect(html).toContain('data-slot="vitals-dashboard-grid"');
|
||||
@@ -78,8 +100,8 @@ describe("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
it("shows a provisional baseline tile with the coverage meter, not a headline", () => {
|
||||
useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => {
|
||||
if (metric === "VITALS_BASELINE" && opts?.type === "WEIGHT") {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") {
|
||||
return insufficient("insufficient_history_for_band", 4);
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
@@ -92,8 +114,8 @@ describe("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
it("renders the fitness-age tile with its age framing", () => {
|
||||
useDerivedMetric.mockImplementation((metric: string) => {
|
||||
if (metric === "FITNESS_AGE") {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "FITNESS_AGE") {
|
||||
return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
@@ -104,8 +126,8 @@ describe("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
it("renders the BMI tile with its WHO category", () => {
|
||||
useDerivedMetric.mockImplementation((metric: string) => {
|
||||
if (metric === "BMI") {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "BMI") {
|
||||
return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
@@ -116,11 +138,44 @@ describe("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
it("hides BMI when there is no height/weight", () => {
|
||||
useDerivedMetric.mockImplementation((metric: string) => {
|
||||
if (metric === "BMI") return insufficient("no_height_on_profile");
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "BMI") return insufficient("no_height_on_profile");
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).not.toContain('data-metric="BMI"');
|
||||
});
|
||||
|
||||
it("surfaces the coincident-deviation flag when it fired", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "COINCIDENT_DEVIATION") {
|
||||
return ok({
|
||||
fired: true,
|
||||
day: "2026-06-02",
|
||||
vitals: [],
|
||||
contributing: [
|
||||
{ type: "RESTING_HEART_RATE", value: 70, center: 55, low: 48, high: 62, outside: true, direction: "above" },
|
||||
{ type: "RESPIRATORY_RATE", value: 20, center: 14, low: 12, high: 16, outside: true, direction: "above" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
expect(html).toContain('data-state="fired"');
|
||||
// The provenance affordance reaches the flag.
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("hides the coincident-deviation flag when it did not fire", () => {
|
||||
mockBatch((token) => {
|
||||
if (token.metric === "COINCIDENT_DEVIATION") {
|
||||
return ok({ fired: false, day: "2026-06-02", vitals: [], contributing: [] });
|
||||
}
|
||||
return insufficient("no_readings_in_window");
|
||||
});
|
||||
const html = render(<VitalsDashboard />);
|
||||
expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
});
|
||||
});
|
||||
@@ -6,44 +6,48 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
ReadinessValue,
|
||||
SleepScoreValue,
|
||||
WellnessScoreValue,
|
||||
} from "@/lib/insights/derived";
|
||||
import {
|
||||
ScoreAnatomyView,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
import type { ProvenanceStandard } from "./provenance-explainer";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the data-bound wrapper that fetches a composite derived metric
|
||||
* and renders the reusable `ScoreAnatomyView`. It owns the per-metric
|
||||
* v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived
|
||||
* score 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.
|
||||
* plain-language method + honesty caveat) 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.
|
||||
* Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` —
|
||||
* each maps its sub-scores onto ranked `AnatomyContributor` rows) AND the
|
||||
* three persisted nightly scores (`RECOVERY_SCORE`, `STRESS_SCORE`,
|
||||
* `STRAIN_SCORE`). The persisted scores carry no sub-decomposition, so they
|
||||
* render the ring + coverage + the provenance surface (method + cited
|
||||
* standard + caveat) with no contributor rows — the acceptance-criterion fix
|
||||
* that gives every wellness ring a provenance surface instead of a read-only
|
||||
* dead-end. STRESS surfaces its "HRV-derived proxy, not an EDA/galvanic
|
||||
* measurement" caveat via the standards map.
|
||||
*
|
||||
* The standard + method/caveat keys come from the single `METRIC_PROVENANCE`
|
||||
* source map so the citation a metric exposes never drifts across surfaces.
|
||||
*
|
||||
* 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 type AnatomyMetricId =
|
||||
| "SLEEP_SCORE"
|
||||
| "READINESS"
|
||||
| "RECOVERY_SCORE"
|
||||
| "STRESS_SCORE"
|
||||
| "STRAIN_SCORE";
|
||||
|
||||
export interface CompositeScoreAnatomyProps {
|
||||
metric: CompositeMetricId;
|
||||
metric: AnatomyMetricId;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -53,14 +57,9 @@ export function CompositeScoreAnatomy({
|
||||
}: 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;
|
||||
const query = useDerivedMetric<
|
||||
SleepScoreValue | ReadinessValue | WellnessScoreValue
|
||||
>(metric);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
@@ -71,8 +70,22 @@ export function CompositeScoreAnatomy({
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
const title = t(`insights.derived.composite.${metric}.title`);
|
||||
const standard = STANDARDS[metric];
|
||||
const meta = METRIC_PROVENANCE[metric];
|
||||
const standard = meta.standard;
|
||||
const title = titleFor(metric, t);
|
||||
// Method copy carries an optional honesty caveat above it (STRESS proxy,
|
||||
// descriptive-not-clinical, …) so the caveat reaches the user, not just
|
||||
// the engine header.
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
// Network/abort fallback — render the insufficient state honestly.
|
||||
@@ -94,7 +107,7 @@ export function CompositeScoreAnatomy({
|
||||
windowDays: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
}}
|
||||
method={t(`insights.derived.composite.${metric}.method`)}
|
||||
method={method}
|
||||
standard={standard}
|
||||
insufficient
|
||||
className={className}
|
||||
@@ -120,7 +133,7 @@ export function CompositeScoreAnatomy({
|
||||
weight: s.weight,
|
||||
}))
|
||||
.sort(rankByImpact);
|
||||
} else {
|
||||
} else if (metric === "READINESS") {
|
||||
const v = data.value as ReadinessValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
@@ -132,6 +145,11 @@ export function CompositeScoreAnatomy({
|
||||
weight: c.weight,
|
||||
}))
|
||||
.sort(rankByImpact);
|
||||
} else {
|
||||
// Persisted nightly score — no sub-decomposition; ring + provenance.
|
||||
const v = data.value as WellnessScoreValue;
|
||||
score = v.score;
|
||||
caption = t(`insights.derived.scoreRing.band.${v.band}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +162,7 @@ export function CompositeScoreAnatomy({
|
||||
coverage={data.coverage}
|
||||
confidence={data.confidence}
|
||||
provenance={data.provenance}
|
||||
method={t(`insights.derived.composite.${metric}.method`)}
|
||||
method={method}
|
||||
standard={standard}
|
||||
insufficient={insufficient}
|
||||
className={className}
|
||||
@@ -152,6 +170,25 @@ export function CompositeScoreAnatomy({
|
||||
);
|
||||
}
|
||||
|
||||
/** Localised title per metric — composites use their existing copy keys;
|
||||
* the persisted scores reuse the wellness-strip labels. */
|
||||
function titleFor(
|
||||
metric: AnatomyMetricId,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (metric) {
|
||||
case "SLEEP_SCORE":
|
||||
case "READINESS":
|
||||
return t(`insights.derived.composite.${metric}.title`);
|
||||
case "RECOVERY_SCORE":
|
||||
return t("insights.derived.scores.recovery");
|
||||
case "STRESS_SCORE":
|
||||
return t("insights.derived.scores.stress");
|
||||
case "STRAIN_SCORE":
|
||||
return t("insights.derived.scores.strain");
|
||||
}
|
||||
}
|
||||
|
||||
/** Rank contributors by impact: present-and-higher-weight first. */
|
||||
function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number {
|
||||
const aPresent = a.value != null ? 1 : 0;
|
||||
|
||||
@@ -53,6 +53,14 @@ export interface SparklineDeltaTileProps {
|
||||
staleDays?: number | null;
|
||||
/** Decimal places for the value + delta. Defaults to 1. */
|
||||
precision?: number;
|
||||
/**
|
||||
* Optional provenance affordance — the `ProvenanceExplainer` ⓘ trigger
|
||||
* rendered in the label row so the method + cited standard reach the user
|
||||
* on a tile that has no detail page (vitals baseline, HRV, BMI,
|
||||
* fitness/vascular age). Plain element children — the caller wires the
|
||||
* explainer; the tile just gives it a slot.
|
||||
*/
|
||||
provenance?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,7 @@ export function SparklineDeltaTile({
|
||||
framing,
|
||||
staleDays = null,
|
||||
precision = 1,
|
||||
provenance,
|
||||
className,
|
||||
}: SparklineDeltaTileProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -119,6 +128,14 @@ export function SparklineDeltaTile({
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{provenance ? (
|
||||
<span
|
||||
data-slot="sparkline-delta-tile-provenance"
|
||||
className="shrink-0"
|
||||
>
|
||||
{provenance}
|
||||
</span>
|
||||
) : null}
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
|
||||
@@ -149,9 +166,11 @@ export function SparklineDeltaTile({
|
||||
</div>
|
||||
|
||||
{/* Inline sparkline — fixed 40px height so the tile never reflows.
|
||||
Suppressed (with the height reserved) when there is no series. */}
|
||||
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
|
||||
{sparkData ? (
|
||||
When there is no trailing series the row collapses entirely rather
|
||||
than reserving an empty dashed placeholder (which read as visual
|
||||
dead space across the whole grid). */}
|
||||
{sparkData ? (
|
||||
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={sparkData}
|
||||
@@ -175,13 +194,8 @@ export function SparklineDeltaTile({
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div
|
||||
className="border-border/40 h-full w-full rounded border border-dashed"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Framing + delta caption row. */}
|
||||
<div className="mt-2 flex min-h-[18px] items-baseline justify-between gap-2">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.10.0 — the single derived-metric provenance source map.
|
||||
*
|
||||
* One place that pins, per derived metric, the cited standard (a real,
|
||||
* linkable primary source — DOI / WHO / professional-society standard) and
|
||||
* the i18n keys for the plain-language method copy + the honesty caveat the
|
||||
* `ProvenanceExplainer` / `ScoreAnatomyView` surface. Every score surface
|
||||
* (the dashboard tiles, the wellness rings, the anatomy detail pages, the
|
||||
* coincident-deviation flag) reads from here so the method + cited standard
|
||||
* a metric exposes stays one source — never a comment buried in the engine
|
||||
* module, never two surfaces disagreeing on the citation.
|
||||
*
|
||||
* Client-safe: a string-keyed metadata map + i18n key names, no compute, no
|
||||
* server imports — a `"use client"` surface value-imports it freely.
|
||||
*/
|
||||
import type { ProvenanceStandard } from "./provenance-explainer";
|
||||
import type { DerivedMetricId } from "@/lib/insights/derived/registry";
|
||||
|
||||
export interface MetricProvenanceMeta {
|
||||
/** i18n key for the plain-language method copy (under composite.*.method). */
|
||||
methodKey: string;
|
||||
/** The cited primary standard rendered as an external link. */
|
||||
standard: ProvenanceStandard;
|
||||
/**
|
||||
* Optional i18n key for an honesty caveat surfaced above the method —
|
||||
* e.g. STRESS_SCORE must state it is an HRV-derived proxy, not an
|
||||
* EDA/galvanic measurement.
|
||||
*/
|
||||
caveatKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cited standard + method/caveat keys for every derived metric. The
|
||||
* `method`/`caveat` keys live under `insights.derived.composite.<ID>.*`
|
||||
* (extending the shape SLEEP_SCORE + READINESS already use) so the i18n
|
||||
* bundle carries one consistent namespace.
|
||||
*/
|
||||
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> = {
|
||||
VITALS_BASELINE: {
|
||||
methodKey: "insights.derived.composite.VITALS_BASELINE.method",
|
||||
standard: {
|
||||
// Robust central-tendency / dispersion via the median ± k·MAD.
|
||||
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
|
||||
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
|
||||
},
|
||||
},
|
||||
HRV_BALANCE: {
|
||||
methodKey: "insights.derived.composite.HRV_BALANCE.method",
|
||||
standard: {
|
||||
// Task Force of the ESC / NASPE — HRV standards of measurement.
|
||||
name: "Task Force ESC/NASPE 1996, Circulation",
|
||||
url: "https://doi.org/10.1161/01.CIR.93.5.1043",
|
||||
},
|
||||
},
|
||||
BMI: {
|
||||
methodKey: "insights.derived.composite.BMI.method",
|
||||
standard: {
|
||||
// WHO Technical Report Series 894 — BMI classification.
|
||||
name: "WHO Technical Report Series 894",
|
||||
url: "https://iris.who.int/handle/10665/42330",
|
||||
},
|
||||
},
|
||||
FITNESS_AGE: {
|
||||
methodKey: "insights.derived.composite.FITNESS_AGE.method",
|
||||
standard: {
|
||||
// FRIEND registry CRF reference standards (Kaminsky et al.).
|
||||
name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
|
||||
url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
|
||||
},
|
||||
},
|
||||
VASCULAR_AGE_DELTA: {
|
||||
methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
|
||||
standard: {
|
||||
// Reference values for arterial stiffness (PWV) collaboration.
|
||||
name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
|
||||
url: "https://doi.org/10.1093/eurheartj/ehq165",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
|
||||
},
|
||||
COINCIDENT_DEVIATION: {
|
||||
methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
|
||||
standard: {
|
||||
// Hampel-identifier / median ± k·MAD outlier flagging.
|
||||
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
|
||||
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
|
||||
},
|
||||
SLEEP_SCORE: {
|
||||
methodKey: "insights.derived.composite.SLEEP_SCORE.method",
|
||||
standard: {
|
||||
name: "Hirshkowitz et al. 2015, Sleep Health",
|
||||
url: "https://doi.org/10.1016/j.sleh.2014.12.010",
|
||||
},
|
||||
},
|
||||
READINESS: {
|
||||
methodKey: "insights.derived.composite.READINESS.method",
|
||||
standard: {
|
||||
name: "Plews et al. 2013, Sports Medicine",
|
||||
url: "https://doi.org/10.1007/s40279-013-0071-8",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.READINESS.caveat",
|
||||
},
|
||||
RECOVERY_SCORE: {
|
||||
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
|
||||
standard: {
|
||||
// HRV-guided recovery / training-readiness framing.
|
||||
name: "Buchheit 2014, Front. Physiol.",
|
||||
url: "https://doi.org/10.3389/fphys.2014.00073",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
|
||||
},
|
||||
STRESS_SCORE: {
|
||||
methodKey: "insights.derived.composite.STRESS_SCORE.method",
|
||||
standard: {
|
||||
// Neurovisceral-integration / HRV-as-stress-index basis.
|
||||
name: "Kim et al. 2018, Psychiatry Investig.",
|
||||
url: "https://doi.org/10.30773/pi.2017.08.17",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
|
||||
},
|
||||
STRAIN_SCORE: {
|
||||
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
|
||||
standard: {
|
||||
// Banister TRIMP training-load model (HR-reserve weighting).
|
||||
name: "Banister 1991, TRIMP model",
|
||||
url: "https://en.wikipedia.org/wiki/Training_Impulse",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Gauge, HeartPulse, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
@@ -12,7 +13,13 @@ import {
|
||||
import { SparklineDeltaTile } from "./sparkline-delta-tile";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import { WellnessScores } from "./wellness-scores";
|
||||
import { useDerivedMetric } from "./use-derived-metric";
|
||||
import { ProvenanceExplainer } from "./provenance-explainer";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
import {
|
||||
useDerivedBatch,
|
||||
type DerivedBatchRead,
|
||||
type DerivedBatchToken,
|
||||
} from "./use-derived-metric";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
|
||||
@@ -20,6 +27,9 @@ import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
|
||||
import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age";
|
||||
import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance";
|
||||
import type { BmiValue } from "@/lib/insights/derived/bmi";
|
||||
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid).
|
||||
@@ -33,10 +43,13 @@ import type { BmiValue } from "@/lib/insights/derived/bmi";
|
||||
* - provisional (`insufficient_history_for_band`) → the tile renders with a
|
||||
* `CoverageMeter` that shows the honest "N of 7 days" state, never a
|
||||
* headline number from too-little history.
|
||||
* - ok → the full value + framing line + sparkline placeholder.
|
||||
* - ok → the full value + framing line + a provenance ⓘ affordance.
|
||||
*
|
||||
* Each tile owns its own `useDerivedMetric` query (keyed off the centralised
|
||||
* factory) so a sparse vital simply un-mounts rather than blocking the grid.
|
||||
* The whole grid (every vital tile AND the wellness-score strip) reads from
|
||||
* ONE batched `/api/insights/derived/batch` request, fanned out server-side
|
||||
* under a bounded limiter with the profile loaded once. This replaces the
|
||||
* cold-mount fan-out of 14+ independent requests sharing the Prisma pool —
|
||||
* the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1).
|
||||
*/
|
||||
|
||||
const SECTION_VITALS: string[] = [
|
||||
@@ -59,6 +72,23 @@ const UP_BAD_VITALS = new Set([
|
||||
/** Up-is-good for the vitals where a rise is favourable. */
|
||||
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
|
||||
|
||||
/**
|
||||
* Storage-unit → display-symbol overrides. `getUnitForType` returns the
|
||||
* canonical *storage* string (e.g. the literal "celsius"); a tile must show
|
||||
* the symbol a user reads ("°C"). Only the units whose storage string is not
|
||||
* already a display symbol need an entry — kg / bpm / ms / % / mg/dL render
|
||||
* fine as-is.
|
||||
*/
|
||||
const DISPLAY_UNIT_SYMBOL: Record<string, string> = {
|
||||
celsius: "°C",
|
||||
fahrenheit: "°F",
|
||||
};
|
||||
|
||||
function displayUnit(type: string): string {
|
||||
const storage = getUnitForType(type);
|
||||
return DISPLAY_UNIT_SYMBOL[storage] ?? storage;
|
||||
}
|
||||
|
||||
function vitalSentiment(type: string): TrendDirectionSentiment {
|
||||
if (UP_BAD_VITALS.has(type)) return "up-bad";
|
||||
if (UP_GOOD_VITALS.has(type)) return "up-good";
|
||||
@@ -70,14 +100,47 @@ interface DashboardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The provenance ⓘ explainer for one derived metric, wired from the map. */
|
||||
function MetricProvenance({
|
||||
metric,
|
||||
provenance,
|
||||
}: {
|
||||
metric: keyof typeof METRIC_PROVENANCE;
|
||||
provenance: DerivedProvenance;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const meta = METRIC_PROVENANCE[metric];
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">{t(meta.caveatKey)}</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
read: DerivedBatchRead;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** A single personal-typical-range tile for one vital. */
|
||||
function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) {
|
||||
function BaselineTile({
|
||||
type,
|
||||
read,
|
||||
isLoading,
|
||||
}: TileProps & { type: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data, isLoading } = useDerivedMetric<VitalsBaselineValue>(
|
||||
"VITALS_BASELINE",
|
||||
{ type, enabled },
|
||||
);
|
||||
const data = read<VitalsBaselineValue>({ metric: "VITALS_BASELINE", type });
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
// Absent → don't render the tile at all.
|
||||
@@ -88,7 +151,7 @@ function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) {
|
||||
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge;
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
const label = labelKey ? t(labelKey) : type;
|
||||
const unit = getUnitForType(type);
|
||||
const unit = displayUnit(type);
|
||||
|
||||
// Provisional — building the band; show value-less coverage state.
|
||||
if (data.status === "insufficient") {
|
||||
@@ -131,17 +194,18 @@ function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) {
|
||||
icon={Icon}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
provenance={
|
||||
<MetricProvenance metric="VITALS_BASELINE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cardio-fitness band tile (VO₂max passthrough re-frame). */
|
||||
function FitnessAgeTile({ enabled }: { enabled: boolean }) {
|
||||
function FitnessAgeTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<FitnessAgeValue>("FITNESS_AGE", {
|
||||
enabled,
|
||||
});
|
||||
const data = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const framing =
|
||||
@@ -167,18 +231,18 @@ function FitnessAgeTile({ enabled }: { enabled: boolean }) {
|
||||
directionSentiment="up-good"
|
||||
framing={framing}
|
||||
precision={1}
|
||||
provenance={
|
||||
<MetricProvenance metric="FITNESS_AGE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vascular-age delta tile (Withings passthrough re-frame). */
|
||||
function VascularAgeTile({ enabled }: { enabled: boolean }) {
|
||||
function VascularAgeTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<VascularAgeDeltaValue>(
|
||||
"VASCULAR_AGE_DELTA",
|
||||
{ enabled },
|
||||
);
|
||||
const data = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const framing =
|
||||
@@ -204,17 +268,21 @@ function VascularAgeTile({ enabled }: { enabled: boolean }) {
|
||||
directionSentiment="up-bad"
|
||||
framing={framing}
|
||||
precision={0}
|
||||
provenance={
|
||||
<MetricProvenance
|
||||
metric="VASCULAR_AGE_DELTA"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** HRV (SDNN) balance tile (reuses the baseline engine). */
|
||||
function HrvBalanceTile({ enabled }: { enabled: boolean }) {
|
||||
function HrvBalanceTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<HrvBalanceValue>("HRV_BALANCE", {
|
||||
enabled,
|
||||
});
|
||||
const data = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
|
||||
if (isLoading || !data) return null;
|
||||
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
|
||||
return null;
|
||||
@@ -255,15 +323,18 @@ function HrvBalanceTile({ enabled }: { enabled: boolean }) {
|
||||
directionSentiment="up-good"
|
||||
framing={framing}
|
||||
precision={0}
|
||||
provenance={
|
||||
<MetricProvenance metric="HRV_BALANCE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** BMI tile (weight + height fallback). */
|
||||
function BmiTile({ enabled }: { enabled: boolean }) {
|
||||
function BmiTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<BmiValue>("BMI", { enabled });
|
||||
const data = read<BmiValue>({ metric: "BMI" });
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const framing = t(`insights.derived.vitals.bmiCategory.${v.category}`);
|
||||
@@ -277,18 +348,108 @@ function BmiTile({ enabled }: { enabled: boolean }) {
|
||||
directionSentiment="neutral"
|
||||
framing={framing}
|
||||
precision={1}
|
||||
provenance={
|
||||
<MetricProvenance metric="BMI" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coincident-deviation flag tile — surfaces ONLY when the flag fired today
|
||||
* (≥ N vitals outside their personal band on the same day). Lists the
|
||||
* contributing vitals as plain text and carries the provenance affordance
|
||||
* (the Hampel/Leys median ± k·MAD basis + the descriptive-not-clinical
|
||||
* caveat). When the flag has not fired the tile un-mounts entirely — never
|
||||
* an alarming empty card.
|
||||
*/
|
||||
function CoincidentDeviationTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
if (!v.fired || v.contributing.length === 0) return null;
|
||||
|
||||
const names = v.contributing
|
||||
.map((d) => {
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type];
|
||||
return labelKey ? t(labelKey) : d.type;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="COINCIDENT_DEVIATION"
|
||||
data-state="fired"
|
||||
className="bg-card border-warning/40 flex h-full w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.coincident.label")}
|
||||
</span>
|
||||
<MetricProvenance
|
||||
metric="COINCIDENT_DEVIATION"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
<AlertTriangle className="text-warning h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm" data-slot="coincident-summary">
|
||||
{t("insights.derived.coincident.summary", {
|
||||
count: v.contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of tokens the dashboard reads in one batch — the five
|
||||
* wellness scores + the four derived re-frames + the coincident-deviation
|
||||
* flag + one baseline per vital (minus HRV, which has its own balance tile).
|
||||
*/
|
||||
function dashboardTokens(): DerivedBatchToken[] {
|
||||
const tokens: DerivedBatchToken[] = [
|
||||
{ metric: "READINESS" },
|
||||
{ metric: "SLEEP_SCORE" },
|
||||
{ metric: "RECOVERY_SCORE" },
|
||||
{ metric: "STRESS_SCORE" },
|
||||
{ metric: "STRAIN_SCORE" },
|
||||
{ metric: "FITNESS_AGE" },
|
||||
{ metric: "VASCULAR_AGE_DELTA" },
|
||||
{ metric: "HRV_BALANCE" },
|
||||
{ metric: "BMI" },
|
||||
{ metric: "COINCIDENT_DEVIATION" },
|
||||
];
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
tokens.push({ metric: "VITALS_BASELINE", type });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const vitals = useMemo(() => SECTION_VITALS, []);
|
||||
const tokens = useMemo(() => dashboardTokens(), []);
|
||||
|
||||
const batch = useDerivedBatch(tokens, {
|
||||
enabled: enabled && isAuthenticated,
|
||||
});
|
||||
const read = batch.read;
|
||||
const isLoading = batch.isLoading;
|
||||
const tileProps: TileProps = { read, isLoading };
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
|
||||
<WellnessScores enabled={enabled} />
|
||||
<WellnessScores read={read} isLoading={isLoading} />
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
@@ -301,14 +462,15 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
data-slot="vitals-dashboard-grid"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<FitnessAgeTile enabled={enabled} />
|
||||
<VascularAgeTile enabled={enabled} />
|
||||
<HrvBalanceTile enabled={enabled} />
|
||||
<BmiTile enabled={enabled} />
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} enabled={enabled} />
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import { useDerivedMetric } from "./use-derived-metric";
|
||||
import type { DerivedBatchRead } from "./use-derived-metric";
|
||||
import type { ScoreBand } from "./band-tokens";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
|
||||
@@ -16,128 +16,145 @@ import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores"
|
||||
*
|
||||
* A row of score rings for the composite + persisted wellness scores, each
|
||||
* data-availability-gated: a score the user has no data for simply does not
|
||||
* render (never an apologetic empty ring). The two composites with a detail
|
||||
* surface (Readiness, Sleep) are tappable and route to their existing
|
||||
* `ScoreAnatomyView` page (`/insights/scores/<slug>`); the persisted nightly
|
||||
* scores (Recovery, Stress, Strain) render as read-only rings.
|
||||
* render (never an apologetic empty ring). All five composites/scores now
|
||||
* route to a `ScoreAnatomyView` detail page (`/insights/scores/<slug>`) so
|
||||
* every ring carries its inputs + method + cited standard one tap away — the
|
||||
* three persisted scores (Recovery, Stress, Strain) are no longer read-only
|
||||
* dead-ends. The band word renders under each ring so a less-technical user
|
||||
* reads "low 62", not a bare reddish 62.
|
||||
*
|
||||
* Every ring reads the SAME `/api/insights/derived` route through the one
|
||||
* `useDerivedMetric` hook — no per-screen recompute, no warm-on-mount fan-out
|
||||
* (the reads are already-computed rollup/persisted values).
|
||||
* Every ring reads from the ONE batched `/api/insights/derived/batch`
|
||||
* request the parent dashboard owns (passed in via `read`) — no per-ring
|
||||
* request, no cold-mount fan-out. The reads are already-computed
|
||||
* rollup/persisted values; nothing warms on visit.
|
||||
*/
|
||||
|
||||
interface ScoreTileProps {
|
||||
enabled: boolean;
|
||||
interface ScoreStripProps {
|
||||
/** The batched-read selector from the parent dashboard's one query. */
|
||||
read: DerivedBatchRead;
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A score ring optionally wrapped in a link to its anatomy detail page. */
|
||||
/** A score ring wrapped in a link to its anatomy detail page. */
|
||||
function RingTile({
|
||||
score,
|
||||
band,
|
||||
label,
|
||||
bandWord,
|
||||
href,
|
||||
metricSlot,
|
||||
}: {
|
||||
score: number;
|
||||
band: ScoreBand;
|
||||
label: string;
|
||||
href?: string;
|
||||
bandWord: string;
|
||||
href: string;
|
||||
metricSlot: string;
|
||||
}) {
|
||||
const ring = (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ScoreRing score={score} band={band} size="sm" label={label} />
|
||||
</div>
|
||||
);
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
data-slot="wellness-score-tile"
|
||||
data-metric={metricSlot}
|
||||
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{ring}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
href={href}
|
||||
data-slot="wellness-score-tile"
|
||||
data-metric={metricSlot}
|
||||
className="bg-card border-border flex items-center justify-center rounded-xl border p-4"
|
||||
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{ring}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<ScoreRing score={score} band={band} size="sm" label={label} />
|
||||
<span
|
||||
data-slot="wellness-score-band-word"
|
||||
className="text-muted-foreground text-xs font-medium"
|
||||
>
|
||||
{bandWord}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessTile({ enabled }: ScoreTileProps) {
|
||||
export function WellnessScores({ read, isLoading, className }: ScoreStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<ReadinessValue>("READINESS", {
|
||||
enabled,
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
return (
|
||||
<RingTile
|
||||
score={data.value.score}
|
||||
band={data.value.band}
|
||||
label={t("insights.derived.composite.READINESS.title")}
|
||||
href="/insights/scores/readiness"
|
||||
metricSlot="READINESS"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SleepTile({ enabled }: ScoreTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<SleepScoreValue>("SLEEP_SCORE", {
|
||||
enabled,
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
return (
|
||||
<RingTile
|
||||
score={data.value.score}
|
||||
band={data.value.band}
|
||||
label={t("insights.derived.composite.SLEEP_SCORE.title")}
|
||||
href="/insights/scores/sleep"
|
||||
metricSlot="SLEEP_SCORE"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const readiness = read<ReadinessValue>({ metric: "READINESS" });
|
||||
const sleep = read<SleepScoreValue>({ metric: "SLEEP_SCORE" });
|
||||
const recovery = read<WellnessScoreValue>({ metric: "RECOVERY_SCORE" });
|
||||
const stress = read<WellnessScoreValue>({ metric: "STRESS_SCORE" });
|
||||
const strain = read<WellnessScoreValue>({ metric: "STRAIN_SCORE" });
|
||||
|
||||
function PersistedScoreTile({
|
||||
metric,
|
||||
labelKey,
|
||||
enabled,
|
||||
}: {
|
||||
metric: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE";
|
||||
labelKey: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<WellnessScoreValue>(metric, {
|
||||
enabled,
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
return (
|
||||
<RingTile
|
||||
score={data.value.score}
|
||||
band={data.value.band}
|
||||
label={t(labelKey)}
|
||||
metricSlot={metric}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const bandWord = (band: ScoreBand) =>
|
||||
t(`insights.derived.scoreRing.band.${band}`);
|
||||
|
||||
const tiles: React.ReactNode[] = [];
|
||||
|
||||
if (!isLoading && readiness?.status === "ok" && readiness.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="READINESS"
|
||||
score={readiness.value.score}
|
||||
band={readiness.value.band}
|
||||
label={t("insights.derived.composite.READINESS.title")}
|
||||
bandWord={bandWord(readiness.value.band)}
|
||||
href="/insights/scores/readiness"
|
||||
metricSlot="READINESS"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && recovery?.status === "ok" && recovery.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="RECOVERY_SCORE"
|
||||
score={recovery.value.score}
|
||||
band={recovery.value.band}
|
||||
label={t("insights.derived.scores.recovery")}
|
||||
bandWord={bandWord(recovery.value.band)}
|
||||
href="/insights/scores/recovery"
|
||||
metricSlot="RECOVERY_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && sleep?.status === "ok" && sleep.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="SLEEP_SCORE"
|
||||
score={sleep.value.score}
|
||||
band={sleep.value.band}
|
||||
label={t("insights.derived.composite.SLEEP_SCORE.title")}
|
||||
bandWord={bandWord(sleep.value.band)}
|
||||
href="/insights/scores/sleep"
|
||||
metricSlot="SLEEP_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && stress?.status === "ok" && stress.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRESS_SCORE"
|
||||
score={stress.value.score}
|
||||
band={stress.value.band}
|
||||
label={t("insights.derived.scores.stress")}
|
||||
bandWord={bandWord(stress.value.band)}
|
||||
href="/insights/scores/stress"
|
||||
metricSlot="STRESS_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && strain?.status === "ok" && strain.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRAIN_SCORE"
|
||||
score={strain.value.score}
|
||||
band={strain.value.band}
|
||||
label={t("insights.derived.scores.strain")}
|
||||
bandWord={bandWord(strain.value.band)}
|
||||
href="/insights/scores/strain"
|
||||
metricSlot="STRAIN_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// Whole strip un-mounts when no score has data — never an empty section.
|
||||
if (tiles.length === 0) return null;
|
||||
|
||||
export function WellnessScores({
|
||||
enabled = true,
|
||||
className,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<section
|
||||
data-slot="wellness-scores"
|
||||
@@ -151,23 +168,7 @@ export function WellnessScores({
|
||||
data-slot="wellness-scores-grid"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
<ReadinessTile enabled={enabled} />
|
||||
<PersistedScoreTile
|
||||
metric="RECOVERY_SCORE"
|
||||
labelKey="insights.derived.scores.recovery"
|
||||
enabled={enabled}
|
||||
/>
|
||||
<SleepTile enabled={enabled} />
|
||||
<PersistedScoreTile
|
||||
metric="STRESS_SCORE"
|
||||
labelKey="insights.derived.scores.stress"
|
||||
enabled={enabled}
|
||||
/>
|
||||
<PersistedScoreTile
|
||||
metric="STRAIN_SCORE"
|
||||
labelKey="insights.derived.scores.strain"
|
||||
enabled={enabled}
|
||||
/>
|
||||
{tiles}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user