mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a8daef7376
commit
d9405e17b9
9 files changed
+237
-22
No files matched your search
@@ -2576,6 +2576,12 @@
|
||||
"mood": "Stimmungsstabilität"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Wellness-Werte",
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
"strain": "Belastung"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"mood": "Mood stability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Wellness scores",
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
"strain": "Strain"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"mood": "Estabilidad del ánimo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Puntuaciones de bienestar",
|
||||
"recovery": "Recuperación",
|
||||
"stress": "Estrés",
|
||||
"strain": "Esfuerzo"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"mood": "Stabilité de l’humeur"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Scores de bien-être",
|
||||
"recovery": "Récupération",
|
||||
"stress": "Stress",
|
||||
"strain": "Effort"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"mood": "Stabilità dell’umore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Punteggi di benessere",
|
||||
"recovery": "Recupero",
|
||||
"stress": "Stress",
|
||||
"strain": "Sforzo"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -2576,6 +2576,12 @@
|
||||
"mood": "Stabilność nastroju"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Wyniki dobrostanu",
|
||||
"recovery": "Regeneracja",
|
||||
"stress": "Stres",
|
||||
"strain": "Obciążenie"
|
||||
}
|
||||
},
|
||||
"cardioFitness": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user