diff --git a/messages/de.json b/messages/de.json index f24eb7f65..851afd6a4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2576,6 +2576,12 @@ "mood": "Stimmungsstabilität" } } + }, + "scores": { + "sectionTitle": "Wellness-Werte", + "recovery": "Erholung", + "stress": "Stress", + "strain": "Belastung" } }, "cardioFitness": { diff --git a/messages/en.json b/messages/en.json index d0cf7cd26..c13a39e48 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2576,6 +2576,12 @@ "mood": "Mood stability" } } + }, + "scores": { + "sectionTitle": "Wellness scores", + "recovery": "Recovery", + "stress": "Stress", + "strain": "Strain" } }, "cardioFitness": { diff --git a/messages/es.json b/messages/es.json index f37f81050..2b5f9085e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2576,6 +2576,12 @@ "mood": "Estabilidad del ánimo" } } + }, + "scores": { + "sectionTitle": "Puntuaciones de bienestar", + "recovery": "Recuperación", + "stress": "Estrés", + "strain": "Esfuerzo" } }, "cardioFitness": { diff --git a/messages/fr.json b/messages/fr.json index df15b2e71..9e5eb8456 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2576,6 +2576,12 @@ "mood": "Stabilité de l’humeur" } } + }, + "scores": { + "sectionTitle": "Scores de bien-être", + "recovery": "Récupération", + "stress": "Stress", + "strain": "Effort" } }, "cardioFitness": { diff --git a/messages/it.json b/messages/it.json index ca367ad3b..9131ec561 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2576,6 +2576,12 @@ "mood": "Stabilità dell’umore" } } + }, + "scores": { + "sectionTitle": "Punteggi di benessere", + "recovery": "Recupero", + "stress": "Stress", + "strain": "Sforzo" } }, "cardioFitness": { diff --git a/messages/pl.json b/messages/pl.json index c39329307..cf5ffd105 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2576,6 +2576,12 @@ "mood": "Stabilność nastroju" } } + }, + "scores": { + "sectionTitle": "Wyniki dobrostanu", + "recovery": "Regeneracja", + "stress": "Stres", + "strain": "Obciążenie" } }, "cardioFitness": { diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts index a86a06aea..9fe957b53 100644 --- a/src/components/insights/derived/index.ts +++ b/src/components/insights/derived/index.ts @@ -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, diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 1f33cc834..d1ec79f6c 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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 ( -
-

- {t("insights.derived.vitals.sectionTitle")} -

-
+ +
- - - - - {vitals - .filter((type) => type !== "HEART_RATE_VARIABILITY") - .map((type) => ( - - ))} -
-
+

+ {t("insights.derived.vitals.sectionTitle")} +

+
+ + + + + {vitals + .filter((type) => type !== "HEART_RATE_VARIABILITY") + .map((type) => ( + + ))} +
+ + ); } diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx new file mode 100644 index 000000000..9b91b09b7 --- /dev/null +++ b/src/components/insights/derived/wellness-scores.tsx @@ -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/`); 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 = ( +
+ +
+ ); + if (href) { + return ( + + {ring} + + ); + } + return ( +
+ {ring} +
+ ); +} + +function ReadinessTile({ enabled }: ScoreTileProps) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("READINESS", { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + return ( + + ); +} + +function SleepTile({ enabled }: ScoreTileProps) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("SLEEP_SCORE", { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + return ( + + ); +} + +function PersistedScoreTile({ + metric, + labelKey, + enabled, +}: { + metric: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE"; + labelKey: string; + enabled: boolean; +}) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric(metric, { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + return ( + + ); +} + +export function WellnessScores({ + enabled = true, + className, +}: { + enabled?: boolean; + className?: string; +}) { + const { t } = useTranslations(); + return ( +
+

+ {t("insights.derived.scores.sectionTitle")} +

+
+ + + + + +
+
+ ); +}