mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
7d38a54d1b
commit
9a020f2122
9 files changed
+331
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user