mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge(modules): hide nav entries and Insights pills for disabled modules
This commit is contained in:
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user