feat(health-score): inline provenance accordion with source attribution

Adds a tap-to-expand 'Driven by' panel under the four component
sub-bars inside <HealthScoreCard>. Each row pairs the existing
value bar with a second weight-share bar tinted in the Coach
provenance accent plus a source pill (manual / withings /
appleHealth / mixed / none). Rows sort by effective weight
descending so the biggest contributor leads; null components sink
to the bottom and dim to 50 % opacity.

A 'Provisional' header badge fires when fewer than half of the
configured inputs carry data, and a role=status 'mixed sources'
banner surfaces above the rows whenever at least one component
blends multiple ingest paths.

i18n keys land under insights.healthScore.provenance.* across all
six locales (en / de / fr / es / it / pl with formal register).
The locale-integrity drift-guard auto-discovers the new keys.
14 new RTL-style SSR tests pin the contract: aria-expanded toggle,
aria-controls/id pairing, sort order, source-pill localisation,
mixed banner gating, empty-state dimming, provisional threshold,
asOf tooltip forwarding, and footnote rendering.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:21:32 +02:00
1 parent 86737fd6a5
commit b48d05a8be
9 files changed
+809 -23

No files matched your search

+18 -1
View File
@@ -878,7 +878,24 @@
"componentCompliance": "Einnahmetreue",
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
"askCoach": "Coach fragen",
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?"
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?",
"provenance": {
"toggle": "Zusammensetzung",
"weightLabel": "Anteil",
"mixedBanner": "Einige Komponenten kombinieren mehrere Quellen. Die Aufschlüsselung zeigt den aktuellsten Wert je Komponente.",
"footnote": "Anteile sind nach verfügbaren Daten neu skaliert.",
"asOfLabel": "Stand {date}",
"provisional": "Vorläufig — nur {count} von {total} Datenpunkten verfügbar",
"provisionalBadge": "Vorläufig",
"sourceAria": "Quelle: {source}",
"sources": {
"manual": "Manuell",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Gemischt",
"none": "Keine Daten"
}
}
},
"report": {
"eyebrow": "Wochenbericht",
+18 -1
View File
@@ -878,7 +878,24 @@
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"askCoach": "Ask the Coach",
"coachPrompt": "Why is my health score {score} out of 100?"
"coachPrompt": "Why is my health score {score} out of 100?",
"provenance": {
"toggle": "Driven by",
"weightLabel": "share",
"mixedBanner": "Some components blend multiple sources. The breakdown shows the most recent value per component.",
"footnote": "Weights are rescaled to the data that's actually available.",
"asOfLabel": "as of {date}",
"provisional": "Provisional — only {count} of {total} inputs available",
"provisionalBadge": "Provisional",
"sourceAria": "Source: {source}",
"sources": {
"manual": "Manual",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mixed",
"none": "No data"
}
}
},
"report": {
"eyebrow": "Weekly report",
+18 -1
View File
@@ -878,7 +878,24 @@
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"askCoach": "Ask the Coach",
"coachPrompt": "Why is my health score {score} out of 100?"
"coachPrompt": "Why is my health score {score} out of 100?",
"provenance": {
"toggle": "Compuesto por",
"weightLabel": "peso",
"mixedBanner": "Algunos componentes combinan varias fuentes. El desglose muestra el valor más reciente por componente.",
"footnote": "Los pesos se reescalan según los datos realmente disponibles.",
"asOfLabel": "a fecha de {date}",
"provisional": "Provisional — sólo {count} de {total} entradas disponibles",
"provisionalBadge": "Provisional",
"sourceAria": "Fuente: {source}",
"sources": {
"manual": "Manual",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mixto",
"none": "Sin datos"
}
}
},
"report": {
"eyebrow": "Weekly report",
+18 -1
View File
@@ -878,7 +878,24 @@
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"askCoach": "Ask the Coach",
"coachPrompt": "Why is my health score {score} out of 100?"
"coachPrompt": "Why is my health score {score} out of 100?",
"provenance": {
"toggle": "Composé de",
"weightLabel": "part",
"mixedBanner": "Certains composants combinent plusieurs sources. La ventilation affiche la valeur la plus récente par composant.",
"footnote": "Les pondérations sont recalibrées en fonction des données disponibles.",
"asOfLabel": "au {date}",
"provisional": "Provisoire — seulement {count} entrée(s) sur {total} disponible(s)",
"provisionalBadge": "Provisoire",
"sourceAria": "Source : {source}",
"sources": {
"manual": "Manuel",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mixte",
"none": "Aucune donnée"
}
}
},
"report": {
"eyebrow": "Weekly report",
+18 -1
View File
@@ -878,7 +878,24 @@
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"askCoach": "Ask the Coach",
"coachPrompt": "Why is my health score {score} out of 100?"
"coachPrompt": "Why is my health score {score} out of 100?",
"provenance": {
"toggle": "Composto da",
"weightLabel": "peso",
"mixedBanner": "Alcuni componenti combinano più fonti. Il dettaglio mostra il valore più recente per componente.",
"footnote": "I pesi sono ricalcolati in base ai dati effettivamente disponibili.",
"asOfLabel": "al {date}",
"provisional": "Provvisorio — solo {count} di {total} valori disponibili",
"provisionalBadge": "Provvisorio",
"sourceAria": "Fonte: {source}",
"sources": {
"manual": "Manuale",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Misto",
"none": "Nessun dato"
}
}
},
"report": {
"eyebrow": "Weekly report",
+18 -1
View File
@@ -878,7 +878,24 @@
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"askCoach": "Ask the Coach",
"coachPrompt": "Why is my health score {score} out of 100?"
"coachPrompt": "Why is my health score {score} out of 100?",
"provenance": {
"toggle": "Składa się z",
"weightLabel": "udział",
"mixedBanner": "Niektóre komponenty łączą wiele źródeł. Zestawienie pokazuje najświeższą wartość dla każdego komponentu.",
"footnote": "Wagi są przeskalowywane do dostępnych danych.",
"asOfLabel": "stan na {date}",
"provisional": "Wstępny — dostępne {count} z {total} danych",
"provisionalBadge": "Wstępny",
"sourceAria": "Źródło: {source}",
"sources": {
"manual": "Ręcznie",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mieszane",
"none": "Brak danych"
}
}
},
"report": {
"eyebrow": "Weekly report",
+27 -4
View File
@@ -60,10 +60,33 @@ interface AnalyticsData {
score: number;
band: "green" | "yellow" | "red";
components: {
bp: { value: number | null; weight: number };
weight: { value: number | null; weight: number };
mood: { value: number | null; weight: number };
compliance: { value: number | null; weight: number };
// v1.4.25 W8e — the optional `source`/`asOf` slots feed the
// provenance accordion. Older clients reading this payload
// happily ignore the extras (additive contract).
bp: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
weight: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
mood: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
compliance: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
};
delta: number | null;
} | null;
@@ -0,0 +1,373 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
HealthScoreCard,
type HealthScoreCardProps,
} from "../health-score-card";
/**
* v1.4.25 W8e — Health-Score provenance accordion.
*
* The card now grows a tap-to-expand accordion under the four
* component sub-bars. The accordion surfaces:
* - per-component value-bar + numeric value
* - effective weight share (second, narrower bar)
* - source pill (manual / withings / appleHealth / mixed / none)
* - a "mixed sources" `role="status"` banner when at least one row
* has `source === "mixed"`
* - a "Provisional" badge in the header when < 50% of inputs are
* present
*
* SSR is the test convention in this project (no `@testing-library/react`
* dependency). The card exposes `initiallyExpanded` so the markup of
* the expanded panel renders inside `renderToStaticMarkup`. Interactive
* behaviour (click toggles `aria-expanded`) is verified at the contract
* level: collapsed state pins `aria-expanded="false"`, expanded state
* (via `initiallyExpanded`) pins `aria-expanded="true"` + renders the
* panel.
*/
const componentsAllPresent: HealthScoreCardProps["components"] = {
bp: {
value: 82,
weight: 0.3,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: {
value: 75,
weight: 0.2,
source: "manual",
asOf: "2026-05-13T18:00:00.000Z",
},
mood: {
value: 90,
weight: 0.2,
source: "manual",
asOf: "2026-05-14T07:00:00.000Z",
},
compliance: {
value: 95,
weight: 0.3,
source: "manual",
asOf: "2026-05-14T00:00:00.000Z",
},
};
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
it("renders the toggle button with aria-expanded=false when collapsed", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="false"/,
);
// Toggle text uses the localised "Driven by" copy.
expect(html).toContain("Driven by");
// Panel is NOT rendered when collapsed.
expect(html).not.toContain(
'data-slot="health-score-card-provenance-panel"',
);
});
it("renders the panel + sets aria-expanded=true when initiallyExpanded", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="true"/,
);
expect(html).toContain('data-slot="health-score-card-provenance-panel"');
});
it("aria-controls on the toggle points at the rendered panel's id", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// Extract aria-controls value from the toggle and confirm the
// panel section uses the same id.
const controlsMatch = html.match(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-controls="([^"]+)"/,
);
expect(controlsMatch).not.toBeNull();
const id = controlsMatch![1];
expect(html).toContain(
`id="${id}" data-slot="health-score-card-provenance-panel"`,
);
});
it("sorts rows by effective weight descending (null components sink to bottom)", () => {
const components: HealthScoreCardProps["components"] = {
bp: { value: null, weight: 0, source: "none" },
weight: { value: 60, weight: 0.4, source: "manual" },
mood: { value: 80, weight: 0.3, source: "manual" },
compliance: { value: 70, weight: 0.3, source: "manual" },
};
const html = ssr(
<HealthScoreCard
score={60}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// Pull every `data-component=` token from the provenance rows.
const rowMatches = Array.from(
html.matchAll(
/data-slot="health-score-card-provenance-row"[^>]*data-component="([^"]+)"/g,
),
).map((m) => m[1]);
// Weight (0.4) > mood (0.3) > compliance (0.3) — mood vs compliance
// tie-break is the alphabetical key order. BP at weight=0 sinks
// to the bottom.
expect(rowMatches).toEqual(["weight", "compliance", "mood", "bp"]);
});
it("renders source pills with locale-resolved labels (EN)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// Withings + Manual both present in the data.
expect(html).toContain("Withings");
expect(html).toContain("Manual");
});
it("renders source pills with locale-resolved labels (DE)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
"de",
);
// Manuell is the DE label for "manual".
expect(html).toContain("Manuell");
expect(html).toContain("Withings");
expect(html).toContain("Zusammensetzung");
});
it("renders the mixed-source banner with role=status when any row is mixed", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
bp: { value: 82, weight: 0.3, source: "mixed" },
};
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={components}
delta={null}
initiallyExpanded
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-mixed-banner"[^>]*role="status"/,
);
// The pill on the BP row reads "Mixed" (EN).
expect(html).toContain("Mixed");
});
it("does NOT render the mixed-source banner when no row is mixed", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-provenance-mixed-banner"',
);
});
it("dims empty-state rows when source is `none`", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
mood: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={70}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// The dimmed row carries opacity-50 alongside the `data-source="none"`
// attribute.
expect(html).toMatch(
/data-component="mood"[^>]*data-source="none"[^>]*class="[^"]*opacity-50/,
);
// The source pill reads "No data" (EN).
expect(html).toContain("No data");
});
it("renders the provisional badge when fewer than 50% of inputs are present", () => {
// Three out of four components null → 1 of 4 = 25 % present.
const components: HealthScoreCardProps["components"] = {
bp: {
value: 80,
weight: 1,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: { value: null, weight: 0, source: "none" },
mood: { value: null, weight: 0, source: "none" },
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={components}
delta={null}
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provisional-badge"',
);
// EN badge copy.
expect(html).toContain("Provisional");
});
it("does NOT render the provisional badge when at least 50% of inputs are present", () => {
// 2 of 4 = 50 % → NOT provisional.
const components: HealthScoreCardProps["components"] = {
bp: {
value: 80,
weight: 0.6,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: {
value: 70,
weight: 0.4,
source: "manual",
asOf: "2026-05-13T08:00:00.000Z",
},
mood: { value: null, weight: 0, source: "none" },
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={75}
band="green"
components={components}
delta={null}
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-provisional-badge"',
);
});
it("renders the source-pill aria-label with localised source name", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// BP row uses Withings; the aria-label uses the localised
// "Source: Withings" form.
expect(html).toContain('aria-label="Source: Withings"');
});
it("renders the value bar at 0% width for empty-state rows", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={70}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// Every row inside the panel carries an inline-styled width.
// We confirm the compliance (none) row's value-bar block paints
// 0% by searching for the matching row.
const complianceRowMatch = html.match(
/data-slot="health-score-card-provenance-row"[^>]*data-component="compliance"[\s\S]*?width:\s*([0-9.]+%)/,
);
expect(complianceRowMatch).not.toBeNull();
expect(complianceRowMatch?.[1]).toBe("0%");
});
it("forwards the asOf timestamp into the source-pill `title` tooltip", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// The BP row's pill carries the title formatted via Intl.DateTimeFormat;
// we only need to confirm `as of` appears (the exact date depends on
// the JS engine's locale data).
expect(html).toContain("as of");
// The data-as-of attribute carries the raw ISO timestamp.
expect(html).toContain('data-as-of="2026-05-14T08:00:00.000Z"');
});
it("renders the footnote inside the expanded panel", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).toContain(
'data-slot="health-score-card-provenance-footnote"',
);
});
});
+301 -13
View File
@@ -1,6 +1,7 @@
"use client";
import { ArrowDown, ArrowUp, Minus, Sparkles } from "lucide-react";
import { useId, useState } from "react";
import { ArrowDown, ArrowUp, ChevronDown, Minus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -18,6 +19,12 @@ import { cn } from "@/lib/utils";
* ("Why is my health score X out of 100?") so the user can drill
* into the explanation without retyping the question
*
* v1.4.25 W8e — collapsible "Driven by" provenance accordion appended
* below the component sub-bars. Each row shows the value bar, the
* effective weight share, and a source pill so the user can see which
* ingest path produced each pillar. A "mixed source" banner fires when
* at least one component blends entries from more than one source.
*
* Pure presentational — the parent owns the analytics query + the
* drawer state. The same `onAskCoach` handler is shared with the hero
* strip's main "Ask the coach" button (different prefill string).
@@ -25,19 +32,80 @@ import { cn } from "@/lib/utils";
export type HealthScoreBand = "green" | "yellow" | "red";
/**
* v1.4.25 W8e — per-component source attribution. Mirrors the analytics
* `HealthScoreComponentSource` type (intentional duplication so the
* client doesn't pull a heavy analytics import for one string union).
*/
export type HealthScoreComponentSource =
| "manual"
| "withings"
| "appleHealth"
| "mixed"
| "none";
export interface HealthScoreCardComponent {
value: number | null;
weight: number;
/**
* v1.4.25 W8e — optional source label. When omitted the row falls
* back to `manual` for present values / `none` for null values.
*/
source?: HealthScoreComponentSource;
/**
* v1.4.25 W8e — optional ISO timestamp of the freshest contributing
* measurement. The provenance accordion surfaces it under "as of
* {date}" on each row.
*/
asOf?: string;
}
export interface HealthScoreCardProps {
score: number;
band: HealthScoreBand;
components: {
bp: { value: number | null; weight: number };
weight: { value: number | null; weight: number };
mood: { value: number | null; weight: number };
compliance: { value: number | null; weight: number };
bp: HealthScoreCardComponent;
weight: HealthScoreCardComponent;
mood: HealthScoreCardComponent;
compliance: HealthScoreCardComponent;
};
delta: number | null;
onAskCoach?: (prefill: string) => void;
/**
* v1.4.25 W8e — opens the provenance accordion on first render.
* The SSR tests use it to exercise the expanded markup without a
* `useEffect` round-trip; in production the user-driven toggle
* starts collapsed.
*/
initiallyExpanded?: boolean;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
const SOURCE_LABEL_KEY: Record<HealthScoreComponentSource, string> = {
manual: "insights.healthScore.provenance.sources.manual",
withings: "insights.healthScore.provenance.sources.withings",
appleHealth: "insights.healthScore.provenance.sources.appleHealth",
mixed: "insights.healthScore.provenance.sources.mixed",
none: "insights.healthScore.provenance.sources.none",
};
/**
* Per-source pill colour vocabulary — mirrors the Coach provenance
* `<SourceChips>` accent for `withings` (dracula-cyan) so the user
* reads "same provenance grammar across surfaces". Manual rides on
* dracula-purple, Apple Health on dracula-pink, mixed on dracula-yellow.
* `none` is a muted slate so the empty-state row reads as dimmed
* without losing the pill affordance entirely.
*/
const SOURCE_PILL_CLASS: Record<HealthScoreComponentSource, string> = {
manual: "border-dracula-purple/30 text-dracula-purple/90",
withings: "border-dracula-cyan/30 text-dracula-cyan/90",
appleHealth: "border-dracula-pink/30 text-dracula-pink/90",
mixed: "border-dracula-yellow/30 text-dracula-yellow/90",
none: "border-muted-foreground/30 text-muted-foreground",
};
const BAND_NUMBER_CLASS: Record<HealthScoreBand, string> = {
green: "text-dracula-green",
yellow: "text-dracula-orange",
@@ -72,16 +140,80 @@ export function HealthScoreCard({
components,
delta,
onAskCoach,
initiallyExpanded = false,
}: HealthScoreCardProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
// (`title`) so the row layout stays one-line. Dates format via the
// user's locale to honour the EU comma / dot convention; on bad input
// we fall through to the raw ISO so the user still sees something.
const formatAsOf = (asOf: string | null): string | null => {
if (!asOf) return null;
try {
const parsed = new Date(asOf);
if (Number.isNaN(parsed.getTime())) return null;
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "2-digit",
}).format(parsed);
} catch {
return null;
}
};
const [expanded, setExpanded] = useState(initiallyExpanded);
// `useId` keeps the aria-controls/section-id pair unique even when
// the card is mounted twice on the same page (lg+ hero strip vs
// smaller stacked previews in tests).
const panelId = useId();
const componentEntries = (
Object.keys(components) as Array<keyof HealthScoreCardProps["components"]>
Object.keys(components) as Array<ComponentKey>
).map((key) => ({
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: components[key].value,
}));
// v1.4.25 W8e — sort the provenance rows by effective weight
// descending so the biggest contributor sits first. Components with
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
const COMPONENT_ORDER: readonly ComponentKey[] = [
"bp",
"weight",
"mood",
"compliance",
];
const provenanceRows = [...COMPONENT_ORDER]
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
c.source ?? (c.value === null ? "none" : "manual");
return {
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: c.value,
weight: c.weight,
source: inferredSource,
asOf: c.asOf ?? null,
effective: c.weight * (c.value !== null ? 1 : 0),
};
})
.sort(
(a, b) =>
b.effective - a.effective || a.key.localeCompare(b.key),
);
const presentCount = provenanceRows.filter((r) => r.value !== null).length;
const totalCount = provenanceRows.length;
const hasMixed = provenanceRows.some((r) => r.source === "mixed");
// v1.4.25 W8e — "provisional" when less than half of the configured
// inputs have data. Subtle badge above the headline number; copy is
// localised via `provenance.provisional`.
const isProvisional =
totalCount > 0 && presentCount > 0 && presentCount < totalCount / 2;
return (
<div
data-slot="health-score-card"
@@ -104,12 +236,26 @@ export function HealthScoreCard({
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p
data-slot="health-score-card-label"
className="text-muted-foreground text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{t("insights.healthScore.label")}
</p>
<div className="flex min-w-0 items-center gap-2">
<p
data-slot="health-score-card-label"
className="text-muted-foreground text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{t("insights.healthScore.label")}
</p>
{isProvisional && (
<span
data-slot="health-score-card-provisional-badge"
className="border-muted-foreground/30 text-muted-foreground inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-medium tracking-wide uppercase"
title={t("insights.healthScore.provenance.provisional", {
count: presentCount,
total: totalCount,
})}
>
{t("insights.healthScore.provenance.provisionalBadge")}
</span>
)}
</div>
{delta !== null && delta > 0 && (
<span
data-slot="health-score-card-delta-chip"
@@ -233,6 +379,148 @@ export function HealthScoreCard({
))}
</ul>
{/* v1.4.25 W8e — tap-to-expand provenance accordion.
Kept inside the card so the visual "owner" of the
breakdown remains the score tile (concept-cohesion). */}
<div className="-mt-1 border-t pt-2">
<button
type="button"
data-slot="health-score-card-provenance-toggle"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => setExpanded((v) => !v)}
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"flex w-full items-center justify-between gap-1 rounded text-[11px]",
)}
>
<span>{t("insights.healthScore.provenance.toggle")}</span>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
expanded && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{expanded && (
<section
id={panelId}
data-slot="health-score-card-provenance-panel"
className="mt-2 space-y-1.5"
>
{hasMixed && (
<p
data-slot="health-score-card-provenance-mixed-banner"
role="status"
className="border-dracula-yellow/30 text-dracula-yellow/90 bg-dracula-yellow/5 rounded border px-2 py-1 text-[10px] leading-snug"
>
{t("insights.healthScore.provenance.mixedBanner")}
</p>
)}
<ul
data-slot="health-score-card-provenance-rows"
className="space-y-1.5"
>
{provenanceRows.map((row) => {
const sourceLabel = t(SOURCE_LABEL_KEY[row.source]);
const isEmpty = row.source === "none" || row.value === null;
const asOfFormatted = formatAsOf(row.asOf);
const asOfLine = asOfFormatted
? t("insights.healthScore.provenance.asOfLabel", {
date: asOfFormatted,
})
: null;
return (
<li
key={row.key}
data-slot="health-score-card-provenance-row"
data-component={row.key}
data-source={row.source}
className={cn(
"flex items-center gap-2 text-[11px]",
isEmpty && "opacity-50",
)}
>
<span className="text-muted-foreground w-20 shrink-0 truncate">
{row.label}
</span>
{/* value bar — same vocabulary as the existing
sub-bar row so the two stride lines align */}
<div
className="bg-muted/50 h-1 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
isEmpty ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width: isEmpty
? "0%"
: `${Math.max(0, Math.min(100, row.value ?? 0))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-provenance-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{row.value === null ? "—" : Math.round(row.value)}
</span>
{/* weight share — second, narrower bar tinted
dracula-cyan to read "provenance grammar"
alongside the Coach <SourceChips> accent */}
<div
className="bg-muted/40 h-1 w-10 shrink-0 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
isEmpty
? "bg-muted"
: "bg-dracula-cyan/60",
)}
style={{
width: isEmpty
? "0%"
: `${Math.max(0, Math.min(100, row.weight * 100))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-provenance-pill"
aria-label={t(
"insights.healthScore.provenance.sourceAria",
{ source: sourceLabel },
)}
title={asOfLine ?? undefined}
data-as-of={row.asOf ?? undefined}
className={cn(
"inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none",
SOURCE_PILL_CLASS[row.source],
)}
>
{sourceLabel}
</span>
</li>
);
})}
</ul>
<p
data-slot="health-score-card-provenance-footnote"
className="text-muted-foreground text-[10px] leading-snug"
>
{t("insights.healthScore.provenance.footnote")}
</p>
</section>
)}
</div>
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[10px] leading-snug"