From 9f98636a43759c4109653080b140fd93143cda46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:51:55 +0200 Subject: [PATCH] fix(settings): read section and overview-block owners from the surface map Settings sections and Layout groups no longer declare their own module keys; they ask the surface map. The Insights overview arrange list marks the breathing, labs changes and cycle blocks unavailable when their modules are off, as the overview itself would not show them. The rows stay in the list so a save keeps each block's place. --- src/app/settings/layout/[module]/page.tsx | 3 +- .../__tests__/nav-model-shared-record.test.ts | 6 +- ...insights-overview-arrange-section.test.tsx | 66 +++++++++++++++++++ .../settings/__tests__/layout-groups.test.ts | 5 +- .../insights-overview-arrange-section.tsx | 22 +++++-- src/components/settings/layout-groups.ts | 20 +++--- .../settings/layout-module-gate.tsx | 4 +- src/components/settings/layout-section.tsx | 6 +- src/components/settings/settings-shell.tsx | 27 +++----- 9 files changed, 119 insertions(+), 40 deletions(-) create mode 100644 src/components/settings/__tests__/insights-overview-arrange-section.test.tsx diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx index 257685689..aee7af407 100644 --- a/src/app/settings/layout/[module]/page.tsx +++ b/src/app/settings/layout/[module]/page.tsx @@ -4,6 +4,7 @@ import { LayoutModuleGate } from "@/components/settings/layout-module-gate"; import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; import { LAYOUT_GROUPS, + layoutGroupModule, LAYOUT_GROUP_IDS, isLayoutGroupId, } from "@/components/settings/layout-groups"; @@ -83,7 +84,7 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) { >
- +
diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts index 92e080398..a635feb9f 100644 --- a/src/components/layout/__tests__/nav-model-shared-record.test.ts +++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts @@ -52,6 +52,7 @@ import { visibleNavDestinations, visibleUtilityDestinations, } from "../nav-model"; +import { surfaceModule } from "@/lib/modules/surface"; /** Everything enabled, so the module gate never masks the sharing gate. */ const ALL_MODULES = Object.fromEntries( @@ -314,7 +315,10 @@ describe("the utility tail under a switch", () => { expect(firstListed, record.recordKind).toBeDefined(); // A module-gated landing could vanish from the shell when its module is // off, leaving the entry pointing at a section the list no longer shows. - expect(firstListed?.moduleGate, record.recordKind).toBeUndefined(); + expect( + surfaceModule(`settings:${firstListed?.slug}`), + record.recordKind, + ).toBeUndefined(); expect(hrefs(record)).toEqual([`/settings/${firstListed?.slug}`]); } }); diff --git a/src/components/settings/__tests__/insights-overview-arrange-section.test.tsx b/src/components/settings/__tests__/insights-overview-arrange-section.test.tsx new file mode 100644 index 000000000..4b3256fe0 --- /dev/null +++ b/src/components/settings/__tests__/insights-overview-arrange-section.test.tsx @@ -0,0 +1,66 @@ +/** + * The overview arrange list marks every section whose module is off as + * unavailable, through the same surface map the overview renders from, so + * Settings never offers a toggle for a block that cannot appear. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const modulesRef: { value: Record } = { value: {} }; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { modules: modulesRef.value }, + }), +})); +vi.mock("@/hooks/use-feature-flags", () => ({ + useFeatureFlags: () => ({ briefing: true }), +})); +vi.mock("@/hooks/use-insights-layout", () => ({ + useInsightsLayoutQuery: () => ({ + layout: { sections: [] }, + isLoading: false, + }), +})); +const seen: { gated: string[] } = { gated: [] }; +vi.mock("@/components/insights/insights-edit-mode", () => ({ + InsightsEditMode: ({ + gatedOffSectionIds, + }: { + gatedOffSectionIds: ReadonlySet; + }) => { + seen.gated = [...gatedOffSectionIds].sort(); + return null; + }, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { InsightsOverviewArrangeSection } from "../insights-overview-arrange-section"; + +function gatedFor(modules: Record): string[] { + modulesRef.value = modules; + renderToStaticMarkup( + + + , + ); + return seen.gated; +} + +describe(" module gate", () => { + it("offers every section with every module on", () => { + expect(gatedFor({})).toEqual([]); + }); + + it("marks the breathing, labs and cycle blocks unavailable with their modules off", () => { + expect(gatedFor({ sleep: false, labs: false, cycle: false })).toEqual([ + "breathing", + "cycle-summary", + "labs-changes", + ]); + }); + + it("marks nothing for AI analysis off", () => { + expect(gatedFor({ insights: false })).toEqual([]); + }); +}); diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index 2401d62ed..a4a666ca4 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -25,6 +25,7 @@ import { LAYOUT_GROUPS, LAYOUT_GROUP_IDS, isLayoutGroupId, + layoutGroupModule, } from "../layout-groups"; describe("layout-groups registry", () => { @@ -59,7 +60,9 @@ describe("layout-groups registry", () => { it("gates the toggleable tracking modules and leaves the rest always-on", () => { const gate = (id: string) => - LAYOUT_GROUPS.find((group) => group.id === id)?.moduleGate; + LAYOUT_GROUPS.some((group) => group.id === id) + ? layoutGroupModule(id) + : "not-a-group"; expect(gate("medications")).toBe("medications"); expect(gate("mood")).toBe("mood"); expect(gate("labs")).toBe("labs"); diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index a2c8b21fc..1d7a41206 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -11,7 +11,11 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { InsightsEditMode } from "@/components/insights/insights-edit-mode"; import { SectionHeading } from "@/components/ui/section-heading"; import { SettingsCard } from "@/components/settings/settings-card"; -import { type InsightsSectionId } from "@/lib/insights-layout"; +import { + INSIGHTS_SECTION_IDS, + type InsightsSectionId, +} from "@/lib/insights-layout"; +import { isSurfaceVisible } from "@/lib/modules/surface"; /** * v1.15.18 — overview-arrange block for the Insights settings section. @@ -23,8 +27,10 @@ import { type InsightsSectionId } from "@/lib/insights-layout"; * shared cache; the editor simply stays mounted). * * `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose - * feature flag / data gate is off renders its row disabled-with-a-hint rather - * than offering a toggle that does nothing. + * feature flag or owning module is off renders its row disabled-with-a-hint + * rather than offering a toggle that does nothing. The row stays in the list + * (not filtered out) so a save keeps the section's stored place for when the + * module comes back. */ export function InsightsOverviewArrangeSection({ id }: { id?: string }) { const { t } = useTranslations(); @@ -38,11 +44,15 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) { gated.add("daily-briefing"); gated.add("period-review"); } - if (!user?.cycleTrackingEnabled) { - gated.add("cycle-summary"); + // A block whose module is off cannot appear on the overview, so its row + // is not offered as a live toggle. Read from the one surface map the + // overview renders from (`overview:`); cycle follows the resolved + // `modules.cycle` like the overview's cycle ring, not the raw column. + for (const id of INSIGHTS_SECTION_IDS) { + if (!isSurfaceVisible(`overview:${id}`, user?.modules)) gated.add(id); } return gated; - }, [flags.briefing, user?.cycleTrackingEnabled]); + }, [flags.briefing, user?.modules]); return (
.description`. */ descriptionKey: string; Body: () => JSX.Element | null; - /** - * When set, the row / subpage renders only while the module is enabled - * (fail-open, `!== false`). Omitted = always shown. - */ - moduleGate?: ModuleKey; +} + +/** + * The module a layout group belongs to, from the one surface map + * (`settings-layout:`), or `undefined` for a group that is always shown. + * The row and its sub-page render only while that module is on (fail-open, + * `!== false`). + */ +export function layoutGroupModule(id: string): ModuleKey | undefined { + return surfaceModule(`settings-layout:${id}`); } export const LAYOUT_GROUPS: ReadonlyArray = [ @@ -68,28 +74,24 @@ export const LAYOUT_GROUPS: ReadonlyArray = [ titleKey: "settings.sections.layout.medications.title", descriptionKey: "settings.sections.layout.medications.description", Body: MedicationsSection, - moduleGate: "medications", }, { id: "mood", titleKey: "settings.sections.layout.mood.title", descriptionKey: "settings.sections.layout.mood.description", Body: MoodSection, - moduleGate: "mood", }, { id: "labs", titleKey: "settings.sections.layout.labs.title", descriptionKey: "settings.sections.layout.labs.description", Body: LabsSection, - moduleGate: "labs", }, { id: "illness", titleKey: "settings.sections.layout.illness.title", descriptionKey: "settings.sections.layout.illness.description", Body: IllnessSection, - moduleGate: "illness", }, { id: "vorsorge", diff --git a/src/components/settings/layout-module-gate.tsx b/src/components/settings/layout-module-gate.tsx index 8d5c2f23b..a23e8bb55 100644 --- a/src/components/settings/layout-module-gate.tsx +++ b/src/components/settings/layout-module-gate.tsx @@ -22,8 +22,8 @@ import type { ModuleKey } from "@/lib/modules/registry"; * an ordinary client update. The redirect is a client-only effect, so it never * diverges the initial render and cannot trigger a React #418 mismatch. * - * Groups with no `moduleGate` (dashboard / insights / vorsorge) pass - * `moduleGate={undefined}` and always render. + * Groups the surface map gives no owner (dashboard / insights / vorsorge) + * pass `moduleGate={undefined}` and always render. */ export function LayoutModuleGate({ moduleGate, diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index e794b1f42..78b3832b3 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsCard } from "./settings-card"; +import { isSurfaceVisible } from "@/lib/modules/surface"; import { LAYOUT_GROUPS } from "./layout-groups"; /** @@ -21,7 +22,8 @@ import { LAYOUT_GROUPS } from "./layout-groups"; * `layout` slug; this body renders only the row list. * * Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so - * a section never silently disappears. Groups with no `moduleGate` always + * a section never silently disappears. Groups with no owner in the surface + * map (`settings-layout:`) always * render. The filter is gated on a post-mount flag (`useMounted()`) so SSR and * the first client paint ALWAYS emit the same fail-open list — the same * hydration-stability contract the shell nav uses, so the module filter can @@ -35,7 +37,7 @@ export function LayoutSection() { const modules = user?.modules; const visibleGroups = LAYOUT_GROUPS.filter( (group) => - !hydrated || !group.moduleGate || modules?.[group.moduleGate] !== false, + !hydrated || isSurfaceVisible(`settings-layout:${group.id}`, modules), ); return ( diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index cdef9f561..8f5cb6275 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -48,7 +48,7 @@ import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; -import type { ModuleKey } from "@/lib/modules/registry"; +import { isSurfaceVisible } from "@/lib/modules/surface"; import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification"; import { SETTINGS_SECTION_SLUGS, @@ -106,14 +106,6 @@ interface SettingsSection { icon: LucideIcon; /** 2026-07-17 UX/IA audit (M6) — which sidebar group this entry renders under. */ group: SettingsSectionGroup; - /** - * v1.18.0 (S5) — per-submodule entries are listed only when their module - * is enabled. When set, the nav entry hides if the resolved - * `useAuth().user.modules` map has the key explicitly `false`. The gate - * fails OPEN (a missing key reads as enabled) so a stale `/me` payload - * never blanks an entry. Omitted = always shown (global / CORE entries). - */ - moduleGate?: ModuleKey; } /** @@ -268,13 +260,13 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ }, // v1.25 (W-ENV) — Environmental context: home location, travel overrides, // and the weather/daylight backfill. Module-gated on the opt-in - // `environment` module, so the entry only appears once the user turns it on. + // `environment` module (`settings:environment` in the surface map), so the + // entry only appears once the user turns it on. { slug: "environment", titleKey: "settings.sections.environment.title", icon: CloudSun, group: "healthProfile", - moduleGate: "environment", }, // ── AI ── { @@ -284,14 +276,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ group: "ai", }, // v1.18.0 (S5) — Coach: the Coach preference cards get their own entry, - // shown only when the coach module is enabled. The AI entry above keeps + // shown only when the coach module is enabled (`settings:coach` in the + // surface map). The AI entry above keeps // provider / model / BYOK configuration. { slug: "coach", titleKey: "settings.sections.coach.title", icon: Bot, group: "ai", - moduleGate: "coach", }, // ── Connectivity ── { @@ -411,8 +403,9 @@ export function SettingsShell({ // is enabled. Read from the resolved `useAuth().user.modules` map (the // same map the Module hub, nav, and Insights pills gate off). Fail OPEN // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, - // reads as enabled so an entry never silently disappears. Entries with - // no `moduleGate` (global / CORE) are always shown. + // reads as enabled so an entry never silently disappears. A section's + // owner comes from the surface map (`settings:`); sections with no + // owner (global / CORE) are always shown. // // v1.25.9 — the module filter must NOT run during SSR / the first client // paint. `useAuth().user` resolves from the `/api/auth/me` query, which is @@ -435,9 +428,7 @@ export function SettingsShell({ const activeRecord = user?.accountAccess?.active ?? null; const allVisibleSections = SETTINGS_SECTIONS.filter( (section) => - !hydrated || - !section.moduleGate || - modules?.[section.moduleGate] !== false, + !hydrated || isSurfaceVisible(`settings:${section.slug}`, modules), ); // The server has already resolved which record is active, and which