feat(modules): hide nav entries and Insights pills for disabled modules

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:18:47 +02:00
1 parent 76d24dd684
commit 5682055fe2
14 files changed
+396 -62

No files matched your search

@@ -408,6 +408,100 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
});
});
describe("<InsightsTabStrip> — 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(
<InsightsTabStrip
availability={moduleAvailability}
modules={{
mood: false,
sleep: false,
glucose: false,
workouts: false,
}}
/>,
);
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(
<InsightsTabStrip
availability={moduleAvailability}
modules={{
mood: true,
sleep: true,
glucose: true,
workouts: true,
}}
/>,
);
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(
<InsightsTabStrip modules={{ recovery: false }} />,
);
expect(disabled).not.toContain(">Recovery<");
expect(disabled).not.toContain('href="/insights/recovery"');
// Overview still anchors the strip.
expect(disabled).toContain(">Overview<");
const enabled = render(<InsightsTabStrip modules={{ recovery: true }} />);
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(
<InsightsTabStrip
availability={moduleAvailability}
modules={modules}
/>,
);
expect(html).toContain(">Mood<");
expect(html).toContain(">Sleep<");
expect(html).toContain(">Workouts<");
expect(html).toContain('data-group="metabolic"');
expect(html).toContain(">Recovery<");
}
});
});
describe("<InsightsTabStrip> — 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
@@ -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.
+73 -13
View File
@@ -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<string, number>;
/**
* 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<Record<SubPageSlug, ModuleKey>> = {
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<Record<ModuleKey, boolean>>;
/** 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<string> | undefined,
tileOrder: ReadonlyMap<string, number> | 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
@@ -25,12 +25,13 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
// `<BottomNav>` reads `cycleTrackingEnabled` from `useAuth()` to gate the
// cycle hub entry. Each test mutates `mockUserRef.value` before rendering.
// v1.18.0 — `<BottomNav>` 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<string, boolean>;
} | null,
};
vi.mock("@/hooks/use-auth", () => ({
@@ -133,19 +134,28 @@ describe("<BottomNav> 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: {} };
});
});
@@ -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");
});
});
@@ -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<string, boolean>,
},
};
vi.mock("@/hooks/use-auth", () => ({
@@ -57,16 +60,16 @@ function render({
pathname = "/",
bugReportEnabled = true,
role = "USER" as "USER" | "ADMIN",
cycleTrackingEnabled = false,
modules = {} as Record<string, boolean>,
}: {
pathname?: string;
bugReportEnabled?: boolean;
role?: "USER" | "ADMIN";
cycleTrackingEnabled?: boolean;
modules?: Record<string, boolean>;
} = {}) {
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("<SidebarNav> bug-report toggle", () => {
});
});
describe("<SidebarNav> cycle entry gate (v1.15.0)", () => {
it("renders the Cycle entry when cycle tracking is enabled", () => {
const html = render({ cycleTrackingEnabled: true });
describe("<SidebarNav> 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("<SidebarNav> cycle entry gate (v1.15.0)", () => {
});
});
describe("<SidebarNav> 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("<SidebarNav> targets deprecation (v1.8.6)", () => {
it("no longer renders the deprecated /targets entry", () => {
// The Targets (Zielwerte) page is deprecated; target editing moved
+2 -2
View File
@@ -80,10 +80,10 @@ export function BottomNav() {
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
cycleTrackingEnabled: user?.cycleTrackingEnabled,
modules: user?.modules,
bugReportEnabled,
}),
[user?.cycleTrackingEnabled, bugReportEnabled],
[user?.modules, bugReportEnabled],
);
function isActiveLink(href: string) {
+56 -12
View File
@@ -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<NavDestination> = [
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<NavDestination> = [
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<NavDestination> = [
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<Record<ModuleKey, boolean>>;
/**
* 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) => ({
+5 -3
View File
@@ -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;
@@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser {
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
modules: {},
};
}
@@ -51,6 +51,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
modules: {},
};
}
@@ -54,6 +54,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
modules: {},
};
}
+18
View File
@@ -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<Record<ModuleKey, boolean>>;
}
async function fetchMe(): Promise<AuthUser> {
@@ -117,6 +130,11 @@ async function fetchMe(): Promise<AuthUser> {
// 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 : {},
};
}
@@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser {
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
modules: {},
};
}