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 ( +
+ + {t("insights.healthScore.deltaExplainer.trigger")} + + + {t("insights.healthScore.deltaExplainer.title")} + + + {t("insights.healthScore.deltaExplainer.body")} + +
+ ); +} + +describe("", () => { + it("renders the icon-only trigger button with an accessible label (EN)", () => { + const html = ssr(); + const trigger = html.match( + /]*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(, "de"); + const trigger = html.match( + /]*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(); + 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( + + + , + ); + const body = html.match( + /]*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( + + + , + ); + const body = html.match( + /]*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( + + + , + ); + // 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", + ); + } + }); +}); diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 4944b2024..4ce8c21ef 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -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({ /> + {/* 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. */}

0 ? `+${delta}` : `${delta}`, })} + )}

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}` }, + )} + > +

+ {body} +

+
+ + ); + } + + return ( + + {trigger} + +

+ {title} +

+

+ {body} +

+
+
+ ); +}