merge: W1 insights surface (i-icon, subtitle, steps, type-click) into v1.22

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:05:27 +02:00
commit 7d3c528157
22 files changed
+345 -562

No files matched your search

+3
View File
@@ -511,6 +511,7 @@
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
"dailyTotalCaption": "({count} Einzelwerte)",
"openMetricInsights": "{type}-Auswertung öffnen",
"sleepNapCaption": "+{count} Nickerchen",
"sleepAwakeningsCaption": "{count} Wachphasen",
"expandDay": "Einzelwerte dieses Tages anzeigen",
@@ -3240,6 +3241,7 @@
},
"vitals": {
"sectionTitle": "Deine Vitalwerte",
"sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline",
"loadingLabel": "Vitalwerte werden geladen",
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
@@ -3366,6 +3368,7 @@
},
"scores": {
"sectionTitle": "Deine Gesundheitswerte",
"sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten",
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
"recovery": "Erholung",
"stress": "Stress",
+3
View File
@@ -511,6 +511,7 @@
"previousPage": "Previous page",
"nextPage": "Next page",
"dailyTotalCaption": "({count} samples)",
"openMetricInsights": "Open {type} insights",
"sleepNapCaption": "+{count} nap(s)",
"sleepAwakeningsCaption": "{count} awakening(s)",
"expandDay": "Show samples for this day",
@@ -3240,6 +3241,7 @@
},
"vitals": {
"sectionTitle": "Your vitals",
"sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline",
"loadingLabel": "Loading your vitals",
"loadError": "Could not load your vitals right now.",
"building": "Building your typical range — {count} of {target} days",
@@ -3366,6 +3368,7 @@
},
"scores": {
"sectionTitle": "Your health scores",
"sectionSubtitle": "Composite recovery, stress and strain read from your connected devices",
"loadError": "Could not load your wellness scores right now.",
"recovery": "Recovery",
"stress": "Stress",
@@ -198,8 +198,8 @@ describe("<CoincidentDeviationCard>", () => {
// At most amber — never destructive/red.
expect(html).toContain("border-warning");
expect(html).not.toContain("text-destructive");
// The provenance affordance reaches the card.
expect(html).toContain('data-slot="provenance-explainer-trigger"');
// The provenance method reaches the card.
expect(html).toContain('data-slot="provenance-explainer-method"');
// The state change is announced politely to SR users (not assertively).
expect(html).toContain('role="status"');
expect(html).not.toContain('role="alert"');
@@ -129,10 +129,11 @@ describe("<HealthScoreCard>", () => {
});
it("threads aria-describedby from the delta text to the explainer body", () => {
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the
// explainer trigger's aria-controls reads the same id. Both
// attributes share one `useId()` thread so SR can connect
// "−3 vs last week" to the three-sentence read.
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the inline
// explainer caption carries the same id. v1.22 — the read no longer
// hides behind a "?" trigger (no aria-controls); the body renders inline
// and the shared `useId()` thread still connects "−3 vs last week" to the
// three-sentence read.
const html = ssr(
<HealthScoreCard
score={64}
@@ -148,8 +149,12 @@ describe("<HealthScoreCard>", () => {
expect(deltaSpan).not.toBeNull();
const threadedId = deltaSpan?.[1] ?? "";
expect(threadedId).not.toBe("");
// The same id sits on the trigger button's aria-controls.
expect(html).toContain(`aria-controls="${threadedId}"`);
// The same id sits on the inline explainer body.
expect(html).toMatch(
new RegExp(
`<span[^>]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`,
),
);
});
it("renders four component rows with their values", () => {
@@ -4,16 +4,15 @@ 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.
* v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a
* three-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.
* v1.22 — the read no longer hides behind a "?" glyph + popover; it renders
* inline as a muted caption beside the delta line. These tests run through
* SSR (no browser surface) and pin: the inline body paints by default, it
* threads the parent-supplied id for aria-describedby, and every locale
* ships the body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -23,114 +22,42 @@ function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
}
/**
* 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.
* Helper that paints the explainer body via the same `t()` call the caption
* uses. If a future refactor drops a locale key the test fails at the `t()`
* call site rather than silently rendering the literal key path.
*/
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>
<span data-slot="probe-body">
{t("insights.healthScore.deltaExplainer.body")}
</span>
);
}
describe("<HealthScoreDeltaExplainer>", () => {
it("renders the icon-only trigger button with an accessible label (EN)", () => {
it("renders the read inline as a muted caption (no trigger glyph)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
expect(html).toContain('data-slot="health-score-delta-explainer-body"');
// The old icon-only disclosure trigger is gone.
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-trigger"',
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 12 px but the click surface inflates to 44 px
// via `min-h-11 min-w-11` on the trigger; the parent row keeps
// its 16 px stride because `-my-3 -mx-2` swallows the extra
// reach. Pinning so a future refactor can't drop back to the
// visual-chip-as-hit-target shape that failed WCAG 2.5.5.
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("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads aria-expanded + aria-controls onto the trigger button", () => {
// SR users get a clear "this opens a disclosure" cue via
// aria-expanded; the aria-controls value is the same id the
// body paints so the SR can jump to the explanation after
// the user activates the trigger.
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-expanded");
expect(trigger?.[0]).toContain("aria-controls");
expect(html).not.toContain("aria-expanded");
});
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
// Parent owns the id and paints aria-describedby on the delta
// span; the explainer re-uses the same id for its
// aria-controls thread. The shared id is the seam SR uses to
// jump from the digit to the explanation.
const html = ssr(
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
);
const trigger = html.match(
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
expect(html).toMatch(
/<span[^>]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/,
);
expect(trigger?.[0]).toContain('aria-controls="parent-id-x"');
});
it("does not wrap the trigger in a clickable <span> (single interactive element)", () => {
// The earlier mobile path wrapped the button in a
// `<span onClick onKeyDown>` which created two interactive
// elements in the a11y tree and intercepted clicks on the 2 px
// gap around the button. The button now owns the open toggle
// directly. Pinning the negative so a future refactor can't
// reintroduce the wrapper.
it("paints the body copy on the initial SSR snapshot (always visible)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).not.toMatch(/<span[^>]*onclick/i);
});
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"',
);
expect(html).toContain("BP, weight, mood and medication adherence");
});
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
@@ -171,20 +98,18 @@ describe("<HealthScoreDeltaExplainer>", () => {
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.
it("ships the body key in every locale supported by R3c-Insights", () => {
// Pinning every locale stops a future feature branch from shipping an
// English fallback for the explainer body key.
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");
// The probe re-uses the same `t()` payload the caption does, so any
// missing key surfaces as the literal key path which never contains a
// space.
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
}
});
@@ -26,7 +26,7 @@ const EMPTY_PROVENANCE: DerivedProvenance = {
};
describe("<ProvenanceExplainer>", () => {
it("renders an accessible trigger that controls the body (collapsed by default)", () => {
it("renders the method inline as a muted caption (no trigger glyph)", () => {
const html = render(
<ProvenanceExplainer
provenance={PROVENANCE}
@@ -34,28 +34,21 @@ describe("<ProvenanceExplainer>", () => {
bodyId="prov-1"
/>,
);
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('aria-controls="prov-1"');
expect(html).toContain("aria-label");
expect(html).toContain('data-slot="provenance-explainer-method"');
expect(html).toContain("BMI is weight divided by height squared.");
expect(html).toContain('id="prov-1"');
// The old icon-only disclosure trigger is gone.
expect(html).not.toContain('data-slot="provenance-explainer-trigger"');
});
it("meets the 44px hit-target floor on the trigger", () => {
const html = render(
<ProvenanceExplainer provenance={PROVENANCE} method="method copy" />,
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
});
it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => {
it("renders the empty-inputs / bad-date provenance without throwing", () => {
const html = render(
<ProvenanceExplainer
provenance={EMPTY_PROVENANCE}
method="method copy"
/>,
);
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('data-slot="provenance-explainer-method"');
});
it("accepts a cited standard prop without erroring", () => {
@@ -69,6 +62,6 @@ describe("<ProvenanceExplainer>", () => {
}}
/>,
);
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('data-slot="provenance-explainer-method"');
});
});
@@ -55,9 +55,9 @@ describe("<ScoreAnatomyView>", () => {
// The missing contributor renders present=false (dropped, not blank).
expect(html).toContain('data-contributor="respiratory"');
expect(html).toContain('data-present="false"');
expect(html).toContain('data-slot="provenance-explainer-trigger"');
expect(html).toContain('data-slot="provenance-explainer-method"');
// v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed;
// the non-clinical framing now lives behind the provenance explainer.
// the non-clinical framing now lives in the inline provenance method.
expect(html).not.toContain('data-slot="score-anatomy-disclaimer"');
});
@@ -1,31 +1,19 @@
"use client";
import { useId, useState } from "react";
import { ExternalLink, Info } 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";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
* v1.10.0 — the "how this is computed / standard cited" surface.
* v1.10.0 — the "how this is computed" surface.
*
* A collapsible `ⓘ` explainer that renders, as React text children (NO
* markdown library — the XSS rule), the metric's inputs, a plain-language
* method, and an optional link to the cited standard (IEEE / RFC / WHO /
* LOINC / Wikipedia). Reads the `provenance` field off `Derived<T>` for the
* inputs chip + the "as of" + window framing.
*
* Surface follows the shipped `health-score-delta-explainer` pattern:
* Popover on `md+`, bottom-sheet on phone-class viewports via
* `ResponsiveSheet`. Owns its own open state. aria-describedby threads the
* trigger to the body.
* v1.22 — the collapsible `ⓘ` glyph that gated the method/inputs/window read
* behind a popover / bottom-sheet is removed; the maintainer wants the
* trailing info affordance gone across the insights surface. The
* plain-language method now renders inline as a muted caption (React text
* children only — the XSS rule still forbids a markdown library), so the
* "how computed" line is always visible rather than disclosure-only. The
* `provenance`, `standard` and `title` props are retained for callers; the
* inputs-chip / window / cited-standard detail folded out with the popover.
*/
export interface ProvenanceStandard {
@@ -43,167 +31,31 @@ export interface ProvenanceExplainerProps {
* passes a localised string; the component never parses markup.
*/
method: React.ReactNode;
/** Optional cited standard rendered as an external link in the footer. */
/** Optional cited standard. Retained for callers. */
standard?: ProvenanceStandard;
/** Accessible title of the surface (sheet header / popover first line). */
/** Accessible title of the surface. Retained for callers. */
title?: string;
/** Optional className for the trigger button. */
/** Optional className for the caption. */
className?: string;
/** Optional id the parent threads as aria-describedby on its own element. */
bodyId?: string;
}
export function ProvenanceExplainer({
provenance,
method,
standard,
title,
className,
bodyId,
}: ProvenanceExplainerProps) {
const { t, locale } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
const resolvedTitle = title ?? t("insights.derived.provenance.title");
const triggerLabel = t("insights.derived.provenance.trigger");
const asOf = (() => {
const parsed = new Date(provenance.computedAt);
if (Number.isNaN(parsed.getTime())) return null;
try {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "2-digit",
}).format(parsed);
} catch {
return null;
}
})();
const body = (
<div
id={resolvedBodyId}
data-slot="provenance-explainer-body"
className="space-y-3"
>
<div data-slot="provenance-explainer-inputs">
<p className="text-foreground text-xs font-semibold">
{t("insights.derived.provenance.inputsLabel")}
</p>
{provenance.inputs.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1.5">
{provenance.inputs.map((input) => (
<li
key={input}
data-slot="provenance-explainer-input-chip"
className="border-info/40 text-info inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none"
>
{input}
</li>
))}
</ul>
) : (
<p className="text-muted-foreground mt-1 text-[11px]">
{t("insights.derived.provenance.noInputs")}
</p>
)}
</div>
<div data-slot="provenance-explainer-method">
<p className="text-foreground text-xs font-semibold">
{t("insights.derived.provenance.methodLabel")}
</p>
<p className="text-muted-foreground mt-1 text-[11px] leading-snug">
{method}
</p>
</div>
<p
data-slot="provenance-explainer-window"
className="text-muted-foreground text-[11px] leading-snug"
>
{t("insights.derived.provenance.window", {
days: provenance.windowDays,
source: provenance.source,
})}
{asOf
? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}`
: ""}
</p>
{standard && (
<a
data-slot="provenance-explainer-standard-link"
href={standard.url}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.derived.provenance.standardLink", {
name: standard.name,
})}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)}
</div>
);
const trigger = (
<button
type="button"
data-slot="provenance-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={resolvedBodyId}
return (
<span
id={bodyId}
data-slot="provenance-explainer-method"
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",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"text-muted-foreground text-[11px] leading-snug",
className,
)}
onClick={() => setOpen(true)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={resolvedTitle}
>
<div className="text-sm">{body}</div>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="provenance-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-2"
>
<p
data-slot="provenance-explainer-title"
className="text-foreground text-xs font-semibold"
>
{resolvedTitle}
</p>
{body}
</PopoverContent>
</Popover>
{method}
</span>
);
}
@@ -729,6 +729,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
<SectionHeading
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
subtitle={t("insights.derived.vitals.sectionSubtitle")}
/>
<div
data-slot="vitals-dashboard-error"
@@ -774,6 +775,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
<SectionHeading
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
subtitle={t("insights.derived.vitals.sectionSubtitle")}
/>
<div
data-slot="vitals-dashboard-grid"
@@ -211,6 +211,7 @@ export function WellnessScores({
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
subtitle={t("insights.derived.scores.sectionSubtitle")}
/>
<div
data-slot="wellness-scores-error"
@@ -394,6 +395,7 @@ export function WellnessScores({
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
subtitle={t("insights.derived.scores.sectionSubtitle")}
/>
<div
data-slot="wellness-scores-grid"
@@ -1,167 +1,63 @@
"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 { useId } from "react";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
* v1.4.28 R3c-Insights — explain the "vs last week" delta.
*
* 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.
* v1.22 — the icon-only `?` glyph that gated the three-sentence read behind
* a popover / bottom-sheet is removed; the maintainer wants the trailing
* help affordance gone across the insights surface. The descriptive read
* now renders inline as a muted caption beside the delta line, so it is
* always visible rather than disclosure-only. The body keeps its id so the
* parent's `aria-describedby` on the delta digit still threads to it.
*
* 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.
* Operator suppression is preserved: when `healthScoreExplainer` is off the
* caption does not render (the parent delta digit stays visible because the
* parent paints it directly).
*/
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.
*/
/** The delta value the parent paints on the line above. Retained for callers. */
delta: number;
/**
* Optional className for the trigger button so the parent can
* align the glyph to the delta line's baseline.
*/
/** Optional className for the caption so the parent can tune its spacing. */
className?: string;
/**
* Optional id the parent owns and threads to the explainer body via
* the popover/sheet markup. The same id sits on the parent's delta
* `<span>` as `aria-describedby` so screen readers can connect "−3
* vs last week" to the three-sentence read on demand.
* Optional id the parent owns and threads to the caption. The same id sits
* on the parent's delta `<span>` as `aria-describedby` so screen readers
* connect the digit to the read.
*/
bodyId?: string;
}
export function HealthScoreDeltaExplainer({
delta,
className,
bodyId,
}: HealthScoreDeltaExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const flags = useFeatureFlags();
const [open, setOpen] = useState(false);
// Stable fallback id when the parent doesn't supply one. The body
// still paints the id so future consumers can thread the same
// describedby pattern without modifying the explainer.
// Stable fallback id when the parent doesn't supply one. The hook sits
// above the conditional return so the hook order stays stable.
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
// v1.4.31 — operator can hide the `?` trigger; the delta digit on
// the parent line stays visible because the parent reads it
// directly. Silent suppression per the architecture brief. The
// early return sits below every hook call so the hook order stays
// stable across renders.
// v1.4.31 — operator can hide the read; silent suppression per the
// architecture brief.
if (!flags.healthScoreExplainer) return null;
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
const title = t("insights.healthScore.deltaExplainer.title");
const body = t("insights.healthScore.deltaExplainer.body");
// Direct click handler on the button on both branches. Earlier
// mobile path wrapped the button in a `<span onClick onKeyDown>`
// which created two interactive elements in the a11y tree and
// intercepted clicks on the 2 px gap around the button. The button
// already handles Enter/Space natively; the only thing the wrapper
// owned was the open toggle, which moves onto the button itself.
const handleOpen = () => setOpen(true);
const trigger = (
<button
type="button"
data-slot="health-score-delta-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={resolvedBodyId}
return (
<span
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className={cn(
// Visible glyph stays 12 px so the chip reads at the delta
// line's height without inflating the row. The button itself
// sits at the 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`;
// negative `-my-3 -mx-2` collapses the surrounding row back
// to its 16 px stride so the optical chip stays small while
// the hit surface lifts to 44 px. Transparent padding owns
// the extra reach.
"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",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"text-muted-foreground text-[11px] leading-snug",
className,
)}
onClick={handleOpen}
>
<HelpCircle className="h-3 w-3" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={title}
description={t("insights.healthScore.deltaExplainer.description", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
>
<p
id={bodyId}
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
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className="text-muted-foreground text-[11px] leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
{t("insights.healthScore.deltaExplainer.body")}
</span>
);
}
@@ -69,10 +69,11 @@ describe("<MoodDiscoveredRelations>", () => {
// factor label resolves to the localized measurement name
expect(html).toContain("Time in Daylight");
expect(html).toContain("higher next-day mood");
// The n/r/q detail rides a focusable explainer icon (its tooltip content
// is portalled on open, so it is absent from the static markup). Assert
// the icon is wired per pair instead of the inline stat string.
expect(html).toContain('aria-label="How this was computed"');
// v1.22 — the n/r/q detail no longer hides behind an "i" glyph + tooltip;
// it reads inline as a muted caption, so the stat string is present in the
// static markup.
expect(html).toContain('data-slot="mood-explainer-detail"');
expect(html).toContain("40 paired days");
});
it("phrases a mood → next-day outcome pair", () => {
@@ -85,7 +86,7 @@ describe("<MoodDiscoveredRelations>", () => {
expect(html).toContain("lower next-day");
});
it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => {
it("filters to mood pairs and reads the full-family caveat inline", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -96,14 +97,12 @@ describe("<MoodDiscoveredRelations>", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
// The false-discovery footer + the observational disclaimer fold into the
// subsection explainer icon; its tooltip content is portalled on open, so
// assert the trigger exists rather than the (now hidden) footer string.
// v1.22 — the false-discovery footer + observational disclaimer no longer
// hide behind an "i" glyph + tooltip; they read inline as a muted caption,
// so both the caption slot and its text are present in the static markup.
expect(
(html.match(/aria-label="How this was computed"/g) ?? []).length,
(html.match(/data-slot="mood-explainer-detail"/g) ?? []).length,
).toBeGreaterThanOrEqual(1);
// The former muted explainer paragraph + inline footnote rows are gone.
expect(html).not.toContain("day-to-day pairs tested");
expect(html).not.toContain("hold up after correcting");
expect(html).toContain("day-to-day pairs tested");
});
});
@@ -1,55 +1,34 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* v1.12.4 (C3/C4) — compact explainer affordance for the mood surface.
* v1.12.4 (C3/C4) — compact explainer for the mood surface.
*
* Replaces the low-density inline "how this was computed" text rows
* (paired-day counts, the false-discovery footer, the observational
* disclaimer) with a single ring/info icon. The detail moves into a
* hover/focus tooltip so the surface stops spending a full-width row on a
* footnote. The trigger is a real focusable `button` carrying an
* `aria-label`, so the explanation is reachable by keyboard and screen
* readers — not hover-only. Self-contained `TooltipProvider` so callers
* don't need an ambient one.
* v1.22 — the round info glyph that hid the statistical caveat behind a
* hover/focus tooltip is gone. The maintainer wants the trailing "i"
* affordance removed across the insights surface; the descriptive text it
* used to gate now reads inline as a muted caption, so the explanation is
* always visible rather than disclosure-only. The `label` prop is retained
* for callers but is no longer rendered as an accessible trigger name —
* there is no trigger to name.
*/
export function MoodExplainerIcon({
label,
detail,
className,
}: {
/** Accessible name for the trigger (what the icon explains). */
/** Accessible name for the (now removed) trigger. Retained for callers. */
label: string;
/** The explanation shown in the tooltip. */
/** The explanation, rendered inline as a muted caption. */
detail: string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-current/40 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
{detail}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span
data-slot="mood-explainer-detail"
className={cn("text-muted-foreground text-xs leading-snug", className)}
>
{detail}
</span>
);
}
@@ -2,18 +2,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarRange, Info } from "lucide-react";
import { CalendarRange } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { apiGet } from "@/lib/api/api-fetch";
/**
@@ -64,6 +59,13 @@ async function fetchNarrative(
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
}
/**
* v1.22 — the round ⓘ provenance glyph that hid the "how this was computed"
* read behind a popover is removed; the maintainer wants the trailing info
* affordance gone across the insights surface. The method line — and, when
* present, the metrics it drew on — now reads inline as a muted caption at the
* foot of the card, always visible rather than disclosure-only.
*/
function ProvenanceDisclosure({
provenance,
}: {
@@ -72,27 +74,15 @@ function ProvenanceDisclosure({
const { t } = useTranslations();
const metrics = provenance.metrics.slice(0, 8).join(", ");
return (
<Popover>
<PopoverTrigger
aria-label={t("insights.narrativeProvenanceLabel")}
className="text-muted-foreground hover:text-foreground inline-flex size-5 shrink-0 items-center justify-center rounded-full transition-colors"
>
<Info className="size-3.5" aria-hidden="true" />
</PopoverTrigger>
<PopoverContent className="w-80 text-sm" align="end">
<p className="text-foreground font-medium">
{t("insights.narrativeProvenanceLabel")}
</p>
<p className="text-muted-foreground mt-1">
{t("insights.narrativeProvenanceMethod")}
</p>
{metrics ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("insights.narrativeProvenanceMetrics", { metrics })}
</p>
) : null}
</PopoverContent>
</Popover>
<p
data-slot="period-narrative-provenance"
className="text-muted-foreground text-[11px] leading-snug"
>
{t("insights.narrativeProvenanceMethod")}
{metrics
? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}`
: ""}
</p>
);
}
@@ -168,11 +158,6 @@ export function PeriodNarrativeCard({
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
action={
narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : undefined
}
/>
<div
data-slot="period-narrative-card"
@@ -215,6 +200,10 @@ export function PeriodNarrativeCard({
</p>
)}
{narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : null}
{narrative ? (
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
+22 -3
View File
@@ -34,6 +34,14 @@ interface SectionHeadingProps {
icon: ComponentType<{ className?: string }>;
/** Heading text rendered inside the `<h2>`. */
title: ReactNode;
/**
* Optional one-line description rendered directly under the `<h2>`, in the
* same muted typography the detail pages use beneath their headings
* (`text-muted-foreground text-sm`). Opt-in per section so the overview can
* carry the "descriptive line under every heading" rule without forcing a
* subtitle where none reads well.
*/
subtitle?: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
action?: ReactNode;
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
@@ -44,6 +52,7 @@ interface SectionHeadingProps {
export function SectionHeading({
icon: Icon,
title,
subtitle,
action,
id,
className,
@@ -58,9 +67,19 @@ export function SectionHeading({
>
<div className="flex min-w-0 items-center gap-2">
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
<div className="min-w-0">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
{subtitle ? (
<p
data-slot="section-heading-subtitle"
className="text-muted-foreground text-sm"
>
{subtitle}
</p>
) : null}
</div>
</div>
{action ? (
<div className="flex shrink-0 items-center gap-2">{action}</div>
+1 -34
View File
@@ -3,16 +3,10 @@
import { useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Info, ListOrdered } from "lucide-react";
import { ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope";
@@ -344,33 +338,6 @@ export function SubPageShell({
className="text-muted-foreground text-sm leading-relaxed"
>
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
{/* v1.18.6 — a discreet general-guidance tooltip travels with
every metric definition. The reference bands in the body
above are population anchors, not medical advice; this
caption pins that framing without crowding the prose. */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="metric-reference-guidance"
className={cn(
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
"focus-visible:ring-ring/50 rounded-full align-middle",
"focus-visible:ring-2 focus-visible:outline-none",
)}
aria-label={t(
"insights.subPage.explainer.referenceGuidance",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-pretty">
{t("insights.subPage.explainer.referenceGuidance")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
) : null}
{description && !explainerMetric ? (
+1 -5
View File
@@ -200,11 +200,7 @@ export function TrendsRow({
<SectionHeading
icon={TrendingUp}
title={t("insights.trendsRow.title")}
action={
<p className="text-muted-foreground text-xs">
{t("insights.trendsRow.subtitle")}
</p>
}
subtitle={t("insights.trendsRow.subtitle")}
/>
{/* v1.4.22 A4 — equal-height cards. The annotation prose
below each chart varies in length, which used to leave the
@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
@@ -90,6 +91,10 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import {
INSIGHTS_OVERVIEW_PATH,
subPageSlugForType,
} from "@/lib/insights/sub-page-metric";
/**
* v1.4.37 W7c — cumulative HK types whose list view collapses to one
@@ -277,6 +282,20 @@ export function MeasurementList({
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const router = useRouter();
// v1.22 — clicking a row's type badge drills into that metric's Insights
// sub-page when one exists, else into the filtered measurements list. The
// reverse map prefers the dedicated single-metric slug (PULSE → `pulse`,
// WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep
// link the page already supports.
const navigateForType = (type: string) => {
const slug = subPageSlugForType(type);
router.push(
slug
? `${INSIGHTS_OVERVIEW_PATH}/${slug}`
: `/measurements?type=${encodeURIComponent(type)}`,
);
};
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
// v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
@@ -922,12 +941,26 @@ export function MeasurementList({
</TableCell>
<TableCell>
<Badge
asChild
variant="secondary"
className={TYPE_COLORS[m.type] ?? ""}
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
>
{TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type}
<button
type="button"
onClick={() => navigateForType(m.type)}
aria-label={t(
"measurements.openMetricInsights",
{
type: TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type,
},
)}
>
{TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type}
</button>
</Badge>
</TableCell>
<TableCell className="font-semibold tabular-nums">
@@ -1111,11 +1144,18 @@ export function MeasurementList({
</div>
)}
{Icon && (
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
<button
type="button"
onClick={() => navigateForType(m.type)}
aria-label={t("measurements.openMetricInsights", {
type: TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type,
})}
className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`}
>
<Icon className="h-4 w-4" />
</div>
</button>
)}
<div className="min-w-0">
{/* v1.4.27 MB7 / CF-76 — bump the metadata
@@ -76,6 +76,14 @@ describe("resolveInsightsLayout — v1 → v2 forward upgrade", () => {
visible: false,
order: 1,
});
// v1.22 — a late-added default tile the saved blob never enumerated
// (e.g. `steps`, which entered the set in v1.12) merges in
// default-VISIBLE, matching the fresh-account default. The old
// force-hidden merge silently dropped its nav pill on every read of an
// existing or iOS-synced layout.
const steps = resolved.tiles.find((t) => t.id === "steps");
expect(steps?.visible).toBe(true);
});
it("returns the full default layout for garbage input", () => {
+8 -2
View File
@@ -215,7 +215,14 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
});
// Merge with defaults so new tiles introduced in later versions show
// up automatically (invisible by default, the user opts in).
// up automatically. v1.22 — appended tiles inherit the default's
// visibility (`visible: true` for every default tile) instead of being
// force-hidden. The old default-invisible override silently dropped the
// pill for any late-added tile id (e.g. `steps`, which entered the set in
// v1.12) on every read of an existing or iOS-synced layout that didn't
// enumerate it, so it never matched the fresh-account default. The
// per-tile data floor (`summaries[...].count > 0` at the call site) still
// keeps an empty metric from bloating the nav.
const savedIds = new Set(filtered.map((t) => t.id));
const missing = DEFAULT_INSIGHTS_LAYOUT.tiles.filter(
(t) => !savedIds.has(t.id),
@@ -223,7 +230,6 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
const maxOrder = Math.max(0, ...filtered.map((t) => t.order));
const appended = missing.map((t, i) => ({
...t,
visible: false, // default-invisible on auto-upgrade
order: maxOrder + 1 + i,
}));
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import {
TYPE_TO_SUB_PAGE_SLUG,
subPageSlugForType,
} from "@/lib/insights/sub-page-metric";
/**
* v1.22 — the measurements list lets a user click a metric type to drill
* into its Insights sub-page. The reverse map underneath must resolve an
* ambiguous type to its dedicated single-metric page, never a multi-metric
* cluster page, and must stay silent for types with no card so the caller
* falls back to the filtered list.
*/
describe("type → insights sub-page slug", () => {
it("prefers the dedicated single-metric page over a multi-metric one", () => {
// PULSE lives on both `pulse` (=[PULSE]) and `blood-pressure`
// (=[SYS, DIA, PULSE]) — the single-metric page wins.
expect(subPageSlugForType("PULSE")).toBe("pulse");
// WEIGHT lives on both `weight` (=[WEIGHT]) and the derived `bmi`
// (=[WEIGHT]) — the first single-metric page in slug order wins.
expect(subPageSlugForType("WEIGHT")).toBe("weight");
});
it("falls back to the multi-metric page for its component types", () => {
expect(subPageSlugForType("BLOOD_PRESSURE_SYS")).toBe("blood-pressure");
expect(subPageSlugForType("BLOOD_PRESSURE_DIA")).toBe("blood-pressure");
// Both HRV flavours only appear on the `hrv` page.
expect(subPageSlugForType("HEART_RATE_VARIABILITY")).toBe("hrv");
expect(subPageSlugForType("HRV_RMSSD")).toBe("hrv");
});
it("maps a plain single-metric type to its page", () => {
expect(subPageSlugForType("ACTIVITY_STEPS")).toBe("steps");
expect(subPageSlugForType("MOOD")).toBe("mood");
expect(subPageSlugForType("BLOOD_GLUCOSE")).toBe("blood-glucose");
});
it("returns undefined for a type with no sub-page", () => {
expect(subPageSlugForType("BODY_FAT")).toBeUndefined();
expect(subPageSlugForType("RECOVERY_SCORE")).toBeUndefined();
expect(subPageSlugForType("NOT_A_REAL_TYPE")).toBeUndefined();
});
it("never resolves a type to an empty-metric (event-driven) page", () => {
// `workouts` and `medications` carry no measurement series, so no type
// should ever resolve to them.
for (const slug of Object.values(TYPE_TO_SUB_PAGE_SLUG)) {
expect(slug).not.toBe("workouts");
expect(slug).not.toBe("medications");
}
});
});
+46
View File
@@ -321,6 +321,52 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record<
]),
) as unknown as Record<ManagerGroup, readonly SubPageSlug[]>;
/**
* v1.22 — reverse lookup: a measurement type → the routed insights sub-page
* slug that focuses it, when one exists.
*
* Built from {@link SUB_PAGE_METRIC}, preferring the slug whose metric list
* is exactly `[type]` so an ambiguous type resolves to its dedicated
* single-metric page rather than a multi-metric cluster: `PULSE` → `pulse`
* (not `blood-pressure`), `WEIGHT` → `weight` (not the derived `bmi`). When
* two single-metric pages list the same type (`weight` + `bmi` both list
* `WEIGHT`) the first in `SUB_PAGE_SLUGS` order wins (`weight`). A type that
* only ever appears on a multi-metric page (the two blood-pressure
* components, the two HRV flavours) falls back to that page. Types with no
* sub-page at all (BODY_FAT, RECOVERY_SCORE, the `*_EVENT` series, most
* device scores) are absent — the caller falls back to the filtered
* measurements list.
*/
export const TYPE_TO_SUB_PAGE_SLUG: Readonly<Record<string, SubPageSlug>> =
(() => {
const map: Record<string, SubPageSlug> = {};
// Pass 1 — exact single-metric pages win (first occurrence in slug order).
for (const slug of SUB_PAGE_SLUGS) {
const metrics = SUB_PAGE_METRIC[slug];
if (metrics.length !== 1) continue;
const type = metrics[0];
if (!(type in map)) map[type] = slug;
}
// Pass 2 — multi-metric pages fill any type a single-metric page misses.
for (const slug of SUB_PAGE_SLUGS) {
const metrics = SUB_PAGE_METRIC[slug];
if (metrics.length === 1) continue;
for (const type of metrics) {
if (!(type in map)) map[type] = slug;
}
}
return map;
})();
/**
* The insights sub-page slug for a measurement type, or `undefined` when no
* routed card focuses it. See {@link TYPE_TO_SUB_PAGE_SLUG} for the
* ambiguity-resolution rule.
*/
export function subPageSlugForType(type: string): SubPageSlug | undefined {
return TYPE_TO_SUB_PAGE_SLUG[type];
}
/**
* Mother-page route. Kept here as a named constant so call sites
* never have to hard-code the string.