feat(modules): add one map from every surface to the module that owns it

SURFACE_MODULE names each surface a toggleable module owns (nav entries,
Insights pages, overview blocks, trend slots, add-menu entries, dashboard
widgets and summaries, derived scores, Settings sections, correlation
channels) as <kind>:<id>. isSurfaceVisible answers from it on the server
and in the browser, useSurfaceVisible reads it against the account.

The insights key owns no surface: it means AI analysis, and the Insights
area is data.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:39:58 +02:00
1 parent c9534b343e
commit 91be6b2967
4 files changed
+417

No files matched your search

@@ -0,0 +1,45 @@
import React from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const useAuthMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => useAuthMock(),
}));
const hasClientMock = vi.fn(() => true);
vi.mock("@/hooks/_internal/use-query-client-safe", () => ({
useQueryClientMounted: () => hasClientMock(),
}));
import { useSurfaceVisible } from "../use-surface-visible";
function Probe({ id }: { id: string }) {
return <span>{String(useSurfaceVisible(id))}</span>;
}
const visible = (id: string) =>
renderToStaticMarkup(<Probe id={id} />).includes(">true<");
describe("useSurfaceVisible", () => {
beforeEach(() => {
vi.clearAllMocks();
hasClientMock.mockReturnValue(true);
});
it("hides a surface whose module the record switched off", () => {
useAuthMock.mockReturnValue({ user: { modules: { mood: false } } });
expect(visible("capture:mood")).toBe(false);
expect(visible("capture:medication")).toBe(true);
});
it("is on before the account resolves", () => {
useAuthMock.mockReturnValue({ user: null });
expect(visible("capture:mood")).toBe(true);
});
it("is on without a query client, and never reads the account then", () => {
hasClientMock.mockReturnValue(false);
expect(visible("capture:mood")).toBe(true);
expect(useAuthMock).not.toHaveBeenCalled();
});
});
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
import { isSurfaceVisible, type StaticSurfaceId } from "@/lib/modules/surface";
/**
* Whether a surface shows for the record this session is on, read through the
* one surface map (`src/lib/modules/surface.ts`) against the resolved
* `modules` of `GET /api/auth/me`.
*
* SSR-safe the way `useModuleEnabled` is: a presentational component rendered
* without a `QueryClientProvider` gets the default-on answer instead of a
* crash. The branch is stable for a component's lifetime, so the conditional
* hook call is safe.
*
* Default-on: before the account resolves, and for every surface no module
* owns, the answer is `true`. A caller whose first paint must match the server
* render (nav lists, anything behind a hydration gate) keeps its own mounted
* check on top.
*/
export function useSurfaceVisible(
surfaceId: StaticSurfaceId | (string & {}),
): boolean {
const hasClient = useQueryClientMounted();
if (!hasClient) return true;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSurfaceVisibleInner(surfaceId);
}
function useSurfaceVisibleInner(surfaceId: string): boolean {
const { user } = useAuth();
return isSurfaceVisible(surfaceId, user?.modules);
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { MODULE_KEYS } from "@/lib/modules/registry";
import {
SURFACE_KINDS,
SURFACE_MODULE,
isSurfaceVisible,
surfaceModule,
surfaceModulesOfKind,
} from "@/lib/modules/surface";
describe("SURFACE_MODULE", () => {
it("owns every surface by a real module key", () => {
const keys = new Set<string>(MODULE_KEYS);
for (const [id, owner] of Object.entries(SURFACE_MODULE)) {
expect(keys.has(owner), `${id} → ${owner}`).toBe(true);
}
});
it("names every surface as <kind>:<local id> with a known kind", () => {
const kinds = new Set<string>(SURFACE_KINDS);
for (const id of Object.keys(SURFACE_MODULE)) {
const kind = id.slice(0, id.indexOf(":"));
expect(kinds.has(kind), id).toBe(true);
expect(id.length).toBeGreaterThan(kind.length + 1);
}
});
it("leaves the Insights area to no module (the insights key is AI analysis only)", () => {
expect(surfaceModule("nav:/insights")).toBeUndefined();
expect(Object.values(SURFACE_MODULE)).not.toContain("insights");
});
});
describe("isSurfaceVisible", () => {
it("hides an owned surface only on an explicit false", () => {
expect(isSurfaceVisible("trend:mood", { mood: false })).toBe(false);
expect(isSurfaceVisible("trend:mood", { mood: true })).toBe(true);
expect(isSurfaceVisible("trend:mood", {})).toBe(true);
expect(isSurfaceVisible("trend:mood", undefined)).toBe(true);
});
it("never hides a surface no module owns", () => {
expect(isSurfaceVisible("trend:bp", { mood: false, sleep: false })).toBe(
true,
);
expect(isSurfaceVisible("nav:/measurements", { mood: false })).toBe(true);
});
it("reads the owner's key, not the surface's name", () => {
expect(isSurfaceVisible("capture:medication", { medications: false })).toBe(
false,
);
expect(
isSurfaceVisible("insights-page:blood-glucose", { glucose: false }),
).toBe(false);
});
});
describe("surfaceModulesOfKind", () => {
it("strips the kind prefix and keeps only that kind", () => {
const widgets = surfaceModulesOfKind("widget");
expect(widgets.mood).toBe("mood");
expect(widgets.recentWorkouts).toBe("workouts");
expect(Object.keys(widgets).some((k) => k.includes(":"))).toBe(false);
expect(widgets["/mood"]).toBeUndefined();
});
it("returns an empty view for a kind with no entries rather than throwing", () => {
for (const kind of SURFACE_KINDS) {
expect(typeof surfaceModulesOfKind(kind)).toBe("object");
}
});
});
+264
View File
@@ -0,0 +1,264 @@
/**
* 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.
*/
export const SURFACE_KINDS = [
"nav",
"insights-page",
"overview",
"trend",
"capture",
"widget",
"summary",
"derived",
"settings",
"settings-layout",
"correlation",
] 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",
// ── 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: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.
*/
export function correlationChannelSurfaceId(channelKey: string): string {
return channelKey.startsWith("FACTOR:")
? "correlation:MOOD"
: `correlation:${channelKey}`;
}