Files
HealthLog/src/lib/modules/surface.ts
T
Marc-André Bombeck faa3ef867b feat(insights): correlate intake, alcohol days, training, rings, State of Mind and room climate
The discovery matrix gains the families people log but could not test:
water, caffeine and up to four varying micronutrients from the nutrient
log, alcohol days from the mood tag, workout minutes (one per session) and
the day's training impulse, exercise, stand and mindful minutes, State of
Mind as an outcome, and the overnight room climate against that night's
sleep only. Every channel runs through the same seasonal adjustment,
effective-n p-value, Benjamini-Hochberg control and effect-size floor, is
read only while its module is on, and drops out below the paired-day floor.

State of Mind, mindful minutes and alcohol days are statistics for the
person: the Coach, the period narrative and the assessment cards drop every
pair that touches them, after the shared scan so the remaining pairs keep
the statistics the correlations page shows. A guard walks the import
closure of the analysis modules and refuses any path to a provider.
2026-10-21 21:39:15 +02:00

381 lines
16 KiB
TypeScript

/**
* 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 `<kind>:<local id>`, 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/<slug>`).
* - `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:<key>`) 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:<code>` 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<string, ModuleKey>;
/** 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<Record<string, ModuleKey>> =
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<Record<ModuleKey, boolean>>;
/** 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 `<kind>:` 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<Record<string, ModuleKey>> {
const prefix = `${kind}:`;
const out: Partial<Record<string, ModuleKey>> = {};
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:<key>` channel names but are mood entries all the same, so they
* resolve to the mood channel; person-defined symptoms (`SYMPTOM:<id>`) 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:<id>`, 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:<code>`, v1.44) rides the nutrient
// log like water and caffeine.
if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT";
return `correlation:${channelKey}`;
}