Gate the bottom-nav Meds slot on the medications module

The mobile bar pinned /medications unconditionally while the page
renders nothing when the medications module is off — a visible tab over
a blank screen. Run both fixed flanks through the one slot filter the
Insights slot already uses (sharing coverage + module map, fail-closed
until mounted), so a disabled module is hidden everywhere instead of
relocated.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 01:52:37 +02:00
1 parent 11aa3dc060
commit c23e677b6e
3 files changed
+128 -34

No files matched your search

@@ -0,0 +1,73 @@
/**
* Bottom-nav primary-slot module gate — the MOUNTED contract.
*
* `useMounted` is mocked true here, so the resolved module map applies
* (the sibling `bottom-nav.test.tsx` pins the fail-closed pre-mount
* markup). The Meds slot pins to `/medications`, whose page renders
* nothing when the medications module is off — a visible tab over a
* blank page — so the slot must honour the same per-user map the
* Insights slot and the More hub already do.
*
* Watched red: with the `requiresModule: "medications"` entry removed
* from PRIMARY_LEFT (the pre-fix ungated pin) the module-off test fails
* with the tab still present. Verified red against the pre-fix bar.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
// Mounted=true: the SSR render below behaves like the hydrated bar.
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
const mockUserRef = {
value: { id: "u1", modules: {} } as {
id: string;
modules: Record<string, boolean>;
} | null,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: mockUserRef.value,
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { BottomNav } from "../bottom-nav";
function render() {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<BottomNav />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<BottomNav> mounted module gate", () => {
it("shows the Meds and Insights slots when their modules are on (default-on map)", () => {
mockUserRef.value = { id: "u1", modules: {} };
const html = render();
for (const href of ["/", "/medications", "/insights"]) {
expect(html).toContain(`href="${href}"`);
}
});
it("drops the Meds slot when the medications module is off", () => {
mockUserRef.value = { id: "u1", modules: { medications: false } };
const html = render();
expect(html).not.toContain('href="/medications"');
// Siblings unaffected.
expect(html).toContain('href="/"');
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
});
@@ -61,22 +61,21 @@ function render() {
}
describe("<BottomNav> iOS-parity layout", () => {
it("renders the always-on flanking primary anchors (Home, Meds)", () => {
it("renders the always-on Home anchor", () => {
const html = render();
// Home + Meds are not module-gated, so they anchor the strip on first
// paint. The Insights slot IS module-gated; v1.25.12 gates it behind
// hydration (fail-closed pre-mount) so a disabled module can't flicker
// in — its presence is asserted in the mounted/e2e path, not here.
for (const href of ["/", "/medications"]) {
expect(html).toContain(`href="${href}"`);
}
// Home is the one ungated primary slot, so it anchors the strip on
// first paint. Meds and Insights are BOTH module-gated now and follow
// the fail-closed-pre-mount contract; their mounted presence is pinned
// in bottom-nav-module-gate.test.tsx.
expect(html).toContain('href="/"');
});
it("keeps the module-gated Insights slot OUT of the first-paint markup (v1.25.12)", () => {
it("keeps the module-gated Meds and Insights slots OUT of the first-paint markup (v1.25.12)", () => {
const html = render();
// Pre-hydration the module map isn't settled; the Insights primary slot
// therefore fails closed (absent) rather than flickering in.
// Pre-hydration the module map isn't settled; a module-gated primary
// slot therefore fails closed (absent) rather than flickering in.
expect(html).not.toContain('href="/insights"');
expect(html).not.toContain('href="/medications"');
});
it("renders the center capture action as a dialog-opening button, not a link", () => {
@@ -111,13 +110,13 @@ describe("<BottomNav> iOS-parity layout", () => {
it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
const html = render();
// The flanking strip slots carry `min-h-11 min-w-11`. On first paint the
// module-gated Insights slot is fail-closed (v1.25.12), so the strip
// shows Home, Meds and More — three slots. We don't hard-pin the count
// because the mounted render adds Insights back and a future entry would
// also satisfy the contract.
// The flanking strip slots carry `min-h-11 min-w-11`. On first paint
// BOTH module-gated slots (Meds, Insights) are fail-closed (v1.25.12),
// so the strip shows Home and More — two slots. We don't hard-pin the
// count because the mounted render adds the gated slots back and a
// future entry would also satisfy the contract.
const matches = html.match(/min-h-11 min-w-11/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(3);
expect(matches.length).toBeGreaterThanOrEqual(2);
});
it("the center capture FAB is a larger, elevated tap target", () => {
@@ -171,9 +170,8 @@ describe("<BottomNav> iOS-parity layout", () => {
mockUserRef.value = { id: "u1", modules: { insights: false } };
const html = render();
expect(html).not.toContain('href="/insights"');
// Sibling primary slots are unaffected.
// The ungated sibling anchor is unaffected.
expect(html).toContain('href="/"');
expect(html).toContain('href="/medications"');
mockUserRef.value = { id: "u1", modules: {} };
});
+38 -15
View File
@@ -18,7 +18,7 @@ import {
mobileMoreHubDestinations,
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
@@ -55,9 +55,17 @@ interface NavLink {
// slots, plus the shared utility tail) — so the two surfaces tell one story
// instead of two hand-curated ones that drift, and the headline invariant
// is a tested model function rather than inline bar logic.
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
// The Meds slot is module-gated exactly like the Insights slot below:
// `/medications` renders nothing when the medications module is off, so a
// pinned tab would be a visible control over a blank page.
const PRIMARY_LEFT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{
href: "/medications",
tKey: "nav.medications",
icon: Pill,
requiresModule: "medications",
},
];
// v1.18.0 — the Insights primary slot is module-gated. Insights is now a
@@ -125,20 +133,35 @@ export function BottomNav() {
// Insights never flickers in), then the real map applies: a missing key /
// unloaded map keeps the slot, mirroring the gate + More-hub default-on
// contract.
const primaryRight = useMemo(
() =>
PRIMARY_RIGHT.filter(
(item) =>
// v1.36.0 — the fixed slot asks the shared destination model
// whether sharing covers it, rather than carrying its own answer:
// Insights is an AI surface and drops out under a switch.
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
(!item.requiresModule ||
(mounted && user?.modules?.[item.requiresModule] !== false)),
),
const primarySlotVisible = useCallback(
(item: NavLink & { requiresModule?: ModuleKey }) => {
// An ungated slot (Home) is pinned unconditionally — the bar always
// has an anchor, whatever the module map or a record switch says.
if (!item.requiresModule) return true;
return (
// v1.36.0 — the fixed slot asks the shared destination model
// whether sharing covers it, rather than carrying its own answer:
// Insights is an AI surface and drops out under a switch.
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
mounted &&
user?.modules?.[item.requiresModule] !== false
);
},
[user?.modules, mounted, sharedRecord, sections],
);
// Both fixed flanks run through the ONE slot filter, so a module-gated
// tab (Meds left, Insights right) is dropped — not relocated — when the
// account turned its module off, matching the More hub's exclusion.
const primaryLeft = useMemo(
() => PRIMARY_LEFT.filter(primarySlotVisible),
[primarySlotVisible],
);
const primaryRight = useMemo(
() => PRIMARY_RIGHT.filter(primarySlotVisible),
[primarySlotVisible],
);
function isActiveLink(href: string) {
return isNavDestinationActive(href, pathname);
}
@@ -189,7 +212,7 @@ export function BottomNav() {
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{PRIMARY_LEFT.map(renderPrimary)}
{primaryLeft.map(renderPrimary)}
{/* Center capture action — a labeled button (NOT a link) that
opens the capture picker. Elevated as a Floating-Action-