/** * 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. * - `settings-module`: a module's own Settings page under Modules, by its id. * - `correlation`: a correlation-discovery channel, by its channel key. * - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`. * - `day-section`: a section of the day view, by its `DaySectionKey`. The * day loader blanks a section whose owner is off, and the views drop it. * - `timeline-lane`: a lane of the timeline, by its `TimelineLaneKey`; the * readiness inventory leaves it out the same way. * - `search`: a source the in-app record search reads, by its * `SearchSourceKey`. A source whose owner is off is not read at all. */ export const SURFACE_KINDS = [ "nav", "insights-page", "overview", "trend", "capture", "widget", "summary", "derived", "settings", "settings-layout", "settings-module", "correlation", "score-ring", "day-section", "timeline-lane", "search", ] 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", "nav:/timeline": "timeline", // ── 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", // The resting heart rate is the recovery module's `resting_hr` domain; its // page, trend slot, correlation channel and summary key follow it. "insights-page:resting-pulse": "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", "trend:resting_hr": "recovery", // ── Add menu ── "capture:mood": "mood", "capture:medication": "medications", "capture:workout": "workouts", // v1.40 — an occurrence of a person-defined symptom rides the illness module. "capture:symptom": "illness", "capture:lifeEvent": "timeline", // ── 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", // The recovery module owns the resting_hr domain (`measurement-scope.ts`), // so the server gates the type on it; the form and the dashboard follow. "summary:RESTING_HEART_RATE": "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:labs": "labs", "settings-layout:illness": "illness", "settings-layout:documents": "inboundDocuments", // ── Settings, a module's own page under Modules ── "settings-module:medications": "medications", "settings-module:mood": "mood", "settings-module:labs": "labs", // ── Correlation channels ── // Rated mood factors (`FACTOR:`) are mood's too; see // `correlationChannelSurfaceId`. "correlation:MOOD": "mood", "correlation:SLEEP_DURATION": "sleep", "correlation:BLOOD_GLUCOSE": "glucose", "correlation:RESTING_HEART_RATE": "recovery", // The RMSSD series is the recovery module's (see `summary:HRV_RMSSD`). "correlation:HRV_RMSSD": "recovery", "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", // v1.43 — the Dawarich movement channels ride the environment module. "correlation:MOVEMENT_AWAY": "environment", "correlation:MOVEMENT_DISTANCE": "environment", // v1.44 — the extended families. Intake rides the opt-in nutrient log (the // `NUTRIENT:` micronutrient channels fold onto `correlation:NUTRIENT`), // alcohol days are read from mood entries' tag, and the two training // channels from the workout record. State of Mind and mindfulness are // self-reports about feeling, so they follow mood. The room climate rides // the environment module like the weather. Exercise and stand minutes are // ambient movement like steps and carry no owner. "correlation:HYDRATION": "nutrients", "correlation:CAFFEINE": "nutrients", "correlation:NUTRIENT": "nutrients", "correlation:ALCOHOL": "mood", "correlation:WORKOUT_MINUTES": "workouts", "correlation:TRAINING_LOAD": "workouts", "correlation:MINDFUL_MINUTES": "mood", "correlation:STATE_OF_MIND_VALENCE": "mood", "correlation:INDOOR_CO2": "environment", "correlation:INDOOR_TEMPERATURE": "environment", "correlation:INDOOR_HUMIDITY": "environment", // ── Day view sections (v1.42) ── // The readings carry no owner here: each value follows its own type's // module (`moduleForMeasurementType`), so a section-level owner would hide // the core vitals with it. Allergies, visits, check-ups and the anamnesis // facts are core record surfaces and stay unowned. "day-section:sleep": "sleep", "day-section:mood": "mood", "day-section:assessments": "mentalHealth", "day-section:medications": "medications", "day-section:illness": "illness", // Person-defined symptoms ride the illness module, as everywhere else. "day-section:symptoms": "illness", "day-section:labs": "labs", "day-section:vaccinations": "vaccinations", "day-section:documents": "inboundDocuments", "day-section:workouts": "workouts", "day-section:cycle": "cycle", "day-section:environment": "environment", // Life events are the timeline module's own content: off, they leave the // day as well. "day-section:lifeEvents": "timeline", // ── Timeline lanes (v1.42) ── // The `life` lane mixes life events (this module) with travel periods (the // environment module) and is filtered item by item; allergies and visits are // core. "timeline-lane:illness": "illness", "timeline-lane:medications": "medications", "timeline-lane:vaccinations": "vaccinations", "timeline-lane:labs": "labs", "timeline-lane:documents": "inboundDocuments", "timeline-lane:cycle": "cycle", // ── Record search sources (v1.42.2) ── // Visits are core. Notes on readings follow the owner of the reading's // type and are filtered item by item, like the timeline's `life` lane. "search:condition": "illness", "search:document": "inboundDocuments", "search:vaccination": "vaccinations", "search:labAnalyte": "labs", "search:labNote": "labs", "search:medication": "medications", "search:symptom": "illness", "search:symptomNote": "illness", "search:workout": "workouts", "search:moodNote": "mood", "search:illnessNote": "illness", } 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; person-defined symptoms (`SYMPTOM:`) resolve * to the illness symptom channel the same way. */ export function correlationChannelSurfaceId(channelKey: string): string { if (channelKey.startsWith("FACTOR:")) return "correlation:MOOD"; // A person-defined symptom (`SYMPTOM:`, v1.40) rides the illness // module, like the symptom-burden channel it sits beside. if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY"; // One micronutrient's intake (`NUTRIENT:`, v1.44) rides the nutrient // log like water and caffeine. if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT"; return `correlation:${channelKey}`; }