diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index db8f61043..27980719a 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -408,6 +408,100 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () }); }); +describe(" — module enable/disable gate (v1.18.0)", () => { + // A pill belonging to a toggleable module (mood / sleep / glucose / + // workouts) is hidden when that module is disabled in the account's + // resolved module map — on TOP of the data + layout gates. Core metric + // pills (BP, pulse, weight, BMI …) carry no module key and ignore it. + const moduleAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + WEIGHT: fakeSummary(3), + SLEEP_DURATION: fakeSummary(8), + BLOOD_GLUCOSE: fakeSummary(6), + }, + hasMood: true, + hasMedication: false, + hasWorkouts: true, + }; + + it("hides the Mood / Sleep / Workouts pills + the glucose (Metabolic) group when their modules are disabled", () => { + // `blood-glucose` is the sole metabolic metric with data here, so its + // module gate collapses the whole "Metabolic" group parent pill — + // mood / sleep / workouts are flat pills that drop directly. + const html = render( + , + ); + expect(html).not.toContain(">Mood<"); + expect(html).not.toContain(">Sleep<"); + expect(html).not.toContain(">Workouts<"); + expect(html).not.toContain('data-group="metabolic"'); + // Core metric pills are unaffected by the module map. + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + expect(html).toContain(">Overview<"); + }); + + it("shows the module pills + the glucose (Metabolic) group when their modules are enabled", () => { + const html = render( + , + ); + expect(html).toContain(">Mood<"); + expect(html).toContain(">Sleep<"); + expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="metabolic"'); + }); + + it("hides the Recovery pill when the recovery module is disabled, keeps it when enabled", () => { + const disabled = render( + , + ); + expect(disabled).not.toContain(">Recovery<"); + expect(disabled).not.toContain('href="/insights/recovery"'); + // Overview still anchors the strip. + expect(disabled).toContain(">Overview<"); + + const enabled = render(); + expect(enabled).toContain(">Recovery<"); + expect(enabled).toContain('href="/insights/recovery"'); + }); + + it("fails open: an empty / omitted module map keeps every module pill", () => { + // Default-on contract — a stale /me payload (no module map) must not + // blank the strip. An empty map and an omitted prop both keep pills. + for (const modules of [{}, undefined]) { + const html = render( + , + ); + expect(html).toContain(">Mood<"); + expect(html).toContain(">Sleep<"); + expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="metabolic"'); + expect(html).toContain(">Recovery<"); + } + }); +}); + describe(" — pill row clip box (v1.16.8)", () => { it("keeps vertical paint room inside the horizontal scroller", () => { // `overflow-x-auto` clips vertically too (overflow-y computes to diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 38070c29f..d6b7cacb8 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -53,7 +53,7 @@ interface ComprehensivePayload { export function InsightsLayoutShell({ children }: { children: ReactNode }) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); // v1.4.33 F18 — gate the advisor POST on the operator's assistant // feature flag. Pre-fix, every /insights mount fired POST // /api/insights/generate even when the operator had disabled the @@ -157,6 +157,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { availability={availability} visibleTileIds={visibleTileIds} tileOrder={tileOrder} + modules={user?.modules} /> {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 b61e02394..3897468e7 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -34,6 +34,7 @@ import { type InsightInputs, type InsightMetric, } from "@/lib/insights/metric-availability"; +import type { ModuleKey } from "@/lib/modules/registry"; /** * v1.4.25 W4 — routed tab strip for `/insights`. @@ -114,6 +115,16 @@ export interface InsightsTabStripProps { * `SUB_PAGE_SLUGS` / `SUB_PAGE_GROUP_ORDER` order. */ tileOrder?: ReadonlyMap; + /** + * v1.18.0 — the account's resolved module enable/disable map (the + * `modules` field from `GET /api/auth/me`; cycle + coach already + * delegated). A pill belonging to a toggleable module (mood / sleep / + * glucose / workouts) is hidden when its key is `false`; the Recovery + * pill gates on `recovery`. Core metric pills carry no module key and are + * unaffected. Absent (legacy mounts / auth not yet loaded) keeps every + * pill — fail-open, matching the gate's default-on contract. + */ + modules?: InsightsModuleMap; } /** @@ -323,6 +334,46 @@ export const SUB_PAGE_TABS: Record< medications: { labelKey: "insights.navMedication", metric: "MEDICATION" }, }; +/** + * v1.18.0 — slug → module gate. A sub-page pill belonging to a toggleable + * module is hidden when that module is disabled in the account's resolved + * module map (from `GET /api/auth/me`'s `modules`), on TOP of the data + + * layout gates. Slugs absent from this map are core metrics (BP, pulse, + * weight, body composition, activity, cardio, hearing, environment, + * medications …) and never gate on a module — only on data availability. + * + * `labs` has no metric pill; it surfaces via the left nav only. The + * Recovery pill is gated separately below (it has no slug — it is a + * composite always-present link, so it reads the `recovery` module key + * directly in `buildTabs`). + */ +const SUB_PAGE_MODULE: Partial> = { + mood: "mood", + sleep: "sleep", + "breathing-disturbances": "sleep", + "blood-glucose": "glucose", + workouts: "workouts", +}; + +/** + * v1.18.0 — a partial `ModuleKey → enabled` map (the `modules` field + * `GET /api/auth/me` returns; cycle + coach already delegated). A pill + * gated by a module is hidden only when its key resolves to an explicit + * `false`; a missing key or an absent map (auth not yet loaded) keeps the + * pill — fail-open, mirroring the gate's default-on contract. + */ +export type InsightsModuleMap = Partial>; + +/** True unless the slug's module is explicitly disabled in the map. */ +function isSlugModuleEnabled( + slug: SubPageSlug, + modules: InsightsModuleMap | undefined, +): boolean { + const key = SUB_PAGE_MODULE[slug]; + if (!key) return true; + return modules?.[key] !== false; +} + /** * v1.4.34 IW-D — group metadata (label + popover header) keyed by * `SubPageGroup`. The strip renders one parent pill per group. v1.7.0 @@ -369,6 +420,7 @@ function buildTabs( availability: InsightInputs | undefined, visibleTileIds: ReadonlySet | undefined, tileOrder: ReadonlyMap | undefined, + modules: InsightsModuleMap | 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 @@ -391,11 +443,16 @@ function buildTabs( // 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. + // v1.18.0 — a THIRD gate on top of data + layout: a pill belonging to a + // toggleable module (mood / sleep / glucose / workouts) is hidden when + // that module is disabled in the account's resolved module map. Core + // metric pills carry no module key and pass this gate unconditionally. const visibleSlugs = availability ? SUB_PAGE_SLUGS.filter( (slug) => hasMetricData(SUB_PAGE_TABS[slug].metric, availability) && - (visibleTileIds === undefined || visibleTileIds.has(slug)), + (visibleTileIds === undefined || visibleTileIds.has(slug)) && + isSlugModuleEnabled(slug, modules), ) : []; @@ -420,19 +477,21 @@ function buildTabs( href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview", }, - // v1.18.0 — Recovery is an Insights pill, no longer a left-nav home. - // It is a composite wearable surface (WHOOP / Polar / Oura recovery, - // strain, ANS charge, cardio load), not a single MeasurementType, so - // it has no `summaries[METRIC].count` to gate on. The page itself - // data-gates every block and falls back to a calm empty note, so the - // pill is always present like Overview rather than carrying its own - // availability signal. - { + ]; + + // v1.18.0 — Recovery is an Insights pill, no longer a left-nav home. It + // is a composite wearable surface (WHOOP / Polar / Oura recovery, strain, + // ANS charge, cardio load), not a single MeasurementType, so it has no + // `summaries[METRIC].count` to gate on — the page data-gates every block + // and falls back to a calm empty note. The pill is otherwise present like + // Overview, but it is hidden when the `recovery` module is disabled. + if (modules?.recovery !== false) { + entries.push({ kind: "link", href: `${INSIGHTS_OVERVIEW_PATH}/recovery`, labelKey: "insights.navRecovery", - }, - ]; + }); + } // v1.4.34 IW-D — track which groups have already been emitted so the // first encountered group-member triggers the parent pill at the @@ -484,6 +543,7 @@ function InsightsTabStripImpl({ availability, visibleTileIds, tileOrder, + modules, }: InsightsTabStripProps) { const { t } = useTranslations(); const pathname = usePathname(); @@ -500,8 +560,8 @@ function InsightsTabStripImpl({ // "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], + () => buildTabs(availability, visibleTileIds, tileOrder, modules), + [availability, visibleTileIds, tileOrder, modules], ); // Fire a toast on the falling edge of `regenerating`. Same rising-edge ref diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index b676af7ce..dd902081a 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -25,12 +25,13 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), })); -// `` reads `cycleTrackingEnabled` from `useAuth()` to gate the -// cycle hub entry. Each test mutates `mockUserRef.value` before rendering. +// v1.18.0 — `` reads the resolved per-user module map from +// `useAuth()` to gate the hub entries (cycle is the delegated `cycle` key). +// Each test mutates `mockUserRef.value` before rendering. const mockUserRef = { - value: { id: "u1", cycleTrackingEnabled: false } as { + value: { id: "u1", modules: {} } as { id: string; - cycleTrackingEnabled: boolean; + modules: Record; } | null, }; vi.mock("@/hooks/use-auth", () => ({ @@ -133,19 +134,28 @@ describe(" iOS-parity layout", () => { expect(hasLabelFirst || hasTestidFirst).toBe(true); }); - it("keeps the cycle hub entry out of the closed-hub strip when the gate is off", () => { - mockUserRef.value = { id: "u1", cycleTrackingEnabled: false }; + it("keeps the cycle hub entry out of the closed-hub strip when its module is off", () => { + mockUserRef.value = { id: "u1", modules: { cycle: false } }; const html = render(); // Like Measurements / Mood, the cycle entry lives in the More hub // (rendered on open — exercised in e2e), never an always-visible strip. expect(html).not.toContain('href="/cycle"'); }); - it("renders without error when the cycle gate is on (entry rides the More hub)", () => { - mockUserRef.value = { id: "u1", cycleTrackingEnabled: true }; + it("renders without error when the cycle module is on (entry rides the More hub)", () => { + mockUserRef.value = { id: "u1", modules: { cycle: true } }; // The closed-hub SSR markup carries no hub items either way; this guards - // that gating on the flag never throws. + // that gating on the module map never throws. expect(() => render()).not.toThrow(); - mockUserRef.value = { id: "u1", cycleTrackingEnabled: false }; + mockUserRef.value = { id: "u1", modules: {} }; + }); + + it("gating on a disabled module never throws (v1.18.0)", () => { + mockUserRef.value = { + id: "u1", + modules: { mood: false, labs: false, coach: false, achievements: false }, + }; + expect(() => render()).not.toThrow(); + mockUserRef.value = { id: "u1", modules: {} }; }); }); diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 24df312a5..44ff3ce31 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -103,23 +103,73 @@ describe("nav-model utility tail (N-1 — one shared list)", () => { }); }); -describe("visibleNavDestinations cycle gate", () => { - it("includes Cycle (between Medications and Insights) only when enabled", () => { - const off = visibleNavDestinations(false).map((d) => d.href); - const on = visibleNavDestinations(true).map((d) => d.href); +describe("visibleNavDestinations module gate", () => { + it("includes Cycle (between Medications and Insights) only when its module is enabled", () => { + // v1.18.0 — cycle is `requiresModule: "cycle"` and reads the delegated + // `cycle` key from the same resolved module map every other gate uses. + const off = visibleNavDestinations({ cycle: false }).map((d) => d.href); + const on = visibleNavDestinations({ cycle: true }).map((d) => d.href); expect(off).not.toContain("/cycle"); expect(on).toContain("/cycle"); expect(on.indexOf("/medications")).toBeLessThan(on.indexOf("/cycle")); expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); }); + + it("drops a module-gated entry (mood / labs / coach / achievements) when its module is disabled", () => { + const disabled = visibleNavDestinations({ + mood: false, + labs: false, + coach: false, + achievements: false, + }).map((d) => d.href); + expect(disabled).not.toContain("/mood"); + expect(disabled).not.toContain("/labs"); + expect(disabled).not.toContain("/coach"); + expect(disabled).not.toContain("/achievements"); + }); + + it("keeps every module-gated entry when its module is enabled", () => { + const enabled = visibleNavDestinations({ + mood: true, + labs: true, + coach: true, + achievements: true, + }).map((d) => d.href); + expect(enabled).toContain("/mood"); + expect(enabled).toContain("/labs"); + expect(enabled).toContain("/coach"); + expect(enabled).toContain("/achievements"); + }); + + it("fails open: a missing key or an undefined map keeps the gated entry", () => { + // Default-on contract — a stale /me payload (no module map) must not + // blank the nav. An empty map and `undefined` both keep every entry. + const emptyMap = visibleNavDestinations({}).map((d) => d.href); + const noMap = visibleNavDestinations(undefined).map((d) => d.href); + for (const hrefs of [emptyMap, noMap]) { + expect(hrefs).toContain("/mood"); + expect(hrefs).toContain("/cycle"); + expect(hrefs).toContain("/labs"); + expect(hrefs).toContain("/coach"); + expect(hrefs).toContain("/achievements"); + } + // Core destinations always render regardless of the map. + expect(emptyMap).toContain("/measurements"); + expect(emptyMap).toContain("/medications"); + expect(emptyMap).toContain("/insights"); + expect(emptyMap).toContain("/vorsorge"); + }); }); describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => { - const opts = { cycleTrackingEnabled: false, bugReportEnabled: false }; + const opts = { + modules: { cycle: false } as const, + bugReportEnabled: false, + }; const hub = mobileMoreHubDestinations(opts).map((d) => d.href); - const expectedFeatures = visibleNavDestinations(opts.cycleTrackingEnabled) + const expectedFeatures = visibleNavDestinations(opts.modules) .map((d) => d.href) .filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href)); const expectedTail = visibleUtilityDestinations( @@ -131,7 +181,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { it("never contains a primary slot (Home / Meds / Insights)", () => { const hub = mobileMoreHubDestinations({ - cycleTrackingEnabled: true, + modules: { cycle: true }, bugReportEnabled: true, }).map((d) => d.href); for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) { @@ -141,7 +191,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { it("keeps the feature destinations and the utility tail reachable in the hub", () => { const hub = mobileMoreHubDestinations({ - cycleTrackingEnabled: false, + modules: { cycle: false }, bugReportEnabled: true, }).map((d) => d.href); // Feature surfaces that left the always-visible strip stay reachable. @@ -154,18 +204,20 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { expect(hub).toContain("/notifications"); }); - it("gates Cycle and Bug Report by the same flags the sidebar uses", () => { + it("gates Cycle by the module map and Bug Report by the operator flag, same as the sidebar", () => { const off = mobileMoreHubDestinations({ - cycleTrackingEnabled: false, + modules: { cycle: false, mood: false }, bugReportEnabled: false, }).map((d) => d.href); const on = mobileMoreHubDestinations({ - cycleTrackingEnabled: true, + modules: { cycle: true, mood: true }, bugReportEnabled: true, }).map((d) => d.href); expect(off).not.toContain("/cycle"); + expect(off).not.toContain("/mood"); expect(off).not.toContain("/bugreport"); expect(on).toContain("/cycle"); + expect(on).toContain("/mood"); expect(on).toContain("/bugreport"); }); }); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 3f1145d20..14e46af07 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -19,7 +19,10 @@ const mockUserRef = { email: "user@example.com", role: "USER" as "USER" | "ADMIN", avatarUrl: null, - cycleTrackingEnabled: false, + // v1.18.0 — nav gating reads the resolved per-user module map; cycle is + // the delegated `cycle` key on it (no bespoke boolean). An absent key + // fails open (entry shows), matching the gate's default-on contract. + modules: {} as Record, }, }; vi.mock("@/hooks/use-auth", () => ({ @@ -57,16 +60,16 @@ function render({ pathname = "/", bugReportEnabled = true, role = "USER" as "USER" | "ADMIN", - cycleTrackingEnabled = false, + modules = {} as Record, }: { pathname?: string; bugReportEnabled?: boolean; role?: "USER" | "ADMIN"; - cycleTrackingEnabled?: boolean; + modules?: Record; } = {}) { mockPathnameRef.value = pathname; mockSettingsRef.value = { bugReportEnabled }; - mockUserRef.value = { ...mockUserRef.value, role, cycleTrackingEnabled }; + mockUserRef.value = { ...mockUserRef.value, role, modules }; return renderToStaticMarkup( // The nav reads `useQueryClient()` for the medications intent // prefetch (v1.16.7); a fresh client per render keeps the SSR @@ -94,20 +97,20 @@ describe(" bug-report toggle", () => { }); }); -describe(" cycle entry gate (v1.15.0)", () => { - it("renders the Cycle entry when cycle tracking is enabled", () => { - const html = render({ cycleTrackingEnabled: true }); +describe(" cycle entry gate (v1.15.0 / v1.18.0 module map)", () => { + it("renders the Cycle entry when the cycle module is enabled", () => { + const html = render({ modules: { cycle: true } }); expect(html).toContain('href="/cycle"'); expect(html).toContain("Cycle"); }); - it("hides the Cycle entry when cycle tracking is disabled", () => { - const html = render({ cycleTrackingEnabled: false }); + it("hides the Cycle entry when the cycle module is disabled", () => { + const html = render({ modules: { cycle: false } }); expect(html).not.toContain('href="/cycle"'); }); it("places the Cycle entry between Medications and Insights when enabled", () => { - const html = render({ cycleTrackingEnabled: true }); + const html = render({ modules: { cycle: true } }); const med = html.indexOf('href="/medications"'); const cycle = html.indexOf('href="/cycle"'); const insights = html.indexOf('href="/insights"'); @@ -120,6 +123,52 @@ describe(" cycle entry gate (v1.15.0)", () => { }); }); +describe(" module gating (v1.18.0)", () => { + it("hides a disabled module's nav entry (mood / labs / coach / achievements)", () => { + const html = render({ + modules: { + mood: false, + labs: false, + coach: false, + achievements: false, + }, + }); + expect(html).not.toContain('href="/mood"'); + expect(html).not.toContain('href="/labs"'); + expect(html).not.toContain('href="/coach"'); + expect(html).not.toContain('href="/achievements"'); + // Core destinations stay regardless of the module map. + expect(html).toContain('href="/measurements"'); + expect(html).toContain('href="/medications"'); + expect(html).toContain('href="/insights"'); + expect(html).toContain('href="/vorsorge"'); + }); + + it("renders a module's nav entry when its module is enabled", () => { + const html = render({ + modules: { + mood: true, + labs: true, + coach: true, + achievements: true, + }, + }); + expect(html).toContain('href="/mood"'); + expect(html).toContain('href="/labs"'); + expect(html).toContain('href="/coach"'); + expect(html).toContain('href="/achievements"'); + }); + + it("fails open: an empty module map keeps every gated entry visible", () => { + const html = render({ modules: {} }); + expect(html).toContain('href="/mood"'); + expect(html).toContain('href="/cycle"'); + expect(html).toContain('href="/labs"'); + expect(html).toContain('href="/coach"'); + expect(html).toContain('href="/achievements"'); + }); +}); + describe(" targets deprecation (v1.8.6)", () => { it("no longer renders the deprecated /targets entry", () => { // The Targets (Zielwerte) page is deprecated; target editing moved diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 2a53a74c1..d4391bf4c 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -80,10 +80,10 @@ export function BottomNav() { const moreHub = useMemo>( () => mobileMoreHubDestinations({ - cycleTrackingEnabled: user?.cycleTrackingEnabled, + modules: user?.modules, bugReportEnabled, }), - [user?.cycleTrackingEnabled, bugReportEnabled], + [user?.modules, bugReportEnabled], ); function isActiveLink(href: string) { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 7af0a288c..dea69565b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -15,6 +15,8 @@ import { type LucideIcon, } from "lucide-react"; +import type { ModuleKey } from "@/lib/modules/registry"; + /** * v1.17.1 — the single navigation information-model. * @@ -42,8 +44,17 @@ export interface NavDestination { * spotlight tour — renaming silently breaks the cutout for that step. */ tourId?: string; - /** Gate the entry on the account's `cycleTrackingEnabled` flag. */ - requiresCycle?: boolean; + /** + * v1.18.0 — gate the entry on a per-user module toggle. When set, the + * entry is dropped unless the account's resolved module map (from + * `GET /api/auth/me`'s `modules`) has the key enabled. Core destinations + * (weight / BP / pulse / medications + always-on pages) carry no key and + * always render. `cycle` and `coach` are delegated keys (cycle → gender + + * opt-in, coach → operator flag + per-user opt-out); the auth/me map + * already reflects that delegation, so reading them here is correct and + * not a re-derivation. + */ + requiresModule?: ModuleKey; } /** @@ -59,7 +70,13 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ icon: Activity, tourId: "nav-measurements", }, - { href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" }, + { + href: "/mood", + tKey: "nav.mood", + icon: Waves, + tourId: "nav-mood", + requiresModule: "mood", + }, { href: "/medications", tKey: "nav.medications", @@ -71,13 +88,14 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tKey: "nav.cycle", icon: Droplets, tourId: "nav-cycle", - requiresCycle: true, + requiresModule: "cycle", }, { href: "/labs", tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs", + requiresModule: "labs", }, // v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the // clinical spine, peer to Labs and Recovery. It is a first-class tracking @@ -110,12 +128,14 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tKey: "nav.coach", icon: MessagesSquare, tourId: "nav-coach", + requiresModule: "coach", }, { href: "/achievements", tKey: "nav.achievements", icon: Trophy, tourId: "nav-achievements", + requiresModule: "achievements", }, ]; @@ -168,15 +188,39 @@ export function visibleUtilityDestinations( } /** - * The ordered destinations visible to this account — drops cycle-gated - * entries when the flag is off. Both bars start from this. + * A partial map of `ModuleKey → enabled`. This is the `modules` field + * `GET /api/auth/me` returns (resolved server-side, with cycle + coach + * already delegated). A `false` value hides the gated entry; a missing + * key, an `undefined` map (auth not yet loaded), or `true` keeps it — + * fail-open, mirroring the gate's default-on contract so a stale /me + * payload never blanks the nav. + */ +export type ModuleVisibilityMap = Partial>; + +/** + * Whether a destination is visible under the given module map. Core + * destinations (no `requiresModule`) always pass; a gated entry passes + * unless its module resolves to an explicit `false`. + */ +function isNavDestinationVisible( + d: NavDestination, + modules: ModuleVisibilityMap | undefined, +): boolean { + if (!d.requiresModule) return true; + return modules?.[d.requiresModule] !== false; +} + +/** + * The ordered destinations visible to this account — drops a module-gated + * entry (mood, cycle, labs, coach, achievements …) when its module is + * disabled in the account's resolved module map. Both bars start from this. + * v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule: + * "cycle"` and reads the delegated `cycle` key from the same map. */ export function visibleNavDestinations( - cycleTrackingEnabled: boolean | undefined, + modules: ModuleVisibilityMap | undefined, ): NavDestination[] { - return NAV_DESTINATIONS.filter( - (d) => !d.requiresCycle || cycleTrackingEnabled === true, - ); + return NAV_DESTINATIONS.filter((d) => isNavDestinationVisible(d, modules)); } /** @@ -207,10 +251,10 @@ export interface MobileMoreHubEntry { * drift into two hand-curated lists. */ export function mobileMoreHubDestinations(opts: { - cycleTrackingEnabled: boolean | undefined; + modules: ModuleVisibilityMap | undefined; bugReportEnabled: boolean | undefined; }): MobileMoreHubEntry[] { - const features = visibleNavDestinations(opts.cycleTrackingEnabled) + const features = visibleNavDestinations(opts.modules) .filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href)) .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({ diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 18b4756cb..205107c52 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -240,10 +240,12 @@ export function SidebarNav() { const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/"); // v1.17.1 (F-1) — the sidebar renders the one shared destination model // (`nav-model.ts`), the same ordered list the mobile bottom-nav derives - // its "More" hub from. Cycle is filtered in/out by the account gate. + // its "More" hub from. v1.18.0 — entries are filtered by the account's + // resolved module map (mood, cycle, labs, coach, achievements …); cycle + + // coach are delegated server-side and already reflected in that map. const visibleNavItems = useMemo( - () => visibleNavDestinations(user?.cycleTrackingEnabled), - [user?.cycleTrackingEnabled], + () => visibleNavDestinations(user?.modules), + [user?.modules], ); const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") return false; diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index cb4468abf..de376e2e5 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 751eed6e1..848d95017 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -51,6 +51,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 6c01150ee..13430adf1 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -54,6 +54,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 246a805fa..492465761 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -9,6 +9,7 @@ import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; +import type { ModuleKey } from "@/lib/modules/registry"; export interface AuthUser { id: string; @@ -80,6 +81,18 @@ export interface AuthUser { * stale /me payload (older server image without the field) in `fetchMe`. */ cycleTrackingEnabled: boolean; + /** + * v1.18.0 — the resolved per-user module enable/disable map. Each + * toggleable module key (mood, sleep, glucose, workouts, recovery, labs, + * achievements, coach, insights, doctorReport, cycle) is `true` when the + * module is enabled for this account, `false` when disabled. `cycle` and + * `coach` are delegated server-side (gender + opt-in / operator flag + + * opt-out) and already reflected here, so nav + Insights pill gates read + * this map rather than re-deriving. Coerced to `{}` against a stale /me + * payload (older server image without the field) so every gate fails open + * — a missing map never blanks the nav. + */ + modules: Partial>; } async function fetchMe(): Promise { @@ -117,6 +130,11 @@ async function fetchMe(): Promise { // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, + // v1.18.0 — coerce against a stale /me payload (older server image + // without the module map) to an empty map so every module gate fails + // open: a missing key reads as enabled and the nav stays intact. + modules: + data.modules && typeof data.modules === "object" ? data.modules : {}, }; } diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 2cf62ce23..1b9b8c47e 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; }