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.