Merge branch 'feat/module-surfaces' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:10:11 +02:00
commit ab0d1b4b71
67 files changed
+1975 -703

No files matched your search

@@ -546,6 +546,28 @@ describe("<InsightsTabStrip> — module enable/disable gate (v1.18.0)", () => {
expect(enabled).toContain('href="/insights/recovery"');
});
it("hides the Medications pill with the medications module off", () => {
const withMeds: InsightInputs = {
...moduleAvailability,
hasMedication: true,
};
const on = render(
<InsightsTabStrip
availability={withMeds}
modules={{ medications: true }}
/>,
);
expect(on).toContain('href="/insights/medications"');
const off = render(
<InsightsTabStrip
availability={withMeds}
modules={{ medications: false }}
/>,
);
expect(off).not.toContain('href="/insights/medications"');
expect(off).toContain(">Overview<");
});
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.
+10 -32
View File
@@ -37,6 +37,7 @@ import {
type InsightMetric,
} from "@/lib/insights/metric-availability";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
/**
* v1.4.25 W4 — routed tab strip for `/insights`.
@@ -392,32 +393,6 @@ export const SUB_PAGE_TABS: Record<
nutrients: { labelKey: "insights.navNutrients", metric: "NUTRIENTS" },
};
/**
* 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.29 — the nutrients pill gates on the opt-in `nutrients` module
// alone; there is no data floor on top (see `hasNutrients` in
// `insights-layout-shell.tsx` — reachability, not row count, is the
// gate once the user has opted in).
nutrients: "nutrients",
};
/**
* v1.18.0 — a partial `ModuleKey → enabled` map (the `modules` field
* `GET /api/auth/me` returns; cycle + coach already delegated). A pill
@@ -427,14 +402,17 @@ const SUB_PAGE_MODULE: Partial<Record<SubPageSlug, ModuleKey>> = {
*/
export type InsightsModuleMap = Partial<Record<ModuleKey, boolean>>;
/** True unless the slug's module is explicitly disabled in the map. */
/**
* True unless the slug's module is explicitly off. A sub-page's owner comes
* from the one surface map (`insights-page:<slug>`), the same entry the
* direct-URL notice reads, so a pill and its page cannot disagree. Slugs with
* no entry are core metrics and gate on data only.
*/
function isSlugModuleEnabled(
slug: SubPageSlug,
modules: InsightsModuleMap | undefined,
): boolean {
const key = SUB_PAGE_MODULE[slug];
if (!key) return true;
return modules?.[key] !== false;
return isSurfaceVisible(`insights-page:${slug}`, modules);
}
/**
@@ -510,7 +488,7 @@ function buildTabs(
// 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
// toggleable module (the `insights-page:*` surfaces) 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
@@ -551,7 +529,7 @@ function buildTabs(
// `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) {
if (isSurfaceVisible("insights-page:recovery", modules)) {
entries.push({
kind: "link",
href: `${INSIGHTS_OVERVIEW_PATH}/recovery`,
+12 -4
View File
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { ArrowRight, TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/ui/section-heading";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
@@ -67,8 +67,8 @@ const MoodChart = dynamic(
interface TrendsRowProps {
/**
* Metrics to leave out because their module is switched off (for
* example `mood`). Passed straight to `selectTrendCharts`.
* Further metrics to leave out. Switched-off modules need no entry here:
* the row reads the record's module map itself.
*/
hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"];
/**
@@ -145,7 +145,15 @@ export function TrendsRow({
// v1.8.5 — derive the chart set from the briefing. No new fetch: the
// briefing payload is already on the page (advisor cache), so this is
// a pure read that respects the v1.8.3 anti-freeze contract.
const charts = selectTrendCharts(briefing, { hiddenMetrics });
//
// A slot whose module is off is left out and the next eligible metric fills
// it (`trend:*` in the surface map). The mount-pinned account keeps the
// hydration render identical to the server's, which had no account.
const account = useAccountOnceMounted();
const charts = selectTrendCharts(briefing, {
modules: account?.modules,
hiddenMetrics,
});
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
@@ -8,9 +8,9 @@
* blank page — so the slot must honour the same per-user map the
* Insights slot and the More hub already do.
*
* Watched red: with the `requiresModule: "medications"` entry removed
* from PRIMARY_LEFT (the pre-fix ungated pin) the module-off test fails
* with the tab still present. Verified red against the pre-fix bar.
* Watched red: with `nav:/medications` removed from the surface map (the
* pre-fix ungated pin) the module-off test fails with the tab still
* present.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -70,4 +70,11 @@ describe("<BottomNav> mounted module gate", () => {
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
it("keeps the Insights slot with AI analysis off", () => {
mockUserRef.value = { id: "u1", modules: { insights: false } };
const html = render();
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
});
@@ -24,6 +24,11 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
}),
}));
// Every module on: this file is about the discard guard, not the module mask.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: {} } }),
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>();
return {
@@ -17,6 +17,14 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
}),
}));
// The record's resolved module map; each test sets what it needs.
const modulesRef: { value: Record<string, boolean> | undefined } = {
value: undefined,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: modulesRef.value } }),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
@@ -51,6 +59,7 @@ function render() {
describe("<CapturePicker> — offered kinds", () => {
it("offers measurement, medication and mood, and no water entry", () => {
modulesRef.value = undefined;
const html = render();
expect(html).toContain('data-testid="capture-picker-measurement"');
@@ -61,3 +70,20 @@ describe("<CapturePicker> — offered kinds", () => {
expect(html).not.toContain('data-testid="capture-picker-water"');
});
});
describe("<CapturePicker> — switched-off modules", () => {
it("drops Mood with the mood module off and Medication with medications off", () => {
modulesRef.value = { mood: false, medications: false };
const html = render();
expect(html).toContain('data-testid="capture-picker-measurement"');
expect(html).not.toContain('data-testid="capture-picker-mood"');
expect(html).not.toContain('data-testid="capture-picker-medication"');
});
it("keeps both while their modules are on", () => {
modulesRef.value = { mood: true, medications: true };
const html = render();
expect(html).toContain('data-testid="capture-picker-mood"');
expect(html).toContain('data-testid="capture-picker-medication"');
});
});
@@ -0,0 +1,101 @@
/**
* The one direct-URL gate for module pages: top-level pages and Insights
* sub-pages answer through the surface map, render the inline notice worded
* from `moduleAccess`, and never redirect.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const pathRef = { value: "/" };
vi.mock("next/navigation", () => ({
usePathname: () => pathRef.value,
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
type MockUser = {
modules: Record<string, boolean>;
moduleAccess?: Record<string, string>;
};
const userRef: { value: MockUser | null } = { value: { modules: {} } };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: userRef.value, isAuthenticated: true }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ModulePageGate, moduleOwningPath } from "../module-page-gate";
function render(pathname: string, user: MockUser) {
pathRef.value = pathname;
userRef.value = user;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ModulePageGate>
<p data-testid="page">page</p>
</ModulePageGate>
</I18nProvider>,
);
}
describe("moduleOwningPath", () => {
it.each([
["/mood", "mood"],
["/labs/123", "labs"],
["/insights/mood", "mood"],
["/insights/workouts/abc", "workouts"],
["/insights/blood-glucose", "glucose"],
["/insights/medications", "medications"],
["/insights/recovery", "recovery"],
["/insights/six-minute-walk", "recovery"],
["/insights/breathing-disturbances", "sleep"],
])("%s belongs to %s", (path, owner) => {
expect(moduleOwningPath(path)).toBe(owner);
});
it.each(["/", "/measurements", "/insights", "/insights/weight", "/checkups"])(
"%s belongs to no module",
(path) => {
expect(moduleOwningPath(path)).toBeUndefined();
},
);
});
describe("<ModulePageGate>", () => {
it("renders the page while its module is on", () => {
const html = render("/insights/mood", { modules: { mood: true } });
expect(html).toContain('data-testid="page"');
expect(html).not.toContain("module-disabled-notice");
});
it("keeps the Insights area with AI analysis off", () => {
const html = render("/insights", { modules: { insights: false } });
expect(html).toContain('data-testid="page"');
});
it("replaces an Insights sub-page with the notice, Settings link included, when the record switched it off", () => {
const html = render("/insights/mood", {
modules: { mood: false },
moduleAccess: { mood: "disabled" },
});
expect(html).not.toContain('data-testid="page"');
expect(html).toContain('data-slot="module-disabled-notice"');
expect(html).toContain('data-module="mood"');
expect(html).toContain('data-module-access="disabled"');
expect(html).toContain('data-slot="module-off-open-settings"');
});
it("offers no Settings link when the operator switched the module off", () => {
const html = render("/insights/sleep", {
modules: { sleep: false },
moduleAccess: { sleep: "unavailable" },
});
expect(html).toContain('data-module-access="unavailable"');
expect(html).not.toContain("module-off-open-settings");
});
it("covers a sub-page no nav entry names (medications)", () => {
const html = render("/insights/medications", {
modules: { medications: false },
});
expect(html).toContain('data-module="medications"');
});
});
@@ -48,16 +48,18 @@ import {
NAV_DESTINATIONS,
isDestinationInSharedRecord,
mobileMoreHubDestinations,
navDestinationModule,
visibleNavDestinations,
visibleUtilityDestinations,
} from "../nav-model";
import { surfaceModule } from "@/lib/modules/surface";
/** Everything enabled, so the module gate never masks the sharing gate. */
const ALL_MODULES = Object.fromEntries(
NAV_DESTINATIONS.filter((d) => d.requiresModule).map((d) => [
d.requiresModule as string,
true,
]),
NAV_DESTINATIONS.flatMap((d) => {
const owner = navDestinationModule(d);
return owner ? [[owner, true]] : [];
}),
);
function hrefsInSharedRecord(): string[] {
@@ -313,7 +315,10 @@ describe("the utility tail under a switch", () => {
expect(firstListed, record.recordKind).toBeDefined();
// A module-gated landing could vanish from the shell when its module is
// off, leaving the entry pointing at a section the list no longer shows.
expect(firstListed?.moduleGate, record.recordKind).toBeUndefined();
expect(
surfaceModule(`settings:${firstListed?.slug}`),
record.recordKind,
).toBeUndefined();
expect(hrefs(record)).toEqual([`/settings/${firstListed?.slug}`]);
}
});
@@ -90,7 +90,7 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
describe("visibleNavDestinations module gate", () => {
it("includes Cycle (between Mood and Medications) only when its module is enabled", () => {
// v1.18.0 — cycle is `requiresModule: "cycle"` and reads the delegated
// v1.18.0 — cycle is owned by `cycle` in the surface map and reads the delegated
// `cycle` key from the same resolved module map every other gate uses.
// v1.19.1 (S4) — cycle sits in the head block, after Mood and before the
// fixed Medications → … → Achievements spine.
@@ -102,15 +102,12 @@ describe("visibleNavDestinations module gate", () => {
expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/medications"));
});
it("drops a module-gated entry (mood / labs / coach / achievements / insights / medications) when its module is disabled", () => {
it("drops a module-gated entry (mood / labs / coach / achievements / medications) when its module is disabled", () => {
const disabled = visibleNavDestinations({
mood: false,
labs: false,
coach: false,
achievements: false,
// v1.18.0 — Insights is now `requiresModule: "insights"`, so the
// top-level /insights entry drops when the module is off.
insights: false,
// v1.18.1 (D3) — medications graduated to a toggleable module.
medications: false,
}).map((d) => d.href);
@@ -118,10 +115,16 @@ describe("visibleNavDestinations module gate", () => {
expect(disabled).not.toContain("/labs");
expect(disabled).not.toContain("/coach");
expect(disabled).not.toContain("/achievements");
expect(disabled).not.toContain("/insights");
expect(disabled).not.toContain("/medications");
});
it("keeps Insights with AI analysis off: the insights key owns no destination", () => {
const hrefs = visibleNavDestinations({ insights: false }).map(
(d) => d.href,
);
expect(hrefs).toContain("/insights");
});
it("keeps every module-gated entry when its module is enabled", () => {
const enabled = visibleNavDestinations({
mood: true,
@@ -86,8 +86,9 @@ function render({
// pure `visibleNavDestinations(modules, true)` contract verified in
// `nav-model.test.ts`; the mounted render is exercised in e2e.
// CORE destinations carry no `requiresModule` and always render.
const CORE_HREFS = ["/", "/measurements", "/checkups"] as const;
// CORE destinations have no owner in the surface map and always render.
// `/insights` is one of them: the `insights` key is AI analysis only.
const CORE_HREFS = ["/", "/measurements", "/checkups", "/insights"] as const;
// A representative slice of the module-gated entries that must stay out of
// the first-paint markup whatever the module map says.
const GATED_HREFS = [
@@ -96,7 +97,6 @@ const GATED_HREFS = [
"/medications",
"/labs",
"/illness",
"/insights",
"/coach",
"/achievements",
] as const;
+23 -38
View File
@@ -17,7 +17,7 @@ import {
isDestinationInSharedRecord,
mobileMoreHubDestinations,
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
@@ -60,34 +60,19 @@ interface NavLink {
// surfaces tell one story instead of two hand-curated ones that drift,
// and the headline invariant is a tested model function rather than
// inline bar logic.
// The Meds slot is module-gated exactly like the Insights slot below:
// `/medications` renders nothing when the medications module is off, so a
// pinned tab would be a visible control over a blank page.
const PRIMARY_LEFT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
// The Meds slot follows the medications module through the surface map
// (`nav:/medications`): a pinned tab over a module that is off would be a
// visible control over a notice.
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{
href: "/medications",
tKey: "nav.medications",
icon: Pill,
requiresModule: "medications",
},
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
// v1.18.0 — the Insights primary slot is module-gated. Insights is now a
// toggleable module (`requiresModule: "insights"` in the shared nav model),
// so the fixed slot must respect the same per-user map the More hub already
// honours rather than pinning a destination the account turned off. When
// insights is disabled the slot is dropped (it is excluded from the More hub
// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden
// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps
// the slot, mirroring the gate's default-on contract.
const PRIMARY_RIGHT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
{
href: "/insights",
tKey: "nav.insights",
icon: Lightbulb,
requiresModule: "insights",
},
// The Insights slot belongs to no module (the `insights` key is AI analysis,
// not the area), so it stays whatever the module map says; it still drops
// inside a shared record, like any destination sharing does not cover.
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
];
export function BottomNav() {
@@ -115,7 +100,8 @@ export function BottomNav() {
// The capture button opens the picker, so it shows exactly when the picker
// would offer at least one kind — the same per-section rule, asked once.
const canCapture =
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER).length > 0;
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
.length > 0;
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
@@ -132,23 +118,22 @@ export function BottomNav() {
[user?.modules, mounted, sharedRecord, sections],
);
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
// has that module disabled. Fail-closed until mounted (so a disabled
// Insights never flickers in), then the real map applies: a missing key /
// unloaded map keeps the slot, mirroring the gate + More-hub default-on
// contract.
// Every slot but Home asks two things: does sharing cover it, and does its
// module (if the surface map names one) stay on. Fail-closed until mounted,
// so SSR and the first paint agree, then the real map applies: a missing
// key / unloaded map keeps the slot, the gate's default-on contract.
const primarySlotVisible = useCallback(
(item: NavLink & { requiresModule?: ModuleKey }) => {
// An ungated slot (Home) is pinned unconditionally — the bar always
// has an anchor, whatever the module map or a record switch says.
if (!item.requiresModule) return true;
(item: NavLink) => {
// Home is pinned unconditionally — the bar always has an anchor,
// whatever the module map or a record switch says.
if (item.href === "/") return true;
return (
// v1.36.0 — the fixed slot asks the shared destination model
// whether sharing covers it, rather than carrying its own answer:
// Insights is an AI surface and drops out under a switch.
// Insights drops out under a switch.
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
mounted &&
user?.modules?.[item.requiresModule] !== false
isSurfaceVisible(`nav:${item.href}`, user?.modules)
);
},
[user?.modules, mounted, sharedRecord, sections],
+22 -5
View File
@@ -19,7 +19,9 @@ import {
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareDomain } from "@/lib/sharing/scope";
import { useAuth } from "@/hooks/use-auth";
import {
useRecordCapabilities,
type RecordCapabilities,
@@ -106,15 +108,25 @@ export function admittedCaptureKind(
}
/**
* The capture kinds to offer, given what the record allows. Exported pure so
* the delegation rule can be pinned without opening a sheet: an SSR test
* cannot tap the button that opens it.
* The capture kinds to offer, given what the record allows and which of its
* modules are on. Exported pure so both rules can be pinned without opening a
* sheet: an SSR test cannot tap the button that opens it.
*
* A kind whose module is off (`capture:<kind>` in the surface map) is not
* offered: a Mood entry with the mood module off would post to a route that
* refuses it. An absent module map (the account still loading) offers every
* kind the record allows, the gate's default-on contract.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canWriteDomain">,
kinds: ReadonlyArray<CaptureKind>,
modules?: SurfaceModuleMap | null,
): CaptureKind[] {
return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]));
return kinds.filter(
(kind) =>
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
isSurfaceVisible(`capture:${kind}`, modules),
);
}
interface CapturePickerProps {
@@ -127,7 +139,12 @@ interface CapturePickerProps {
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const { t } = useTranslations();
const capabilities = useRecordCapabilities();
const offered = visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER);
const { user } = useAuth();
const offered = visibleCaptureKinds(
capabilities,
CAPTURE_KIND_ORDER,
user?.modules,
);
const [kind, setKind] = useState<CaptureKind | null>(null);
// Re-derived on every render, never latched: the offer can shrink under a
// sheet that is already open. See `admittedCaptureKind`.
@@ -79,7 +79,11 @@ export function ModuleDisabledNotice({
const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey);
return (
<div data-slot="module-disabled-notice" data-module-access={state}>
<div
data-slot="module-disabled-notice"
data-module={moduleKey}
data-module-access={state}
>
<EmptyState
icon={icon}
title={t(copy.title, { module: moduleName })}
+42 -20
View File
@@ -8,25 +8,48 @@ import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice
import {
isNavDestinationActive,
NAV_DESTINATIONS,
navDestinationModule,
} from "@/components/layout/nav-model";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
const INSIGHTS_PREFIX = "/insights/";
/**
* v1.39 (C2) — the page of a module that is switched off answers with an
* empty state that names the switch (design spec §After the flow), in one
* place for every module page.
* The module that owns the page at `pathname`, from the one surface map.
*
* The setup flow switches modules OFF for a new record — an explicit `false`
* for every module the answers did not name — and the navigation drops the
* entry. A person who reaches the page anyway (a bookmark, a link in a
* report, the tour) used to get the page's own empty state, which said
* "nothing here yet" about a module that is not there at all. This maps the
* route to the module through the navigation model's own gate, so a module
* page and its nav entry can never disagree about which switch they follow,
* and renders the shared notice with the one action that can help: the
* Modules settings, offered only when the record's own switch is what is off.
* An Insights sub-page answers by its slug (`insights-page:<slug>`), so
* `/insights/mood` and `/insights/workouts/<id>` follow their own module while
* `/insights` itself follows none. Every other page answers through the nav
* destination it sits under (`nav:<href>`), most specific first.
*/
export function moduleOwningPath(pathname: string): ModuleKey | undefined {
if (pathname.startsWith(INSIGHTS_PREFIX)) {
const slug = pathname.slice(INSIGHTS_PREFIX.length).split("/")[0];
const owner = surfaceModule(`insights-page:${slug}`);
if (owner !== undefined) return owner;
}
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
);
return destination ? navDestinationModule(destination) : undefined;
}
/**
* A page of a module that is switched off answers with an inline notice that
* names the reason, in one place for every module page: top-level pages
* (`/mood`, `/labs` …) and Insights sub-pages (`/insights/mood`,
* `/insights/sleep`, `/insights/medications` …) alike.
*
* A person who reaches such a page anyway (a bookmark, a link in a report,
* the tour) gets the notice rather than a redirect, which would drop where
* they were, and rather than the page's own error rows, which would say
* "could not be loaded" about a module that is not there. The notice reads
* the reason from `moduleAccess` and offers Settings only when the record's
* own switch is what is off.
*
* Paint, not enforcement: every route behind a module refuses on its own
* through the server gate. Mounted inside the shell, which renders children
@@ -38,19 +61,18 @@ export function ModulePageGate({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { t } = useTranslations();
const destination = NAV_DESTINATIONS.find(
(d) => d.requiresModule && isNavDestinationActive(d.href, pathname),
);
const moduleKey = destination?.requiresModule;
const moduleKey = moduleOwningPath(pathname);
const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
if (!off) return <>{children}</>;
if (!off || !destination || !moduleKey) return <>{children}</>;
const Icon = destination.icon;
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
);
const Icon = destination?.icon;
return (
<ModuleDisabledNotice
moduleKey={moduleKey}
icon={<Icon className="size-6" />}
icon={Icon ? <Icon className="size-6" /> : undefined}
action={
<Button asChild size="sm">
<Link href="/settings/modules" data-slot="module-off-open-settings">
+29 -41
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface";
// The leaf module, not the `@/lib/record-settings` barrel, for the reason
// `auth-shell.tsx` gives: the barrel drags server-side schemas into the chrome.
import {
@@ -56,17 +57,6 @@ export interface NavDestination {
* spotlight tour — renaming silently breaks the cutout for that step.
*/
tourId?: string;
/**
* 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 + 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;
/**
* v1.36.0 — is this destination part of what account sharing covers.
*
@@ -119,12 +109,9 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.mood",
icon: Waves,
tourId: "nav-mood",
requiresModule: "mood",
},
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood.
// Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off)
// key from the resolved module map, so the entry is absent until the account
// turns the module on from the Modules hub. The destination is the dedicated
// Owned by `mentalHealth` in the surface map. The destination is the dedicated
// top-level `/mental-wellbeing` check-in surface (its own module page — it
// no longer borrows the Insights tab strip / layout shell).
{
@@ -133,7 +120,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.mentalWellbeing",
icon: Brain,
tourId: "nav-mental-wellbeing",
requiresModule: "mentalHealth",
},
{
href: "/cycle",
@@ -141,7 +127,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.cycle",
icon: Droplets,
tourId: "nav-cycle",
requiresModule: "cycle",
},
{
href: "/medications",
@@ -151,7 +136,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-medications",
// v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable
// module; the nav entry now drops when the account turns the module off.
requiresModule: "medications",
},
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
// clinical spine. It is a first-class tracking surface ("wann muss ich was
@@ -164,7 +148,8 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vorsorge",
icon: Stethoscope,
tourId: "nav-vorsorge",
// v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike
// v1.18.1 — deliberately owned by no module (absent from the surface
// map). Unlike
// labs / illness / cycle (opt-in clinical-spine verticals born off by
// default), preventive-care reminders are a CORE surface available to
// every account from birth: a reminder can target core vitals
@@ -178,7 +163,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.labs",
icon: FlaskConical,
tourId: "nav-labs",
requiresModule: "labs",
},
{
href: "/profile",
@@ -189,21 +173,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-profile",
},
// v1.18.1 — the illness/condition journal sits in the clinical spine
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
// opt-in `illness` key from the resolved module map, so the entry is
// absent until the account turns the module on from the Modules hub.
// next to Labs. Owned by `illness` in the surface map.
{
href: "/illness",
sharedRecord: true,
tKey: "nav.illness",
icon: Thermometer,
tourId: "nav-illness",
requiresModule: "illness",
},
// v1.37.3: the immunization log sits in the clinical spine beside Illness.
// Born-gated: `requiresModule: "vaccinations"` reads the resolved module map,
// so the entry is absent until the account keeps the module on (default-on —
// it drops only when a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// Owned by `vaccinations` in the surface map (default-on; it drops only when
// a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// data routes stay reachable so a restore / import keeps working and
// re-enabling finds every dose intact.
{
@@ -212,24 +192,23 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vaccinations",
icon: Syringe,
tourId: "nav-vaccinations",
requiresModule: "vaccinations",
},
// v1.18.0 — Workouts and Recovery both left the left-nav: each already
// surfaces as an Insights tab-strip pill (`/insights/workouts` gated on
// a workout row, `/insights/recovery` always present), so neither is a
// top-level `NAV_DESTINATIONS` entry any more.
// v1.25.0 (W-DOCS-IN) — inbound clinical documents sit in the clinical
// spine after Illness. Born-gated: `requiresModule: "inboundDocuments"`
// reads the opt-in key from the resolved module map, so the entry is absent
// until the account turns the module on from the Modules hub.
// spine after Illness. Owned by `inboundDocuments` in the surface map.
{
href: "/documents",
sharedRecord: true,
tKey: "nav.documents",
icon: FileScan,
tourId: "nav-documents",
requiresModule: "inboundDocuments",
},
// Insights belongs to no module: the `insights` key means AI analysis, and
// the area is data, so switching AI analysis off leaves this entry standing.
//
// Insights and the Coach carry no `sharedRecord` flag, so they drop out
// under a switch. The reason has changed since v1.36.0 and the old wording
// ("non-delegable, server-side and here") is no longer true of the tiles:
@@ -257,7 +236,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.insights",
icon: Lightbulb,
tourId: "nav-insights",
requiresModule: "insights",
},
// v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty
@@ -268,7 +246,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.coach",
icon: MessagesSquare,
tourId: "nav-coach",
requiresModule: "coach",
},
{
href: "/achievements",
@@ -276,7 +253,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.achievements",
icon: Trophy,
tourId: "nav-achievements",
requiresModule: "achievements",
},
];
@@ -355,10 +331,22 @@ export function visibleUtilityDestinations(
*/
export type ModuleVisibilityMap = Partial<Record<ModuleKey, boolean>>;
/**
* The module that owns a destination, from the one surface map
* (`nav:<href>` in `@/lib/modules/surface`), or `undefined` for a core
* destination. The nav bars, the direct-URL notice and the tour all ask this,
* so a page and its nav entry cannot disagree about which switch they follow.
*/
export function navDestinationModule(
d: Pick<NavDestination, "href">,
): ModuleKey | undefined {
return surfaceModule(`nav:${d.href}`);
}
/**
* 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`.
* destinations (no owner in the surface map) always pass; an owned entry
* passes unless its module resolves to an explicit `false`.
*
* `mounted` (default `true`) is the hydration gate the nav bars thread in.
* The resolved module map rides the client-only `/api/auth/me` query, which
@@ -389,7 +377,7 @@ function isNavDestinationVisible(
// than reached only through the shared arm, which is what it relied on
// before the module gate below started applying to a shared record too.
if (!sharedRecord && d.sharedRecordOnly) return false;
if (!d.requiresModule) return true;
if (navDestinationModule(d) === undefined) return true;
if (!mounted) return false;
// The module map, and whose it is, is the whole point. A shared record used
// to skip this line: the grant's scope decided which doors existed, and the
@@ -400,7 +388,7 @@ function isNavDestinationVisible(
// it, turning Cycle off for a profile left the Cycle door standing in that
// profile's own navigation. Scope decides which doors the grant opens;
// this decides which of them the record tracks at all.
return modules?.[d.requiresModule] !== false;
return isSurfaceVisible(`nav:${d.href}`, modules);
}
/**
@@ -427,8 +415,8 @@ export function isDestinationInSharedRecord(
* 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.
* Cycle reads the delegated `cycle` key from the same map; `/insights` has
* no owner (the `insights` key is AI analysis, not the area).
*
* v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing
* covers, so a delegate is not offered a door the server will shut. Paint
+1 -1
View File
@@ -60,7 +60,7 @@ import {
interface TourProps {
/**
* Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose
* `requiresModule` resolves to `false` are dropped; the counter total
* page belongs to a switched-off module are dropped; the counter total
* tracks the resolved list so "Schritt n/total" stays honest.
*/
modules?: TourModuleMap;
@@ -0,0 +1,66 @@
/**
* The overview arrange list marks every section whose module is off as
* unavailable, through the same surface map the overview renders from, so
* Settings never offers a toggle for a block that cannot appear.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const modulesRef: { value: Record<string, boolean> } = { value: {} };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { modules: modulesRef.value },
}),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ briefing: true }),
}));
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => ({
layout: { sections: [] },
isLoading: false,
}),
}));
const seen: { gated: string[] } = { gated: [] };
vi.mock("@/components/insights/insights-edit-mode", () => ({
InsightsEditMode: ({
gatedOffSectionIds,
}: {
gatedOffSectionIds: ReadonlySet<string>;
}) => {
seen.gated = [...gatedOffSectionIds].sort();
return null;
},
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightsOverviewArrangeSection } from "../insights-overview-arrange-section";
function gatedFor(modules: Record<string, boolean>): string[] {
modulesRef.value = modules;
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightsOverviewArrangeSection />
</I18nProvider>,
);
return seen.gated;
}
describe("<InsightsOverviewArrangeSection> module gate", () => {
it("offers every section with every module on", () => {
expect(gatedFor({})).toEqual([]);
});
it("marks the breathing, labs and cycle blocks unavailable with their modules off", () => {
expect(gatedFor({ sleep: false, labs: false, cycle: false })).toEqual([
"breathing",
"cycle-summary",
"labs-changes",
]);
});
it("marks nothing for AI analysis off", () => {
expect(gatedFor({ insights: false })).toEqual([]);
});
});
@@ -25,6 +25,7 @@ import {
LAYOUT_GROUPS,
LAYOUT_GROUP_IDS,
isLayoutGroupId,
layoutGroupModule,
} from "../layout-groups";
describe("layout-groups registry", () => {
@@ -59,7 +60,9 @@ describe("layout-groups registry", () => {
it("gates the toggleable tracking modules and leaves the rest always-on", () => {
const gate = (id: string) =>
LAYOUT_GROUPS.find((group) => group.id === id)?.moduleGate;
LAYOUT_GROUPS.some((group) => group.id === id)
? layoutGroupModule(id)
: "not-a-group";
expect(gate("medications")).toBe("medications");
expect(gate("mood")).toBe("mood");
expect(gate("labs")).toBe("labs");
@@ -53,10 +53,8 @@ import {
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
import { useMounted } from "@/hooks/use-mounted";
import { useAuth } from "@/hooks/use-auth";
import {
NATIVE_ONLY_WIDGET_LABEL_KEYS,
WIDGET_MODULE_BY_ID,
} from "@/lib/dashboard/widget-modules";
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -605,14 +603,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
.filter((w): w is typeof w & { id: DashboardWidgetId } =>
webWidgetIds.has(w.id),
)
// v1.18.0 — hide a widget toggle whose owning module is
// disabled. Map the widget id → ModuleKey FIRST (undefined =
// core widget = always shown), THEN check the module map.
// Fail-open: only an explicit `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
// Hide a widget toggle whose owning module is off, through the
// one surface map. A core widget has no owner and always shows;
// only an explicit `false` hides the row.
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
.filter((w) => !unavailableIds.has(w.id))
.sort((a, b) => a.order - b.order);
const sortedIds = sortedWidgets.map((w) => w.id);
@@ -685,10 +679,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
.filter((w) => nativeOnlyIds.has(w.id))
// Same fail-open module gate as the web list: only an explicit
// `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
.filter((w) => !unavailableIds.has(w.id))
.sort((a, b) => a.order - b.order);
if (nativeWidgets.length === 0) return null;
@@ -11,7 +11,11 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { SectionHeading } from "@/components/ui/section-heading";
import { SettingsCard } from "@/components/settings/settings-card";
import { type InsightsSectionId } from "@/lib/insights-layout";
import {
INSIGHTS_SECTION_IDS,
type InsightsSectionId,
} from "@/lib/insights-layout";
import { isSurfaceVisible } from "@/lib/modules/surface";
/**
* v1.15.18 — overview-arrange block for the Insights settings section.
@@ -23,8 +27,10 @@ import { type InsightsSectionId } from "@/lib/insights-layout";
* shared cache; the editor simply stays mounted).
*
* `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose
* feature flag / data gate is off renders its row disabled-with-a-hint rather
* than offering a toggle that does nothing.
* feature flag or owning module is off renders its row disabled-with-a-hint
* rather than offering a toggle that does nothing. The row stays in the list
* (not filtered out) so a save keeps the section's stored place for when the
* module comes back.
*/
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
const { t } = useTranslations();
@@ -38,11 +44,15 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
gated.add("daily-briefing");
gated.add("period-review");
}
if (!user?.cycleTrackingEnabled) {
gated.add("cycle-summary");
// A block whose module is off cannot appear on the overview, so its row
// is not offered as a live toggle. Read from the one surface map the
// overview renders from (`overview:<id>`); cycle follows the resolved
// `modules.cycle` like the overview's cycle ring, not the raw column.
for (const id of INSIGHTS_SECTION_IDS) {
if (!isSurfaceVisible(`overview:${id}`, user?.modules)) gated.add(id);
}
return gated;
}, [flags.briefing, user?.cycleTrackingEnabled]);
}, [flags.briefing, user?.modules]);
return (
<section
+11 -9
View File
@@ -1,6 +1,7 @@
import type { JSX } from "react";
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
import { DashboardSection } from "./dashboard-section";
import { InsightsSection } from "./insights-section";
import { MedicationsSection } from "./medications-section";
@@ -43,11 +44,16 @@ export interface LayoutGroup {
/** i18n key under `settings.sections.layout.<slug>.description`. */
descriptionKey: string;
Body: () => JSX.Element | null;
/**
* When set, the row / subpage renders only while the module is enabled
* (fail-open, `!== false`). Omitted = always shown.
*/
moduleGate?: ModuleKey;
}
/**
* The module a layout group belongs to, from the one surface map
* (`settings-layout:<id>`), or `undefined` for a group that is always shown.
* The row and its sub-page render only while that module is on (fail-open,
* `!== false`).
*/
export function layoutGroupModule(id: string): ModuleKey | undefined {
return surfaceModule(`settings-layout:${id}`);
}
export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
@@ -68,28 +74,24 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
titleKey: "settings.sections.layout.medications.title",
descriptionKey: "settings.sections.layout.medications.description",
Body: MedicationsSection,
moduleGate: "medications",
},
{
id: "mood",
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
Body: MoodSection,
moduleGate: "mood",
},
{
id: "labs",
titleKey: "settings.sections.layout.labs.title",
descriptionKey: "settings.sections.layout.labs.description",
Body: LabsSection,
moduleGate: "labs",
},
{
id: "illness",
titleKey: "settings.sections.layout.illness.title",
descriptionKey: "settings.sections.layout.illness.description",
Body: IllnessSection,
moduleGate: "illness",
},
{
id: "vorsorge",
@@ -22,14 +22,14 @@ import type { ModuleKey } from "@/lib/modules/registry";
* an ordinary client update. The redirect is a client-only effect, so it never
* diverges the initial render and cannot trigger a React #418 mismatch.
*
* Groups with no `moduleGate` (dashboard / insights / vorsorge) pass
* `moduleGate={undefined}` and always render.
* Groups the surface map gives no owner (dashboard / insights / vorsorge)
* pass `moduleKey={undefined}` and always render.
*/
export function LayoutModuleGate({
moduleGate,
moduleKey,
children,
}: {
moduleGate?: ModuleKey;
moduleKey?: ModuleKey;
children: React.ReactNode;
}) {
const hydrated = useMounted();
@@ -37,9 +37,7 @@ export function LayoutModuleGate({
const router = useRouter();
const disabled =
hydrated &&
moduleGate !== undefined &&
user?.modules?.[moduleGate] === false;
hydrated && moduleKey !== undefined && user?.modules?.[moduleKey] === false;
React.useEffect(() => {
if (disabled) {
+4 -2
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "./settings-card";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { LAYOUT_GROUPS } from "./layout-groups";
/**
@@ -21,7 +22,8 @@ import { LAYOUT_GROUPS } from "./layout-groups";
* `layout` slug; this body renders only the row list.
*
* Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so
* a section never silently disappears. Groups with no `moduleGate` always
* a section never silently disappears. Groups with no owner in the surface
* map (`settings-layout:<id>`) always
* render. The filter is gated on a post-mount flag (`useMounted()`) so SSR and
* the first client paint ALWAYS emit the same fail-open list — the same
* hydration-stability contract the shell nav uses, so the module filter can
@@ -35,7 +37,7 @@ export function LayoutSection() {
const modules = user?.modules;
const visibleGroups = LAYOUT_GROUPS.filter(
(group) =>
!hydrated || !group.moduleGate || modules?.[group.moduleGate] !== false,
!hydrated || isSurfaceVisible(`settings-layout:${group.id}`, modules),
);
return (
+9 -18
View File
@@ -48,7 +48,7 @@ import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification";
import {
SETTINGS_SECTION_SLUGS,
@@ -106,14 +106,6 @@ interface SettingsSection {
icon: LucideIcon;
/** 2026-07-17 UX/IA audit (M6) — which sidebar group this entry renders under. */
group: SettingsSectionGroup;
/**
* v1.18.0 (S5) — per-submodule entries are listed only when their module
* is enabled. When set, the nav entry hides if the resolved
* `useAuth().user.modules` map has the key explicitly `false`. The gate
* fails OPEN (a missing key reads as enabled) so a stale `/me` payload
* never blanks an entry. Omitted = always shown (global / CORE entries).
*/
moduleGate?: ModuleKey;
}
/**
@@ -268,13 +260,13 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
// v1.25 (W-ENV) — Environmental context: home location, travel overrides,
// and the weather/daylight backfill. Module-gated on the opt-in
// `environment` module, so the entry only appears once the user turns it on.
// `environment` module (`settings:environment` in the surface map), so the
// entry only appears once the user turns it on.
{
slug: "environment",
titleKey: "settings.sections.environment.title",
icon: CloudSun,
group: "healthProfile",
moduleGate: "environment",
},
// ── AI ──
{
@@ -284,14 +276,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
group: "ai",
},
// v1.18.0 (S5) — Coach: the Coach preference cards get their own entry,
// shown only when the coach module is enabled. The AI entry above keeps
// shown only when the coach module is enabled (`settings:coach` in the
// surface map). The AI entry above keeps
// provider / model / BYOK configuration.
{
slug: "coach",
titleKey: "settings.sections.coach.title",
icon: Bot,
group: "ai",
moduleGate: "coach",
},
// ── Connectivity ──
{
@@ -411,8 +403,9 @@ export function SettingsShell({
// is enabled. Read from the resolved `useAuth().user.modules` map (the
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
// reads as enabled so an entry never silently disappears. Entries with
// no `moduleGate` (global / CORE) are always shown.
// reads as enabled so an entry never silently disappears. A section's
// owner comes from the surface map (`settings:<slug>`); sections with no
// owner (global / CORE) are always shown.
//
// v1.25.9 — the module filter must NOT run during SSR / the first client
// paint. `useAuth().user` resolves from the `/api/auth/me` query, which is
@@ -435,9 +428,7 @@ export function SettingsShell({
const activeRecord = user?.accountAccess?.active ?? null;
const allVisibleSections = SETTINGS_SECTIONS.filter(
(section) =>
!hydrated ||
!section.moduleGate ||
modules?.[section.moduleGate] !== false,
!hydrated || isSurfaceVisible(`settings:${section.slug}`, modules),
);
// The server has already resolved which record is active, and which