mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user