/** * 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. * - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`. */ export const SURFACE_KINDS = [ "nav", "insights-page", "overview", "trend", "capture", "widget", "summary", "derived", "settings", "settings-layout", "correlation", "score-ring", ] 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", // ── Dashboard hero score rings ── // The three derived rings follow their derived score; the dose ring is // medications'. "score-ring:READINESS": "recovery", "score-ring:RECOVERY_SCORE": "recovery", "score-ring:SLEEP_SCORE": "sleep", "score-ring:MED_COMPLIANCE": "medications", // ── 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}`; }