From 32bc11f968b12baf9f9ed8df1c3eaf16e4fe719f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:14:49 +0200 Subject: [PATCH] feat(insights): add the derived-metrics design-system primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the four reusable presentational components the v1.10 vitals and score-anatomy surfaces share: a radial score ring, a coverage meter, a sparkline delta tile, and a provenance explainer. Each extends the existing dracula band + trend-sentiment vocabulary rather than a new colour system, and ships its populated, provisional/empty, and stale states up front so a sparse-data surface never goes blank. - score-ring: centred tabular-nums number on a Recharts RadialBarChart, band-coloured, size prop (sm tile / md / lg anatomy), count-up + sweep on mount via a 0 KB requestAnimationFrame hook; role=img aria-label restates the number so the ring is never colour-only. - coverage-meter: CSS-only filled/hollow dots driven by the Derived coverage + confidence facets, with an accessible breakdown tooltip (present vs required inputs, history days, missing list). - sparkline-delta-tile: extends the trend-card grammar with an inline Recharts area sparkline, a signed period delta, and an age/normal-range framing slot. - provenance-explainer: collapsible Popover (desktop) / bottom-sheet (mobile) rendering inputs, plain-language method, and an optional cited standard as React text children — no markdown library. All four read their data off the Derived union as a type-only import so the client bundle stays free of the server graph. Recharts only, no new runtime dependency. New i18n keys propagated across all six locales. --- messages/de.json | 28 +++ messages/en.json | 28 +++ messages/es.json | 28 +++ messages/fr.json | 28 +++ messages/it.json | 28 +++ messages/pl.json | 28 +++ .../derived/__tests__/coverage-meter.test.tsx | 82 +++++++ .../__tests__/provenance-explainer.test.tsx | 77 ++++++ .../derived/__tests__/score-ring.test.tsx | 55 +++++ .../__tests__/sparkline-delta-tile.test.tsx | 83 +++++++ .../insights/derived/band-tokens.ts | 66 ++++++ .../insights/derived/coverage-meter.tsx | 181 ++++++++++++++ src/components/insights/derived/index.ts | 29 +++ .../insights/derived/provenance-explainer.tsx | 209 +++++++++++++++++ .../insights/derived/score-ring.tsx | 198 ++++++++++++++++ .../insights/derived/sparkline-delta-tile.tsx | 221 ++++++++++++++++++ src/hooks/use-count-up.ts | 95 ++++++++ 17 files changed, 1464 insertions(+) create mode 100644 src/components/insights/derived/__tests__/coverage-meter.test.tsx create mode 100644 src/components/insights/derived/__tests__/provenance-explainer.test.tsx create mode 100644 src/components/insights/derived/__tests__/score-ring.test.tsx create mode 100644 src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx create mode 100644 src/components/insights/derived/band-tokens.ts create mode 100644 src/components/insights/derived/coverage-meter.tsx create mode 100644 src/components/insights/derived/index.ts create mode 100644 src/components/insights/derived/provenance-explainer.tsx create mode 100644 src/components/insights/derived/score-ring.tsx create mode 100644 src/components/insights/derived/sparkline-delta-tile.tsx create mode 100644 src/hooks/use-count-up.ts diff --git a/messages/de.json b/messages/de.json index 32dd32d17..ea34405fb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2460,6 +2460,34 @@ "bandVariable": "Deine Stimmung war merklich schwankend", "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" } + }, + "derived": { + "coverage": { + "summary": "{present} von {required} Eingaben · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basiert auf {count} Tagen Verlauf", + "missing": "Erfasse diese, um den Wert zu schärfen: {list}" + }, + "scoreRing": { + "aria": "Wert {score} von 100, {band}", + "ariaProvisional": "Wert noch nicht verfügbar — zu wenig Daten", + "provisionalCaption": "Noch zu wenig Daten", + "band": { + "green": "gut", + "yellow": "mittel", + "red": "niedrig" + } + }, + "provenance": { + "title": "So wird das berechnet", + "trigger": "So wird das berechnet", + "inputsLabel": "Eingaben", + "noInputs": "Noch keine Eingaben für diesen Wert vorhanden.", + "methodLabel": "Methode", + "window": "Aus deinen letzten {days} Tagen · {source}-Daten", + "asOf": "Stand {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 07974000f..f9cf3ccc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2460,6 +2460,34 @@ "bandVariable": "Your mood has been noticeably variable", "bandVeryVariable": "Your mood has swung a lot lately" } + }, + "derived": { + "coverage": { + "summary": "{present} of {required} inputs · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Based on {count} days of history", + "missing": "Track these to sharpen this: {list}" + }, + "scoreRing": { + "aria": "Score {score} out of 100, {band}", + "ariaProvisional": "Score not available yet — not enough data", + "provisionalCaption": "Not enough data yet", + "band": { + "green": "good", + "yellow": "fair", + "red": "low" + } + }, + "provenance": { + "title": "How this is computed", + "trigger": "How this is computed", + "inputsLabel": "Inputs", + "noInputs": "No inputs backed this value yet.", + "methodLabel": "Method", + "window": "From your last {days} days · {source} data", + "asOf": "as of {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index ce163e91d..ee9f48e3d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2460,6 +2460,34 @@ "bandVariable": "Tu estado de ánimo ha sido notablemente variable", "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" } + }, + "derived": { + "coverage": { + "summary": "{present} de {required} entradas · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basado en {count} días de historial", + "missing": "Registra estos para afinar el valor: {list}" + }, + "scoreRing": { + "aria": "Puntuación {score} de 100, {band}", + "ariaProvisional": "Puntuación aún no disponible — datos insuficientes", + "provisionalCaption": "Datos aún insuficientes", + "band": { + "green": "bueno", + "yellow": "medio", + "red": "bajo" + } + }, + "provenance": { + "title": "Cómo se calcula esto", + "trigger": "Cómo se calcula esto", + "inputsLabel": "Entradas", + "noInputs": "Aún no hay entradas que respalden este valor.", + "methodLabel": "Método", + "window": "De tus últimos {days} días · datos de {source}", + "asOf": "a fecha de {date}", + "standardLink": "Estándar: {name}" + } } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 07d74a4ce..3530e02ba 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2460,6 +2460,34 @@ "bandVariable": "Votre humeur a été nettement variable", "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" } + }, + "derived": { + "coverage": { + "summary": "{present} entrées sur {required} · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basé sur {count} jours d'historique", + "missing": "Enregistre ceci pour affiner la valeur : {list}" + }, + "scoreRing": { + "aria": "Score {score} sur 100, {band}", + "ariaProvisional": "Score pas encore disponible — données insuffisantes", + "provisionalCaption": "Données encore insuffisantes", + "band": { + "green": "bon", + "yellow": "moyen", + "red": "faible" + } + }, + "provenance": { + "title": "Comment c'est calculé", + "trigger": "Comment c'est calculé", + "inputsLabel": "Entrées", + "noInputs": "Aucune entrée ne soutient encore cette valeur.", + "methodLabel": "Méthode", + "window": "Sur tes {days} derniers jours · données {source}", + "asOf": "au {date}", + "standardLink": "Norme : {name}" + } } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 59c1d98bb..994715c69 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2460,6 +2460,34 @@ "bandVariable": "Il tuo umore è stato notevolmente variabile", "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" } + }, + "derived": { + "coverage": { + "summary": "{present} di {required} input · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basato su {count} giorni di cronologia", + "missing": "Registra questi per affinare il valore: {list}" + }, + "scoreRing": { + "aria": "Punteggio {score} su 100, {band}", + "ariaProvisional": "Punteggio non ancora disponibile — dati insufficienti", + "provisionalCaption": "Dati ancora insufficienti", + "band": { + "green": "buono", + "yellow": "medio", + "red": "basso" + } + }, + "provenance": { + "title": "Come viene calcolato", + "trigger": "Come viene calcolato", + "inputsLabel": "Input", + "noInputs": "Nessun input supporta ancora questo valore.", + "methodLabel": "Metodo", + "window": "Dai tuoi ultimi {days} giorni · dati {source}", + "asOf": "al {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 3d5f69844..c17fdbd1d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2460,6 +2460,34 @@ "bandVariable": "Twój nastrój był zauważalnie zmienny", "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" } + }, + "derived": { + "coverage": { + "summary": "{present} z {required} danych · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Na podstawie {count} dni historii", + "missing": "Zapisuj te dane, aby uściślić wartość: {list}" + }, + "scoreRing": { + "aria": "Wynik {score} na 100, {band}", + "ariaProvisional": "Wynik jeszcze niedostępny — za mało danych", + "provisionalCaption": "Wciąż za mało danych", + "band": { + "green": "dobry", + "yellow": "średni", + "red": "niski" + } + }, + "provenance": { + "title": "Jak to jest obliczane", + "trigger": "Jak to jest obliczane", + "inputsLabel": "Dane wejściowe", + "noInputs": "Żadne dane nie potwierdzają jeszcze tej wartości.", + "methodLabel": "Metoda", + "window": "Z ostatnich {days} dni · dane {source}", + "asOf": "na dzień {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/src/components/insights/derived/__tests__/coverage-meter.test.tsx b/src/components/insights/derived/__tests__/coverage-meter.test.tsx new file mode 100644 index 000000000..f58ab2a51 --- /dev/null +++ b/src/components/insights/derived/__tests__/coverage-meter.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CoverageMeter } from "../coverage-meter"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { + DerivedConfidence, + DerivedCoverage, +} from "@/lib/insights/derived/types"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const FULL: DerivedCoverage = { + requiredInputs: 5, + presentInputs: 5, + historyDays: 30, + missing: [], +}; +const PARTIAL: DerivedCoverage = { + requiredInputs: 5, + presentInputs: 3, + historyDays: 12, + missing: ["HRV_SDNN", "RESTING_HEART_RATE"], +}; +const EMPTY: DerivedCoverage = { + requiredInputs: 5, + presentInputs: 0, + historyDays: 0, + missing: ["WEIGHT", "HRV_SDNN", "RESTING_HEART_RATE", "VO2_MAX", "SLEEP"], +}; +const HIGH: DerivedConfidence = { score: 90, band: "high" }; +const LOW: DerivedConfidence = { score: 35, band: "low" }; + +describe("", () => { + it("renders the root data-slot and ratio attributes (populated)", () => { + const html = render(); + expect(html).toContain('data-slot="coverage-meter"'); + expect(html).toContain('data-present="5"'); + expect(html).toContain('data-required="5"'); + expect(html).toContain('data-band="high"'); + }); + + it("lights all dots at full confidence", () => { + const html = render(); + const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; + expect(lit).toBe(5); + expect(html).toContain("bg-dracula-green"); + }); + + it("lights a partial number of dots and tints by band (provisional)", () => { + const html = render(); + const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; + expect(lit).toBeGreaterThan(0); + expect(lit).toBeLessThan(5); + expect(html).toContain("bg-dracula-orange"); + expect(html).toContain('data-percent="35"'); + }); + + it("falls back to the present/required ratio when confidence is absent", () => { + const html = render(); + // 3/5 = 60% + expect(html).toContain('data-percent="60"'); + }); + + it("renders zero lit dots for the empty/insufficient state", () => { + const html = render(); + const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; + expect(lit).toBe(0); + expect(html).toContain('data-percent="0"'); + }); + + it("carries an accessible summary label", () => { + const html = render(); + expect(html).toContain("aria-label"); + expect(html).toContain("3"); + expect(html).toContain("5"); + }); +}); diff --git a/src/components/insights/derived/__tests__/provenance-explainer.test.tsx b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx new file mode 100644 index 000000000..572e948fa --- /dev/null +++ b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ProvenanceExplainer } from "../provenance-explainer"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const PROVENANCE: DerivedProvenance = { + inputs: ["WEIGHT", "HEIGHT"], + source: "DAY", + windowDays: 30, + computedAt: "2026-06-02T08:00:00+02:00", +}; + +const EMPTY_PROVENANCE: DerivedProvenance = { + inputs: [], + source: "none", + windowDays: 0, + computedAt: "not-a-date", +}; + +describe("", () => { + it("renders an accessible trigger that controls the body (collapsed by default)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('aria-controls="prov-1"'); + expect(html).toContain("aria-label"); + }); + + it("meets the 44px hit-target floor on the trigger", () => { + const html = render( + , + ); + expect(html).toContain("min-h-11"); + expect(html).toContain("min-w-11"); + }); + + it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + }); + + it("accepts a cited standard prop without erroring", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + }); +}); diff --git a/src/components/insights/derived/__tests__/score-ring.test.tsx b/src/components/insights/derived/__tests__/score-ring.test.tsx new file mode 100644 index 000000000..dffccf055 --- /dev/null +++ b/src/components/insights/derived/__tests__/score-ring.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ScoreRing } from "../score-ring"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the root data-slot with a band derived from the score (populated, good)", () => { + const html = render(); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-band="green"'); + expect(html).not.toContain('data-provisional="true"'); + }); + + it("derives a yellow band for a mid score", () => { + const html = render(); + expect(html).toContain('data-band="yellow"'); + }); + + it("derives a red band for a low score", () => { + const html = render(); + expect(html).toContain('data-band="red"'); + }); + + it("honours an explicit band override", () => { + const html = render(); + expect(html).toContain('data-band="green"'); + }); + + it("renders the provisional/empty state for a null score", () => { + const html = render(); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain('data-band="none"'); + expect(html).toContain('data-slot="score-ring-provisional"'); + }); + + it("carries an aria-label restating the number (not colour-only)", () => { + const html = render(); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + expect(html).toContain("74"); + }); + + it("carries a provisional aria-label when no score is available", () => { + const html = render(); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + }); +}); diff --git a/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx new file mode 100644 index 000000000..484f45509 --- /dev/null +++ b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Heart } from "lucide-react"; + +import { SparklineDeltaTile } from "../sparkline-delta-tile"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const base = { + label: "Resting HR", + unit: "bpm", + icon: Heart, +}; + +describe("", () => { + it("renders the value, unit and a framing line (populated)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile"'); + expect(html).toContain('data-slot="sparkline-delta-tile-value"'); + expect(html).toContain("58"); + expect(html).toContain("bpm"); + expect(html).toContain("typical for age 31"); + expect(html).toContain('data-slot="sparkline-delta-tile-framing"'); + }); + + it("paints a falling delta on an up-bad metric as green (improvement)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile-delta"'); + expect(html).toContain("text-dracula-green"); + }); + + it("renders the empty state for a null value (no value, dashed sparkline slot)", () => { + const html = render(); + expect(html).toContain('data-slot="sparkline-delta-tile-value"'); + expect(html).toContain("—"); + // No series → dashed placeholder, not an SVG. + expect(html).toContain("border-dashed"); + }); + + it("reserves the sparkline slot but omits the chart when series < 2 points", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile-spark"'); + expect(html).toContain("border-dashed"); + }); + + it("surfaces the stale caption when staleDays > 7", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile-stale"'); + expect(html).toContain('data-stale-days="12"'); + }); + + it("omits the stale caption for fresh data", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="sparkline-delta-tile-stale"'); + }); +}); diff --git a/src/components/insights/derived/band-tokens.ts b/src/components/insights/derived/band-tokens.ts new file mode 100644 index 000000000..0204d56f6 --- /dev/null +++ b/src/components/insights/derived/band-tokens.ts @@ -0,0 +1,66 @@ +/** + * v1.10.0 — shared score-band token vocabulary for the derived-metrics + * design system. Extracted so the new primitives (score-ring, + * coverage-meter, sparkline-delta-tile, provenance-explainer) paint the + * exact same band colour grammar the shipped `health-score-card` already + * uses, without each component redeclaring the map (concept cohesion). + * + * No new tokens — these point at the existing `--dracula-*` variables in + * `globals.css`. A green/yellow/red band is the one semantic the whole + * v1.10 score language extends. + */ + +export type ScoreBand = "green" | "yellow" | "red"; + +/** Tailwind text-colour class per band (the headline number colour). */ +export const BAND_NUMBER_CLASS: Record = { + green: "text-dracula-green", + yellow: "text-dracula-orange", + red: "text-dracula-red", +}; + +/** Tailwind background class per band (progress/fill bars). */ +export const BAND_PROGRESS_CLASS: Record = { + green: "bg-dracula-green", + yellow: "bg-dracula-orange", + red: "bg-dracula-red", +}; + +/** Tailwind border-colour class per band (card / chip outlines). */ +export const BAND_BORDER_CLASS: Record = { + green: "border-dracula-green/40", + yellow: "border-dracula-orange/40", + red: "border-dracula-red/40", +}; + +/** + * The raw CSS variable per band — used where a Recharts `fill` needs an + * actual colour string rather than a Tailwind class (the score ring's + * ``). Reads the same `--dracula-*` tokens so dark and + * Alucard light modes track automatically. + */ +export const BAND_VAR: Record = { + green: "var(--dracula-green)", + yellow: "var(--dracula-orange)", + red: "var(--dracula-red)", +}; + +/** + * Map a 0..100 score to its band. Mirrors the thresholds the existing + * health-score blend uses (≥ 70 green, ≥ 40 yellow, else red) so a + * derived score and the composite health score never disagree on colour + * for the same number. + */ +export function bandForScore(score: number): ScoreBand { + if (score >= 70) return "green"; + if (score >= 40) return "yellow"; + return "red"; +} + +/** Clamp a score into the 0..100 rendering range, defending bad input. */ +export function clampScore(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +} diff --git a/src/components/insights/derived/coverage-meter.tsx b/src/components/insights/derived/coverage-meter.tsx new file mode 100644 index 000000000..5cf194da4 --- /dev/null +++ b/src/components/insights/derived/coverage-meter.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useId } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { + DerivedConfidence, + DerivedConfidenceBand, + DerivedCoverage, +} from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — the single reusable data-confidence indicator. + * + * `●●●●○` filled/hollow dots driven by the `presentInputs / requiredInputs` + * ratio off `Derived.coverage`, tinted by the `confidence.band`, with an + * accessible breakdown tooltip (present vs required inputs, history days, + * and the named missing inputs). This is the one place the whole app speaks + * data-confidence — every score tile, anatomy view, correlation card, + * briefing and report header renders it identically. + * + * It is the load-bearing graceful-degradation primitive: a partial score is + * shown WITH its confidence, never silently downgraded and never blank. The + * dots read as intentional ("4 of 5 inputs · 80%"), not apologetic. + * + * CSS-only dots + a Radix `Tooltip` for the breakdown — 0 KB runtime. + */ + +export interface CoverageMeterProps { + coverage: DerivedCoverage; + /** + * Optional confidence facet. When present, the dots take the band tint + * and the percent label reads the confidence score; when omitted the + * meter falls back to the present/required ratio for both. + */ + confidence?: DerivedConfidence; + /** Total number of dots rendered. Defaults to 5 (the `●●●●○` grammar). */ + dots?: number; + /** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */ + size?: "sm" | "md"; + /** Optional className applied to the outer wrapper. */ + className?: string; +} + +/** Dot tint per confidence band — reuses the existing dracula vocabulary. */ +const BAND_DOT_CLASS: Record = { + high: "bg-dracula-green", + medium: "bg-dracula-yellow", + low: "bg-dracula-orange", + draft: "bg-dracula-red", +}; + +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.round(value))); +} + +export function CoverageMeter({ + coverage, + confidence, + dots = 5, + size = "sm", + className, +}: CoverageMeterProps) { + const { t } = useTranslations(); + const tooltipId = useId(); + + const required = clampInt(coverage.requiredInputs, 0, 999); + const present = clampInt(coverage.presentInputs, 0, required || 999); + const totalDots = clampInt(dots, 1, 12); + + // The fill ratio prefers the explicit confidence score (so the meter + // tracks the same number the band is derived from); without it, fall + // back to the present/required ratio. + const ratio = + confidence != null + ? clampInt(confidence.score, 0, 100) / 100 + : required > 0 + ? present / required + : 0; + const litDots = clampInt(ratio * totalDots, 0, totalDots); + const percent = Math.round(ratio * 100); + const band: DerivedConfidenceBand = confidence?.band ?? "low"; + const dotLit = BAND_DOT_CLASS[band]; + + const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2"; + + const summaryLabel = t("insights.derived.coverage.summary", { + present, + required, + percent, + }); + + return ( + + + + + + +
+

{summaryLabel}

+

+ {t("insights.derived.coverage.historyDays", { + count: clampInt(coverage.historyDays, 0, 99999), + })} +

+ {coverage.missing.length > 0 && ( +

+ {t("insights.derived.coverage.missing", { + list: coverage.missing.join(", "), + })} +

+ )} +
+
+
+
+ ); +} diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts new file mode 100644 index 000000000..e37fd5087 --- /dev/null +++ b/src/components/insights/derived/index.ts @@ -0,0 +1,29 @@ +/** + * v1.10.0 — the derived-metrics design-system primitives barrel. + * + * Four reusable, presentational components that extend the existing token + + * sentiment vocabulary; none introduce a new colour system. Reused across + * the dashboard, Insights pages, the Coach / briefing and the doctor/FHIR + * report. See `.planning/v1.10-build/design-sota-and-direction.md`. + */ + +export { ScoreRing, type ScoreRingProps } from "./score-ring"; +export { CoverageMeter, type CoverageMeterProps } from "./coverage-meter"; +export { + SparklineDeltaTile, + type SparklineDeltaTileProps, +} from "./sparkline-delta-tile"; +export { + ProvenanceExplainer, + type ProvenanceExplainerProps, + type ProvenanceStandard, +} from "./provenance-explainer"; +export { + bandForScore, + clampScore, + BAND_NUMBER_CLASS, + BAND_PROGRESS_CLASS, + BAND_BORDER_CLASS, + BAND_VAR, + type ScoreBand, +} from "./band-tokens"; diff --git a/src/components/insights/derived/provenance-explainer.tsx b/src/components/insights/derived/provenance-explainer.tsx new file mode 100644 index 000000000..64c217beb --- /dev/null +++ b/src/components/insights/derived/provenance-explainer.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useId, useState } from "react"; +import { ExternalLink, Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — the "how this is computed / standard cited" surface. + * + * A collapsible `ⓘ` explainer that renders, as React text children (NO + * markdown library — the XSS rule), the metric's inputs, a plain-language + * method, and an optional link to the cited standard (IEEE / RFC / WHO / + * LOINC / Wikipedia). Reads the `provenance` field off `Derived` for the + * inputs chip + the "as of" + window framing. + * + * Surface follows the shipped `health-score-delta-explainer` pattern: + * Popover on `md+`, bottom-sheet on phone-class viewports via + * `ResponsiveSheet`. Owns its own open state. aria-describedby threads the + * trigger to the body. + */ + +export interface ProvenanceStandard { + /** Display name of the cited standard (e.g. "WHO BMI classification"). */ + name: string; + /** Absolute URL to the standard. Rendered as a plain ``, never HTML. */ + url: string; +} + +export interface ProvenanceExplainerProps { + /** The provenance facet off `Derived` — inputs, source, window, asOf. */ + provenance: DerivedProvenance; + /** + * Plain-language method description. Text children only — the caller + * passes a localised string; the component never parses markup. + */ + method: React.ReactNode; + /** Optional cited standard rendered as an external link in the footer. */ + standard?: ProvenanceStandard; + /** Accessible title of the surface (sheet header / popover first line). */ + title?: string; + /** Optional className for the trigger button. */ + className?: string; + /** Optional id the parent threads as aria-describedby on its own element. */ + bodyId?: string; +} + +export function ProvenanceExplainer({ + provenance, + method, + standard, + title, + className, + bodyId, +}: ProvenanceExplainerProps) { + const { t, locale } = useTranslations(); + const isMobile = useIsMobile(); + const [open, setOpen] = useState(false); + const generatedId = useId(); + const resolvedBodyId = bodyId ?? generatedId; + + const resolvedTitle = title ?? t("insights.derived.provenance.title"); + const triggerLabel = t("insights.derived.provenance.trigger"); + + const asOf = (() => { + const parsed = new Date(provenance.computedAt); + if (Number.isNaN(parsed.getTime())) return null; + try { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "2-digit", + }).format(parsed); + } catch { + return null; + } + })(); + + const body = ( + + ); + + const trigger = ( + + ); + + if (isMobile) { + return ( + <> + {trigger} + +
{body}
+
+ + ); + } + + return ( + + {trigger} + +

+ {resolvedTitle} +

+ {body} +
+
+ ); +} diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx new file mode 100644 index 000000000..06514dc1e --- /dev/null +++ b/src/components/insights/derived/score-ring.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + Label, + PolarRadiusAxis, + RadialBar, + RadialBarChart, + ResponsiveContainer, +} from "recharts"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { useCountUp } from "@/hooks/use-count-up"; +import { + bandForScore, + BAND_NUMBER_CLASS, + BAND_VAR, + clampScore, + type ScoreBand, +} from "./band-tokens"; + +/** + * v1.10.0 — the composite-score dial. A radial Recharts ring with the + * `tabular-nums` number centred, band-coloured, sweeping to fill on first + * paint. Rendered small in a grid tile and large on the score-anatomy + * detail view from the same component via the `size` prop — one score + * language, two intensities (the Hybrid direction). + * + * Built on raw Recharts `RadialBarChart` (the existing house pattern — 6 + * sites already use Recharts via `ResponsiveContainer`), NOT the shadcn + * `chart` primitive: the ring needs no per-series CSS-var injection, so it + * paints its fill straight from the `--dracula-*` band token and avoids + * adding a second `dangerouslySetInnerHTML` to the tree. 0 KB new runtime. + * + * `score === null` renders the provisional/empty state: an unfilled ring + * with an em-dash and a localised "not enough data yet" caption, so a + * sparse-data hero never goes blank. + * + * a11y: `role="img"` + an aria-label restating the number and band, so the + * ring is never colour-only. `prefers-reduced-motion` disables the sweep + * (Recharts `isAnimationActive`) and the count-up. + */ + +const SIZE: Record< + NonNullable, + { px: number; numberClass: string; labelClass: string; barSize: number } +> = { + sm: { px: 96, numberClass: "text-2xl", labelClass: "text-[10px]", barSize: 10 }, + md: { px: 140, numberClass: "text-4xl", labelClass: "text-xs", barSize: 14 }, + lg: { px: 200, numberClass: "text-6xl", labelClass: "text-sm", barSize: 18 }, +}; + +export interface ScoreRingProps { + /** The 0..100 score, or `null` for the provisional/empty state. */ + score: number | null; + /** Band override; when omitted it is derived from the score via thresholds. */ + band?: ScoreBand; + /** Short label rendered under the number (e.g. "Readiness", "/100"). */ + label?: string; + /** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */ + size?: "sm" | "md" | "lg"; + /** Disable the sweep + count-up (e.g. when already animated by a parent). */ + animate?: boolean; + className?: string; +} + +export function ScoreRing({ + score, + band, + label, + size = "md", + animate = true, + className, +}: ScoreRingProps) { + const { t } = useTranslations(); + const dims = SIZE[size]; + + const hasScore = score != null && Number.isFinite(score); + const clamped = hasScore ? clampScore(score) : 0; + const resolvedBand: ScoreBand = band ?? bandForScore(clamped); + const fill = hasScore ? BAND_VAR[resolvedBand] : "var(--muted-foreground)"; + + // Count-up only feeds the centred number; the ring arc reads the final + // value (Recharts owns its own sweep via isAnimationActive). + const displayed = useCountUp(clamped, { enabled: animate && hasScore }); + const displayedRounded = Math.round(displayed); + + const ariaLabel = hasScore + ? t("insights.derived.scoreRing.aria", { + score: Math.round(clamped), + band: t(`insights.derived.scoreRing.band.${resolvedBand}`), + }) + : t("insights.derived.scoreRing.ariaProvisional"); + + // RadialBar fills the arc proportionally to the bar's value against the + // PolarRadiusAxis [0,100] domain; the track spans a full clockwise circle + // from 12 o'clock, so a value of 74 fills 74% of the ring. + const data = [{ name: "score", value: hasScore ? clamped : 0, fill }]; + const startAngle = 90; + const endAngle = -270; + + // Geometry — keep the inner ring thin and the centre clear for the number. + const outerRadius = "100%"; + const innerRadius = "78%"; + + return ( +
+ + + + + + + + {!hasScore && ( + + {t("insights.derived.scoreRing.provisionalCaption")} + + )} +
+ ); +} diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx new file mode 100644 index 000000000..d95a2fef6 --- /dev/null +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react"; +import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; +import { cn } from "@/lib/utils"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + getTrendSentiment, + sentimentColorClass, + type TrendDirectionSentiment, +} from "@/lib/insights/trend-sentiment"; + +/** + * v1.10.0 — the Apple-Health-Highlights grid tile. + * + * Extends the shipped `trend-card` grammar (label + icon row, big + * `tabular-nums` value, sentiment-coloured trend arrow) with the single + * biggest visible grid upgrade the design direction names: an inline + * Recharts sparkline, a signed period delta, and an age / normal-range + * framing slot. Reuses the already-imported Recharts (Area sparkline, + * 40–60px fixed height) — 0 KB new runtime. + * + * States, defined up front (no fallback afterthought): + * - populated: value + sparkline + delta + framing line + * - empty (`value == null`): em-dash, no sparkline, optional framing line + * - stale (`staleDays`): "last value Xd ago" caption, value kept visible + * Fixed footprint so a tile never reflows its neighbours. + */ + +export interface SparklineDeltaTileProps { + label: string; + /** Latest reading; `null` renders the empty state. */ + value: number | null; + unit: string; + icon: React.ComponentType<{ className?: string }>; + /** + * Trailing sparkline points (oldest → newest). < 2 points suppresses the + * sparkline (a line needs at least two). Reuses Recharts, capped to a + * small fixed height. + */ + series?: number[]; + /** Signed delta vs the baseline; drives the arrow + the (Δ) caption. */ + delta?: number | null; + /** Whether an up move is good / bad / neutral for this metric. */ + directionSentiment?: TrendDirectionSentiment; + /** + * The age / normal-range framing line (e.g. "typical for age 31", + * "~5 yrs younger", "within normal range"). Plain text children — no + * markdown. Rendered muted under the value row. + */ + framing?: React.ReactNode; + /** Days since the last reading; > 7 surfaces the stale caption. */ + staleDays?: number | null; + /** Decimal places for the value + delta. Defaults to 1. */ + precision?: number; + className?: string; +} + +export function SparklineDeltaTile({ + label, + value, + unit, + icon: Icon, + series, + delta = null, + directionSentiment = "neutral", + framing, + staleDays = null, + precision = 1, + className, +}: SparklineDeltaTileProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment); + const trendColor = sentimentColorClass(arrowSentiment); + + const TrendIcon = ((): typeof ArrowUp => { + if (delta == null) return Minus; + if (Math.abs(delta) < 0.05) return ArrowRight; + return delta > 0 ? ArrowUp : ArrowDown; + })(); + + const formatDelta = (d: number): string => { + if (Math.abs(d) < 0.05) return "±0"; + const sign = d > 0 ? "+" : "−"; + return `${sign}${fmt.number(Math.abs(d), precision)}`; + }; + + const sparkData = + series && series.length >= 2 + ? series.map((v, i) => ({ i, v })) + : null; + + // The sparkline tints with the trend sentiment so it reads "same signal" + // as the arrow; neutral metrics ride the muted-foreground line. + const strokeVar = + arrowSentiment === "positive" + ? "var(--dracula-green)" + : arrowSentiment === "negative" + ? "var(--dracula-orange)" + : "var(--muted-foreground)"; + const fillId = `spark-${label.replace(/[^a-zA-Z0-9]/g, "")}`; + + return ( +
+
+ + {label} + + +
+ +
+ + {value !== null ? fmt.number(value, precision) : "—"} + + + {unit} + + +
+ + {/* Inline sparkline — fixed 40px height so the tile never reflows. + Suppressed (with the height reserved) when there is no series. */} +
+ {sparkData ? ( + + + + + + + + + + + + + ) : ( + + + {/* Framing + delta caption row. */} +
+ {framing ? ( + + {framing} + + ) : ( + + )} + {delta != null && ( + + {formatDelta(delta)} + + )} +
+ + {staleDays != null && staleDays > 7 && ( + + {t("dashboard.staleHint", { count: staleDays })} + + )} +
+ ); +} diff --git a/src/hooks/use-count-up.ts b/src/hooks/use-count-up.ts new file mode 100644 index 000000000..ab4b6c1b0 --- /dev/null +++ b/src/hooks/use-count-up.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * v1.10.0 — a tiny zero-dependency count-up animation hook for the + * score-ring / score-number "sweep to fill on first paint" affordance + * the premium-health direction calls for. No motion library — ~30 LOC of + * `requestAnimationFrame` instead, per the 0 KB runtime budget. + * + * Returns a number that eases from 0 to `target` once, on mount (and + * re-runs when `target` changes). Honours `prefers-reduced-motion`: a + * reduced-motion user (or any environment without rAF, e.g. SSR / the + * Vitest jsdom render) gets the final value immediately with no + * intermediate frames — the state is seeded to `target` so no + * synchronous-in-effect setState is needed for the no-animation path. + */ +export function useCountUp( + target: number, + options: { durationMs?: number; enabled?: boolean } = {}, +): number { + const { durationMs = 600, enabled = true } = options; + // Seed at the final value so the reduced-motion / SSR path is already + // correct without any setState; only the animated path drives frames, + // and those setState calls all happen inside the rAF callback (async). + const [value, setValue] = useState(target); + const frameRef = useRef(null); + + useEffect(() => { + const prefersReduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + if ( + !enabled || + prefersReduced || + typeof window === "undefined" || + typeof window.requestAnimationFrame !== "function" || + !Number.isFinite(target) + ) { + // The animated path is skipped; the rAF callback below never runs, + // so the displayed value stays at whatever the last frame / seed + // left it. Schedule a single async correction in case the previous + // target differed — done via rAF so it is not a sync-in-effect set. + if ( + typeof window !== "undefined" && + typeof window.requestAnimationFrame === "function" + ) { + frameRef.current = window.requestAnimationFrame(() => + setValue(target), + ); + return () => { + if (frameRef.current != null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + } + return; + } + + const start = performance.now(); + + const tick = (now: number) => { + const elapsed = now - start; + const progress = Math.min(1, elapsed / durationMs); + // ease-out cubic — matches the "never bouncy, ease-out ~300–500ms" + // motion note in the design direction. + const eased = 1 - Math.pow(1 - progress, 3); + setValue(target * eased); + if (progress < 1) { + frameRef.current = window.requestAnimationFrame(tick); + } else { + setValue(target); + } + }; + + // First frame (and every subsequent one) runs inside the rAF callback, + // so the initial 0-state set is asynchronous — no cascading render. + frameRef.current = window.requestAnimationFrame((now) => { + setValue(0); + tick(now); + }); + + return () => { + if (frameRef.current != null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + }, [target, durationMs, enabled]); + + return value; +}