feat(dashboard): surface the wellness scores as a data-gated ring strip

Add a wellness-scores section to the vitals dashboard: a row of score rings for
Readiness, Recovery, Sleep, Stress, and Strain. Every ring is
data-availability-gated — a score with no underlying data simply does not
render, so the strip never shows an apologetic empty ring. Readiness and Sleep
are tappable and route to their existing score-anatomy detail pages; the
persisted nightly scores render read-only. Every ring reads the one
/api/insights/derived route through the shared useDerivedMetric hook.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 23:13:26 +02:00
1 parent a8daef7376
commit d9405e17b9
9 files changed
+237 -22

No files matched your search

+6
View File
@@ -2576,6 +2576,12 @@
"mood": "Stimmungsstabilität"
}
}
},
"scores": {
"sectionTitle": "Wellness-Werte",
"recovery": "Erholung",
"stress": "Stress",
"strain": "Belastung"
}
},
"cardioFitness": {
+6
View File
@@ -2576,6 +2576,12 @@
"mood": "Mood stability"
}
}
},
"scores": {
"sectionTitle": "Wellness scores",
"recovery": "Recovery",
"stress": "Stress",
"strain": "Strain"
}
},
"cardioFitness": {
+6
View File
@@ -2576,6 +2576,12 @@
"mood": "Estabilidad del ánimo"
}
}
},
"scores": {
"sectionTitle": "Puntuaciones de bienestar",
"recovery": "Recuperación",
"stress": "Estrés",
"strain": "Esfuerzo"
}
},
"cardioFitness": {
+6
View File
@@ -2576,6 +2576,12 @@
"mood": "Stabilité de l’humeur"
}
}
},
"scores": {
"sectionTitle": "Scores de bien-être",
"recovery": "Récupération",
"stress": "Stress",
"strain": "Effort"
}
},
"cardioFitness": {
+6
View File
@@ -2576,6 +2576,12 @@
"mood": "Stabilità dell’umore"
}
}
},
"scores": {
"sectionTitle": "Punteggi di benessere",
"recovery": "Recupero",
"stress": "Stress",
"strain": "Sforzo"
}
},
"cardioFitness": {
+6
View File
@@ -2576,6 +2576,12 @@
"mood": "Stabilność nastroju"
}
}
},
"scores": {
"sectionTitle": "Wyniki dobrostanu",
"recovery": "Regeneracja",
"stress": "Stres",
"strain": "Obciążenie"
}
},
"cardioFitness": {
+1
View File
@@ -35,6 +35,7 @@ export {
// ── W2b vitals dashboard surface + query hook ────────────────────────
export { VitalsDashboard } from "./vitals-dashboard";
export { WellnessScores } from "./wellness-scores";
export {
useDerivedMetric,
type DerivedMetricResponse,
@@ -11,6 +11,7 @@ import {
} from "@/components/measurements/measurement-list-meta";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { CoverageMeter } from "./coverage-meter";
import { WellnessScores } from "./wellness-scores";
import { useDerivedMetric } 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.
@@ -286,28 +287,31 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
const vitals = useMemo(() => SECTION_VITALS, []);
return (
<section
data-slot="vitals-dashboard"
aria-label={t("insights.derived.vitals.sectionTitle")}
className={cn("space-y-3", className)}
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.vitals.sectionTitle")}
</h2>
<div
data-slot="vitals-dashboard-grid"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
<WellnessScores enabled={enabled} />
<section
data-slot="vitals-dashboard"
aria-label={t("insights.derived.vitals.sectionTitle")}
className="space-y-3"
>
<FitnessAgeTile enabled={enabled} />
<VascularAgeTile enabled={enabled} />
<HrvBalanceTile enabled={enabled} />
<BmiTile enabled={enabled} />
{vitals
.filter((type) => type !== "HEART_RATE_VARIABILITY")
.map((type) => (
<BaselineTile key={type} type={type} enabled={enabled} />
))}
</div>
</section>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.vitals.sectionTitle")}
</h2>
<div
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} />
{vitals
.filter((type) => type !== "HEART_RATE_VARIABILITY")
.map((type) => (
<BaselineTile key={type} type={type} enabled={enabled} />
))}
</div>
</section>
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
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 { 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";
import type { SleepScoreValue } from "@/lib/insights/derived/sleep-score";
import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores";
/**
* v1.10.0 — the wellness-scores strip.
*
* 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.
*
* 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).
*/
interface ScoreTileProps {
enabled: boolean;
}
/** A score ring optionally wrapped in a link to its anatomy detail page. */
function RingTile({
score,
band,
label,
href,
metricSlot,
}: {
score: number;
band: ScoreBand;
label: 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
data-slot="wellness-score-tile"
data-metric={metricSlot}
className="bg-card border-border flex items-center justify-center rounded-xl border p-4"
>
{ring}
</div>
);
}
function ReadinessTile({ enabled }: ScoreTileProps) {
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"
/>
);
}
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}
/>
);
}
export function WellnessScores({
enabled = true,
className,
}: {
enabled?: boolean;
className?: string;
}) {
const { t } = useTranslations();
return (
<section
data-slot="wellness-scores"
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.scores.sectionTitle")}
</h2>
<div
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}
/>
</div>
</section>
);
}