mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
86737fd6a5
commit
b48d05a8be
9 files changed
+809
-23
No files matched your search
+18
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user