mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
236b4986a4
commit
067bf312c3
4 files changed
+583
No files matched your search
@@ -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}",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user