Files
HealthLog/src/lib/modules/surface.ts
T
Marc-André Bombeck 9272daa59c feat(symptoms): log a symptom in one step and show it on the illness pages
- A new capture kind "symptom" (illness module, illness write grant) in
  the bottom bar's picker and the dashboard Add menu. The sheet follows
  the measurement and mood forms: the person's symptoms as chips with the
  cycle sheet's dashed "new symptom" chip, the 0-10 slider with its
  anchors, the time prefilled with now, an optional note, and a "during"
  selector only while an illness episode is open.
- "Your symptoms" on the illness page: one line per symptom (how often in
  30 days, how strong at worst, when last), the last 30 days grouped by
  day, edit and delete per entry, rename, icon, hide and delete per
  symptom. Renders nothing until a symptom exists.
- "Symptoms logged" on an episode's page: the entries filed against it or
  logged while it ran, the filed ones marked. This is the reader of the
  episode link.
- Query keys live under the illness root, so either side's writes
  refresh both.
2026-10-03 21:24:12 +02:00

289 lines
12 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",
// v1.40 — an occurrence of a person-defined symptom rides the illness module.
"capture:symptom": "illness",
// ── 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}`;
}