mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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<");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user