From 91be6b29671467a53ab4aab2d15f40d100672e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:39:58 +0200 Subject: [PATCH 01/13] feat(modules): add one map from every surface to the module that owns it SURFACE_MODULE names each surface a toggleable module owns (nav entries, Insights pages, overview blocks, trend slots, add-menu entries, dashboard widgets and summaries, derived scores, Settings sections, correlation channels) as :. isSurfaceVisible answers from it on the server and in the browser, useSurfaceVisible reads it against the account. The insights key owns no surface: it means AI analysis, and the Insights area is data. --- .../__tests__/use-surface-visible.test.tsx | 45 +++ src/hooks/use-surface-visible.ts | 34 +++ src/lib/modules/__tests__/surface.test.ts | 74 +++++ src/lib/modules/surface.ts | 264 ++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 src/hooks/__tests__/use-surface-visible.test.tsx create mode 100644 src/hooks/use-surface-visible.ts create mode 100644 src/lib/modules/__tests__/surface.test.ts create mode 100644 src/lib/modules/surface.ts diff --git a/src/hooks/__tests__/use-surface-visible.test.tsx b/src/hooks/__tests__/use-surface-visible.test.tsx new file mode 100644 index 000000000..7004b2bb3 --- /dev/null +++ b/src/hooks/__tests__/use-surface-visible.test.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const useAuthMock = vi.fn(); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => useAuthMock(), +})); +const hasClientMock = vi.fn(() => true); +vi.mock("@/hooks/_internal/use-query-client-safe", () => ({ + useQueryClientMounted: () => hasClientMock(), +})); + +import { useSurfaceVisible } from "../use-surface-visible"; + +function Probe({ id }: { id: string }) { + return {String(useSurfaceVisible(id))}; +} + +const visible = (id: string) => + renderToStaticMarkup().includes(">true<"); + +describe("useSurfaceVisible", () => { + beforeEach(() => { + vi.clearAllMocks(); + hasClientMock.mockReturnValue(true); + }); + + it("hides a surface whose module the record switched off", () => { + useAuthMock.mockReturnValue({ user: { modules: { mood: false } } }); + expect(visible("capture:mood")).toBe(false); + expect(visible("capture:medication")).toBe(true); + }); + + it("is on before the account resolves", () => { + useAuthMock.mockReturnValue({ user: null }); + expect(visible("capture:mood")).toBe(true); + }); + + it("is on without a query client, and never reads the account then", () => { + hasClientMock.mockReturnValue(false); + expect(visible("capture:mood")).toBe(true); + expect(useAuthMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-surface-visible.ts b/src/hooks/use-surface-visible.ts new file mode 100644 index 000000000..e05168db1 --- /dev/null +++ b/src/hooks/use-surface-visible.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; +import { isSurfaceVisible, type StaticSurfaceId } from "@/lib/modules/surface"; + +/** + * Whether a surface shows for the record this session is on, read through the + * one surface map (`src/lib/modules/surface.ts`) against the resolved + * `modules` of `GET /api/auth/me`. + * + * SSR-safe the way `useModuleEnabled` is: a presentational component rendered + * without a `QueryClientProvider` gets the default-on answer instead of a + * crash. The branch is stable for a component's lifetime, so the conditional + * hook call is safe. + * + * Default-on: before the account resolves, and for every surface no module + * owns, the answer is `true`. A caller whose first paint must match the server + * render (nav lists, anything behind a hydration gate) keeps its own mounted + * check on top. + */ +export function useSurfaceVisible( + surfaceId: StaticSurfaceId | (string & {}), +): boolean { + const hasClient = useQueryClientMounted(); + if (!hasClient) return true; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useSurfaceVisibleInner(surfaceId); +} + +function useSurfaceVisibleInner(surfaceId: string): boolean { + const { user } = useAuth(); + return isSurfaceVisible(surfaceId, user?.modules); +} diff --git a/src/lib/modules/__tests__/surface.test.ts b/src/lib/modules/__tests__/surface.test.ts new file mode 100644 index 000000000..7680ed184 --- /dev/null +++ b/src/lib/modules/__tests__/surface.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { MODULE_KEYS } from "@/lib/modules/registry"; +import { + SURFACE_KINDS, + SURFACE_MODULE, + isSurfaceVisible, + surfaceModule, + surfaceModulesOfKind, +} from "@/lib/modules/surface"; + +describe("SURFACE_MODULE", () => { + it("owns every surface by a real module key", () => { + const keys = new Set(MODULE_KEYS); + for (const [id, owner] of Object.entries(SURFACE_MODULE)) { + expect(keys.has(owner), `${id} → ${owner}`).toBe(true); + } + }); + + it("names every surface as : with a known kind", () => { + const kinds = new Set(SURFACE_KINDS); + for (const id of Object.keys(SURFACE_MODULE)) { + const kind = id.slice(0, id.indexOf(":")); + expect(kinds.has(kind), id).toBe(true); + expect(id.length).toBeGreaterThan(kind.length + 1); + } + }); + + it("leaves the Insights area to no module (the insights key is AI analysis only)", () => { + expect(surfaceModule("nav:/insights")).toBeUndefined(); + expect(Object.values(SURFACE_MODULE)).not.toContain("insights"); + }); +}); + +describe("isSurfaceVisible", () => { + it("hides an owned surface only on an explicit false", () => { + expect(isSurfaceVisible("trend:mood", { mood: false })).toBe(false); + expect(isSurfaceVisible("trend:mood", { mood: true })).toBe(true); + expect(isSurfaceVisible("trend:mood", {})).toBe(true); + expect(isSurfaceVisible("trend:mood", undefined)).toBe(true); + }); + + it("never hides a surface no module owns", () => { + expect(isSurfaceVisible("trend:bp", { mood: false, sleep: false })).toBe( + true, + ); + expect(isSurfaceVisible("nav:/measurements", { mood: false })).toBe(true); + }); + + it("reads the owner's key, not the surface's name", () => { + expect(isSurfaceVisible("capture:medication", { medications: false })).toBe( + false, + ); + expect( + isSurfaceVisible("insights-page:blood-glucose", { glucose: false }), + ).toBe(false); + }); +}); + +describe("surfaceModulesOfKind", () => { + it("strips the kind prefix and keeps only that kind", () => { + const widgets = surfaceModulesOfKind("widget"); + expect(widgets.mood).toBe("mood"); + expect(widgets.recentWorkouts).toBe("workouts"); + expect(Object.keys(widgets).some((k) => k.includes(":"))).toBe(false); + expect(widgets["/mood"]).toBeUndefined(); + }); + + it("returns an empty view for a kind with no entries rather than throwing", () => { + for (const kind of SURFACE_KINDS) { + expect(typeof surfaceModulesOfKind(kind)).toBe("object"); + } + }); +}); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts new file mode 100644 index 000000000..e1c0c7602 --- /dev/null +++ b/src/lib/modules/surface.ts @@ -0,0 +1,264 @@ +/** + * Which module owns which surface, in one declarative map. + * + * A module a person switches off has to disappear everywhere at once: the nav + * entry, the Insights pill, the overview card, the trends slot, the add menu, + * the dashboard tile, the Settings row, the statistics. Each of those used to + * keep its own little map (or none), and the ones with none were exactly where + * a switched-off module kept showing up. This file is the one answer. Every + * surface a toggleable module owns is listed here as `:`, and a + * surface that is absent belongs to no module and is never hidden by one. + * + * The older per-surface maps are views of this one (`surfaceModulesOfKind`), + * so they cannot drift from it. `src/lib/modules/__tests__/ + * surface-module-registry.test.ts` holds every list of surfaces the app renders + * against this map. + * + * The `insights` key is deliberately absent. It means "AI analysis" (model + * written text) and owns no surface: the Insights area, its charts and its + * statistics are data and stay whatever their own module says. The AI + * capability resolver reads that key; surfaces never do. + * + * Pure data plus two pure functions, no server or React dependency, so the + * server builders and the browser read the same file. The React accessor is + * `useSurfaceVisible` in `src/hooks/use-surface-visible.ts`. + * + * Paint, not enforcement: the routes behind a module refuse on their own + * through `requireModuleEnabled`. Hiding a surface spares a click that would + * end in a refusal or an empty chart. + */ +import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * The surface families. The part of an id before the first `:`. + * + * - `nav`: a navigation destination, by its href (sidebar, bottom bar, the + * More hub, the tour stop on that page, and the direct-URL notice). + * - `insights-page`: an Insights sub-page, by its slug (the tab-strip pill + * and the direct-URL notice under `/insights/`). + * - `overview`: a block on the Insights overview, by its section id. + * - `trend`: a trends-row chart slot, by its briefing `sourceMetric`. + * - `capture`: an entry in the add menu, by its capture kind. + * - `widget`: a dashboard widget, by its layout id. + * - `summary`: a dashboard summary key (a `MeasurementType` or a synthetic + * key such as `MOOD_ENTRY`), stripped from every summaries feed. + * - `derived`: a derived score, by its `DerivedMetricId`. + * - `settings`: a Settings section, by its slug. + * - `settings-layout`: a Settings, Layout group, by its id. + * - `correlation`: a correlation-discovery channel, by its channel key. + */ +export const SURFACE_KINDS = [ + "nav", + "insights-page", + "overview", + "trend", + "capture", + "widget", + "summary", + "derived", + "settings", + "settings-layout", + "correlation", +] as const; + +export type SurfaceKind = (typeof SURFACE_KINDS)[number]; + +const STATIC_SURFACE_MODULE = { + // ── Navigation destinations ── + // `/insights` carries no owner: the area is data, not AI analysis. + "nav:/mood": "mood", + "nav:/mental-wellbeing": "mentalHealth", + "nav:/cycle": "cycle", + "nav:/medications": "medications", + "nav:/labs": "labs", + "nav:/illness": "illness", + "nav:/vaccinations": "vaccinations", + "nav:/documents": "inboundDocuments", + "nav:/coach": "coach", + "nav:/achievements": "achievements", + + // ── Insights sub-pages ── + "insights-page:mood": "mood", + "insights-page:sleep": "sleep", + // The per-night breathing-disturbance index is a sleep-page signal. + "insights-page:breathing-disturbances": "sleep", + "insights-page:blood-glucose": "glucose", + "insights-page:workouts": "workouts", + "insights-page:nutrients": "nutrients", + // Medications is surface-gated by design: its data routes stay open so an + // import or a sync keeps working, and these surfaces are what the switch + // hides. + "insights-page:medications": "medications", + // Recovery is a composite page, not a slug with a series; the four + // HealthKit recovery metrics below are the same module's, as on the + // dashboard (`widget:cardioRecovery` …). + "insights-page:recovery": "recovery", + "insights-page:cardio-recovery": "recovery", + "insights-page:six-minute-walk": "recovery", + "insights-page:stair-ascent-speed": "recovery", + "insights-page:stair-descent-speed": "recovery", + + // ── Insights overview blocks ── + "overview:cycle-summary": "cycle", + // The cycle ring rides the wellness-scores strip rather than being a + // section of its own; it follows the same key as the cycle summary, so the + // two cannot disagree. + "overview:cycle-ring": "cycle", + // Breathing disturbances are measured overnight; the screening card is the + // sleep module's. + "overview:breathing": "sleep", + "overview:labs-changes": "labs", + + // ── Trends row ── + "trend:mood": "mood", + "trend:sleep": "sleep", + + // ── Add menu ── + "capture:mood": "mood", + "capture:medication": "medications", + + // ── Dashboard widgets ── + // CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived + // HealthKit metrics) carry no entry and are never hidden. + "widget:mood": "mood", + "widget:sleep": "sleep", + "widget:glucose": "glucose", + "widget:achievements": "achievements", + "widget:recentWorkouts": "workouts", + "widget:medications": "medications", + "widget:cardioRecovery": "recovery", + "widget:sixMinuteWalk": "recovery", + "widget:stairAscentSpeed": "recovery", + "widget:stairDescentSpeed": "recovery", + "widget:breathingDisturbances": "sleep", + // The fluid-intake tile reads the nutrient store. + "widget:waterIntake": "nutrients", + + // ── Dashboard summary keys ── + "summary:SLEEP_DURATION": "sleep", + "summary:BLOOD_GLUCOSE": "glucose", + "summary:CARDIO_RECOVERY": "recovery", + "summary:SIX_MINUTE_WALK_DISTANCE": "recovery", + "summary:STAIR_ASCENT_SPEED": "recovery", + "summary:STAIR_DESCENT_SPEED": "recovery", + // The nightly RMSSD series is the recovery module's. The dashboard HRV tile + // falls back to it when an account has no SDNN, and a fallback must not + // serve recovery data the primary type refuses. `HEART_RATE_VARIABILITY` + // (SDNN) is a plain vital and stays unowned. + "summary:HRV_RMSSD": "recovery", + "summary:BREATHING_DISTURBANCES": "sleep", + // Synthetic keys: derived from `NutrientIntakeDay` and `MoodEntry` rather + // than a `MeasurementType`, riding the same gate. + "summary:NUTRIENT_WATER": "nutrients", + "summary:MOOD_ENTRY": "mood", + + // ── Derived scores ── + // READINESS equals the computed recovery score, so it follows recovery. + // Composite baselines built on core vitals (VITALS_BASELINE, HRV_BALANCE …) + // carry no entry. + "derived:SLEEP_SCORE": "sleep", + "derived:RECOVERY_SCORE": "recovery", + "derived:STRAIN_SCORE": "recovery", + "derived:STRESS_SCORE": "recovery", + "derived:READINESS": "recovery", + + // ── Settings sections ── + "settings:environment": "environment", + "settings:coach": "coach", + + // ── Settings, Layout groups ── + "settings-layout:medications": "medications", + "settings-layout:mood": "mood", + "settings-layout:labs": "labs", + "settings-layout:illness": "illness", + + // ── Correlation channels ── + // Rated mood factors (`FACTOR:`) are mood's too; see + // `correlationChannelSurfaceId`. + "correlation:MOOD": "mood", + "correlation:SLEEP_DURATION": "sleep", + "correlation:BLOOD_GLUCOSE": "glucose", + "correlation:MEDICATION_COMPLIANCE": "medications", + // Symptom burden is read from the illness journal. + "correlation:SYMPTOM_SEVERITY": "illness", + // The labs ↔ outcome pass over lab draws. + "correlation:LAB_DRAWS": "labs", +} as const satisfies Record; + +/** A surface id the map declares literally. */ +export type StaticSurfaceId = keyof typeof STATIC_SURFACE_MODULE; + +/** + * The map. The weather channels are spread in from the environment field + * table so a field added there is owned the moment it exists. + */ +export const SURFACE_MODULE: Readonly> = + Object.freeze({ + ...STATIC_SURFACE_MODULE, + ...Object.fromEntries( + ENVIRONMENT_FIELDS.map((field) => [ + `correlation:${field.key}`, + "environment" as const, + ]), + ), + }); + +/** + * A partial `ModuleKey → enabled` map: the `modules` field of + * `GET /api/auth/me`, or `resolveModuleMap()` on the server. Only an explicit + * `false` hides; a missing key or a missing map reads as on, matching the + * gate's default-on contract. + */ +export type SurfaceModuleMap = Partial>; + +/** The module that owns a surface, or `undefined` for an unowned one. */ +export function surfaceModule(surfaceId: string): ModuleKey | undefined { + return Object.prototype.hasOwnProperty.call(SURFACE_MODULE, surfaceId) + ? SURFACE_MODULE[surfaceId] + : undefined; +} + +/** + * Whether a surface shows under a module map. An unowned surface always + * shows; an owned one hides only when its module is explicitly `false`. + * + * Takes any string so callers can build ids from runtime values + * (`widget:${id}`); the guard test is what keeps those ids honest. + */ +export function isSurfaceVisible( + surfaceId: StaticSurfaceId | (string & {}), + modules: SurfaceModuleMap | null | undefined, +): boolean { + const owner = surfaceModule(surfaceId); + if (owner === undefined) return true; + return modules?.[owner] !== false; +} + +/** + * One family of the map with the `:` prefix stripped: `{ mood: "mood", + * recentWorkouts: "workouts", … }` for `"widget"`. This is how the older + * per-surface maps (`WIDGET_MODULE_BY_ID`, `SUMMARY_TYPE_MODULE`, …) are now + * built, so they are views of this file rather than copies of it. + */ +export function surfaceModulesOfKind( + kind: SurfaceKind, +): Partial> { + const prefix = `${kind}:`; + const out: Partial> = {}; + for (const [id, owner] of Object.entries(SURFACE_MODULE)) { + if (id.startsWith(prefix)) out[id.slice(prefix.length)] = owner; + } + return out; +} + +/** + * The surface id of a correlation channel. Rated mood factors carry their own + * `FACTOR:` channel names but are mood entries all the same, so they + * resolve to the mood channel. + */ +export function correlationChannelSurfaceId(channelKey: string): string { + return channelKey.startsWith("FACTOR:") + ? "correlation:MOOD" + : `correlation:${channelKey}`; +} From 94e07d0d90279fc9c32edacabb44069e9bcfa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:42:17 +0200 Subject: [PATCH 02/13] fix(dashboard): hide a switched-off module's widgets on every feed The dashboard widget and summary maps are now views of the surface map. The legacy dashboard feed painted the stored layout as saved, so the medications card stayed with the module off; the mask the snapshot applies now runs on that feed too, in the browser, from the same map. The widgets route keeps returning the stored layout unmasked, because Settings saves that value back. The Today digest is the day's data and no longer waits on the AI analysis switch, on the server prefetch or in the browser. --- src/app/page-client.tsx | 35 ++--- src/app/page.tsx | 13 +- .../settings/dashboard-layout-section.tsx | 23 +--- .../__tests__/hide-module-widgets.test.ts | 42 ++++++ src/lib/dashboard/snapshot.ts | 28 +--- src/lib/dashboard/widget-modules.ts | 122 ++++++++---------- 6 files changed, 130 insertions(+), 133 deletions(-) create mode 100644 src/lib/dashboard/__tests__/hide-module-widgets.test.ts diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index f26a276a1..f664db6f0 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -144,7 +144,8 @@ import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { useDailyDigest } from "@/lib/queries/use-daily-digest"; -import { useModuleEnabled } from "@/hooks/use-module-enabled"; +import { useSurfaceVisible } from "@/hooks/use-surface-visible"; +import { hideModuleWidgets } from "@/lib/dashboard/widget-modules"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { BATCH_COVERAGE_DAYS, @@ -311,17 +312,10 @@ export default function DashboardPageClient({ const snapshotQuery = useDashboardSnapshot(snapshotEnabled); // S2 — the Today hero reads the unified daily digest (the S1 DTO) from - // its own cached route. Gated on the `insights` module (the digest is - // the AI-narrative daily layer; the route 403s when insights is off) - // and the auth state so a logged-out / gated account never fires it. - // Two readings of one flag, on purpose. The FETCH gate takes the live - // module map, so an account with insights off never fires the request. The - // RENDER gate below takes the mount-pinned account, so the hydration render - // agrees with the server (which had no account, and therefore the default-on - // answer). See `useAccountOnceMounted`. - const insightsModuleEnabled = useModuleEnabled("insights"); - const insightsEnabled = user?.modules?.insights !== false; - const digestQuery = useDailyDigest(isAuthenticated && insightsModuleEnabled); + // its own cached route. It is the day's data, so no module owns it: the + // `insights` key means AI analysis, and the digest leaves its model-written + // lead out on its own when that is off. Gated on auth only. + const digestQuery = useDailyDigest(isAuthenticated); // v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web // hero (Marc, live-use: uneven, wasted tile space). The snapshot still @@ -423,9 +417,14 @@ export default function DashboardPageClient({ enabled: !snapshotEnabled && isAuthenticated, ...DASHBOARD_QUERY_OPTS, }); + // The snapshot publishes its layout with switched-off modules' widgets + // already hidden. The legacy feed is the stored layout as saved (Settings + // edits that exact value), so the same mask is applied here, from the same + // surface map, before anything paints. const layoutData = snapshotEnabled ? snapshotQuery.data?.layout - : layoutDataLegacy; + : layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules); + const moodWidgetVisible = useSurfaceVisible("widget:mood"); const { data: moodDataLegacy } = useQuery({ queryKey: queryKeys.moodAnalytics(), @@ -435,7 +434,8 @@ export default function DashboardPageClient({ summary: DataSummary; }>("/api/mood/analytics"); }, - enabled: !snapshotEnabled && isAuthenticated, + // The route refuses with the mood module off; do not ask. + enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible, ...DASHBOARD_QUERY_OPTS, }); const moodData = snapshotEnabled @@ -947,7 +947,7 @@ export default function DashboardPageClient({ `` with a retry action rather than silently falling through to nothing. The dense tile grid + charts below are untouched. */} - {insightsEnabled && + { // v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero // (the LCP element) instead of a skeleton. This is hydration-safe ONLY // because of a bound invariant: the digest is server-DEHYDRATED @@ -966,7 +966,7 @@ export default function DashboardPageClient({ // rehydrates from disk. The error / empty branches stay reachable only // post-mount. DO NOT add `["daily", …]` to that allowlist, or render // the hero from any client-only source, without revisiting this gate. - (digestQuery.data ? ( + digestQuery.data ? ( ) : digestQuery.isError ? ( digestQuery.refetch()} /> - ) : null)} + ) : null + } {/* v1.18.6 — the spotlight tour launcher moved to the app-shell * (`AuthShell`) so its overlay survives the cross-page diff --git a/src/app/page.tsx b/src/app/page.tsx index 26848887d..8f77e9367 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,7 +13,6 @@ import { type BatchWindow, } from "@/lib/dashboard/batch-chart-types"; import { resolveDashboardLayout } from "@/lib/dashboard-layout"; -import { resolveModuleMap } from "@/lib/modules/gate"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import { readSeriesBatch } from "@/lib/measurements/series-batch-read"; import { queryKeys } from "@/lib/query-keys"; @@ -62,8 +61,9 @@ function softTimeout(ms: number): Promise { * window from `computeBatchWindow(now, user.timezone)` threaded to the * client as the `batchWindow` prop. Server and client build the identical * key by construction, so the prefetched slice lands instead of refetching. - * - Module-gate parity: the digest route gates on `insights`; skip its - * prefetch when the module is off (the client hook is disabled then too). + * - The digest is the day's data and belongs to no module (the `insights` + * key is AI analysis, which the digest omits on its own), so it is + * prefetched for every account, as the client fetches it for every account. * - Record identity: the session comes from `getUnswitchedSession()`, which * answers null while the browser is acting on somebody else's record. Every * read below scopes to `user`, and under a switch `user` is the DELEGATE — @@ -117,9 +117,6 @@ export default async function DashboardPage() { batchWindow = computeBatchWindow(new Date(), user.timezone); } - const modules = await resolveModuleMap(user.id); - const insightsEnabled = modules.insights !== false; - const seriesTypes = snapshotMode ? deriveBatchChartTypes( resolveDashboardLayout(wireBody.layout), @@ -133,9 +130,7 @@ export default async function DashboardPage() { // Digest carries a soft budget (cold-day extras rebuild); series is a // warm rollup read (< 100 ms) with none. Both fail soft to `undefined`. - const digestWork = insightsEnabled - ? loadDailyDigest(user).catch(() => undefined) - : Promise.resolve(undefined); + const digestWork = loadDailyDigest(user).catch(() => undefined); const seriesWork = batchWindow && seriesTypes.length > 0 ? readSeriesBatch( diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index ef9549330..b1a2bd9df 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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; diff --git a/src/lib/dashboard/__tests__/hide-module-widgets.test.ts b/src/lib/dashboard/__tests__/hide-module-widgets.test.ts new file mode 100644 index 000000000..982f88a32 --- /dev/null +++ b/src/lib/dashboard/__tests__/hide-module-widgets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { hideModuleWidgets } from "@/lib/dashboard/widget-modules"; + +const layout = { + version: 2, + widgets: [ + { id: "weight", visible: true, tileVisible: true, order: 0 }, + { id: "medications", visible: true, tileVisible: true, order: 1 }, + { id: "mood", visible: true, tileVisible: true, order: 2 }, + { id: "hrv", visible: true, tileVisible: true, order: 3 }, + ], +}; + +describe("hideModuleWidgets", () => { + it("hides the widgets of a switched-off module and keeps their order", () => { + const out = hideModuleWidgets(layout, { medications: false }); + expect(out.widgets.map((w) => [w.id, w.visible, w.tileVisible])).toEqual([ + ["weight", true, true], + ["medications", false, false], + ["mood", true, true], + ["hrv", true, true], + ]); + }); + + it("hides the ids it is told to on top of the module mask", () => { + const out = hideModuleWidgets(layout, {}, new Set(["hrv"])); + expect(out.widgets.find((w) => w.id === "hrv")?.visible).toBe(false); + expect(out.widgets.find((w) => w.id === "mood")?.visible).toBe(true); + }); + + it("is a no-op before the module map is known", () => { + expect(hideModuleWidgets(layout, undefined).widgets).toEqual( + layout.widgets, + ); + }); + + it("never writes through to the layout it was given", () => { + hideModuleWidgets(layout, { mood: false, medications: false }); + expect(layout.widgets.every((w) => w.visible)).toBe(true); + }); +}); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index da3f1f631..87771fc6f 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -130,8 +130,8 @@ export { SUMMARY_TYPE_MODULE, } from "@/lib/dashboard/widget-modules"; import { - WIDGET_MODULE_BY_ID, gateSummariesByModules, + hideModuleWidgets, unavailableWidgetIds, } from "@/lib/dashboard/widget-modules"; @@ -1065,38 +1065,22 @@ function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { } /** - * v1.18.0 — force every widget whose module is disabled to invisible on - * the resolved layout (both `visible` and `tileVisible`). Order is - * preserved so a re-enable restores the user's saved position. Pure - * projection — the persisted `dashboardWidgetsJson` is untouched; only - * what the snapshot publishes is gated. + * The layout the snapshot publishes: widgets of a switched-off module, and + * widgets that cannot paint for this account, forced invisible. Same rule the + * Settings screen hides their toggles with, so the dashboard and the switches + * never disagree. The stored layout is untouched. */ function gateLayoutByModules( layout: DashboardLayout, modules: Record, summaries: Record, ): DashboardLayout { - // Widgets that cannot paint for THIS account, on the same rule the - // Settings screen hides their toggle with — so the layout the dashboard - // renders and the switches the user is offered never disagree. const unavailable = new Set( unavailableWidgetIds(modules, { hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0, }), ); - return { - ...layout, - widgets: layout.widgets.map((w) => { - const moduleKey = WIDGET_MODULE_BY_ID[w.id]; - if (moduleKey && modules[moduleKey] === false) { - return { ...w, visible: false, tileVisible: false }; - } - if (unavailable.has(w.id)) { - return { ...w, visible: false, tileVisible: false }; - } - return w; - }), - }; + return hideModuleWidgets(layout, modules, unavailable); } /** diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 1d2c39b44..a8b025d99 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -13,83 +13,67 @@ * imports), so this file stays browser-bundle-safe. */ import type { ModuleKey } from "@/lib/modules/registry"; +import { isSurfaceVisible, surfaceModulesOfKind } from "@/lib/modules/surface"; /** - * Dashboard widget id → toggleable module key. When the user disables the - * module, the matching widget is forced invisible on both the web `layout` - * and the iOS `layoutCatalogue`, so the tile/chart never paints. Only the - * toggleable surfaces appear here; CORE widgets (weight / bp / pulse / - * bodyFat / bpInTarget and the vital-derived HealthKit metrics) carry NO - * entry and are never hidden. + * Dashboard widget id → toggleable module key. A widget whose module is off + * is forced invisible on both the web `layout` and the iOS + * `layoutCatalogue`. CORE widgets carry no entry and are never hidden. * - * v1.18.1 (D3) — `medications` graduated from CORE to a toggleable module, - * so the medication tile now gates off it like the other toggleable widgets. + * A view of the one surface map (`widget:*` in `@/lib/modules/surface`), so + * the dashboard, the Settings list and every other surface answer from the + * same declaration. */ -export const WIDGET_MODULE_BY_ID: Partial> = { - mood: "mood", - sleep: "sleep", - glucose: "glucose", - achievements: "achievements", - recentWorkouts: "workouts", - medications: "medications", - // v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery - // module; the per-night breathing-disturbance widget belongs to sleep. - cardioRecovery: "recovery", - sixMinuteWalk: "recovery", - stairAscentSpeed: "recovery", - stairDescentSpeed: "recovery", - breathingDisturbances: "sleep", - // v1.29 — fluid-intake strip tile, nutrients-store-backed (see - // `SUMMARY_TYPE_MODULE.NUTRIENT_WATER` below). - waterIntake: "nutrients", -}; +export const WIDGET_MODULE_BY_ID: Partial> = + surfaceModulesOfKind("widget"); /** - * Slim-summary keys that belong to a toggleable module. When the module - * is off the key is stripped from `tiles.summaries` / - * `tiles.lastSeenByType` (so `metricStates` and the client data-floor - * gates also drop it) before the snapshot leaves the server. Core vital - * types are absent here and always pass through. + * Summary keys that belong to a toggleable module. When the module is off the + * key is stripped from `tiles.summaries` / `tiles.lastSeenByType` (so + * `metricStates` and the client data-floor gates drop it too) before the + * snapshot leaves the server. Core vital types are absent and always pass. * - * v1.29 — widened from `Partial>` to - * `Partial>` so the synthetic `NUTRIENT_WATER` - * key (a `NutrientIntakeDay`-derived summary, not a real - * `MeasurementType` — the abandoned `feat/water` branch's parallel - * `WATER_INTAKE` enum value is deliberately NOT added) can ride the same - * gate without widening the `MeasurementType` enum itself. + * Keyed by string rather than `MeasurementType` because two keys are + * synthetic (`NUTRIENT_WATER`, `MOOD_ENTRY`: day totals derived from other + * stores). A view of `summary:*` in `@/lib/modules/surface`. */ -export const SUMMARY_TYPE_MODULE: Partial> = { - SLEEP_DURATION: "sleep", - BLOOD_GLUCOSE: "glucose", - // v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page + - // its tiles are the recovery module's surface; when it is off these - // device-native signals must drop from the dashboard snapshot too. - CARDIO_RECOVERY: "recovery", - SIX_MINUTE_WALK_DISTANCE: "recovery", - STAIR_ASCENT_SPEED: "recovery", - STAIR_DESCENT_SPEED: "recovery", - // The HRV fallback series. `METRIC_STATUS_MODULE_OWNERS` already owns this - // type for the metric-status / MCP reads, for the reason stated there: a - // fallback path must not serve the recovery data the primary type refuses. - // The dashboard snapshot gates off THIS map instead, and the entry was - // missing — harmless only while the dashboard HRV tile read SDNN alone. - // Now that the tile falls back to RMSSD (`pickHrvSummary`), the omission - // would hand a recovery-off account a tile built from recovery-owned rows. - // `HEART_RATE_VARIABILITY` stays deliberately ungated: SDNN is a plain - // vital, and it is what the tile shows whenever the account has it. - HRV_RMSSD: "recovery", - // Per-night breathing-disturbance index is a sleep-page signal. - BREATHING_DISTURBANCES: "sleep", - // v1.29 — fluid-intake dashboard tile summary, derived server-side from - // `NutrientIntakeDay` (nutrient="water", summed across sources). Gated - // on the `nutrients` module like the rest of that store's surfaces. - NUTRIENT_WATER: "nutrients", - // Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it - // rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the - // `mood` module: turning the module off must drop the dashboard card on - // every client, not just hide the web widget. - MOOD_ENTRY: "mood", -}; +export const SUMMARY_TYPE_MODULE: Partial> = + surfaceModulesOfKind("summary"); + +/** + * Force every widget whose owning module is off, and every id in `alsoHide`, + * to invisible (both `visible` and `tileVisible`). Order is kept so a + * re-enable restores the saved position. + * + * A projection for RENDERING only. The stored `dashboardWidgetsJson` is never + * written through this: `GET /api/dashboard/widgets` returns the stored + * layout as saved, because Settings edits and re-saves exactly that value, + * and a masked read saved back would switch the widget off for good. The + * dashboard snapshot applies it server-side; the dashboard's legacy (no + * snapshot) path applies it in the browser, so both feeds paint the same. + */ +export function hideModuleWidgets< + L extends { + widgets: ReadonlyArray<{ + id: string; + visible: boolean; + tileVisible?: boolean; + }>; + }, +>( + layout: L, + modules: Partial> | null | undefined, + alsoHide: ReadonlySet = new Set(), +): L { + return { + ...layout, + widgets: layout.widgets.map((w) => + isSurfaceVisible(`widget:${w.id}`, modules) && !alsoHide.has(w.id) + ? w + : { ...w, visible: false, tileVisible: false }, + ), + }; +} /** * The summary types a module map turns off. Lifted out of From a4f8e6db2719610e22c14ea2beae4cd47e05b528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:48:05 +0200 Subject: [PATCH 03/13] fix(capture): leave Mood and Medication out of the add menu with their modules off --- .../__tests__/capture-picker-discard.test.tsx | 5 ++++ .../layout/__tests__/capture-picker.test.tsx | 26 ++++++++++++++++++ src/components/layout/capture-picker.tsx | 27 +++++++++++++++---- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/components/layout/__tests__/capture-picker-discard.test.tsx b/src/components/layout/__tests__/capture-picker-discard.test.tsx index f701b3a94..a62bfc7b4 100644 --- a/src/components/layout/__tests__/capture-picker-discard.test.tsx +++ b/src/components/layout/__tests__/capture-picker-discard.test.tsx @@ -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(); return { diff --git a/src/components/layout/__tests__/capture-picker.test.tsx b/src/components/layout/__tests__/capture-picker.test.tsx index ecb0d3463..20d5e86af 100644 --- a/src/components/layout/__tests__/capture-picker.test.tsx +++ b/src/components/layout/__tests__/capture-picker.test.tsx @@ -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 | 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(" — 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(" — offered kinds", () => { expect(html).not.toContain('data-testid="capture-picker-water"'); }); }); + +describe(" — 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"'); + }); +}); diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index b3415e891..068dd5f46 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -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:` 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, kinds: ReadonlyArray, + 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(null); // Re-derived on every render, never latched: the offer can shrink under a // sheet that is already open. See `admittedCaptureKind`. From 39ae034b8c0eade4d7c934c8825e2491b7c83ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:48:12 +0200 Subject: [PATCH 04/13] fix(modules): route nav entries and module pages through the surface map Navigation destinations, the bottom bar's fixed slots and the tour stops no longer carry their own module keys; each asks the surface map by its href. Insights has no owner there, so switching AI analysis off leaves the Insights entry, slot and tour stop in place. The shell's page gate now resolves Insights sub-pages by slug as well as top-level pages, so /insights/mood, /insights/medications and the recovery metric pages show the module notice when their module is off, worded from the access reason, with the Settings link only for the record's own switch. The per-page redirect guard, the achievements redirect and the nutrients page's own notice are gone; nothing redirects any more. --- src/app/achievements/page.tsx | 25 +---- src/app/insights/blood-glucose/page.tsx | 13 +-- src/app/insights/nutrients/page.tsx | 57 ++-------- src/app/insights/recovery/page.tsx | 13 +-- src/app/insights/sleep/page.tsx | 14 +-- src/app/insights/workouts/[id]/page.tsx | 13 --- src/app/insights/workouts/page-client.tsx | 13 +-- src/app/insights/workouts/page.tsx | 8 +- .../__tests__/bottom-nav-module-gate.test.tsx | 13 ++- .../__tests__/module-page-gate.test.tsx | 101 ++++++++++++++++++ .../__tests__/nav-model-shared-record.test.ts | 9 +- .../layout/__tests__/nav-model.test.ts | 13 ++- .../layout/__tests__/sidebar-nav.test.tsx | 6 +- src/components/layout/bottom-nav.tsx | 61 ++++------- .../layout/module-disabled-notice.tsx | 6 +- src/components/layout/module-page-gate.tsx | 62 +++++++---- src/components/layout/nav-model.ts | 70 +++++------- src/components/onboarding/tour.tsx | 2 +- .../__tests__/use-module-page-guard.test.tsx | 86 --------------- src/hooks/use-module-page-guard.ts | 45 -------- .../onboarding/__tests__/tour-state.test.ts | 13 +++ src/lib/onboarding/tour-state.ts | 21 +--- 22 files changed, 263 insertions(+), 401 deletions(-) create mode 100644 src/components/layout/__tests__/module-page-gate.test.tsx delete mode 100644 src/hooks/__tests__/use-module-page-guard.test.tsx delete mode 100644 src/hooks/use-module-page-guard.ts diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index abee3ed10..57d1d4f8a 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -34,8 +34,6 @@ import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; @@ -262,7 +260,6 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { export default function AchievementsPage() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); - const router = useRouter(); // v1.18.0 — the achievements module gate. When the account has the // module turned off the whole page disappears: an empty state stands in @@ -279,29 +276,13 @@ export default function AchievementsPage() { enabled: isAuthenticated && achievementsEnabled, }); - useEffect(() => { - if (!authLoading && isAuthenticated && !achievementsEnabled) { - router.push("/insights"); - } - }, [authLoading, isAuthenticated, achievementsEnabled, router]); - if (authLoading || (achievementsEnabled && isLoading)) { return ; } - // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is - // hidden by the same gate, so this only catches a direct URL hit. It used to - // render `null` and stop there, which left a blank page with no explanation - // and no route anywhere — a dead end rather than a gate. It now bounces to - // /insights, matching every other module-gated page (`useModulePageGuard`); - // the brief `null` is the pre-redirect frame, not the destination. - // - // The shared guard is not used wholesale here because it also redirects an - // unauthenticated visitor to /auth/login, and this page deliberately answers - // that case with a "sign in to see your achievements" header instead. - if (isAuthenticated && !achievementsEnabled) { - return null; - } + // Module off: the shell answers this URL with the module notice + // (`ModulePageGate`) before the page renders, so there is no branch for it + // here. `achievementsEnabled` above only keeps the query from firing. if (!isAuthenticated) { return ( diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index d8dcdddb2..0007fef34 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -1,11 +1,10 @@ "use client"; -import { Droplet, Loader2 } from "lucide-react"; +import { Droplet } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel"; import { useAuth } from "@/hooks/use-auth"; -import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { useTranslations } from "@/lib/i18n/context"; import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose"; @@ -28,19 +27,9 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose"; export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); - const { ready } = useModulePageGuard("glucose"); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const isMmol = glucoseUnit === "mmol/L"; - // v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account. - if (!ready) { - return ( -
- -
- ); - } - return ( apiPatch("/api/auth/me/modules", { nutrients: true }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - }, - onError: () => toast.error(t("nutrients.page.moduleEnableError")), - }); - - if (!nutrientsEnabled) { - return ( - - {/* - The shared notice rather than this page's own copy: "nutrients is - off" was three situations wearing one sentence, and only one of them - is fixed by the button below. A delegate whose grant does not reach - the measurements section, and an account on an instance where the - operator removed the module, both used to be handed a switch that - would refuse them. The notice reads the reason off `moduleAccess` - and drops the action for the two cases it cannot help. - */} - } - action={ - - } - /> - - ); - } - if (overview.isError) { return ( - - - ); - } return ( - - - ); - } - if (isEmpty) { return ( - - - ); - } - return ( (undefined); const { workouts, @@ -76,15 +74,6 @@ export default function InsightsWorkoutsPageClient() { return label === key ? sport : label; }; - // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. - if (!ready) { - return ( -
- -
- ); - } - return ( 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: {} }; + }); }); diff --git a/src/components/layout/__tests__/module-page-gate.test.tsx b/src/components/layout/__tests__/module-page-gate.test.tsx new file mode 100644 index 000000000..37828815d --- /dev/null +++ b/src/components/layout/__tests__/module-page-gate.test.tsx @@ -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; + moduleAccess?: Record; +}; +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( + + +

page

+
+
, + ); +} + +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("", () => { + 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"'); + }); +}); diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts index f9c6005f9..92e080398 100644 --- a/src/components/layout/__tests__/nav-model-shared-record.test.ts +++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts @@ -48,16 +48,17 @@ import { NAV_DESTINATIONS, isDestinationInSharedRecord, mobileMoreHubDestinations, + navDestinationModule, visibleNavDestinations, visibleUtilityDestinations, } from "../nav-model"; /** 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[] { diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index e37cbd032..99541122c 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -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, diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 176fdb67a..7effc6cb9 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -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; diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 8990eb73d..6200f8be4 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -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 = [ +// 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 = [ { 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 = [ - { - 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 = [ + { 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], diff --git a/src/components/layout/module-disabled-notice.tsx b/src/components/layout/module-disabled-notice.tsx index 9cb047ee3..84e5a35aa 100644 --- a/src/components/layout/module-disabled-notice.tsx +++ b/src/components/layout/module-disabled-notice.tsx @@ -79,7 +79,11 @@ export function ModuleDisabledNotice({ const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey); return ( -
+
`), so + * `/insights/mood` and `/insights/workouts/` follow their own module while + * `/insights` itself follows none. Every other page answers through the nav + * destination it sits under (`nav:`), 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 ( } + icon={Icon ? : undefined} action={