feat(insights): Health Score panel component

Pure presentational <HealthScoreCard> for the right-side hero
panel. Surfaces the composite 0..100 score with a band-coloured
number, a band-tinted progress bar, four sub-component rows
(BP / Weight / Mood / Compliance) with their individual scores, a
"vs last week" delta line with arrow + chip, the "Indicative — not
a clinical assessment" disclaimer, and an "Ask the Coach" button
that pre-fills the drawer with "Why is my health score X out of
100?".

Layout: 220 px wide on desktop, full-width on mobile so it stacks
below the hero title block on `<lg`. Dracula band tokens
(`--dracula-green/orange/red`) carry WCAG AA contrast against
`bg-card/65`.

Tests: 17 SSR cases covering each band, delta-arrow shapes, the
null-component em-dash fallback, the German locale, the score-aware
prefill argument format, the progressbar role + ARIA values, and
the score-clamp at 0..100 % bar width.

i18n keys land under `insights.healthScore.*` in en.json + de.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 17:40:15 +02:00
1 parent 236b4986a4
commit 067bf312c3
4 files changed
+583

No files matched your search

+13
View File
@@ -769,6 +769,19 @@
"shareCopied": "Berichts-Link in die Zwischenablage kopiert.",
"shareFailed": "Teilen fehlgeschlagen — der Browser unterstützt das nicht."
},
"healthScore": {
"label": "Gesundheitsscore",
"progressAria": "Gesundheitsscore-Fortschritt",
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
"componentBp": "BD",
"componentWeight": "Gewicht",
"componentMood": "Mood",
"componentCompliance": "Medis",
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
"askCoach": "Coach fragen",
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?"
},
"report": {
"eyebrow": "Wochenbericht",
"title": "Deine Woche — {week}",
+13
View File
@@ -769,6 +769,19 @@
"shareCopied": "Report link copied to clipboard.",
"shareFailed": "Couldn't share — link copy is unavailable in this browser."
},
"healthScore": {
"label": "Health Score",
"progressAria": "Health Score progress",
"deltaVsLastWeek": "{delta} vs last week",
"deltaUnavailable": "No history yet — check back next week.",
"componentBp": "BP",
"componentWeight": "Weight",
"componentMood": "Mood",
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"askCoach": "Ask the Coach",
"coachPrompt": "Why is my health score {score} out of 100?"
},
"report": {
"eyebrow": "Weekly report",
"title": "Your week — {week}",
@@ -0,0 +1,308 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import {
HealthScoreCard,
type HealthScoreCardProps,
} 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 interactive
* "Ask the Coach" button is exercised through Testing Library so the
* prefill argument forwarded to the parent handler stays stable.
*/
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 },
};
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
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("renders the score number, /100 suffix, and band number colour", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).toMatch(/data-slot="health-score-card-number"/);
expect(html).toContain(">86<");
expect(html).toContain("/ 100");
// Green band uses the dracula-green token.
expect(html).toContain("text-dracula-green");
});
it("paints the yellow band correctly", () => {
const html = ssr(
<HealthScoreCard
score={62}
band="yellow"
components={baseComponents}
delta={null}
/>,
);
expect(html).toMatch(/data-band="yellow"/);
expect(html).toContain("text-dracula-orange");
});
it("paints the red band correctly", () => {
const html = ssr(
<HealthScoreCard
score={32}
band="red"
components={baseComponents}
delta={null}
/>,
);
expect(html).toMatch(/data-band="red"/);
expect(html).toContain("text-dracula-red");
});
it("renders the unavailable-delta caption when delta is null", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).toContain("No history yet");
// No delta chip in the corner.
expect(html).not.toContain(
'data-slot="health-score-card-delta-chip"',
);
});
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("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("renders the disclaimer", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).toMatch(/data-slot="health-score-card-disclaimer"/);
expect(html).toContain("Indicative");
});
it("does NOT render the Ask-the-Coach button when onAskCoach is omitted", () => {
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 the Ask-the-Coach button when onAskCoach is supplied", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
onAskCoach={() => {}}
/>,
);
expect(html).toMatch(/data-slot="health-score-card-ask-coach"/);
expect(html).toContain("Ask the Coach");
});
it("captures the score-aware prefill argument when the button is invoked", () => {
// The handler closure receives `t("insights.healthScore.coachPrompt", { score })`
// which interpolates the current score. We assert the closure shape by
// probing the i18n provider directly rather than wiring a DOM testing
// library — the component's onClick lambda is one line and its
// contract is the prefill string format, not the click plumbing.
let captured: string | null = null;
const onAskCoach = vi.fn((prefill: string) => {
captured = prefill;
});
function Probe() {
const { t } = useTranslations();
// Reproduce the same call the button's onClick performs.
onAskCoach(t("insights.healthScore.coachPrompt", { score: 86 }));
return null;
}
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<Probe />
</I18nProvider>,
);
expect(onAskCoach).toHaveBeenCalledTimes(1);
expect(captured).toBe("Why is my health score 86 out of 100?");
});
it("renders German strings", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={5}
onAskCoach={() => {}}
/>,
"de",
);
expect(html).toContain("Gesundheitsscore");
expect(html).toContain("Coach fragen");
expect(html).toContain("im Vergleich zur Vorwoche");
expect(html).toContain("Orientierungshilfe");
});
it("uses tabular-nums on the headline number for stable layout", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
// 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%");
});
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"');
});
});
@@ -0,0 +1,249 @@
"use client";
import { ArrowDown, ArrowUp, Minus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.20 phase B5 — Personal Health Score panel.
*
* Lives on the right side of the `<HeroStrip>` band on `lg+` viewports
* and stacks below the title block on `<lg`. Surfaces:
* - the composite 0..100 score with a band-coloured number
* - "vs last week" delta line with arrow + percentage
* - 4 component rows (BP / Weight / Mood / Compliance) with sub-bars
* - "Indicative — not a clinical assessment" disclaimer
* - "Ask the Coach" button that opens the B2 drawer with a prefill
* ("Why is my health score X out of 100?") so the user can drill
* into the explanation without retyping the question
*
* 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).
*/
export type HealthScoreBand = "green" | "yellow" | "red";
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 };
};
delta: number | null;
onAskCoach?: (prefill: string) => void;
}
const BAND_NUMBER_CLASS: Record<HealthScoreBand, string> = {
green: "text-dracula-green",
yellow: "text-dracula-orange",
red: "text-dracula-red",
};
const BAND_BORDER_CLASS: Record<HealthScoreBand, string> = {
green: "border-dracula-green/40",
yellow: "border-dracula-orange/40",
red: "border-dracula-red/40",
};
const BAND_PROGRESS_CLASS: Record<HealthScoreBand, string> = {
green: "bg-dracula-green",
yellow: "bg-dracula-orange",
red: "bg-dracula-red",
};
const COMPONENT_LABEL_KEY: Record<
keyof HealthScoreCardProps["components"],
string
> = {
bp: "insights.healthScore.componentBp",
weight: "insights.healthScore.componentWeight",
mood: "insights.healthScore.componentMood",
compliance: "insights.healthScore.componentCompliance",
};
export function HealthScoreCard({
score,
band,
components,
delta,
onAskCoach,
}: HealthScoreCardProps) {
const { t } = useTranslations();
const componentEntries = (
Object.keys(components) as Array<keyof HealthScoreCardProps["components"]>
).map((key) => ({
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: components[key].value,
}));
return (
<div
data-slot="health-score-card"
data-band={band}
className={cn(
"bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
BAND_BORDER_CLASS[band],
"w-full lg:w-[220px] lg:shrink-0",
)}
>
<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>
{delta !== null && delta > 0 && (
<span
data-slot="health-score-card-delta-chip"
className="bg-dracula-green/15 text-dracula-green rounded-full px-2 py-0.5 text-[10px] font-semibold"
>
+{delta}
</span>
)}
</div>
<div className="flex items-baseline gap-1">
<span
data-slot="health-score-card-number"
className={cn(
"text-4xl font-semibold tabular-nums leading-none",
BAND_NUMBER_CLASS[band],
)}
>
{score}
</span>
<span
aria-hidden="true"
className="text-muted-foreground text-sm tabular-nums"
>
/ 100
</span>
</div>
<div
data-slot="health-score-card-progress"
className="bg-muted/50 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("insights.healthScore.progressAria")}
>
<div
className={cn("h-full transition-all", BAND_PROGRESS_CLASS[band])}
style={{ width: `${Math.max(0, Math.min(100, score))}%` }}
/>
</div>
<p
data-slot="health-score-card-delta"
className="text-muted-foreground inline-flex items-center gap-1 text-[11px]"
>
{delta === null ? (
<span>{t("insights.healthScore.deltaUnavailable")}</span>
) : (
<>
{delta > 0 && (
<ArrowUp
className="text-dracula-green h-3 w-3"
aria-hidden="true"
/>
)}
{delta < 0 && (
<ArrowDown
className="text-dracula-red h-3 w-3"
aria-hidden="true"
/>
)}
{delta === 0 && (
<Minus
className="text-muted-foreground h-3 w-3"
aria-hidden="true"
/>
)}
<span>
{t("insights.healthScore.deltaVsLastWeek", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
</span>
</>
)}
</p>
<ul
data-slot="health-score-card-components"
className="space-y-1.5 border-t pt-3"
>
{componentEntries.map(({ key, label, value }) => (
<li
key={key}
data-slot="health-score-card-component-row"
data-component={key}
className="flex items-center gap-2 text-[11px]"
>
<span className="text-muted-foreground w-16 shrink-0">
{label}
</span>
<div
className="bg-muted/50 h-1 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
value === null ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width:
value === null
? "0%"
: `${Math.max(0, Math.min(100, value))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-component-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{value === null ? "—" : Math.round(value)}
</span>
</li>
))}
</ul>
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[10px] leading-snug"
>
{t("insights.healthScore.disclaimer")}
</p>
{onAskCoach && (
<Button
type="button"
size="sm"
variant="outline"
data-slot="health-score-card-ask-coach"
className="w-full gap-1.5"
onClick={() =>
onAskCoach(
t("insights.healthScore.coachPrompt", { score }),
)
}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.healthScore.askCoach")}</span>
</Button>
)}
</div>
</div>
);
}