mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(insights): drop the question-mark explainer circle from the heading
The round `?` popover next to each category heading read as restless beside the title and duplicated copy already shown inline. Remove the `MetricExplainer` trigger entirely; the static one- to two-sentence definition stays as the muted caption directly beneath the heading, so the definition is still always visible without a tap. The bundle keeps the per-metric Title leaf for stable key pairs even though only the Body caption now renders.
This commit is contained in:
1 parent
e5b873833a
commit
0e9fa7bf80
3 files changed
+10
-278
No files matched your search
@@ -5,14 +5,20 @@ import { join } from "node:path";
|
||||
/**
|
||||
* v1.8.0 — explainer-metric key guard.
|
||||
*
|
||||
* `<MetricExplainer>` 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 `<metric>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/**`
|
||||
|
||||
@@ -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 — `<MetricExplainer>` 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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div>
|
||||
<span data-slot="probe-title">
|
||||
{t(`insights.subPage.explainer.${metric}Title`)}
|
||||
</span>
|
||||
<span data-slot="probe-body">
|
||||
{t(`insights.subPage.explainer.${metric}Body`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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("<MetricExplainer>", () => {
|
||||
it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => {
|
||||
const html = ssr(<MetricExplainer metric="bloodPressure" />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(<MetricExplainer metric="pulse" />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(<MetricExplainer metric="hrv" />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(<MetricExplainer metric="weight" />);
|
||||
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(<MetricExplainer metric="sleep" />, "de");
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<KeyProbe metric={metric} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// A missing key surfaces as the literal `insights.subPage…`
|
||||
// path, which never contains a space.
|
||||
expect(html).not.toContain(`insights.subPage.explainer.${metric}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.<metric>Title
|
||||
* insights.subPage.explainer.<metric>Body
|
||||
*
|
||||
* The surface choice mirrors `<HealthScoreDeltaExplainer>`: 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 `<button>` (Enter / Space activate it
|
||||
* natively) carrying a descriptive `aria-label`, `aria-expanded`, and
|
||||
* `aria-controls` pointing at the body. The optical glyph stays 14 px so
|
||||
* it reads at the heading's height, but the hit surface lifts to the
|
||||
* 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`; negative margins
|
||||
* collapse the surrounding row back to its natural stride so the chip
|
||||
* stays visually small while the reach is full-size.
|
||||
*/
|
||||
|
||||
interface MetricExplainerProps {
|
||||
/**
|
||||
* The metric key. Drives the title + body lookup under
|
||||
* `insights.subPage.explainer.<metric>{Title,Body}`.
|
||||
*/
|
||||
metric: string;
|
||||
/** Optional className forwarded to the trigger button. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricExplainer({ metric, className }: MetricExplainerProps) {
|
||||
const { t } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyId = useId();
|
||||
|
||||
const title = t(`insights.subPage.explainer.${metric}Title`);
|
||||
const body = t(`insights.subPage.explainer.${metric}Body`);
|
||||
const triggerLabel = t("insights.subPage.explainer.trigger", { title });
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="metric-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-my-3 -mx-2",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet open={open} onOpenChange={setOpen} title={title}>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="metric-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="metric-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p
|
||||
data-slot="metric-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="metric-explainer-body"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user