refactor(insights): collapse five wave-A vital pills under a Vitals parent

The Insights tab strip carried fourteen pills end-to-end after the
v1.4.32 wave-A HealthKit additions: Overview + thirteen metrics.
On a `<sm` viewport that meant Overview + roughly two-and-a-half
metric pills before the right-edge fade hid the rest.

The five wave-A entries (HRV, Resting HR, Oxygen, Body Temperature,
Active Energy) collapse behind a single "Vitals" / "Vitalwerte"
parent pill that opens a popover sub-list. Each sub-page keeps its
own URL and own page component — only the strip presentation
changes, so deep-links, bookmarks, and the iOS app's surface paths
all resolve unchanged.

Implementation:
- `SUB_PAGE_GROUP` + `SUB_PAGE_GROUP_ORDER` in
  `sub-page-metric.ts` flag a sub-page as belonging to a parent
  pill. Adding a new group is one row in `SUB_PAGE_GROUP` plus a
  matching label key under `insights.tabStrip.<group>Parent.*`.
- `<InsightsTabStrip>` discriminated `TabEntry` union — flat
  `<Link>` 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).
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:48:05 +02:00
1 parent fd81d3b806
commit bcca7664a5
3 files changed
+293 -51

No files matched your search

@@ -70,6 +70,9 @@ describe("<InsightsTabStrip> — 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("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
expect(html).toContain(">BMI<");
});
});
describe("<InsightsTabStrip> — 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(<InsightsTabStrip />);
// Parent pill — there is exactly one.
const parentMatches = html.match(/>Vitals</g);
expect(parentMatches).not.toBeNull();
expect(parentMatches!.length).toBe(1);
// The flat strip-row should NOT carry the five individual labels —
// they live behind the parent pill now.
expect(html).not.toContain(">HRV<");
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(<InsightsTabStrip availability={availability} />);
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(<InsightsTabStrip />);
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(<InsightsTabStrip />);
// 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<");
});
});
+196 -42
View File
@@ -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 `<Link>` 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<SubPageGroup>();
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({
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
{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 (
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? "page" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
// 44px touch-target floor (W8.3) — pills are primary
// navigation; the regenerate icon-button to the right
// already meets the same minimum.
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
}
// 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 `<Popover>` MB3 decision).
// Sub-page navigation happens via the `<Link>` 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 (
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? "page" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
// 44px touch-target floor (W8.3) — pills are primary
// navigation; the regenerate icon-button to the right
// already meets the same minimum.
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
<Popover key={`group-${tab.group}`}>
<PopoverTrigger asChild>
<button
type="button"
aria-current={isGroupActive ? "page" : undefined}
data-slot="insights-tab-strip-group"
data-group={tab.group}
data-active={isGroupActive ? "true" : undefined}
className={cn(
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isGroupActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
<ChevronDown
className="h-3 w-3 opacity-70"
aria-hidden="true"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
data-slot="insights-tab-strip-group-popover"
className="w-56 p-2 text-sm"
>
<p className="text-muted-foreground px-2 py-1 text-[11px] font-semibold tracking-wide uppercase">
{t(tab.headerKey)}
</p>
<ul className="space-y-1" role="list">
{tab.children.map((child) => {
const isChildActive =
pathname === child.href ||
pathname.startsWith(`${child.href}/`);
return (
<li key={child.href}>
<Link
href={child.href}
aria-current={isChildActive ? "page" : undefined}
data-slot="insights-tab-strip-group-item"
data-active={isChildActive ? "true" : undefined}
className={cn(
"flex min-h-10 items-center rounded-md px-2 py-1.5 text-sm transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isChildActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
{t(child.labelKey)}
</Link>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
);
})}
</div>
+39 -9
View File
@@ -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.<group>Parent.*` in every locale.
*/
export type SubPageGroup = "vitals";
export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
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<SubPageGroup, readonly SubPageSlug[]> =
{
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.