From 0f1b0021080c59063dd4694f3fb5087489bf3feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 20:49:07 +0200 Subject: [PATCH] feat(insights): gate the tab strip on the saved layout The top insights navigation was driven purely by data availability: a sub-page pill appeared whenever its metric had readings in the 90-day window, with no connection to the per-tile visibility the customize panel writes. Hiding a sub-page in Anpassen therefore never removed its nav pill, and the two surfaces drifted apart. Thread the resolved insights layout into the tab strip via the layout shell. A pill now shows iff its metric has data AND its slug is visible in the saved layout; data availability stays the floor, so a layout-visible metric with no readings still gets no pill. Pills and group children order by the saved layout order, falling back to the natural slug order. While the layout query is in flight the strip keeps the data-only gate, so first paint is unchanged and a slow or failed layout fetch never suppresses a pill the user has data for. Add a total slug to manager-group mapping in sub-page-metric so every routed sub-page resolves to a category, a superset of the tab-strip nav grouping that also covers the flat pills and the sleep / mood / events categories the strip never collapses. --- .../__tests__/insights-tab-strip.test.tsx | 117 ++++++++++++++++++ .../insights/insights-layout-shell.tsx | 26 ++++ .../insights/insights-tab-strip.tsx | 77 ++++++++++-- .../__tests__/sub-page-manager-group.test.ts | 49 ++++++++ src/lib/insights/sub-page-metric.ts | 89 +++++++++++++ 5 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 src/lib/insights/__tests__/sub-page-manager-group.test.ts diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 8323e2595..1c8967edd 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -223,3 +223,120 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { expect(html).toContain(">Workouts<"); }); }); + +describe(" — saved-layout visibility gate (v1.15.14 W2)", () => { + const dataAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + WEIGHT: fakeSummary(3), + }, + hasMood: false, + hasMedication: false, + }; + + it("hides a pill whose slug is layout-hidden even though it has data", () => { + // `pulse` has data but is NOT in the visible set ⇒ no pill. `weight` + // has data AND is visible ⇒ pill shows. + const visibleTileIds = new Set(["overview", "weight", "bmi"]); + const html = render( + , + ); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + expect(html).not.toContain(">Pulse<"); + }); + + it("shows a pill when its slug is layout-visible AND has data", () => { + const visibleTileIds = new Set([ + "overview", + "pulse", + "weight", + "bmi", + ]); + const html = render( + , + ); + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + }); + + it("keeps data-availability as the FLOOR — a layout-visible metric with zero data stays hidden", () => { + // `blood-pressure` is layout-visible but has no data ⇒ still no pill. + const visibleTileIds = new Set([ + "overview", + "blood-pressure", + "weight", + "bmi", + ]); + const html = render( + , + ); + expect(html).not.toContain(">Blood Pressure<"); + expect(html).toContain(">Weight<"); + }); + + it("falls back to the data-only gate when no layout is loaded (visibleTileIds undefined)", () => { + // No visibleTileIds prop ⇒ pre-W2 behaviour: every data-having pill shows. + const html = render(); + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + }); + + it("always shows the Overview pill regardless of layout visibility", () => { + const visibleTileIds = new Set(); // nothing visible + const html = render( + , + ); + expect(html).toContain(">Overview<"); + expect(html).not.toContain(">Pulse<"); + expect(html).not.toContain(">Weight<"); + }); + + it("hides a group parent pill when every child is layout-hidden", () => { + // Activity group: ACTIVITY_STEPS has data, but `steps` is not visible. + const availability: InsightInputs = { + summaries: { ACTIVITY_STEPS: fakeSummary(4200) }, + hasMood: false, + hasMedication: false, + }; + const visibleTileIds = new Set(["overview"]); // steps hidden + const html = render( + , + ); + expect(html).not.toContain('data-group="activity"'); + expect(html).not.toContain(">Activity<"); + }); + + it("shows a group parent pill when at least one child is visible + has data", () => { + const availability: InsightInputs = { + summaries: { ACTIVITY_STEPS: fakeSummary(4200) }, + hasMood: false, + hasMedication: false, + }; + const visibleTileIds = new Set(["overview", "steps"]); + const html = render( + , + ); + expect(html).toContain('data-group="activity"'); + expect(html).toContain(">Activity<"); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 2f6d31349..942b33a13 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -11,6 +11,7 @@ import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { queryKeys } from "@/lib/query-keys"; import type { InsightInputs } from "@/lib/insights/metric-availability"; @@ -112,12 +113,37 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { return { summaries, hasMood, hasMedication, hasWorkouts }; }, [isAuthenticated, summaries, hasMood, hasMedication, hasWorkouts]); + // v1.15.14 W2 — couple the tab strip to the saved insights layout. + // A sub-page pill now shows iff its metric has data AND its slug is + // layout-`visible`; the strip orders pills by the saved `order`. We + // pass the derived visibility set + order map only once the GET has + // SETTLED (`isSuccess`) — while the layout query is in flight the + // strip falls back to the data-only gate (props `undefined`) so the + // first paint is unchanged and a slow/failed layout fetch never + // suppresses a pill the user has data for. The query shares + // `queryKeys.insightsLayout()` with the overview + edit mode, so a + // "Fertig" save repaints the strip in lockstep. + const { layout: insightsLayout, isSuccess: layoutSettled } = + useInsightsLayoutQuery(isAuthenticated); + const visibleTileIds = useMemo(() => { + if (!layoutSettled) return undefined; + return new Set( + insightsLayout.tiles.filter((tile) => tile.visible).map((tile) => tile.id), + ); + }, [layoutSettled, insightsLayout]); + const tileOrder = useMemo(() => { + if (!layoutSettled) return undefined; + return new Map(insightsLayout.tiles.map((tile) => [tile.id, tile.order])); + }, [layoutSettled, insightsLayout]); + return (
{children} {/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer. diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index b92503cff..91488705a 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -73,6 +73,22 @@ export interface InsightsTabStripProps { * stay backward-compatible. */ availability?: InsightInputs; + /** + * v1.15.14 W2 — the set of sub-page slugs the user's saved insights + * layout marks `visible`. A pill shows iff its metric has data AND its + * slug is in this set, so hiding a sub-page in "Anpassen" removes its + * nav pill. `undefined` (layout not yet loaded) falls back to the + * data-only gate so first paint is unchanged. Data availability stays + * the FLOOR — a layout-visible metric with zero data never gets a pill. + */ + visibleTileIds?: ReadonlySet; + /** + * v1.15.14 W2 — slug → saved-layout `order`. When present the strip + * orders flat pills, group parent pills (by first member), and group + * children by this order; absent, it falls back to the natural + * `SUB_PAGE_SLUGS` / `SUB_PAGE_GROUP_ORDER` order. + */ + tileOrder?: ReadonlyMap; } /** @@ -111,7 +127,7 @@ type TabEntry = // ORDER is owned by `SUB_PAGE_SLUGS` (which iterates the keys of // `SUB_PAGE_METRIC`) so adding a new metric is one row in // `sub-page-metric.ts` and the strip picks it up automatically. -const SUB_PAGE_TABS: Record< +export const SUB_PAGE_TABS: Record< SubPageSlug, { labelKey: string; metric: InsightMetric } > = { @@ -321,7 +337,11 @@ const SUB_PAGE_GROUP_META: Record< }, }; -function buildTabs(availability: InsightInputs | undefined): TabEntry[] { +function buildTabs( + availability: InsightInputs | undefined, + visibleTileIds: ReadonlySet | undefined, + tileOrder: ReadonlyMap | undefined, +): TabEntry[] { // v1.4.36 W4d — strict gating contract: a sub-page only surfaces a // pill when the availability inputs CONFIRM the metric has at // least one observation in the 90-day window. The legacy @@ -333,13 +353,39 @@ function buildTabs(availability: InsightInputs | undefined): TabEntry[] { // The pills appear progressively as `summaries[METRIC].count` // flips from 0 to ≥1 — exactly the auto-light-up behaviour // described in `metric-availability.ts` line 26. + // + // v1.15.14 W2 — the saved insights layout is now a SECOND gate on top + // of data availability. A pill shows iff its metric has data AND its + // slug is layout-`visible`. Data availability stays the FLOOR (a + // layout-visible metric with zero data still gets no pill). When the + // layout has not loaded yet (`visibleTileIds === undefined`) we fall + // back to the data-only gate so first paint matches the pre-W2 + // behaviour — the layout query then narrows the set on resolve. The + // group child `order` follows the layout (via `orderedSlugs` below), + // falling back to `SUB_PAGE_GROUP_ORDER` when the layout is absent. const visibleSlugs = availability - ? SUB_PAGE_SLUGS.filter((slug) => - hasMetricData(SUB_PAGE_TABS[slug].metric, availability), + ? SUB_PAGE_SLUGS.filter( + (slug) => + hasMetricData(SUB_PAGE_TABS[slug].metric, availability) && + (visibleTileIds === undefined || visibleTileIds.has(slug)), ) : []; const visibleSet = new Set(visibleSlugs); + + // v1.15.14 W2 — order the strip by the saved layout when present. The + // top-level iteration order decides where each flat pill and each group + // parent pill land; a group parent appears at the position of its + // first (lowest-order) visible member. Falling back to the natural + // `SUB_PAGE_SLUGS` order keeps the pre-W2 layout-absent behaviour. + const orderedSlugs = tileOrder + ? [...visibleSlugs].sort( + (a, b) => + (tileOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - + (tileOrder.get(b) ?? Number.MAX_SAFE_INTEGER), + ) + : visibleSlugs; + const entries: TabEntry[] = [ { kind: "link", @@ -353,12 +399,19 @@ function buildTabs(availability: InsightInputs | undefined): TabEntry[] { // same strip position (preserving categorical order). const emittedGroups = new Set(); - for (const slug of visibleSlugs) { + for (const slug of orderedSlugs) { const group = SUB_PAGE_GROUP[slug]; if (group) { if (emittedGroups.has(group)) continue; emittedGroups.add(group); - const children = SUB_PAGE_GROUP_ORDER[group] + const orderedChildren = tileOrder + ? [...SUB_PAGE_GROUP_ORDER[group]].sort( + (a, b) => + (tileOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - + (tileOrder.get(b) ?? Number.MAX_SAFE_INTEGER), + ) + : SUB_PAGE_GROUP_ORDER[group]; + const children = orderedChildren .filter((childSlug) => visibleSet.has(childSlug)) .map((childSlug) => ({ slug: childSlug, @@ -388,6 +441,8 @@ function InsightsTabStripImpl({ onRegenerate, regenerating = false, availability, + visibleTileIds, + tileOrder, }: InsightsTabStripProps) { const { t } = useTranslations(); const pathname = usePathname(); @@ -398,7 +453,15 @@ function InsightsTabStripImpl({ // reconciliation short-circuits when neither pathname nor // availability changed. Per // `.planning/research/v15-insights-blocking-bug.md` fix 2. - const tabs = useMemo(() => buildTabs(availability), [availability]); + // + // v1.15.14 W2 — the saved-layout visibility set + order map join the + // deps so the strip re-derives when the user toggles a sub-page in + // "Anpassen". The shell memoises both props so an unchanged layout + // doesn't churn the array. + const tabs = useMemo( + () => buildTabs(availability, visibleTileIds, tileOrder), + [availability, visibleTileIds, tileOrder], + ); // Fire success toast on the falling edge of `regenerating`. Same // rising-edge ref guard as the W3 implementation so the toast fires diff --git a/src/lib/insights/__tests__/sub-page-manager-group.test.ts b/src/lib/insights/__tests__/sub-page-manager-group.test.ts new file mode 100644 index 000000000..82c339234 --- /dev/null +++ b/src/lib/insights/__tests__/sub-page-manager-group.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; + +import { + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP, + SUB_PAGE_MANAGER_GROUP_SLUGS, + SUB_PAGE_SLUGS, + SUB_PAGE_GROUP, +} from "@/lib/insights/sub-page-metric"; + +/** + * v1.15.14 W2 — the "Anpassen" sub-page manager lists EVERY routed + * sub-page slug grouped by category. These guards pin the total grouping + * so a future slug can never silently drop out of the manager. + */ +describe("sub-page manager grouping", () => { + it("assigns every slug to exactly one manager group", () => { + for (const slug of SUB_PAGE_SLUGS) { + const group = SUB_PAGE_MANAGER_GROUP[slug]; + expect(group, `slug ${slug} has no manager group`).toBeDefined(); + expect(MANAGER_GROUP_ORDER).toContain(group); + } + }); + + it("the grouped-slugs map partitions the full slug universe", () => { + const flattened = MANAGER_GROUP_ORDER.flatMap( + (group) => SUB_PAGE_MANAGER_GROUP_SLUGS[group], + ); + // No slug missing. + expect(new Set(flattened)).toEqual(new Set(SUB_PAGE_SLUGS)); + // No slug counted twice. + expect(flattened.length).toBe(SUB_PAGE_SLUGS.length); + }); + + it("is a superset of the tab-strip nav grouping (SUB_PAGE_GROUP agrees)", () => { + for (const slug of SUB_PAGE_SLUGS) { + const navGroup = SUB_PAGE_GROUP[slug]; + if (navGroup) { + expect(SUB_PAGE_MANAGER_GROUP[slug]).toBe(navGroup); + } + } + }); + + it("covers the three categories the tab strip never collapses", () => { + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.sleep.length).toBeGreaterThan(0); + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.mood).toContain("mood"); + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.events).toContain("medications"); + }); +}); diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 0659d6759..103034569 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -226,6 +226,95 @@ export const SUB_PAGE_GROUP_ORDER: Record ), }; +/** + * v1.15.14 W2 — manager grouping for the "Anpassen" → "Detailseiten + * verwalten" surface. + * + * The tab strip only assigns a `SUB_PAGE_GROUP` to the long-tail metrics + * that collapse behind a popover parent pill; the eight legacy flat pills + * (blood-pressure, pulse, weight, bmi, sleep, mood, medications, workouts) + * render as direct entries and carry NO `SUB_PAGE_GROUP`. The sub-page + * MANAGER, by contrast, lists EVERY slug grouped under a category header, + * so it needs a total slug → group mapping that covers the flat pills too + * and adds the three categories the tab strip never groups (`sleep`, + * `mood`, `events`). This map is that total assignment; it is a superset + * of `SUB_PAGE_GROUP` (every `SUB_PAGE_GROUP` entry agrees with it) plus a + * fallback for the flat slugs. Single source of truth for the manager — + * the tab-strip nav grouping stays `SUB_PAGE_GROUP` exactly as before. + */ +export type ManagerGroup = + | "vitals" + | "body" + | "activity" + | "sleep" + | "cardiovascular" + | "hearing" + | "environment" + | "metabolic" + | "mood" + | "events"; + +/** + * Render order of the manager's category sections — mirrors the + * MeasurementCategory overlay the rest of insights follows + * (vitals → body → activity → sleep → cardiovascular → hearing → + * environment → metabolic → mood → events). + */ +export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ + "vitals", + "body", + "activity", + "sleep", + "cardiovascular", + "hearing", + "environment", + "metabolic", + "mood", + "events", +]; + +/** Explicit group for the flat tab-strip slugs (those `SUB_PAGE_GROUP` omits). */ +const FLAT_SLUG_MANAGER_GROUP: Partial> = { + "blood-pressure": "vitals", + pulse: "vitals", + weight: "body", + bmi: "body", + workouts: "activity", + sleep: "sleep", + mood: "mood", + medications: "events", +}; + +/** + * Total slug → manager-group assignment. A slug grouped by the tab strip + * keeps that group; a flat slug falls back to {@link FLAT_SLUG_MANAGER_GROUP}. + * Every slug resolves (asserted by the manager-coverage test). + */ +export const SUB_PAGE_MANAGER_GROUP: Record = + Object.fromEntries( + SUB_PAGE_SLUGS.map((slug) => [ + slug, + (SUB_PAGE_GROUP[slug] as ManagerGroup | undefined) ?? + FLAT_SLUG_MANAGER_GROUP[slug] ?? + "vitals", + ]), + ) as unknown as Record; + +/** + * Slugs per manager group, in `SUB_PAGE_SLUGS` order. The manager renders + * one collapsible section per group following {@link MANAGER_GROUP_ORDER}, + * each listing these slugs. + */ +export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record< + ManagerGroup, + readonly SubPageSlug[] +> = Object.fromEntries( + MANAGER_GROUP_ORDER.map((group) => [ + group, + SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_MANAGER_GROUP[slug] === group), + ]), +) as unknown as Record; + /** * Mother-page route. Kept here as a named constant so call sites * never have to hard-code the string.