mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(insights): add the derived-metrics design-system primitives
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<T> 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<T> 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.
This commit is contained in:
1 parent
aa281d6649
commit
32bc11f968
17 files changed
+1464
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<CoverageMeter>", () => {
|
||||
it("renders the root data-slot and ratio attributes (populated)", () => {
|
||||
const html = render(<CoverageMeter coverage={FULL} confidence={HIGH} />);
|
||||
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(<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");
|
||||
});
|
||||
|
||||
it("lights a partial number of dots and tints by band (provisional)", () => {
|
||||
const html = render(<CoverageMeter coverage={PARTIAL} confidence={LOW} />);
|
||||
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(<CoverageMeter coverage={PARTIAL} />);
|
||||
// 3/5 = 60%
|
||||
expect(html).toContain('data-percent="60"');
|
||||
});
|
||||
|
||||
it("renders zero lit dots for the empty/insufficient state", () => {
|
||||
const html = render(<CoverageMeter coverage={EMPTY} />);
|
||||
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(<CoverageMeter coverage={PARTIAL} confidence={LOW} />);
|
||||
expect(html).toContain("aria-label");
|
||||
expect(html).toContain("3");
|
||||
expect(html).toContain("5");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<ProvenanceExplainer>", () => {
|
||||
it("renders an accessible trigger that controls the body (collapsed by default)", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
method="BMI is weight divided by height squared."
|
||||
bodyId="prov-1"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
method="method copy"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ProvenanceExplainer
|
||||
provenance={EMPTY_PROVENANCE}
|
||||
method="method copy"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("accepts a cited standard prop without erroring", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
method="method copy"
|
||||
standard={{
|
||||
name: "WHO BMI classification",
|
||||
url: "https://www.who.int/health-topics/obesity",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ScoreRing>", () => {
|
||||
it("renders the root data-slot with a band derived from the score (populated, good)", () => {
|
||||
const html = render(<ScoreRing score={82} label="/100" />);
|
||||
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(<ScoreRing score={55} />);
|
||||
expect(html).toContain('data-band="yellow"');
|
||||
});
|
||||
|
||||
it("derives a red band for a low score", () => {
|
||||
const html = render(<ScoreRing score={20} />);
|
||||
expect(html).toContain('data-band="red"');
|
||||
});
|
||||
|
||||
it("honours an explicit band override", () => {
|
||||
const html = render(<ScoreRing score={20} band="green" />);
|
||||
expect(html).toContain('data-band="green"');
|
||||
});
|
||||
|
||||
it("renders the provisional/empty state for a null score", () => {
|
||||
const html = render(<ScoreRing score={null} />);
|
||||
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(<ScoreRing score={74} />);
|
||||
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(<ScoreRing score={null} />);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain("aria-label");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
label: "Resting HR",
|
||||
unit: "bpm",
|
||||
icon: Heart,
|
||||
};
|
||||
|
||||
describe("<SparklineDeltaTile>", () => {
|
||||
it("renders the value, unit and a framing line (populated)", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
series={[60, 59, 58, 57, 58]}
|
||||
delta={-3}
|
||||
directionSentiment="up-bad"
|
||||
framing="typical for age 31"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
delta={-3}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
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(<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");
|
||||
});
|
||||
|
||||
it("reserves the sparkline slot but omits the chart when series < 2 points", () => {
|
||||
const html = render(
|
||||
<SparklineDeltaTile {...base} value={58} series={[58]} />,
|
||||
);
|
||||
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(
|
||||
<SparklineDeltaTile {...base} value={58} staleDays={12} />,
|
||||
);
|
||||
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(
|
||||
<SparklineDeltaTile {...base} value={58} staleDays={3} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sparkline-delta-tile-stale"');
|
||||
});
|
||||
});
|
||||
@@ -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<ScoreBand, string> = {
|
||||
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<ScoreBand, string> = {
|
||||
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<ScoreBand, string> = {
|
||||
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
|
||||
* `<RadialBar fill>`). Reads the same `--dracula-*` tokens so dark and
|
||||
* Alucard light modes track automatically.
|
||||
*/
|
||||
export const BAND_VAR: Record<ScoreBand, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<T>.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<DerivedConfidenceBand, string> = {
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coverage-meter"
|
||||
data-band={band}
|
||||
data-present={present}
|
||||
data-required={required}
|
||||
data-percent={percent}
|
||||
aria-label={summaryLabel}
|
||||
aria-describedby={tooltipId}
|
||||
className={cn(
|
||||
// 44px hit target via padding while the dots stay optically
|
||||
// small; negative margin collapses the row back so the meter
|
||||
// doesn't inflate its host row.
|
||||
"inline-flex min-h-11 items-center gap-2 rounded -my-3 px-1 py-3",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-slot="coverage-meter-dots"
|
||||
className="inline-flex items-center gap-[3px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: totalDots }).map((_, i) => {
|
||||
const isLit = i < litDots;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
data-dot-index={i}
|
||||
data-dot-state={isLit ? "lit" : "unlit"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
dotSize,
|
||||
isLit ? dotLit : "bg-muted/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
data-slot="coverage-meter-label"
|
||||
className={cn(
|
||||
"text-muted-foreground tabular-nums",
|
||||
size === "md" ? "text-xs" : "text-[11px]",
|
||||
)}
|
||||
>
|
||||
{t("insights.derived.coverage.ratioLabel", { present, required })}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
id={tooltipId}
|
||||
data-slot="coverage-meter-tooltip"
|
||||
className="bg-muted border-border text-foreground max-w-[15rem]"
|
||||
>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-medium">{summaryLabel}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("insights.derived.coverage.historyDays", {
|
||||
count: clampInt(coverage.historyDays, 0, 99999),
|
||||
})}
|
||||
</p>
|
||||
{coverage.missing.length > 0 && (
|
||||
<p
|
||||
data-slot="coverage-meter-missing"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("insights.derived.coverage.missing", {
|
||||
list: coverage.missing.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<T>` 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 `<a>`, never HTML. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceExplainerProps {
|
||||
/** The provenance facet off `Derived<T>` — 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 = (
|
||||
<div
|
||||
id={resolvedBodyId}
|
||||
data-slot="provenance-explainer-body"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div data-slot="provenance-explainer-inputs">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.inputsLabel")}
|
||||
</p>
|
||||
{provenance.inputs.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{provenance.inputs.map((input) => (
|
||||
<li
|
||||
key={input}
|
||||
data-slot="provenance-explainer-input-chip"
|
||||
className="border-dracula-cyan/30 text-dracula-cyan/90 inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none"
|
||||
>
|
||||
{input}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||
{t("insights.derived.provenance.noInputs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-slot="provenance-explainer-method">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.methodLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-[11px] leading-snug">
|
||||
{method}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-slot="provenance-explainer-window"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.derived.provenance.window", {
|
||||
days: provenance.windowDays,
|
||||
source: provenance.source,
|
||||
})}
|
||||
{asOf
|
||||
? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{standard && (
|
||||
<a
|
||||
data-slot="provenance-explainer-standard-link"
|
||||
href={standard.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-dracula-cyan hover:text-dracula-cyan/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.derived.provenance.standardLink", {
|
||||
name: standard.name,
|
||||
})}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="provenance-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-my-3 -mx-2",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
<div className="text-sm">{body}</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="provenance-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-2"
|
||||
>
|
||||
<p
|
||||
data-slot="provenance-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{resolvedTitle}
|
||||
</p>
|
||||
{body}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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<ScoreRingProps["size"]>,
|
||||
{ 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 (
|
||||
<div
|
||||
data-slot="score-ring"
|
||||
data-band={hasScore ? resolvedBand : "none"}
|
||||
data-provisional={hasScore ? undefined : "true"}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ width: dims.px, height: dims.px }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
data={data}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
barSize={dims.barSize}
|
||||
>
|
||||
<RadialBar
|
||||
dataKey="value"
|
||||
background={{ fill: "var(--muted)", opacity: 0.4 }}
|
||||
cornerRadius={dims.barSize}
|
||||
isAnimationActive={animate && hasScore}
|
||||
animationDuration={600}
|
||||
aria-hidden
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
tick={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
|
||||
return null;
|
||||
}
|
||||
const cx = viewBox.cx ?? 0;
|
||||
const cy = viewBox.cy ?? 0;
|
||||
return (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={cx}
|
||||
y={cy}
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
dims.numberClass,
|
||||
hasScore
|
||||
? BAND_NUMBER_CLASS[resolvedBand].replace(
|
||||
"text-",
|
||||
"fill-",
|
||||
)
|
||||
: "fill-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasScore ? displayedRounded : "—"}
|
||||
</tspan>
|
||||
{label ? (
|
||||
<tspan
|
||||
x={cx}
|
||||
y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)}
|
||||
className={cn(
|
||||
"fill-muted-foreground",
|
||||
dims.labelClass,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</tspan>
|
||||
) : null}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
{!hasScore && (
|
||||
<span
|
||||
data-slot="score-ring-provisional"
|
||||
className="text-muted-foreground absolute inset-x-0 bottom-0 text-center text-[10px] leading-tight"
|
||||
>
|
||||
{t("insights.derived.scoreRing.provisionalCaption")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="sparkline-delta-tile"
|
||||
className={cn(
|
||||
"bg-card border-border flex h-full w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 min-w-0 items-center justify-between gap-2">
|
||||
<span
|
||||
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-5 font-medium tracking-wide whitespace-nowrap uppercase"
|
||||
data-slot="sparkline-delta-tile-label"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 flex items-baseline gap-x-1.5"
|
||||
data-slot="sparkline-delta-tile-value-row"
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-3xl leading-none font-semibold tracking-tight whitespace-nowrap tabular-nums"
|
||||
data-slot="sparkline-delta-tile-value"
|
||||
>
|
||||
{value !== null ? fmt.number(value, precision) : "—"}
|
||||
</span>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
|
||||
{unit}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center"
|
||||
data-slot="sparkline-delta-tile-arrow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{delta != null ? (
|
||||
<TrendIcon className={cn("h-4 w-4", trendColor)} />
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs opacity-30">—</span>
|
||||
)}
|
||||
</span>
|
||||
</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 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={sparkData}
|
||||
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke={strokeVar}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#${fillId})`}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div
|
||||
className="border-border/40 h-full w-full rounded border border-dashed"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Framing + delta caption row. */}
|
||||
<div className="mt-2 flex min-h-[18px] items-baseline justify-between gap-2">
|
||||
{framing ? (
|
||||
<span
|
||||
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-snug"
|
||||
data-slot="sparkline-delta-tile-framing"
|
||||
>
|
||||
{framing}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" />
|
||||
)}
|
||||
{delta != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium tabular-nums",
|
||||
trendColor,
|
||||
)}
|
||||
data-slot="sparkline-delta-tile-delta"
|
||||
aria-label={`${formatDelta(delta)} ${unit}`.trim()}
|
||||
>
|
||||
{formatDelta(delta)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{staleDays != null && staleDays > 7 && (
|
||||
<span
|
||||
className="text-muted-foreground mt-1 line-clamp-1 text-xs leading-snug tabular-nums"
|
||||
data-slot="sparkline-delta-tile-stale"
|
||||
data-stale-days={staleDays}
|
||||
>
|
||||
{t("dashboard.staleHint", { count: staleDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number>(target);
|
||||
const frameRef = useRef<number | null>(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;
|
||||
}
|
||||
Reference in new issue
Block a user