From 5682055fe230ddef36c32ee68560165ec82a7f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:18:35 +0200 Subject: [PATCH] feat(modules): hide nav entries and Insights pills for disabled modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise the per-destination cycle gate into a requiresModule marker on each nav destination and read it from the account's resolved module map (the modules field on GET /api/auth/me). Fold the bespoke cycle boolean into requiresModule: "cycle" so there is one gating mechanism; cycle and coach are delegated keys already reflected in the map, so reading them here is not a re-derivation. Tag mood, cycle, labs, coach and achievements with their module keys; core destinations (measurements, medications, vorsorge, insights, the dashboard) carry none and always render. The sidebar, the mobile More hub, and the Insights tab strip all filter on the same map. Insights pills: a metric pill belonging to a toggleable module (mood, sleep, glucose, workouts) hides when its module is disabled, on top of the existing data + saved-layout gates; the Recovery pill gates on the recovery module. Overview and core-metric pills always show. Every gate fails open — a missing key or an absent map keeps the entry, matching the gate's default-on contract, so a stale /me payload never blanks the nav. --- .../__tests__/insights-tab-strip.test.tsx | 94 +++++++++++++++++++ .../insights/insights-layout-shell.tsx | 3 +- .../insights/insights-tab-strip.tsx | 86 ++++++++++++++--- .../layout/__tests__/bottom-nav.test.tsx | 30 ++++-- .../layout/__tests__/nav-model.test.ts | 74 ++++++++++++--- .../layout/__tests__/sidebar-nav.test.tsx | 69 ++++++++++++-- src/components/layout/bottom-nav.tsx | 4 +- src/components/layout/nav-model.ts | 68 +++++++++++--- src/components/layout/sidebar-nav.tsx | 8 +- .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + src/hooks/use-auth.ts | 18 ++++ .../__tests__/coach-user-disable.test.tsx | 1 + 14 files changed, 396 insertions(+), 62 deletions(-) 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: {}, }; }