feat(insights): explain the HealthScore delta on tap

The "vs last week" delta line on the HealthScore card has been
shipping a digit without explaining what the comparison covers, why
the digit moved, or what the user can do next. FB-I1 — Marc asked
for a tappable `?` glyph next to the delta line that opens a short
read explaining the three things in one shot.

The new `<HealthScoreDeltaExplainer>` mounts a single icon-only
`HelpCircle` button next to the existing delta caption. Tapping the
glyph opens a popover on `md+` and a bottom-sheet on phone-class
viewports — the existing `<ResponsiveSheet>` primitive handles the
branch, mirroring the same pattern landed in v1.4.27 MB1.

Body copy is a three-sentence Marc-Voice read: which components
contributed, what window the comparison covers, what concrete next
step the user can take. Six locales ship native translations — no
English fallback strings on the new keys.

The explainer only mounts when the parent surfaces a numeric delta;
the "no history yet" branch keeps the existing caption untouched
because the explainer would have nothing to compare against.

Tests pin the trigger button's `data-slot` and locale-grounded
accessible label across EN/DE, lock in the "closed by default" SSR
snapshot, and verify the body copy ships in every locale via the
existing `t()` payload. The interactive open behaviour is exercised
on the live page; the SSR contract is enough to catch a future
silent locale drop.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:50:04 +02:00
1 parent 7d38a54d1b
commit 9a020f2122
9 files changed
+331

No files matched your search

