From 9a020f2122ddebd3c53ec3a77a87550e1f97cef1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 16 May 2026 01:50:04 +0200
Subject: [PATCH] feat(insights): explain the HealthScore delta on tap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 `` 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 `` 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.
---
messages/de.json | 6 +
messages/en.json | 6 +
messages/es.json | 6 +
messages/fr.json | 6 +
messages/it.json | 6 +
messages/pl.json | 6 +
.../health-score-delta-explainer.test.tsx | 145 ++++++++++++++++++
src/components/insights/health-score-card.tsx | 9 ++
.../insights/health-score-delta-explainer.tsx | 141 +++++++++++++++++
9 files changed, 331 insertions(+)
create mode 100644 src/components/insights/__tests__/health-score-delta-explainer.test.tsx
create mode 100644 src/components/insights/health-score-delta-explainer.tsx
diff --git a/messages/de.json b/messages/de.json
index 9d5e4c563..553ab6c1a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 8ecd87c26..af3ee8585 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index 4db619488..d1bcb844b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index fa16f5ccb..d921873e5 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 482034b86..a1b99a756 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 07647b3a8..6e12a7bf9 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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ł",
diff --git a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx
new file mode 100644
index 000000000..4f89d7c1d
--- /dev/null
+++ b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx
@@ -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(
+ {node},
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
+
+describe("", () => {
+ it("renders the icon-only trigger button with an accessible label (EN)", () => {
+ const html = ssr();
+ const trigger = html.match(
+ /
diff --git a/src/components/insights/health-score-delta-explainer.tsx b/src/components/insights/health-score-delta-explainer.tsx
new file mode 100644
index 000000000..38b0d96b7
--- /dev/null
+++ b/src/components/insights/health-score-delta-explainer.tsx
@@ -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 = (
+
+
+
+ );
+
+ if (isMobile) {
+ return (
+ <>
+ setOpen(true)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setOpen(true);
+ }
+ }}
+ >
+ {trigger}
+
+ 0 ? `+${delta}` : `${delta}` },
+ )}
+ >
+