diff --git a/messages/de.json b/messages/de.json index cb9ed212e..88584d7d5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 94cd10b04..296f81685 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index aa899bd54..55e3b9aff 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index c86cb9658..e2546ef47 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 7d4f5c40f..9e8847268 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 7858390b9..1e2f66193 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 6887d5e0d..00df750b8 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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; diff --git a/src/components/insights/__tests__/health-score-card-provenance.test.tsx b/src/components/insights/__tests__/health-score-card-provenance.test.tsx new file mode 100644 index 000000000..a7c2fc948 --- /dev/null +++ b/src/components/insights/__tests__/health-score-card-provenance.test.tsx @@ -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( + {node}, + ); +} + +describe(" — provenance accordion (W8e)", () => { + it("renders the toggle button with aria-expanded=false when collapsed", () => { + const html = ssr( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + // 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( + , + ); + // 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( + , + "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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + // 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( + , + ); + // 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( + , + ); + expect(html).toContain( + 'data-slot="health-score-card-provenance-footnote"', + ); + }); +}); diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 2af4c1ed3..717b18745 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -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 = { + 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 + * `` 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 = { + 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 = { 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 + Object.keys(components) as Array ).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 (
-

- {t("insights.healthScore.label")} -

+
+

+ {t("insights.healthScore.label")} +

+ {isProvisional && ( + + {t("insights.healthScore.provenance.provisionalBadge")} + + )} +
{delta !== null && delta > 0 && ( + {/* 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). */} +
+ + + {expanded && ( +
+ {hasMixed && ( +

+ {t("insights.healthScore.provenance.mixedBanner")} +

+ )} +
    + {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 ( +
  • + + {row.label} + + {/* value bar — same vocabulary as the existing + sub-bar row so the two stride lines align */} +
  • + ); + })} +
+

+ {t("insights.healthScore.provenance.footnote")} +

+
+ )} +
+