+6
View File
@@ -921,6 +921,12 @@
"componentMood": "Stimmung",
"componentCompliance": "Einnahmetreue",
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
"deltaExplainer": {
"trigger": "Was bedeutet diese Veränderung?",
"title": "So wird die Veränderung berechnet",
"description": "Veränderung gegenüber letzter Woche: {delta}",
"body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Einnahmetreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen."
},
"provenance": {
"toggle": "Zusammensetzung",
"weightLabel": "Anteil",
+6
View File
@@ -921,6 +921,12 @@
"componentMood": "Mood",
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "What does this delta mean?",
"title": "How the delta is computed",
"description": "Change vs last week: {delta}",
"body": "The score combines BP, weight, mood and meds. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up."
},
"provenance": {
"toggle": "Driven by",
"weightLabel": "share",
+6
View File
@@ -921,6 +921,12 @@
"componentMood": "Estado de ánimo",
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "¿Qué significa este cambio?",
"title": "Cómo se calcula el cambio",
"description": "Cambio respecto a la semana pasada: {delta}",
"body": "La puntuación combina PA, peso, ánimo y adherencia. Comparamos la puntuación de hoy con la del mismo momento de la semana anterior. Registra una medición nueva o una nota de ánimo para empujarla hacia arriba."
},
"provenance": {
"toggle": "Compuesto por",
"weightLabel": "peso",
+6
View File
@@ -921,6 +921,12 @@
"componentMood": "Humeur",
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "Que signifie cet écart ?",
"title": "Comment l'écart est calculé",
"description": "Écart par rapport à la semaine dernière : {delta}",
"body": "Le score combine TA, poids, humeur et observance. Nous comparons le score d'aujourd'hui à celui de la semaine précédente à la même période. Enregistre une nouvelle mesure ou une note d'humeur pour le faire remonter."
},
"provenance": {
"toggle": "Composé de",
"weightLabel": "part",
+6
View File
@@ -921,6 +921,12 @@
"componentMood": "Umore",
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "Cosa significa questa variazione?",
"title": "Come si calcola la variazione",
"description": "Variazione rispetto alla settimana scorsa: {delta}",
"body": "Il punteggio combina PA, peso, umore e aderenza. Confrontiamo il punteggio di oggi con quello dello stesso momento della settimana scorsa. Registra una nuova misurazione o una nota di umore per spingerlo verso l'alto."
},
"provenance": {
"toggle": "Composto da",
"weightLabel": "peso",
+6
View File
@@ -921,6 +921,12 @@
"componentMood": "Nastrój",
"componentCompliance": "Meds",
"disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "Co oznacza ta zmiana?",
"title": "Jak liczona jest ta zmiana",
"description": "Zmiana względem ubiegłego tygodnia: {delta}",
"body": "Wynik łączy ciśnienie, wagę, nastrój i przyjmowanie leków. Porównujemy dzisiejszy wynik z tym samym momentem tydzień wcześniej. Dodaj świeży pomiar lub wpis o nastroju, żeby pchnąć go w górę."
},
"provenance": {
"toggle": "Składa się z",
"weightLabel": "udział",
@@ -0,0 +1,145 @@
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 "?" affordance next to the delta
* line opens a 3-sentence read of which components contributed, what
* the comparison window is, and one concrete next step.
*
* Tests run through SSR so they don't depend on a browser surface.
* The popover / sheet body mounts on click on the live page; the
* SSR snapshot captures the trigger button (always present) plus a
* locale-grounded check that the body copy actually exists in the
* translation payload — the load-bearing constraint is "every
* locale ships the 3-sentence read", not the popover open animation.
*/
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
* popover uses. If a future refactor drops a locale key the test
* fails at the `t()` call site rather than silently rendering the
* fallback chevron.
*/
function BodyProbe() {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-trigger">
{t("insights.healthScore.deltaExplainer.trigger")}
</span>
<span data-slot="probe-title">
{t("insights.healthScore.deltaExplainer.title")}
</span>
<span data-slot="probe-body">
{t("insights.healthScore.deltaExplainer.body")}
</span>
</div>
);
}
describe("<HealthScoreDeltaExplainer>", () => {
it("renders the icon-only trigger button with an accessible label (EN)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
});
it("renders the trigger in German with the localised aria-label", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain(
'aria-label="Was bedeutet diese Veränderung?"',
);
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default contract: the trigger button is the only
// surface the SSR snapshot owns; the body lives in a portal that
// mounts after the user taps. Pinning the negative side keeps a
// future "open on render" refactor from leaking long-form copy
// into the static markup.
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-body"',
);
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-title"',
);
});
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 meds");
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 Marc-Voice 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("Einnahmetreue");
expect(text).toContain("vor einer Woche");
});
it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => {
// Pinning every locale stops a future feature branch from
// shipping an English fallback for the new explainer keys.
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 popover does,
// so any missing key surfaces as the literal key path which
// would never contain a space.
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.trigger",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.title",
);
expect(html).not.toContain(
"insights.healthScore.deltaExplainer.body",
);
}
});
});
@@ -4,6 +4,7 @@ import { useId, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
/**
* v1.4.20 phase B5 — Personal Health Score panel.
@@ -319,6 +320,13 @@ export function HealthScoreCard({
/>
</div>
{/* v1.4.28 R3c-Insights — FB-I1 — the delta line gets a
sibling `?` glyph that opens a 3-sentence read of which
components contributed, what the comparison window is,
and one concrete next step. Popover on `md+`, bottom-sheet
on phone-class viewports. The explainer only mounts when
a numeric delta is available; the "no history yet"
branch keeps the existing single-line caption. */}
<p
data-slot="health-score-card-delta"
className="text-muted-foreground inline-flex items-center gap-1 text-[11px]"
@@ -350,6 +358,7 @@ export function HealthScoreCard({
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
</span>
<HealthScoreDeltaExplainer delta={delta} />
</>
)}
</p>
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
*
* FB-I1: the user asked why the headline number moved. The earlier
* delta line surfaced the digit ("-3 vs last week") without any
* mention of which components shifted, what the comparison window
* actually is, or what the user can do about it. The explainer is a
* single icon-only `?` button next to the delta line that opens a
* three-sentence read.
*
* Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet
* on phone-class viewports. Both share the same body copy via the
* `insights.healthScore.deltaExplainer.body` key; the title is
* surfaced as the popover-content first line on desktop and as the
* sheet header on mobile (the ResponsiveSheet primitive already
* paints a header band on the sheet branch).
*
* Pure presentational. Owns its own open state — the parent doesn't
* need to thread an extra prop through.
*/
interface HealthScoreDeltaExplainerProps {
/**
* The delta value the parent already painted on the line above. The
* explainer doesn't repaint the digit; it sits next to the line
* with the `?` glyph and lets the user request the prose.
*/
delta: number;
/**
* Optional className for the trigger button so the parent can
* align the glyph to the delta line's baseline.
*/
className?: string;
}
export function HealthScoreDeltaExplainer({
delta,
className,
}: HealthScoreDeltaExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
const title = t("insights.healthScore.deltaExplainer.title");
const body = t("insights.healthScore.deltaExplainer.body");
const trigger = (
<button
type="button"
data-slot="health-score-delta-explainer-trigger"
aria-label={triggerLabel}
className={cn(
// Icon-only chip sized to read at the delta line's height
// without inflating the row. The 16 px hit target satisfies
// the existing 11 px delta line's reading rhythm while the
// outer focus ring still grants a comfortable tap target
// because the ring sits outside the box.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<HelpCircle className="h-3 w-3" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
<span
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
>
{trigger}
</span>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={title}
description={t(
"insights.healthScore.deltaExplainer.description",
{ delta: delta > 0 ? `+${delta}` : `${delta}` },
)}
>
<p
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="health-score-delta-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="health-score-delta-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-[11px] leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
);
}