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}`; +}