diff --git a/messages/de.json b/messages/de.json index 5db331c6c..fc469ffe7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 029f3f1ec..c02b890d4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx index ace97f219..17ed6ec87 100644 --- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx +++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx @@ -198,8 +198,8 @@ describe("", () => { // 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"'); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index bc4cee525..ef0ee82e5 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -129,10 +129,11 @@ describe("", () => { }); it("threads aria-describedby from the delta text to the explainer body", () => { - // FB-I1 a11y — the delta `` 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 `` 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( ", () => { 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( + `]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`, + ), + ); }); it("renders four component rows with their values", () => { diff --git a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx index 05ed40648..ba54d1f8c 100644 --- a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx +++ b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx @@ -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 ( -
- - {t("insights.healthScore.deltaExplainer.trigger")} - - - {t("insights.healthScore.deltaExplainer.title")} - - - {t("insights.healthScore.deltaExplainer.body")} - -
+ + {t("insights.healthScore.deltaExplainer.body")} + ); } describe("", () => { - 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(); - const trigger = html.match( - /]*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(); - const trigger = html.match( - /]*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(); - const trigger = html.match( - /]*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( , ); - const trigger = html.match( - /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, + expect(html).toMatch( + /]*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 (single interactive element)", () => { - // The earlier mobile path wrapped the button in a - // `` 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(); - expect(html).not.toMatch(/]*onclick/i); - }); - - 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"', - ); + 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("", () => { 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( , ); - // 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"); } }); diff --git a/src/components/insights/derived/__tests__/provenance-explainer.test.tsx b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx index 4bcee28f3..69ed6e1d9 100644 --- a/src/components/insights/derived/__tests__/provenance-explainer.test.tsx +++ b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx @@ -26,7 +26,7 @@ const EMPTY_PROVENANCE: DerivedProvenance = { }; describe("", () => { - 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( ", () => { 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( - , - ); - 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( , ); - 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("", () => { }} />, ); - expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('data-slot="provenance-explainer-method"'); }); }); diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx index 7329346b2..cd193728f 100644 --- a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx +++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx @@ -55,9 +55,9 @@ describe("", () => { // 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"'); }); diff --git a/src/components/insights/derived/provenance-explainer.tsx b/src/components/insights/derived/provenance-explainer.tsx index c3058e167..71d00b017 100644 --- a/src/components/insights/derived/provenance-explainer.tsx +++ b/src/components/insights/derived/provenance-explainer.tsx @@ -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` 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 = ( -
-
-

- {t("insights.derived.provenance.inputsLabel")} -

- {provenance.inputs.length > 0 ? ( -
    - {provenance.inputs.map((input) => ( -
  • - {input} -
  • - ))} -
- ) : ( -

- {t("insights.derived.provenance.noInputs")} -

- )} -
- -
-

- {t("insights.derived.provenance.methodLabel")} -

-

- {method} -

-
- -

- {t("insights.derived.provenance.window", { - days: provenance.windowDays, - source: provenance.source, - })} - {asOf - ? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}` - : ""} -

- - {standard && ( - - {t("insights.derived.provenance.standardLink", { - name: standard.name, - })} - - )} -
- ); - - const trigger = ( - - ); - - if (isMobile) { - return ( - <> - {trigger} - -
{body}
-
- - ); - } - - return ( - - {trigger} - -

- {resolvedTitle} -

- {body} -
-
+ {method} +
); } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 00e400389..76f0c1394 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -729,6 +729,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
` 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 `` 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 `` - // 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 = ( - - ); - - if (isMobile) { - return ( - <> - {trigger} - 0 ? `+${delta}` : `${delta}`, - })} - > -

- {body} -

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

- {title} -

-

- {body} -

-
-
+ {t("insights.healthScore.deltaExplainer.body")} +
); } diff --git a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx index 63151a0f2..7dde14d36 100644 --- a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx +++ b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx @@ -69,10 +69,11 @@ describe("", () => { // 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("", () => { 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("", () => { 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"); }); }); diff --git a/src/components/insights/mood/mood-explainer-icon.tsx b/src/components/insights/mood/mood-explainer-icon.tsx index 8ccc0a35d..c65ca0295 100644 --- a/src/components/insights/mood/mood-explainer-icon.tsx +++ b/src/components/insights/mood/mood-explainer-icon.tsx @@ -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 ( - - - - - - {detail} - - - + + {detail} + ); } diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index dd2ccce1d..b7a8615d3 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -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(`/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 ( - - - - -

- {t("insights.narrativeProvenanceLabel")} -

-

- {t("insights.narrativeProvenanceMethod")} -

- {metrics ? ( -

- {t("insights.narrativeProvenanceMetrics", { metrics })} -

- ) : null} -
-
+

+ {t("insights.narrativeProvenanceMethod")} + {metrics + ? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}` + : ""} +

); } @@ -168,11 +158,6 @@ export function PeriodNarrativeCard({ - ) : undefined - } />
)} + {narrative?.provenance ? ( + + ) : null} + {narrative ? (

{data?.revalidating diff --git a/src/components/insights/section-heading.tsx b/src/components/insights/section-heading.tsx index d15a9de99..67a4260e3 100644 --- a/src/components/insights/section-heading.tsx +++ b/src/components/insights/section-heading.tsx @@ -34,6 +34,14 @@ interface SectionHeadingProps { icon: ComponentType<{ className?: string }>; /** Heading text rendered inside the `

`. */ title: ReactNode; + /** + * Optional one-line description rendered directly under the `

`, 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 `

`, 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({ >
{action ? (
{action}
diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 31486d280..875099824 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -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. */} - - - - - - - {t("insights.subPage.explainer.referenceGuidance")} - - -

) : null} {description && !explainerMetric ? ( diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 5bc1c51b3..4056c052e 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -200,11 +200,7 @@ export function TrendsRow({ - {t("insights.trendsRow.subtitle")} -

- } + 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 diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index b270a1fd4..aa6c3bf59 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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({ - {TYPE_LABEL_KEYS[m.type] - ? t(TYPE_LABEL_KEYS[m.type]) - : m.type} + @@ -1111,11 +1144,18 @@ export function MeasurementList({

)} {Icon && ( -
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] ?? ""}`} > -
+ )}
{/* v1.4.27 MB7 / CF-76 — bump the metadata diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index c68033ce7..b637a385f 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -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", () => { diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 44cba5e6f..7b2d3367a 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -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, })); diff --git a/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts new file mode 100644 index 000000000..ffaa085f0 --- /dev/null +++ b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts @@ -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"); + } + }); +}); diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 8c6421aa9..73967b7da 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -321,6 +321,52 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record< ]), ) as unknown as Record; +/** + * 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> = + (() => { + const map: Record = {}; + // 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.