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

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:56:17 +02:00
commit 8de15a0400
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: {},
};
}