mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(analytics): replace health score with reference composite
This commit is contained in:
1 parent
666a85d311
commit
c91558f18f
87 files changed
+6033
-5868
No files matched your search
@@ -52,7 +52,14 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
generatedAt: "2026-07-16T06:00:00.000Z",
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "green", delta: 3 },
|
||||
score: {
|
||||
value: 82,
|
||||
band: "green",
|
||||
delta: 3,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
topSignal: {
|
||||
sourceMetric: "bp",
|
||||
tone: "watch",
|
||||
@@ -91,6 +98,24 @@ describe("<TodayHero>", () => {
|
||||
expect(html).toContain('data-slot="today-hero-score-delta"');
|
||||
});
|
||||
|
||||
it("does not present a suppressed algorithm jump as score movement", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
score: {
|
||||
value: 82,
|
||||
band: "green",
|
||||
delta: 20,
|
||||
deltaReason: "algorithm_changed",
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-delta"');
|
||||
});
|
||||
|
||||
it("wires each PriorityItem action to its existing destination via href", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
// Every S1 rail action carries an href, so PriorityCard renders it as a
|
||||
|
||||
@@ -229,7 +229,9 @@ export function TodayHero({
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{digest.score && digest.score.delta !== null ? (
|
||||
{digest.score &&
|
||||
digest.score.delta !== null &&
|
||||
digest.score.deltaReason === null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
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];
|
||||
// The panel section must carry the same id; we don't pin the exact
|
||||
// attribute order because additional ARIA attributes (e.g.
|
||||
// `aria-labelledby`) may sit between `id` and `data-slot`.
|
||||
const panelMatch = html.match(
|
||||
new RegExp(
|
||||
`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`,
|
||||
),
|
||||
);
|
||||
expect(panelMatch).not.toBeNull();
|
||||
});
|
||||
|
||||
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,433 +1,342 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { HealthScoreReport } from "@/lib/analytics/score/types";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
type HealthScoreCardProps,
|
||||
markAlgorithmNoticeDismissed,
|
||||
} from "../health-score-card";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B5 — Personal Health Score card.
|
||||
*
|
||||
* SSR markup pins the slot wrappers so future polish can't silently
|
||||
* drop the score number, sub-bars or disclaimer. The inline
|
||||
* Ask-the-Coach button retired in v1.4.27 B1 — the hero strip
|
||||
* carries the CTA. The negative-assertion test below pins that
|
||||
* decision so a future revival has to update the test in lock-step.
|
||||
*/
|
||||
|
||||
const baseComponents: HealthScoreCardProps["components"] = {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
let mockAuthUser: { unitPreference: string; glucoseUnit: string } = {
|
||||
unitPreference: "metric",
|
||||
glucoseUnit: "mg/dL",
|
||||
};
|
||||
|
||||
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockAuthUser,
|
||||
}),
|
||||
}));
|
||||
|
||||
const provenance = {
|
||||
inputs: ["BLOOD_PRESSURE"],
|
||||
source: "live" as const,
|
||||
windowDays: 90,
|
||||
computedAt: "2026-07-28T12:00:00.000Z",
|
||||
};
|
||||
const coverage = {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 1,
|
||||
historyDays: 7,
|
||||
missing: ["ACTIVITY", "SLEEP"],
|
||||
};
|
||||
|
||||
function render(report: HealthScoreReport, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<HealthScoreCard report={report} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
|
||||
return {
|
||||
composite: {
|
||||
status: "insufficient",
|
||||
coverage,
|
||||
provenance,
|
||||
reason: "three_domains_required",
|
||||
},
|
||||
pillars: [
|
||||
{
|
||||
id: "BLOOD_PRESSURE",
|
||||
domain: "cardiometabolic",
|
||||
result: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 82,
|
||||
observed: {
|
||||
value: 126,
|
||||
unit: "mmHg",
|
||||
label: "126/78 mmHg",
|
||||
asOf: "2026-07-27T08:00:00.000Z",
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "clinical-threshold",
|
||||
low: 120,
|
||||
high: 129,
|
||||
label: "120 to 129/70 to 79 mmHg",
|
||||
source: "ESH 2023",
|
||||
},
|
||||
personalReference: {
|
||||
kind: "guideline-band",
|
||||
low: 115,
|
||||
high: 125,
|
||||
label: "115 to 125/65 to 75 mmHg",
|
||||
source: "personal target",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: "graded_bp_pair_series",
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 12,
|
||||
presentInputs: 12,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ACTIVITY",
|
||||
domain: "activity",
|
||||
result: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 21,
|
||||
presentInputs: 7,
|
||||
historyDays: 7,
|
||||
missing: ["14 days"],
|
||||
},
|
||||
provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 },
|
||||
reason: "below_day_floor_or_stale",
|
||||
},
|
||||
},
|
||||
],
|
||||
delta: null,
|
||||
deltaReason: "no_current_score",
|
||||
scoreVersion: 2,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: ["weight reading", "personal weight target"],
|
||||
},
|
||||
provenance: { ...provenance, inputs: ["WEIGHT"] },
|
||||
reason: "no_personal_goal",
|
||||
},
|
||||
algorithmNotice: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" };
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard>", () => {
|
||||
it("renders the slot wrapper + band data attribute (green)", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="health-score-card"/);
|
||||
expect(html).toMatch(/data-band="green"/);
|
||||
it("keeps below-floor pillars visible without a composite headline", () => {
|
||||
const html = render(report());
|
||||
expect(html).toContain('data-status="insufficient"');
|
||||
expect(html).toContain('data-contributor="BLOOD_PRESSURE"');
|
||||
expect(html).toContain('data-contributor="ACTIVITY"');
|
||||
expect(html).toContain("Not enough recent eligible data");
|
||||
expect(html).not.toContain("below_day_floor_or_stale");
|
||||
expect(html).toContain("Your target: 115 to 125/65 to 75 mmHg");
|
||||
expect(html).not.toContain('data-slot="score-ring"');
|
||||
});
|
||||
|
||||
it("renders the score number, /100 suffix, and band number colour", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
it("shows personal goal progress as explicitly unscored context", () => {
|
||||
const html = render(
|
||||
report({
|
||||
weightGoal: {
|
||||
status: "ok",
|
||||
value: {
|
||||
currentKg: 81,
|
||||
target: { min: 74, max: 78 },
|
||||
distanceKg: 3,
|
||||
deltaKg: 1,
|
||||
asOf: "2026-07-27T08:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 2,
|
||||
historyDays: 1,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toMatch(/data-slot="health-score-card-number"/);
|
||||
expect(html).toContain(">86<");
|
||||
expect(html).toContain("/ 100");
|
||||
// v1.12.4 — the headline number paints through the semantic `--success`
|
||||
// token (AA-safe on both themes) rather than the raw dracula primitive.
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).toContain('data-slot="health-score-weight-goal"');
|
||||
expect(html).toContain('data-status="ok"');
|
||||
expect(html).toContain("does not affect the Health Score");
|
||||
});
|
||||
|
||||
it("paints the yellow band correctly", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={62}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
it("renders the personal weight goal in the account unit system", () => {
|
||||
mockAuthUser = { unitPreference: "imperial", glucoseUnit: "mg/dL" };
|
||||
const html = render(
|
||||
report({
|
||||
weightGoal: {
|
||||
status: "ok",
|
||||
value: {
|
||||
currentKg: 81,
|
||||
target: { min: 74, max: 78 },
|
||||
distanceKg: 3,
|
||||
deltaKg: 1,
|
||||
asOf: "2026-07-27T08:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 2,
|
||||
historyDays: 1,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toMatch(/data-band="yellow"/);
|
||||
expect(html).toContain("text-warning");
|
||||
expect(html).toContain("178.6 lb now");
|
||||
expect(html).toContain("Goal 163.1 to 172 lb");
|
||||
expect(html).toContain("6.6 lb");
|
||||
expect(html).not.toContain("81 kg");
|
||||
});
|
||||
|
||||
it("paints the red band correctly", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={32}
|
||||
band="red"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
it("renders fasting glucose and its reference in the account unit", () => {
|
||||
mockAuthUser = { unitPreference: "metric", glucoseUnit: "mmol/L" };
|
||||
const base = report();
|
||||
const html = render(
|
||||
report({
|
||||
pillars: [
|
||||
{
|
||||
id: "GLYCAEMIA",
|
||||
domain: "cardiometabolic",
|
||||
result: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 80,
|
||||
observed: {
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
label: "Fasting glucose",
|
||||
asOf: "2026-07-27T08:00:00.000Z",
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "clinical-threshold",
|
||||
low: 70,
|
||||
high: 99,
|
||||
label: "70 to 99 mg/dL fasting",
|
||||
source: "ADA 2026",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: "fasting_glucose",
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 1,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
},
|
||||
...base.pillars.slice(1),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).toMatch(/data-band="red"/);
|
||||
expect(html).toContain("text-destructive");
|
||||
expect(html).toContain("Fasting glucose: 5.5 mmol/L");
|
||||
expect(html).toContain("3.9 to 5.5 mmol/L fasting");
|
||||
expect(html).not.toContain("100 mg/dL");
|
||||
expect(html).not.toContain("70 to 99 mg/dL");
|
||||
});
|
||||
|
||||
it("renders the unavailable-delta caption when delta is null", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
it("renders the one-time method note and every score identity field", () => {
|
||||
const html = render(
|
||||
report({
|
||||
algorithmNotice: {
|
||||
itemKey: "health_score_algorithm:2",
|
||||
dismissed: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("No history yet");
|
||||
// No delta chip in the corner.
|
||||
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
|
||||
expect(html).toContain('data-slot="health-score-algorithm-notice"');
|
||||
expect(html).toContain("cardiometabolic reference ranges");
|
||||
});
|
||||
|
||||
it("renders the up-arrow + delta chip when delta > 0", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={5}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="health-score-card-delta-chip"/);
|
||||
expect(html).toContain("+5");
|
||||
expect(html).toContain("vs last week");
|
||||
});
|
||||
|
||||
it("renders the down-arrow line when delta < 0", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-7}
|
||||
/>,
|
||||
);
|
||||
// Negative delta does NOT render the green chip.
|
||||
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
|
||||
expect(html).toContain("-7 vs last week");
|
||||
});
|
||||
|
||||
it("threads aria-describedby from the delta text to the explainer body", () => {
|
||||
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the inline
|
||||
// explainer caption carries the same id. v1.22 — the read no longer
|
||||
// hides behind a "?" trigger (no aria-controls); the body renders inline
|
||||
// and the shared `useId()` thread still connects "−3 vs last week" to the
|
||||
// three-sentence read.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-3}
|
||||
/>,
|
||||
);
|
||||
// The aria-describedby attribute lands on the delta span.
|
||||
const deltaSpan = html.match(
|
||||
/<span\s+aria-describedby="([^"]+)"[^>]*>[^<]*vs last week[^<]*<\/span>/,
|
||||
);
|
||||
expect(deltaSpan).not.toBeNull();
|
||||
const threadedId = deltaSpan?.[1] ?? "";
|
||||
expect(threadedId).not.toBe("");
|
||||
// The same id sits on the inline explainer body.
|
||||
expect(html).toMatch(
|
||||
new RegExp(
|
||||
`<span[^>]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("stacks the explainer caption BELOW the delta line, not beside it", () => {
|
||||
// The caption is a multi-sentence read; rendering it inside the
|
||||
// delta's inline-flex row squeezed the short delta into a narrow
|
||||
// multi-line column on every viewport. The wrapper stacks instead.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-3}
|
||||
/>,
|
||||
);
|
||||
const wrapper = html.match(
|
||||
/data-slot="health-score-card-delta"[^>]*class="([^"]*)"/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper?.[1]).toContain("space-y-1");
|
||||
expect(wrapper?.[1]).not.toContain("inline-flex");
|
||||
// The delta line's <p> closes before the explainer body opens —
|
||||
// the caption is a sibling below, never a flex neighbour.
|
||||
expect(html).toMatch(
|
||||
/vs last week[^<]*<\/span><\/p><span[^>]*data-slot="health-score-delta-explainer-body"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders four component rows with their values", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
for (const key of ["bp", "weight", "mood", "compliance"]) {
|
||||
const re = new RegExp(`data-component="${key}"`);
|
||||
expect(html).toMatch(re);
|
||||
}
|
||||
// Hand-picked values from baseComponents.
|
||||
expect(html).toContain(">80<");
|
||||
expect(html).toContain(">70<");
|
||||
expect(html).toContain(">90<");
|
||||
expect(html).toContain(">100<");
|
||||
});
|
||||
|
||||
it("renders an em-dash for null component values", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={50}
|
||||
band="yellow"
|
||||
components={{
|
||||
...baseComponents,
|
||||
mood: { value: null, weight: 0 },
|
||||
}}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
// The mood row exists but its value cell is "—".
|
||||
const moodSliceMatch = html.match(
|
||||
/data-component="mood"[\s\S]*?data-slot="health-score-card-component-value"[^>]*>([^<]+)</,
|
||||
);
|
||||
expect(moodSliceMatch).not.toBeNull();
|
||||
expect(moodSliceMatch?.[1]).toBe("—");
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
|
||||
// The hero strip's existing "Ask the coach" action covers this
|
||||
// surface; the card-internal duplicate button retired. v1.18.6 (L13)
|
||||
// dropped the now-dead `onAskCoach` prop too — the card renders no
|
||||
// Coach affordance at all.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
});
|
||||
|
||||
it("renders German strings", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={5}
|
||||
/>,
|
||||
it("localises structured activity labels, references, and reasons", () => {
|
||||
const base = report();
|
||||
const html = render(
|
||||
report({
|
||||
pillars: [
|
||||
{
|
||||
id: "ACTIVITY",
|
||||
domain: "activity",
|
||||
result: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 80,
|
||||
observed: {
|
||||
value: 8_000,
|
||||
unit: "steps/day",
|
||||
label: "8,000 steps/day",
|
||||
asOf: "2026-07-27T00:00:00.000Z",
|
||||
sources: ["APPLE_HEALTH"],
|
||||
},
|
||||
reference: {
|
||||
kind: "guideline-band",
|
||||
low: 0,
|
||||
high: 10_000,
|
||||
label: "benefit plateaus by 10,000 steps/day",
|
||||
source: "Paluch 2022",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: "ACTIVITY_STEPS",
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 21,
|
||||
presentInputs: 21,
|
||||
historyDays: 21,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
},
|
||||
...base.pillars.slice(1),
|
||||
],
|
||||
}),
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Gesundheitsscore");
|
||||
expect(html).toContain("im Vergleich zur Vorwoche");
|
||||
// v1.4.27 B1 — the inline Coach button retired; hero strip carries
|
||||
// the action now.
|
||||
expect(html).not.toContain("Coach fragen");
|
||||
expect(html).toContain("Schritte pro Tag");
|
||||
expect(html).toContain("Der Nutzen flacht");
|
||||
expect(html).toContain("Leitlinienbereich");
|
||||
expect(html).not.toContain("benefit plateaus by");
|
||||
expect(html).not.toContain("guideline-band");
|
||||
});
|
||||
|
||||
it("uses tabular-nums on the headline number for stable layout", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
it("patches the cached analytics report after the notice is dismissed", () => {
|
||||
const payload = {
|
||||
healthScore: report({
|
||||
algorithmNotice: {
|
||||
itemKey: "health_score_algorithm:2",
|
||||
dismissed: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const patched = markAlgorithmNoticeDismissed(
|
||||
payload,
|
||||
"health_score_algorithm:2",
|
||||
);
|
||||
// The headline number wrapper uses the tabular-nums utility.
|
||||
const numberMatch = html.match(
|
||||
/<span[^>]*data-slot="health-score-card-number"[^>]*>/,
|
||||
);
|
||||
expect(numberMatch).not.toBeNull();
|
||||
expect(numberMatch?.[0]).toContain("tabular-nums");
|
||||
});
|
||||
|
||||
it("clamps the progress bar width between 0 and 100", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={120}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
// Even a > 100 score must render at exactly 100 % width.
|
||||
const progressMatch = html.match(
|
||||
/data-slot="health-score-card-progress"[\s\S]*?width:\s*([0-9.]+%)/,
|
||||
);
|
||||
expect(progressMatch).not.toBeNull();
|
||||
expect(progressMatch?.[1]).toBe("100%");
|
||||
});
|
||||
|
||||
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
|
||||
it("opts the wrapper into the parent's flex-stretch row via h-full + flex-col", () => {
|
||||
// The hero strip's row now uses `items-stretch`; the card has to
|
||||
// declare `h-full flex flex-col` so the stretched shell actually
|
||||
// affects its rendered height. Pin both classes so a refactor
|
||||
// can't silently drop one.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const cardOpen = html.match(/<div[^>]*data-slot="health-score-card"[^>]*>/);
|
||||
expect(cardOpen).not.toBeNull();
|
||||
expect(cardOpen?.[0]).toContain("h-full");
|
||||
expect(cardOpen?.[0]).toContain("flex");
|
||||
expect(cardOpen?.[0]).toContain("flex-col");
|
||||
});
|
||||
|
||||
// v1.4.37 W4a item 1 — the inner column switched from `flex
|
||||
// flex-col gap-3` to a 7-row grid so the slack collects on row 6
|
||||
// (the provenance accordion) instead of clumping under the
|
||||
// disclaimer. Pin both the grid switch and the `1fr` slack row so
|
||||
// a future refactor can't silently revert to flex and re-introduce
|
||||
// the "Karte hört bei der Trennlinie auf" maintainer-reported bug.
|
||||
it("uses a 7-row grid on the inner column so slack distributes on the accordion row", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
// The inner column lives directly after the card wrapper. Match
|
||||
// the first <div> that opens after the wrapper and assert the
|
||||
// grid + grid-rows-[...1fr...] contract.
|
||||
const innerColumn = html.match(
|
||||
/<div[^>]*data-slot="health-score-card"[^>]*>\s*(?:<!--[\s\S]*?-->\s*)?<div([^>]*)>/,
|
||||
);
|
||||
expect(innerColumn).not.toBeNull();
|
||||
const innerAttrs = innerColumn?.[1] ?? "";
|
||||
expect(innerAttrs).toContain("grid");
|
||||
expect(innerAttrs).toContain("flex-1");
|
||||
expect(innerAttrs).toContain(
|
||||
"grid-rows-[auto_auto_auto_auto_auto_1fr_auto]",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the progressbar role + ARIA values for screen readers", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const progressMatch = html.match(
|
||||
/<div[^>]*data-slot="health-score-card-progress"[^>]*>/,
|
||||
);
|
||||
expect(progressMatch).not.toBeNull();
|
||||
expect(progressMatch?.[0]).toContain('role="progressbar"');
|
||||
expect(progressMatch?.[0]).toContain('aria-valuenow="86"');
|
||||
expect(progressMatch?.[0]).toContain('aria-valuemin="0"');
|
||||
expect(progressMatch?.[0]).toContain('aria-valuemax="100"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — the weight row names its yardstick. Before this the score graded
|
||||
* weight against a band the user never set and the card said nothing at all,
|
||||
* which is why the defect survived for months.
|
||||
*/
|
||||
describe("<HealthScoreCard> — weight yardstick disclosure", () => {
|
||||
it("names the user's own target band", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "user", targetLabel: "64-70 kg" }}
|
||||
/>,
|
||||
);
|
||||
const line = html.match(
|
||||
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
|
||||
);
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.[0]).toContain('data-target-source="user"');
|
||||
expect(line?.[1]).toContain("64-70 kg");
|
||||
});
|
||||
|
||||
it("says plainly when no target graded the row", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "none", targetLabel: null }}
|
||||
/>,
|
||||
);
|
||||
const line = html.match(
|
||||
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
|
||||
);
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.[0]).toContain('data-target-source="none"');
|
||||
expect(line?.[1]).toMatch(/trend/i);
|
||||
});
|
||||
|
||||
it("discloses nothing when the payload carries no yardstick", () => {
|
||||
// An older cached body has no `targetSource`; silence beats a guess.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("health-score-card-weight-yardstick");
|
||||
});
|
||||
|
||||
it("stays quiet when the weight pillar itself has no value", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={{
|
||||
...baseComponents,
|
||||
weight: { value: null, weight: 0 },
|
||||
}}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "none", targetLabel: null }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("health-score-card-weight-yardstick");
|
||||
expect(patched).toMatchObject({
|
||||
healthScore: {
|
||||
algorithmNotice: {
|
||||
itemKey: "health_score_algorithm:2",
|
||||
dismissed: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a
|
||||
* three-sentence read of which components contributed, what the comparison
|
||||
* window is, and one concrete next step.
|
||||
*
|
||||
* v1.22 — the read no longer hides behind a "?" glyph + popover; it renders
|
||||
* inline as a muted caption beside the delta line. These tests run through
|
||||
* SSR (no browser surface) and pin: the inline body paints by default, it
|
||||
* threads the parent-supplied id for aria-describedby, and every locale
|
||||
* ships the body copy.
|
||||
*/
|
||||
|
||||
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that paints the explainer body via the same `t()` call the caption
|
||||
* uses. If a future refactor drops a locale key the test fails at the `t()`
|
||||
* call site rather than silently rendering the literal key path.
|
||||
*/
|
||||
function BodyProbe() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HealthScoreDeltaExplainer>", () => {
|
||||
it("renders the read inline as a muted caption (no trigger glyph)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).toContain('data-slot="health-score-delta-explainer-body"');
|
||||
// The old icon-only disclosure trigger is gone.
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-trigger"',
|
||||
);
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
});
|
||||
|
||||
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("paints the body copy on the initial SSR snapshot (always visible)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).toContain("BP, weight, mood and medication adherence");
|
||||
});
|
||||
|
||||
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const body = html.match(
|
||||
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
|
||||
);
|
||||
expect(body).not.toBeNull();
|
||||
const text = body?.[1] ?? "";
|
||||
// Components — window — action triad pinned.
|
||||
expect(text).toContain("BP, weight, mood and medication adherence");
|
||||
expect(text).toContain("same time last week");
|
||||
expect(text).toContain("Log a fresh reading");
|
||||
// 3-sentence ceiling. Soft check: at most 4 sentence-ending
|
||||
// punctuation marks (covering the Oxford comma + 3 periods).
|
||||
const periods = (text.match(/\./g) ?? []).length;
|
||||
expect(periods).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("ships the DE body copy with the same structure (3-sentence translation)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const body = html.match(
|
||||
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
|
||||
);
|
||||
expect(body).not.toBeNull();
|
||||
const text = body?.[1] ?? "";
|
||||
expect(text).toContain("Blutdruck");
|
||||
expect(text).toContain("Stimmung");
|
||||
expect(text).toContain("Therapietreue");
|
||||
expect(text).toContain("vor einer Woche");
|
||||
});
|
||||
|
||||
it("ships the body key in every locale supported by R3c-Insights", () => {
|
||||
// Pinning every locale stops a future feature branch from shipping an
|
||||
// English fallback for the explainer body key.
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The probe re-uses the same `t()` payload the caption does, so any
|
||||
// missing key surfaces as the literal key path which never contains a
|
||||
// space.
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { HeroStrip } from "../hero-strip";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
import type { HealthScoreReport } from "@/lib/analytics/score/types";
|
||||
|
||||
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
|
||||
// it so the SSR render does not reach for a QueryClient the test omits.
|
||||
@@ -40,6 +41,53 @@ const sampleBriefing: DailyBriefingPayload = {
|
||||
const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin
|
||||
const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin
|
||||
const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin
|
||||
const SCORE_REPORT: HealthScoreReport = {
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 86,
|
||||
band: "green",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 3,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: {
|
||||
inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
source: "live",
|
||||
windowDays: 90,
|
||||
computedAt: "2026-05-10T07:00:00.000Z",
|
||||
},
|
||||
},
|
||||
pillars: [],
|
||||
delta: 5,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: ["weight reading", "personal weight target"],
|
||||
},
|
||||
provenance: {
|
||||
inputs: ["WEIGHT"],
|
||||
source: "none",
|
||||
windowDays: 90,
|
||||
computedAt: "2026-05-10T07:00:00.000Z",
|
||||
},
|
||||
reason: "no_personal_goal",
|
||||
},
|
||||
algorithmNotice: null,
|
||||
};
|
||||
|
||||
describe("<HeroStrip>", () => {
|
||||
it("renders the slot wrapper + Dracula gradient utility", () => {
|
||||
@@ -250,17 +298,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={{
|
||||
score: 86,
|
||||
band: "green",
|
||||
components: {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
},
|
||||
delta: 5,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="health-score-card"/);
|
||||
@@ -277,17 +315,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={{
|
||||
score: 86,
|
||||
band: "green",
|
||||
components: {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
},
|
||||
delta: 5,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
@@ -299,17 +327,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={{
|
||||
score: 86,
|
||||
band: "green",
|
||||
components: {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
},
|
||||
delta: null,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
// The wrapper picks up the `lg:flex-row` modifier when the score
|
||||
@@ -329,17 +347,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={{
|
||||
score: 86,
|
||||
band: "green",
|
||||
components: {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
},
|
||||
delta: null,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("md:items-stretch");
|
||||
@@ -395,17 +403,7 @@ describe("<HeroStrip>", () => {
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScorePending
|
||||
healthScore={{
|
||||
score: 86,
|
||||
band: "green",
|
||||
components: {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
},
|
||||
delta: 5,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-card"');
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface ScoreAnatomyViewProps {
|
||||
insufficient?: boolean;
|
||||
/** Localised "not enough data" nudge for the insufficient state. */
|
||||
insufficientNote?: ReactNode;
|
||||
/** Keep the shared provisional ring unless the caller requires rows only. */
|
||||
showProvisionalRing?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -107,6 +109,7 @@ export function ScoreAnatomyView({
|
||||
standard,
|
||||
insufficient = false,
|
||||
insufficientNote,
|
||||
showProvisionalRing = true,
|
||||
className,
|
||||
}: ScoreAnatomyViewProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -142,15 +145,17 @@ export function ScoreAnatomyView({
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
{/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is
|
||||
never clipped top/bottom by the hero container. */}
|
||||
<div className="py-2">
|
||||
<ScoreRing
|
||||
score={effectiveScore}
|
||||
band={band}
|
||||
hue={insufficient ? undefined : hue}
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{!insufficient || showProvisionalRing ? (
|
||||
<div className="py-2">
|
||||
<ScoreRing
|
||||
score={effectiveScore}
|
||||
band={band}
|
||||
hue={insufficient ? undefined : hue}
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{caption && !insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-caption"
|
||||
@@ -174,7 +179,8 @@ export function ScoreAnatomyView({
|
||||
insufficientNote ?? t("insights.derived.anatomy.insufficient")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : null}
|
||||
{contributors.length > 0 ? (
|
||||
<div data-slot="score-anatomy-contributors" className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{t("insights.derived.anatomy.contributorsLabel")}
|
||||
@@ -185,7 +191,7 @@ export function ScoreAnatomyView({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* The plain-language method caption closes the card, in normal flow.
|
||||
`ProvenanceExplainer` (v1.22) is no longer a top-right ⓘ glyph — it
|
||||
|
||||
@@ -188,4 +188,11 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
|
||||
},
|
||||
caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat",
|
||||
},
|
||||
HEALTH_SCORE: {
|
||||
methodKey: "insights.healthScore.method",
|
||||
standard: {
|
||||
name: "WHO HEARTS technical package",
|
||||
url: "https://www.who.int/publications/i/item/9789240001367",
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — explain the "vs last week" delta.
|
||||
*
|
||||
* v1.22 — the icon-only `?` glyph that gated the three-sentence read behind
|
||||
* a popover / bottom-sheet is removed; the maintainer wants the trailing
|
||||
* help affordance gone across the insights surface. The descriptive read
|
||||
* now renders inline as a muted caption beside the delta line, so it is
|
||||
* always visible rather than disclosure-only. The body keeps its id so the
|
||||
* parent's `aria-describedby` on the delta digit still threads to it.
|
||||
*
|
||||
* Operator suppression is preserved: when `healthScoreExplainer` is off the
|
||||
* caption does not render (the parent delta digit stays visible because the
|
||||
* parent paints it directly).
|
||||
*/
|
||||
|
||||
interface HealthScoreDeltaExplainerProps {
|
||||
/** The delta value the parent paints on the line above. Retained for callers. */
|
||||
delta: number;
|
||||
/** Optional className for the caption so the parent can tune its spacing. */
|
||||
className?: string;
|
||||
/**
|
||||
* Optional id the parent owns and threads to the caption. The same id sits
|
||||
* on the parent's delta `<span>` as `aria-describedby` so screen readers
|
||||
* connect the digit to the read.
|
||||
*/
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function HealthScoreDeltaExplainer({
|
||||
className,
|
||||
bodyId,
|
||||
}: HealthScoreDeltaExplainerProps) {
|
||||
const { t } = useTranslations();
|
||||
const flags = useFeatureFlags();
|
||||
// Stable fallback id when the parent doesn't supply one. The hook sits
|
||||
// above the conditional return so the hook order stays stable.
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
|
||||
// v1.4.31 — operator can hide the read; silent suppression per the
|
||||
// architecture brief.
|
||||
if (!flags.healthScoreExplainer) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className={cn("text-muted-foreground text-xs leading-snug", className)}
|
||||
>
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -10,15 +10,11 @@ import {
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
type HealthScoreCardProps,
|
||||
type HealthScoreBand,
|
||||
} from "./health-score-card";
|
||||
import { HealthScoreCard } from "./health-score-card";
|
||||
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
|
||||
/**
|
||||
@@ -78,29 +74,8 @@ interface HeroStripProps {
|
||||
* footer hint below.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* v1.4.20 phase B5 — Personal Health Score panel data. When supplied
|
||||
* the right side of the hero band paints the score card. When the
|
||||
* parent omits the field the right side stays empty.
|
||||
*/
|
||||
healthScore?: {
|
||||
score: HealthScoreCardProps["score"];
|
||||
band: HealthScoreCardProps["band"];
|
||||
components: HealthScoreCardProps["components"];
|
||||
delta: HealthScoreCardProps["delta"];
|
||||
/**
|
||||
* v1.18.6 — value-free Rest Mode annotation off the analytics
|
||||
* payload. When present + active the score card states it is paused
|
||||
* during illness (suppressed, not penalised).
|
||||
*/
|
||||
restMode?: { active: boolean } | null;
|
||||
} | null;
|
||||
/**
|
||||
* v1.34 — the weight pillar's yardstick, already resolved into the reader's
|
||||
* display unit by the page (the hero carries no unit context of its own).
|
||||
* Forwarded verbatim to the score card, which renders the disclosure line.
|
||||
*/
|
||||
weightYardstick?: HealthScoreCardProps["weightYardstick"];
|
||||
/** Complete transparent Health Score report from the analytics route. */
|
||||
healthScore?: HealthScoreReport | null;
|
||||
/**
|
||||
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
|
||||
* is the readiness composite's band; `positive` / `negative` carry the
|
||||
@@ -112,7 +87,7 @@ interface HeroStripProps {
|
||||
* is present.
|
||||
*/
|
||||
tension?: {
|
||||
band: HealthScoreBand;
|
||||
band: ScoreBand;
|
||||
positive: ReadinessContributorKey[];
|
||||
negative: ReadinessContributorKey[];
|
||||
} | null;
|
||||
@@ -194,7 +169,6 @@ export function HeroStrip({
|
||||
noProviderStale = false,
|
||||
tension = null,
|
||||
returnToBand = null,
|
||||
weightYardstick = null,
|
||||
}: HeroStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -230,11 +204,6 @@ export function HeroStrip({
|
||||
daysInside: returnToBand.daysInside,
|
||||
}
|
||||
: null;
|
||||
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
|
||||
// when the account turned the module off (the server already drops the
|
||||
// pillar from the number itself). SSR-safe + default-on, matching the
|
||||
// disabled-allowlist gate.
|
||||
const moodEnabled = useModuleEnabled("mood");
|
||||
// Greeting hour in the user's configured zone, not the browser's — a
|
||||
// traveller / VPN user / wrong device clock otherwise sees the wrong
|
||||
// salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`).
|
||||
@@ -411,15 +380,9 @@ export function HeroStrip({
|
||||
)}
|
||||
{healthScore && (
|
||||
<HealthScoreCard
|
||||
score={healthScore.score}
|
||||
band={healthScore.band}
|
||||
components={healthScore.components}
|
||||
delta={healthScore.delta}
|
||||
moodEnabled={moodEnabled}
|
||||
restModeActive={healthScore.restMode?.active ?? false}
|
||||
report={healthScore}
|
||||
tension={tensionProp}
|
||||
returnToBand={returnToBandProp}
|
||||
weightYardstick={weightYardstick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { HeartPulse } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
|
||||
|
||||
/**
|
||||
* v1.18.1 — calm, neutral Rest Mode indicator.
|
||||
|
||||
Reference in new issue
Block a user