diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index dd902081a..b1de1dbd8 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -150,6 +150,27 @@ describe(" iOS-parity layout", () => { mockUserRef.value = { id: "u1", modules: {} }; }); + it("drops the Insights primary slot when the insights module is off (v1.18.0)", () => { + // The Insights slot is a fixed primary anchor, but it is now + // module-gated: a disabled insights module must remove it from the + // strip (and it is excluded from the More hub by the primary-slot + // filter, so the module is hidden everywhere, not relocated). + mockUserRef.value = { id: "u1", modules: { insights: false } }; + const html = render(); + expect(html).not.toContain('href="/insights"'); + // Sibling primary slots are unaffected. + expect(html).toContain('href="/"'); + expect(html).toContain('href="/medications"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); + + it("keeps the Insights primary slot when the insights module is on", () => { + mockUserRef.value = { id: "u1", modules: { insights: true } }; + const html = render(); + expect(html).toContain('href="/insights"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); + it("gating on a disabled module never throws (v1.18.0)", () => { mockUserRef.value = { id: "u1", diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 44ff3ce31..7b43cc980 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -115,17 +115,21 @@ describe("visibleNavDestinations module gate", () => { expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); }); - it("drops a module-gated entry (mood / labs / coach / achievements) when its module is disabled", () => { + it("drops a module-gated entry (mood / labs / coach / achievements / insights) when its module is disabled", () => { const disabled = visibleNavDestinations({ mood: false, labs: false, coach: false, achievements: false, + // v1.18.0 — Insights is now `requiresModule: "insights"`, so the + // top-level /insights entry drops when the module is off. + insights: 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"); + expect(disabled).not.toContain("/insights"); }); it("keeps every module-gated entry when its module is enabled", () => { @@ -134,11 +138,13 @@ describe("visibleNavDestinations module gate", () => { labs: true, coach: true, achievements: true, + insights: true, }).map((d) => d.href); expect(enabled).toContain("/mood"); expect(enabled).toContain("/labs"); expect(enabled).toContain("/coach"); expect(enabled).toContain("/achievements"); + expect(enabled).toContain("/insights"); }); it("fails open: a missing key or an undefined map keeps the gated entry", () => { diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index d4391bf4c..2ffd8c6a1 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -16,6 +16,7 @@ import { isNavDestinationActive, mobileMoreHubDestinations, } from "@/components/layout/nav-model"; +import type { ModuleKey } from "@/lib/modules/registry"; import { useMemo, useState } from "react"; import { useAppSettings } from "@/components/app-settings-provider"; import { useAuth } from "@/hooks/use-auth"; @@ -57,8 +58,21 @@ const PRIMARY_LEFT: ReadonlyArray = [ { href: "/medications", tKey: "nav.medications", icon: Pill }, ]; -const PRIMARY_RIGHT: ReadonlyArray = [ - { href: "/insights", tKey: "nav.insights", icon: Lightbulb }, +// v1.18.0 — the Insights primary slot is module-gated. Insights is now a +// toggleable module (`requiresModule: "insights"` in the shared nav model), +// so the fixed slot must respect the same per-user map the More hub already +// honours rather than pinning a destination the account turned off. When +// insights is disabled the slot is dropped (it is excluded from the More hub +// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden +// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps +// the slot, mirroring the gate's default-on contract. +const PRIMARY_RIGHT: ReadonlyArray = [ + { + href: "/insights", + tKey: "nav.insights", + icon: Lightbulb, + requiresModule: "insights", + }, ]; export function BottomNav() { @@ -86,6 +100,18 @@ export function BottomNav() { [user?.modules, bugReportEnabled], ); + // v1.18.0 — drop a module-gated primary slot (Insights) when the account + // has that module disabled. Fail-open: a missing key / unloaded map keeps + // the slot, mirroring the gate + More-hub default-on contract. + const primaryRight = useMemo( + () => + PRIMARY_RIGHT.filter( + (item) => + !item.requiresModule || user?.modules?.[item.requiresModule] !== false, + ), + [user?.modules], + ); + function isActiveLink(href: string) { return isNavDestinationActive(href, pathname); } @@ -160,7 +186,7 @@ export function BottomNav() { - {PRIMARY_RIGHT.map(renderPrimary)} + {primaryRight.map(renderPrimary)}