Files
HealthLog/src/lib/modules/surface.ts
T
Marc-André Bombeck 9d9e1f153c feat(insights): make each person-defined symptom its own correlation channel
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
2026-10-03 21:14:08 +02:00

287 lines
11 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.
* - `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",
// 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",
// ── 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:mood": "mood",
"settings-layout:labs": "labs",
"settings-layout:illness": "illness",
// ── 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",
"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<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";
return `correlation:${channelKey}`;
}