From bcca7664a54fe13cca0d71f7c7b055171b970cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 20:48:05 +0200 Subject: [PATCH] refactor(insights): collapse five wave-A vital pills under a Vitals parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights tab strip carried fourteen pills end-to-end after the v1.4.32 wave-A HealthKit additions: Overview + thirteen metrics. On a `Parent.*`. - `` discriminated `TabEntry` union — flat `` pills coexist with `Popover`-driven group entries. Availability gating sits on the leaf slugs: the parent pill is only rendered when at least one of its children has data. - Parent-pill active state mirrors the URL — when a user is on any vital-sign sub-page, the parent pill carries `aria-current="page"` and the `border-primary` style so spatial orientation survives the collapse. Reduces the strip footprint from 14 to 10 entries when every metric has data — Overview + Blood Pressure + Pulse + Vitals (parent) + Weight + BMI + Workouts + Sleep + Mood + Medication. Per `.planning/research/v1434-r-2-carryover-scope.md` §8 and `.planning/round-v1433-audit-menu.md` §7.1 (item 1). --- .../__tests__/insights-tab-strip.test.tsx | 58 +++++ .../insights/insights-tab-strip.tsx | 238 ++++++++++++++---- src/lib/insights/sub-page-metric.ts | 48 +++- 3 files changed, 293 insertions(+), 51 deletions(-) diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index b90819795..1d7eef2f2 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -70,6 +70,9 @@ describe(" — availability gating (v1.4.27 F19)", () => { expect(html).not.toContain(">Mood<"); expect(html).not.toContain(">Medication<"); expect(html).not.toContain(">Sleep<"); + // v1.4.34 IW-D — the parent "Vitals" pill stays out when no + // wave-A HealthKit metric has data (none in this availability). + expect(html).not.toContain(">Vitals<"); }); it("keeps the overview pill regardless of availability", () => { @@ -115,3 +118,58 @@ describe(" — availability gating (v1.4.27 F19)", () => { expect(html).toContain(">BMI<"); }); }); + +describe(" — vitals group collapse (v1.4.34 IW-D)", () => { + it("renders a single 'Vitals' parent pill instead of five wave-A pills in the strip row", () => { + // No availability ⇒ every wave-A pill is visible ⇒ the parent pill + // appears once. The five sub-pages still render in the popover + // body but that lives in a Radix Portal which `renderToStaticMarkup` + // does not serialise (no portal target in SSR) — so we assert on + // the strip-row only. Behaviour test for the popover body sits in + // a Playwright spec when the dev server runs. + const html = render(); + // Parent pill — there is exactly one. + const parentMatches = html.match(/>VitalsHRV<"); + expect(html).not.toContain(">Resting HR<"); + expect(html).not.toContain(">Oxygen<"); + expect(html).not.toContain(">Temperature<"); + // "Active energy" overlaps with no other label in en/de so we can + // assert the exact match. + expect(html).not.toContain(">Active energy<"); + }); + + it("hides the parent pill when no wave-A metric has data", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(2) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).not.toContain(">Vitals<"); + expect(html).not.toContain('data-slot="insights-tab-strip-group"'); + }); + + it("renders the parent pill as a popover trigger (button, not link)", () => { + const html = render(); + expect(html).toContain('data-slot="insights-tab-strip-group"'); + expect(html).toContain('data-group="vitals"'); + }); + + it("preserves the non-grouped pills inline", () => { + const html = render(); + // Flat (non-grouped) pills must still be reachable directly. + expect(html).toContain(">Blood Pressure<"); + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + expect(html).toContain(">Sleep<"); + expect(html).toContain(">Mood<"); + expect(html).toContain(">Medication<"); + expect(html).toContain(">Workouts<"); + }); +}); diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 7b69eb7cf..ae70ef6cd 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -3,14 +3,22 @@ import { memo, useEffect, useMemo, useRef } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Loader2, RefreshCw } from "lucide-react"; +import { ChevronDown, Loader2, RefreshCw } from "lucide-react"; import { toast } from "sonner"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { INSIGHTS_OVERVIEW_PATH, + SUB_PAGE_GROUP, + SUB_PAGE_GROUP_ORDER, SUB_PAGE_SLUGS, + type SubPageGroup, type SubPageSlug, } from "@/lib/insights/sub-page-metric"; import { @@ -67,12 +75,31 @@ export interface InsightsTabStripProps { availability?: InsightInputs; } -interface TabEntry { - /** Pathname this pill links to. */ - href: string; - /** Translation key for the pill label. */ - labelKey: string; -} +/** + * v1.4.34 IW-D — a strip entry is either a flat `` pill or a + * group "parent" pill that opens a popover containing the sub-pages + * in the group. The discriminated union keeps the renderer branch- + * exhaustive and lets future groups land with one new variant. + */ +type TabEntry = + | { + kind: "link"; + /** Pathname this pill links to. */ + href: string; + /** Translation key for the pill label. */ + labelKey: string; + } + | { + kind: "group"; + /** Group key (e.g. `"vitals"`). Used for keys + a11y wiring. */ + group: SubPageGroup; + /** Translation key for the parent-pill label. */ + labelKey: string; + /** Translation key for the popover header. */ + headerKey: string; + /** Visible sub-pages inside the group (post-availability gating). */ + children: Array<{ href: string; labelKey: string; slug: SubPageSlug }>; + }; /** * Slug → (label key, gating metric) mapping. Keeping the metric here @@ -128,18 +155,70 @@ const SUB_PAGE_TABS: Record< medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" }, }; +/** + * v1.4.34 IW-D — group metadata (label + popover header) keyed by + * `SubPageGroup`. The strip renders one parent pill per group; the + * five wave-A HealthKit pills are the only group today. + */ +const SUB_PAGE_GROUP_META: Record< + SubPageGroup, + { labelKey: string; headerKey: string } +> = { + vitals: { + labelKey: "insights.tabStrip.vitalsParent.label", + headerKey: "insights.tabStrip.vitalsParent.header", + }, +}; + function buildTabs(availability: InsightInputs | undefined): TabEntry[] { - const subPageEntries = SUB_PAGE_SLUGS.filter((slug) => { + const visibleSlugs = SUB_PAGE_SLUGS.filter((slug) => { if (!availability) return true; return hasMetricData(SUB_PAGE_TABS[slug].metric, availability); - }).map((slug) => ({ - href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`, - labelKey: SUB_PAGE_TABS[slug].labelKey, - })); - return [ - { href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" }, - ...subPageEntries, + }); + + const visibleSet = new Set(visibleSlugs); + const entries: TabEntry[] = [ + { + kind: "link", + href: INSIGHTS_OVERVIEW_PATH, + labelKey: "insights.navOverview", + }, ]; + + // v1.4.34 IW-D — track which groups have already been emitted so the + // first encountered group-member triggers the parent pill at the + // same strip position (preserving categorical order). + const emittedGroups = new Set(); + + for (const slug of visibleSlugs) { + const group = SUB_PAGE_GROUP[slug]; + if (group) { + if (emittedGroups.has(group)) continue; + emittedGroups.add(group); + const children = SUB_PAGE_GROUP_ORDER[group] + .filter((childSlug) => visibleSet.has(childSlug)) + .map((childSlug) => ({ + slug: childSlug, + href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`, + labelKey: SUB_PAGE_TABS[childSlug].labelKey, + })); + if (children.length === 0) continue; + entries.push({ + kind: "group", + group, + labelKey: SUB_PAGE_GROUP_META[group].labelKey, + headerKey: SUB_PAGE_GROUP_META[group].headerKey, + children, + }); + continue; + } + entries.push({ + kind: "link", + href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`, + labelKey: SUB_PAGE_TABS[slug].labelKey, + }); + } + return entries; } function InsightsTabStripImpl({ @@ -198,34 +277,109 @@ function InsightsTabStripImpl({
{tabs.map((tab) => { - // The overview pill matches the mother page exactly; the - // sub-page pills match a prefix so future nested routes - // (e.g. `/insights/schlaf/2026-05-11`) still highlight the - // parent tab. - const isActive = - tab.href === INSIGHTS_OVERVIEW_PATH - ? pathname === INSIGHTS_OVERVIEW_PATH - : pathname === tab.href || pathname.startsWith(`${tab.href}/`); + if (tab.kind === "link") { + // The overview pill matches the mother page exactly; the + // sub-page pills match a prefix so future nested routes + // (e.g. `/insights/schlaf/2026-05-11`) still highlight the + // parent tab. + const isActive = + tab.href === INSIGHTS_OVERVIEW_PATH + ? pathname === INSIGHTS_OVERVIEW_PATH + : pathname === tab.href || + pathname.startsWith(`${tab.href}/`); + return ( + + {t(tab.labelKey)} + + ); + } + // v1.4.34 IW-D — group parent pill. Renders as a popover + // trigger so desktop hover (`onMouseEnter` falls through to + // Radix click semantics — tap-to-open is the same gesture + // on every device, matching the `` MB3 decision). + // Sub-page navigation happens via the `` inside the + // popover; the parent pill never navigates on its own. + const isGroupActive = tab.children.some( + (child) => + pathname === child.href || + pathname.startsWith(`${child.href}/`), + ); return ( - - {t(tab.labelKey)} - + + + + + +

+ {t(tab.headerKey)} +

+
    + {tab.children.map((child) => { + const isChildActive = + pathname === child.href || + pathname.startsWith(`${child.href}/`); + return ( +
  • + + {t(child.labelKey)} + +
  • + ); + })} +
+
+
); })}
diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index becab143a..04af80070 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -29,15 +29,17 @@ // // vitals → body → activity → sleep → cardiovascular → mood → events // -// The wave-A HealthKit entries added in v1.4.32 (HRV, resting HR, SpO2, -// body temperature, active energy) used to stack at the end of the -// strip in insertion order; the regrouping below interleaves them into -// their categorical neighbours so a user reading left-to-right scans -// each metric domain as a block. Heavier regrouping (collapse five -// wave-A pills under a single "Apple Health" pill, fold BMI into -// Gewicht, fold Sauerstoff/Körpertemperatur into a single Vitals pill) -// is deferred to v1.4.34 — that needs a screenshot review per -// .planning/round-v1433-audit-menu.md §7. +// v1.4.34 IW-D — the five wave-A HealthKit entries (HRV, resting HR, +// SpO2, body temperature, active energy) added in v1.4.32 share a +// presentation group on the routed tab strip. Each sub-page keeps its +// own URL and own page component — only the strip presentation changes +// so the five pills collapse behind one "Vitalwerte" / "Vitals" parent +// pill with a popover sub-list. The `group: "vitals"` field below +// drives that grouping; the canonical order inside the popover follows +// the strip order. The earlier inline pills (`puls`, `blutdruck`, +// `gewicht`, `bmi`, `schlaf`, `stimmung`, `medikamente`, `workouts`) +// stay flat — they predate v1.4.32 and the maintainer's audit kept +// them as direct entries. export const SUB_PAGE_METRIC = { // ── vitals ── blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], @@ -69,6 +71,34 @@ export type SubPageSlug = keyof typeof SUB_PAGE_METRIC; export const SUB_PAGE_SLUGS = Object.keys(SUB_PAGE_METRIC) as SubPageSlug[]; +/** + * v1.4.34 IW-D — strip-presentation grouping. Only `"vitals"` is in + * use today; every other slug renders as a top-level pill. The group + * key is rendered through a single parent pill that opens a popover + * containing the grouped sub-pages. Adding a new group is a one-row + * change here plus a matching label key under + * `insights.tabStrip.Parent.*` in every locale. + */ +export type SubPageGroup = "vitals"; + +export const SUB_PAGE_GROUP: Partial> = { + hrv: "vitals", + ruhepuls: "vitals", + sauerstoff: "vitals", + koerpertemperatur: "vitals", + "aktive-energie": "vitals", +}; + +/** + * v1.4.34 IW-D — canonical order of the sub-pages inside each parent + * popover. The strip iterates `SUB_PAGE_SLUGS`, so a vitals member's + * position in the popover follows its position in that array. + */ +export const SUB_PAGE_GROUP_ORDER: Record = + { + vitals: SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_GROUP[slug] === "vitals"), + }; + /** * Mother-page route. Kept here as a named constant so call sites * never have to hard-code the string.