feat(nav): gate the Insights destination on the insights module

Mark the top-level `/insights` nav destination `requiresModule: "insights"`
so it drops from both bars when the account has the insights module off,
mirroring mood / labs / coach / achievements. The bottom-nav pinned
`/insights` as a fixed primary slot regardless of the module map; it now
filters that slot through the same per-user map the More hub honours
(fail-open on a missing key / unloaded /me payload). A disabled insights
module is hidden everywhere — the primary-slot filter keeps it out of the
More hub too, so it is hidden, not relocated.

Extend the nav-model + bottom-nav tests with insights on/off coverage.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:42:43 +02:00
1 parent 4cf923647d
commit b2684a2e21
4 files changed
+58 -4

No files matched your search

@@ -150,6 +150,27 @@ describe("<BottomNav> 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",
@@ -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", () => {
+29 -3
View File
@@ -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<NavLink> = [
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ 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<NavLink & { requiresModule?: ModuleKey }> = [
{
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() {
</button>
</div>
{PRIMARY_RIGHT.map(renderPrimary)}
{primaryRight.map(renderPrimary)}
<button
type="button"
+1
View File
@@ -118,6 +118,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.insights",
icon: Lightbulb,
tourId: "nav-insights",
requiresModule: "insights",
},
// v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty