diff --git a/src/app/insights/__tests__/explainer-metric-keys.test.ts b/src/app/insights/__tests__/explainer-metric-keys.test.ts index 18618a09d..dec10a84b 100644 --- a/src/app/insights/__tests__/explainer-metric-keys.test.ts +++ b/src/app/insights/__tests__/explainer-metric-keys.test.ts @@ -5,14 +5,20 @@ import { join } from "node:path"; /** * v1.8.0 — explainer-metric key guard. * - * `` builds its i18n lookups from a template literal: + * The sub-page shell builds its i18n lookup from a template literal off + * the `explainerMetric` prop: * - * t(`insights.subPage.explainer.${metric}Title`) - * t(`insights.subPage.explainer.${metric}Body`) + * t(`insights.subPage.explainer.${metric}Body`) // inline definition + * + * v1.8.6 — the round `?` popover that also read the `Title` leaf + * is gone; the shell now renders only the inline `Body` caption. The + * `Title` leaf is still pinned here so the bundle keeps a stable, fully + * populated key pair per metric (the locale-integrity guard propagates + * it across the other five locales). * * The static `i18n-call-site-coverage.test.ts` only sees literal * `t("…")` calls, so a typo in an `explainerMetric=` prop would resolve - * to a missing key and paint the raw key string into the popover — with + * to a missing key and paint the raw key string under the heading — with * no CI signal. This test closes that gap the same way the * Health-Score provenance key tests pin their dynamic keys: it walks * every `explainerMetric="…"` value used under `src/app/insights/**` diff --git a/src/components/insights/__tests__/metric-explainer.test.tsx b/src/components/insights/__tests__/metric-explainer.test.tsx deleted file mode 100644 index 6a93fd9c5..000000000 --- a/src/components/insights/__tests__/metric-explainer.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider, useTranslations } from "@/lib/i18n/context"; -import { MetricExplainer } from "../metric-explainer"; - -/** - * v1.8.0 — `` unit tests. - * - * The explainer is the `?` glyph next to a metric sub-page heading that - * opens a static "What is X?" read on tap / Enter / Space. Tests run - * through SSR so they don't depend on a browser surface: the trigger - * button is always present, the popover / sheet body mounts only after - * the user activates it. The load-bearing constraints pinned here are - * the a11y contract (real button, labelled, disclosure semantics, 44 px - * hit surface) and that every locale ships the title + body copy. - */ - -function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -/** - * Probe that resolves the explainer keys through the same `t()` calls - * the component uses, so a dropped locale key fails here at the call - * site instead of silently rendering the literal key path on the page. - */ -function KeyProbe({ metric }: { metric: string }) { - const { t } = useTranslations(); - return ( -
- - {t(`insights.subPage.explainer.${metric}Title`)} - - - {t(`insights.subPage.explainer.${metric}Body`)} - -
- ); -} - -// v1.8.0 — English key segments (ADR-0001). Every routed insights -// category page now passes one of these; the explainer copy ships in -// all six locales. -const METRICS = [ - "bloodPressure", - "pulse", - "weight", - "bmi", - "sleep", - "mood", - "medications", - "restingHr", - "hrv", - "oxygenSaturation", - "bodyTemperature", - "activeEnergy", - "workouts", - "respiratoryRate", - "bodyWater", - "boneMass", - "fatFreeMass", - "fatMass", - "muscleMass", - "visceralFat", - "leanBodyMass", - "flightsClimbed", - "walkingDistance", - "walkingSteadiness", - "walkingHeartRate", - "walkingAsymmetry", - "doubleSupportTime", - "stepLength", - "walkingSpeed", - "pulseWaveVelocity", - "vascularAge", - "environmentalAudio", - "headphoneAudio", - "audioEvents", - "daylight", - "bloodGlucose", - "skinTemperature", -] as const; - -describe("", () => { - it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => { - const html = ssr(); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain('aria-label="What is Blood pressure?"'); - }); - - it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => { - // The glyph stays 14 px but the click surface inflates to 44 px via - // `min-h-11 min-w-11`; the surrounding heading row keeps its stride - // because `-my-3 -mx-2` swallows the extra reach. Pinned so a future - // refactor can't collapse the hit surface back to the visual chip. - const html = ssr(); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger?.[0]).toContain("min-h-11"); - expect(trigger?.[0]).toContain("min-w-11"); - }); - - it("threads disclosure semantics onto the trigger button", () => { - const html = ssr(); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger?.[0]).toContain("aria-expanded"); - expect(trigger?.[0]).toContain("aria-controls"); - }); - - it("does not paint the popover/sheet body on the initial SSR snapshot", () => { - // Closed-by-default: the body lives in a portal that mounts after - // the user activates the trigger. Pinning the negative keeps a - // future "open on render" refactor from leaking copy into the - // static markup. - const html = ssr(); - expect(html).not.toContain('data-slot="metric-explainer-body"'); - expect(html).not.toContain('data-slot="metric-explainer-title"'); - }); - - it("localises the trigger label in German", () => { - const html = ssr(, "de"); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger?.[0]).toContain('aria-label="Was ist Schlaf?"'); - }); - - it("ships the title + body copy for every category metric in every locale", () => { - for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) { - for (const metric of METRICS) { - const html = renderToStaticMarkup( - - - , - ); - // A missing key surfaces as the literal `insights.subPage…` - // path, which never contains a space. - expect(html).not.toContain(`insights.subPage.explainer.${metric}`); - } - } - }); -}); diff --git a/src/components/insights/metric-explainer.tsx b/src/components/insights/metric-explainer.tsx deleted file mode 100644 index 6f9ba4630..000000000 --- a/src/components/insights/metric-explainer.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useId, 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.8.0 — per-category "What is X?" explainer. - * - * A question-mark glyph that sits next to a metric sub-page heading and, - * on tap / Enter / Space, surfaces one to two layperson sentences that - * define the metric. The copy is static — there is no network call — so - * the explainer paints instantly and works offline. Each metric resolves - * its title + body off a stable i18n key pair: - * - * insights.subPage.explainer.Title - * insights.subPage.explainer.Body - * - * The surface choice mirrors ``: a popover on - * `md+`, a bottom-sheet on phone-class viewports. Both branches share the - * same body copy so the read is identical regardless of viewport. - * - * a11y: the trigger is a real ` - ); - - if (isMobile) { - return ( - <> - {trigger} - -

- {body} -

-
- - ); - } - - return ( - - {trigger} - -

- {title} -

-

- {body} -

-
-
- ); -}