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