Merge branch 'feat/module-surfaces' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:10:11 +02:00
commit ab0d1b4b71
67 files changed
+1975 -703

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
/**
* A module switched off disappears everywhere, and a page of it answers with
* the notice instead of errors.
*
* One journey on its own account (`E2E_MODULES`): mood and medications are
* switched on, the surfaces they own are proven present (the positive control
* every "is gone" assertion below leans on), then both are switched off
* through the real module route and the same surfaces are proven gone:
*
* - the Mood nav entry,
* - the Mood and Medications pills in the Insights tab strip,
* - the mood slot in the Insights trends row (the row fills from the next
* metric instead),
* - Mood and Medication in the add menu,
* - `/insights/mood` renders the module notice, not the page,
*
* and no chart on the overview paints its error state.
*
* Every assertion is addressed to a stable `data-*` attribute, never to copy.
* The journey moves its account's module switches, so it runs in one project
* and serially (see `playwright.config.ts`).
*/
import type { Page } from "@playwright/test";
import { MODULES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { modulesSeedState } from "./setup/modules-fixture";
import { expect, test } from "./setup/test";
test.use({ storageState: MODULES_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
/** A same-origin JSON write from the page, so the session cookie rides along. */
async function send(
page: Page,
method: "POST" | "PATCH",
path: string,
body: unknown,
): Promise<number> {
return page.evaluate(
async ({ method, path, body }) => {
const res = await fetch(path, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return res.status;
},
{ method, path, body },
);
}
async function setModules(page: Page, on: boolean): Promise<void> {
expect(
await send(page, "PATCH", "/api/auth/me/modules", {
mood: on,
medications: on,
}),
).toBe(200);
}
/** One mood entry and one medication, created once, through the app. */
async function seed(page: Page): Promise<void> {
const state = await modulesSeedState();
if (state.moodEntries === 0) {
expect(
await send(page, "POST", "/api/mood-entries", {
mood: "GUT",
moodLoggedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
}),
).toBe(201);
}
if (state.medications === 0) {
expect(
await send(page, "POST", "/api/medications", {
name: "Module journey",
dose: "5 mg",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
).toBe(201);
}
}
const moodPill = '[data-slot="insights-tab-strip-pill"][href="/insights/mood"]';
const medsPill =
'[data-slot="insights-tab-strip-pill"][href="/insights/medications"]';
const trendCard = (metric: string) =>
`[data-slot="trends-row-card"][data-metric="${metric}"]`;
async function openCapturePicker(page: Page): Promise<void> {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
const capture = page.locator('[data-testid="bottom-nav-capture"]');
await expect(capture).toBeVisible({ timeout: 15_000 });
await capture.click();
await expect(
page.locator('[data-testid="capture-picker-measurement"]'),
).toBeVisible();
}
test("mood and medications, switched off, leave every surface they own", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/");
await setModules(page, true);
await seed(page);
// ── Positive control: with both modules on, each surface is there. ──
await page.goto("/insights");
await expect(page.locator(moodPill)).toBeVisible({ timeout: 20_000 });
await expect(page.locator(medsPill)).toBeVisible();
await expect(page.locator(trendCard("mood"))).toHaveCount(1);
await expect(page.locator('[data-tour-id="nav-mood"]')).toHaveCount(1);
await openCapturePicker(page);
await expect(page.locator('[data-testid="capture-picker-mood"]')).toHaveCount(
1,
);
await expect(
page.locator('[data-testid="capture-picker-medication"]'),
).toHaveCount(1);
await page.setViewportSize({ width: 1280, height: 720 });
// ── Switch both off through the real route. ──
await setModules(page, false);
// Insights overview: no Mood or Medications pill, no mood trend slot, the
// row still fills three slots from the next metric, and nothing errors.
await page.goto("/insights");
await expect(
page.locator('[data-slot="insights-tab-strip-pill"][href="/insights"]'),
).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="trends-row"]')).toBeVisible();
await expect(page.locator(moodPill)).toHaveCount(0);
await expect(page.locator(medsPill)).toHaveCount(0);
await expect(page.locator(trendCard("mood"))).toHaveCount(0);
await expect(page.locator(trendCard("pulse"))).toHaveCount(1);
await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount(0);
// The nav entry is gone; Insights itself is not a module's and stays.
await expect(page.locator('[data-tour-id="nav-mood"]')).toHaveCount(0);
await expect(page.locator('[data-tour-id="nav-insights"]')).toHaveCount(1);
// A direct visit: the notice, named for mood and for the record's own
// switch (so it offers Settings), at the same URL, never the page.
await page.goto("/insights/mood");
await expect(page).toHaveURL(/\/insights\/mood$/);
const notice = page.locator(
'[data-slot="module-disabled-notice"][data-module="mood"]',
);
await expect(notice).toBeVisible({ timeout: 15_000 });
await expect(notice).toHaveAttribute("data-module-access", "disabled");
await expect(
page.locator('[data-slot="module-off-open-settings"]'),
).toBeVisible();
await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount(0);
// The add menu offers a measurement and nothing the modules owned.
await openCapturePicker(page);
await expect(page.locator('[data-testid="capture-picker-mood"]')).toHaveCount(
0,
);
await expect(
page.locator('[data-testid="capture-picker-medication"]'),
).toHaveCount(0);
// Leave the account as the next run expects to find it.
await setModules(page, true);
});
+45
View File
@@ -145,6 +145,26 @@ export const E2E_MEDICATION = {
role: "USER",
} as const;
/**
* The module-surfaces journey's own account (`modules-off.spec.ts`).
*
* It switches mood and medications off and on again, and those switches are
* read by every surface of the account. On the shared account a parallel spec
* reading a mood chart or the add menu would see them flip underneath it, so
* the journey gets a record nobody else reads.
*/
export const E2E_MODULES = {
email: "e2e-modules@healthlog.test",
username: "e2e-modules",
password: "Qm4!Vr8tK2wZp7Ls",
role: "USER",
} as const;
export const MODULES_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateModules.json",
);
/**
* The notification-dispatch journey's own account.
*
@@ -724,6 +744,28 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The module-surfaces journey's account. Its module switches are set by
// `e2e/setup/modules-fixture.ts` before every test.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
[
cuid(),
E2E_MODULES.username,
E2E_MODULES.email,
await hashPassword(E2E_MODULES.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1257,6 +1299,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// login — see `E2E_NOTIFY` for why the ledger counts need one.
await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH);
// The module-surfaces journey's jar — see `E2E_MODULES`.
await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
+51
View File
@@ -0,0 +1,51 @@
/**
* Fixture for the module-surfaces journey (`e2e/modules-off.spec.ts`).
*
* The journey runs on its own account (`E2E_MODULES`). This reads what that
* account holds straight from Postgres; every WRITE goes through the app
* (the module switch and the seed rows), so the caches the app keeps are
* evicted by the app's own paths rather than read stale.
*/
import pg from "pg";
import { E2E_MODULES } from "./global-setup";
/** Rows the journey needs so the surfaces it hides would otherwise paint. */
export interface ModulesSeedState {
moodEntries: number;
medications: number;
}
export async function modulesSeedState(): Promise<ModulesSeedState> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[modules-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const user = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_MODULES.username],
);
const userId = user.rows[0]?.id;
if (!userId) {
throw new Error(
"[modules-fixture] modules account not seeded — global-setup must run first",
);
}
const [mood, meds] = await Promise.all([
pool.query<{ n: string }>(
"SELECT count(*)::text AS n FROM mood_entries WHERE user_id = $1",
[userId],
),
pool.query<{ n: string }>(
"SELECT count(*)::text AS n FROM medications WHERE user_id = $1 AND active = true",
[userId],
),
]);
return {
moodEntries: Number(mood.rows[0]?.n ?? 0),
medications: Number(meds.rows[0]?.n ?? 0),
};
} finally {
await pool.end();
}
}
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.",
"upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.",
"unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden."
},
"moduleEnableError": "Das Nährstoffmodul konnte nicht aktiviert werden. Versuch es noch mal."
}
},
"hydration": {
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA"
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.",
"upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.",
"unknown": "These entries arrived but couldn't be stored."
},
"moduleEnableError": "The nutrients module couldn't be turned on. Please try again."
}
},
"hydration": {
"referenceMeta": "Reference intake {value} {unit}/day · EFSA"
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "Las entradas llevaban una fecha que el servidor no pudo aceptar. Esto puede ocurrir cuando el reloj o la zona horaria del lado que envía los datos están mal configurados; merece la pena revisarlo.",
"upsertFailed": "Estas entradas llegaron al servidor, pero un problema de nuestro lado impidió guardarlas. No tienes que hacer nada.",
"unknown": "Estas entradas llegaron, pero no se pudieron guardar."
},
"moduleEnableError": "No se pudo activar el módulo de nutrientes. Inténtalo de nuevo."
}
},
"hydration": {
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA"
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.",
"upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.",
"unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées."
},
"moduleEnableError": "Le module nutriments n'a pas pu être activé. Réessaie."
}
},
"hydration": {
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA"
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "Le voci riportavano una data che il server non ha potuto accettare. Questo può succedere quando l'orologio o il fuso orario dal lato che invia i dati non sono corretti: vale la pena controllare.",
"upsertFailed": "Queste voci hanno raggiunto il server, ma un problema da questa parte ne ha impedito il salvataggio. Non devi fare nulla.",
"unknown": "Queste voci sono arrivate ma non è stato possibile salvarle."
},
"moduleEnableError": "Non è stato possibile attivare il modulo nutrienti. Riprova."
}
},
"hydration": {
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA"
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "기록에 서버가 받을 수 없는 날짜가 담겨 있었어요. 보내는 쪽의 시계나 시간대가 잘못됐을 때 생길 수 있으니 한번 확인해 보세요.",
"upsertFailed": "기록이 서버까지 도착했지만 이쪽 문제로 저장되지 않았어요. 따로 하실 일은 없어요.",
"unknown": "기록이 도착했지만 저장하지 못했어요."
},
"moduleEnableError": "영양소 모듈을 켜지 못했어요. 다시 시도해 주세요."
}
},
"hydration": {
"referenceMeta": "권장 섭취량 {value} {unit}/일 · EFSA"
+1 -2
View File
@@ -10163,8 +10163,7 @@
"dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.",
"upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.",
"unknown": "Te wpisy dotarły, ale nie udało się ich zapisać."
},
"moduleEnableError": "Nie udało się włączyć modułu składników odżywczych. Spróbuj ponownie."
}
},
"hydration": {
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA"
+5
View File
@@ -246,6 +246,11 @@ export default defineConfig({
// share the five-per-fifteen-minutes budget on `auth:register`, so
// the second one meets a rate limit rather than the form.
"invite-registration.spec.ts",
// The module-surfaces journey switches its account's mood and
// medications modules off and on; a second project would flip them
// under the first mid-assertion. It sets the mobile width itself for
// the add menu, so it runs in one project.
"modules-off.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
+3 -22
View File
@@ -34,8 +34,6 @@ import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -262,7 +260,6 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
export default function AchievementsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
const router = useRouter();
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the whole page disappears: an empty state stands in
@@ -279,29 +276,13 @@ export default function AchievementsPage() {
enabled: isAuthenticated && achievementsEnabled,
});
useEffect(() => {
if (!authLoading && isAuthenticated && !achievementsEnabled) {
router.push("/insights");
}
}, [authLoading, isAuthenticated, achievementsEnabled, router]);
if (authLoading || (achievementsEnabled && isLoading)) {
return <PageAuthGate label={t("common.loading")} />;
}
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is
// hidden by the same gate, so this only catches a direct URL hit. It used to
// render `null` and stop there, which left a blank page with no explanation
// and no route anywhere — a dead end rather than a gate. It now bounces to
// /insights, matching every other module-gated page (`useModulePageGuard`);
// the brief `null` is the pre-redirect frame, not the destination.
//
// The shared guard is not used wholesale here because it also redirects an
// unauthenticated visitor to /auth/login, and this page deliberately answers
// that case with a "sign in to see your achievements" header instead.
if (isAuthenticated && !achievementsEnabled) {
return null;
}
// Module off: the shell answers this URL with the module notice
// (`ModulePageGate`) before the page renders, so there is no branch for it
// here. `achievementsEnabled` above only keeps the query from firing.
if (!isAuthenticated) {
return (
@@ -83,7 +83,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
@@ -100,6 +100,10 @@ beforeEach(() => {
// prior test would serve a hit and skip the builder every assertion spies on.
__resetAllCachesForTests();
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
// Every module on unless a test says otherwise.
vi.mocked(resolveModuleMap).mockResolvedValue(
{} as Awaited<ReturnType<typeof resolveModuleMap>>,
);
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
@@ -249,6 +253,29 @@ describe("GET /api/insights/correlations", () => {
);
});
it("skips the lab draws with the labs module off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveModuleMap).mockResolvedValue({
labs: false,
} as Awaited<ReturnType<typeof resolveModuleMap>>);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
});
it("recomputes rather than serve a cached body after a module was switched off", async () => {
// The switched-off set is part of the cache key. Watched red with the
// suffix removed from the key: the second call is a cache hit and the
// measurement window is read once.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
vi.mocked(resolveModuleMap).mockResolvedValue({
mood: false,
} as Awaited<ReturnType<typeof resolveModuleMap>>);
await callGet(makeReq());
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+24 -5
View File
@@ -23,7 +23,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
MODULE_KEYS,
requireModuleEnabled,
resolveModuleMap,
} from "@/lib/modules/gate";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
@@ -89,12 +94,18 @@ export const GET = apiHandler(async () => {
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
// so a cache hit performs no write on the read path.
// Statistics serve only with their owning modules: a channel whose module
// is off never enters the scan. The switched-off set is part of the cache
// key, so toggling a module never serves a body computed under the other
// setting.
const modules = await resolveModuleMap(user.id);
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
const body = await cachedSwr(
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildCorrelationsResponse>>
>,
`${user.id}|correlations|${locale}`,
() => buildCorrelationsResponse(user.id, locale),
`${user.id}|correlations|${locale}|off:${modulesOff.join(",")}`,
() => buildCorrelationsResponse(user.id, locale, modules),
annotate,
);
@@ -104,6 +115,7 @@ export const GET = apiHandler(async () => {
async function buildCorrelationsResponse(
userId: string,
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
modules: SurfaceModuleMap,
) {
const profile = await prisma.user.findUnique({
where: { id: userId },
@@ -125,8 +137,15 @@ async function buildCorrelationsResponse(
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed
// a different pass over a different grain, so they are not matrix channels.
const [matrix, labDraws] = await Promise.all([
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }),
fetchLabDraws(userId, tz, since),
assembleDiscoveryMatrix(userId, {
tz,
since,
fetchMode: "tiered",
modules,
}),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
]);
const { series, diagnostics } = matrix;
+1 -12
View File
@@ -1,11 +1,10 @@
"use client";
import { Droplet, Loader2 } from "lucide-react";
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
import { useAuth } from "@/hooks/use-auth";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { useTranslations } from "@/lib/i18n/context";
import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
@@ -28,19 +27,9 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
export default function InsightsBlutzuckerPage() {
const { user } = useAuth();
const { t } = useTranslations();
const { ready } = useModulePageGuard("glucose");
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const isMmol = glucoseUnit === "mmol/L";
// v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<HealthKitMetricPage
measurementType="BLOOD_GLUCOSE"
+7 -50
View File
@@ -1,20 +1,17 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Leaf } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types";
@@ -53,12 +50,11 @@ function lastAttemptReasonKey(reason: string): string {
* (self-gates to nothing without data), micronutrients (self-gates to
* an EmptyState without data). Degradation ladder:
*
* - module off → the shared `ModuleDisabledNotice`, which names the reason
* and carries the in-context enable CTA when it is the record's own
* switch that is off. The module STAYS opt-in (2026-07-17 memo — the
* HealthKit read prompt on the device is not visible consent to a
* server / Coach holding a supplement pattern); this page just makes
* the toggle discoverable in context instead of buried in Settings.
* - module off → never reaches this page: the shell answers the URL with
* the module notice (`ModulePageGate`), like every other module page.
* The module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt on
* the device is not visible consent to a server / Coach holding a
* supplement pattern).
* - module on, the overview read failed → a `QueryErrorCard` with a
* retry. This branch has to sit ABOVE the empty-state check below:
* `overview.data` is `undefined` on a failed read exactly like it is
@@ -86,7 +82,6 @@ export default function InsightsNutrientsPage() {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const queryClient = useQueryClient();
const nutrientsEnabled = user?.modules?.nutrients === true;
@@ -97,44 +92,6 @@ export default function InsightsNutrientsPage() {
enabled: nutrientsEnabled,
});
const enableModule = useMutation({
mutationFn: () => apiPatch("/api/auth/me/modules", { nutrients: true }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
onError: () => toast.error(t("nutrients.page.moduleEnableError")),
});
if (!nutrientsEnabled) {
return (
<SubPageShell title={t("nutrients.page.title")}>
{/*
The shared notice rather than this page's own copy: "nutrients is
off" was three situations wearing one sentence, and only one of them
is fixed by the button below. A delegate whose grant does not reach
the measurements section, and an account on an instance where the
operator removed the module, both used to be handed a switch that
would refuse them. The notice reads the reason off `moduleAccess`
and drops the action for the two cases it cannot help.
*/}
<ModuleDisabledNotice
moduleKey="nutrients"
icon={<Leaf className="size-6" />}
action={
<Button
size="sm"
onClick={() => enableModule.mutate()}
disabled={enableModule.isPending}
data-slot="nutrients-enable-module"
>
{t("nutrients.page.moduleOffCta")}
</Button>
}
/>
</SubPageShell>
);
}
if (overview.isError) {
return (
<SubPageShell
+1 -12
View File
@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { ArrowRight, Loader2 } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
@@ -29,16 +28,6 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
*/
export default function InsightsRecoveryPage() {
const { t } = useTranslations();
const { ready } = useModulePageGuard("recovery");
// v1.18.0 B1 — bounce a direct URL hit on a disabled-recovery account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<SubPageShell
+1 -13
View File
@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { Loader2, Moon } from "lucide-react";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
@@ -40,7 +39,6 @@ import { useSleepRhythm } from "@/components/insights/sleep/use-sleep-rhythm";
export default function InsightsSchlafPage() {
const { t } = useTranslations();
const { user } = useAuth();
const { ready } = useModulePageGuard("sleep");
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
// The three rhythm cards share one `["sleep-rhythm"]` read, so a failure used
@@ -51,16 +49,6 @@ export default function InsightsSchlafPage() {
Boolean(user) && !isEmpty,
);
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
// /insights instead of half-rendering the sleep surface.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
if (isEmpty) {
return (
<SubPageShell
-13
View File
@@ -3,11 +3,8 @@
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkoutDetail } from "@/hooks/use-workouts";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { BackLink } from "@/components/ui/back-link";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Skeleton } from "@/components/ui/skeleton";
@@ -54,7 +51,6 @@ export default function InsightsWorkoutDetailPage({
const { t } = useTranslations();
const { id } = use(params);
const router = useRouter();
const { ready } = useModulePageGuard("workouts");
const { data, isLoading, error, refetch } = useWorkoutDetail(id);
const canonicalHref = canonicalWorkoutDetailHref(id, data?.canonicalId);
@@ -62,15 +58,6 @@ export default function InsightsWorkoutDetailPage({
if (canonicalHref) router.replace(canonicalHref);
}, [canonicalHref, router]);
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<SubPageShell
title={t("insights.workouts.title")}
+1 -12
View File
@@ -1,12 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { Activity, Loader2 } from "lucide-react";
import { Activity } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useInfiniteWorkouts } from "@/hooks/use-workouts";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
@@ -42,7 +41,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
*/
export default function InsightsWorkoutsPageClient() {
const { t, tCount } = useTranslations();
const { ready } = useModulePageGuard("workouts");
const [sportType, setSportType] = useState<string | undefined>(undefined);
const {
workouts,
@@ -76,15 +74,6 @@ export default function InsightsWorkoutsPageClient() {
return label === key ? sport : label;
};
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<SubPageShell
title={t("insights.workouts.title")}
+4 -4
View File
@@ -34,10 +34,10 @@ import InsightsWorkoutsPageClient from "./page-client";
* - The dehydrated first page is JSON-round-tripped and wrapped in TanStack's
* `{ pages, pageParams }` infinite-data shape. Prisma `Date`s therefore
* hydrate as the same ISO strings produced by the client wire response.
* - Module-gate parity: the client page bounces a workouts-off account
* (`useModulePageGuard`), and the API route refuses it server-side; skip the
* prefetch when the module is off so a disabled page never seeds a cache it
* will not read.
* - Module-gate parity: the shell answers a workouts-off account with the
* module notice instead of this page (`ModulePageGate`), and the API route
* refuses it server-side; skip the prefetch when the module is off so a
* disabled page never seeds a cache it will not read.
* - The client cell keeps its own fetch (`staleTime: 60s`): the prefetch seeds
* fresh data, so the mounted cell paints immediately and only refetches once
* the TTL lapses — the "empty then fills" flash is gone without dropping the
+18 -17
View File
@@ -144,7 +144,8 @@ import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import {
BATCH_COVERAGE_DAYS,
@@ -315,17 +316,10 @@ export default function DashboardPageClient({
const snapshotQuery = useDashboardSnapshot(snapshotEnabled);
// S2 — the Today hero reads the unified daily digest (the S1 DTO) from
// its own cached route. Gated on the `insights` module (the digest is
// the AI-narrative daily layer; the route 403s when insights is off)
// and the auth state so a logged-out / gated account never fires it.
// Two readings of one flag, on purpose. The FETCH gate takes the live
// module map, so an account with insights off never fires the request. The
// RENDER gate below takes the mount-pinned account, so the hydration render
// agrees with the server (which had no account, and therefore the default-on
// answer). See `useAccountOnceMounted`.
const insightsModuleEnabled = useModuleEnabled("insights");
const insightsEnabled = user?.modules?.insights !== false;
const digestQuery = useDailyDigest(isAuthenticated && insightsModuleEnabled);
// its own cached route. It is the day's data, so no module owns it: the
// `insights` key means AI analysis, and the digest leaves its model-written
// lead out on its own when that is off. Gated on auth only.
const digestQuery = useDailyDigest(isAuthenticated);
// v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web
// hero (Marc, live-use: uneven, wasted tile space). The snapshot still
@@ -427,9 +421,14 @@ export default function DashboardPageClient({
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
// The snapshot publishes its layout with switched-off modules' widgets
// already hidden. The legacy feed is the stored layout as saved (Settings
// edits that exact value), so the same mask is applied here, from the same
// surface map, before anything paints.
const layoutData = snapshotEnabled
? snapshotQuery.data?.layout
: layoutDataLegacy;
: layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules);
const moodWidgetVisible = useSurfaceVisible("widget:mood");
const { data: moodDataLegacy } = useQuery({
queryKey: queryKeys.moodAnalytics(),
@@ -439,7 +438,8 @@ export default function DashboardPageClient({
summary: DataSummary;
}>("/api/mood/analytics");
},
enabled: !snapshotEnabled && isAuthenticated,
// The route refuses with the mood module off; do not ask.
enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible,
...DASHBOARD_QUERY_OPTS,
});
const moodData = snapshotEnabled
@@ -963,7 +963,7 @@ export default function DashboardPageClient({
`<QueryErrorCard>` with a retry action rather than silently
falling through to nothing. The dense tile grid + charts below
are untouched. */}
{insightsEnabled &&
{
// v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero
// (the LCP element) instead of a skeleton. This is hydration-safe ONLY
// because of a bound invariant: the digest is server-DEHYDRATED
@@ -982,7 +982,7 @@ export default function DashboardPageClient({
// rehydrates from disk. The error / empty branches stay reachable only
// post-mount. DO NOT add `["daily", …]` to that allowlist, or render
// the hero from any client-only source, without revisiting this gate.
(digestQuery.data ? (
digestQuery.data ? (
<TodayHero
digest={digestQuery.data}
renderFilteredAllClear={renderFilteredHeroAllClear}
@@ -994,7 +994,8 @@ export default function DashboardPageClient({
<TodayHeroSkeleton />
) : digestQuery.isError ? (
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
) : null)}
) : null
}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
* (`AuthShell`) so its overlay survives the cross-page
+4 -9
View File
@@ -13,7 +13,6 @@ import {
type BatchWindow,
} from "@/lib/dashboard/batch-chart-types";
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
import { resolveModuleMap } from "@/lib/modules/gate";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
import { queryKeys } from "@/lib/query-keys";
@@ -62,8 +61,9 @@ function softTimeout<T>(ms: number): Promise<T | undefined> {
* window from `computeBatchWindow(now, user.timezone)` threaded to the
* client as the `batchWindow` prop. Server and client build the identical
* key by construction, so the prefetched slice lands instead of refetching.
* - Module-gate parity: the digest route gates on `insights`; skip its
* prefetch when the module is off (the client hook is disabled then too).
* - The digest is the day's data and belongs to no module (the `insights`
* key is AI analysis, which the digest omits on its own), so it is
* prefetched for every account, as the client fetches it for every account.
* - Record identity: the session comes from `getUnswitchedSession()`, which
* answers null while the browser is acting on somebody else's record. Every
* read below scopes to `user`, and under a switch `user` is the DELEGATE —
@@ -117,9 +117,6 @@ export default async function DashboardPage() {
batchWindow = computeBatchWindow(new Date(), user.timezone);
}
const modules = await resolveModuleMap(user.id);
const insightsEnabled = modules.insights !== false;
const seriesTypes = snapshotMode
? deriveBatchChartTypes(
resolveDashboardLayout(wireBody.layout),
@@ -133,9 +130,7 @@ export default async function DashboardPage() {
// Digest carries a soft budget (cold-day extras rebuild); series is a
// warm rollup read (< 100 ms) with none. Both fail soft to `undefined`.
const digestWork = insightsEnabled
? loadDailyDigest(user).catch(() => undefined)
: Promise.resolve(undefined);
const digestWork = loadDailyDigest(user).catch(() => undefined);
const seriesWork =
batchWindow && seriesTypes.length > 0
? readSeriesBatch(
+2 -1
View File
@@ -4,6 +4,7 @@ import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
import {
LAYOUT_GROUPS,
layoutGroupModule,
LAYOUT_GROUP_IDS,
isLayoutGroupId,
} from "@/components/settings/layout-groups";
@@ -83,7 +84,7 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
>
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleGate={group.moduleGate}>
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
<Body />
</LayoutModuleGate>
</section>
@@ -546,6 +546,28 @@ describe("<InsightsTabStrip> — module enable/disable gate (v1.18.0)", () => {
expect(enabled).toContain('href="/insights/recovery"');
});
it("hides the Medications pill with the medications module off", () => {
const withMeds: InsightInputs = {
...moduleAvailability,
hasMedication: true,
};
const on = render(
<InsightsTabStrip
availability={withMeds}
modules={{ medications: true }}
/>,
);
expect(on).toContain('href="/insights/medications"');
const off = render(
<InsightsTabStrip
availability={withMeds}
modules={{ medications: false }}
/>,
);
expect(off).not.toContain('href="/insights/medications"');
expect(off).toContain(">Overview<");
});
it("fails open: an empty / omitted module map keeps every module pill", () => {
// Default-on contract — a stale /me payload (no module map) must not
// blank the strip. An empty map and an omitted prop both keep pills.
+10 -32
View File
@@ -37,6 +37,7 @@ import {
type InsightMetric,
} from "@/lib/insights/metric-availability";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
/**
* v1.4.25 W4 — routed tab strip for `/insights`.
@@ -392,32 +393,6 @@ export const SUB_PAGE_TABS: Record<
nutrients: { labelKey: "insights.navNutrients", metric: "NUTRIENTS" },
};
/**
* v1.18.0 — slug → module gate. A sub-page pill belonging to a toggleable
* module is hidden when that module is disabled in the account's resolved
* module map (from `GET /api/auth/me`'s `modules`), on TOP of the data +
* layout gates. Slugs absent from this map are core metrics (BP, pulse,
* weight, body composition, activity, cardio, hearing, environment,
* medications …) and never gate on a module — only on data availability.
*
* `labs` has no metric pill; it surfaces via the left nav only. The
* Recovery pill is gated separately below (it has no slug — it is a
* composite always-present link, so it reads the `recovery` module key
* directly in `buildTabs`).
*/
const SUB_PAGE_MODULE: Partial<Record<SubPageSlug, ModuleKey>> = {
mood: "mood",
sleep: "sleep",
"breathing-disturbances": "sleep",
"blood-glucose": "glucose",
workouts: "workouts",
// v1.29 — the nutrients pill gates on the opt-in `nutrients` module
// alone; there is no data floor on top (see `hasNutrients` in
// `insights-layout-shell.tsx` — reachability, not row count, is the
// gate once the user has opted in).
nutrients: "nutrients",
};
/**
* v1.18.0 — a partial `ModuleKey → enabled` map (the `modules` field
* `GET /api/auth/me` returns; cycle + coach already delegated). A pill
@@ -427,14 +402,17 @@ const SUB_PAGE_MODULE: Partial<Record<SubPageSlug, ModuleKey>> = {
*/
export type InsightsModuleMap = Partial<Record<ModuleKey, boolean>>;
/** True unless the slug's module is explicitly disabled in the map. */
/**
* True unless the slug's module is explicitly off. A sub-page's owner comes
* from the one surface map (`insights-page:<slug>`), the same entry the
* direct-URL notice reads, so a pill and its page cannot disagree. Slugs with
* no entry are core metrics and gate on data only.
*/
function isSlugModuleEnabled(
slug: SubPageSlug,
modules: InsightsModuleMap | undefined,
): boolean {
const key = SUB_PAGE_MODULE[slug];
if (!key) return true;
return modules?.[key] !== false;
return isSurfaceVisible(`insights-page:${slug}`, modules);
}
/**
@@ -510,7 +488,7 @@ function buildTabs(
// group child `order` follows the layout (via `orderedSlugs` below),
// falling back to `SUB_PAGE_GROUP_ORDER` when the layout is absent.
// v1.18.0 — a THIRD gate on top of data + layout: a pill belonging to a
// toggleable module (mood / sleep / glucose / workouts) is hidden when
// toggleable module (the `insights-page:*` surfaces) is hidden when
// that module is disabled in the account's resolved module map. Core
// metric pills carry no module key and pass this gate unconditionally.
const visibleSlugs = availability
@@ -551,7 +529,7 @@ function buildTabs(
// `summaries[METRIC].count` to gate on — the page data-gates every block
// and falls back to a calm empty note. The pill is otherwise present like
// Overview, but it is hidden when the `recovery` module is disabled.
if (modules?.recovery !== false) {
if (isSurfaceVisible("insights-page:recovery", modules)) {
entries.push({
kind: "link",
href: `${INSIGHTS_OVERVIEW_PATH}/recovery`,
+12 -4
View File
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { ArrowRight, TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/ui/section-heading";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
@@ -67,8 +67,8 @@ const MoodChart = dynamic(
interface TrendsRowProps {
/**
* Metrics to leave out because their module is switched off (for
* example `mood`). Passed straight to `selectTrendCharts`.
* Further metrics to leave out. Switched-off modules need no entry here:
* the row reads the record's module map itself.
*/
hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"];
/**
@@ -145,7 +145,15 @@ export function TrendsRow({
// v1.8.5 — derive the chart set from the briefing. No new fetch: the
// briefing payload is already on the page (advisor cache), so this is
// a pure read that respects the v1.8.3 anti-freeze contract.
const charts = selectTrendCharts(briefing, { hiddenMetrics });
//
// A slot whose module is off is left out and the next eligible metric fills
// it (`trend:*` in the surface map). The mount-pinned account keeps the
// hydration render identical to the server's, which had no account.
const account = useAccountOnceMounted();
const charts = selectTrendCharts(briefing, {
modules: account?.modules,
hiddenMetrics,
});
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
@@ -8,9 +8,9 @@
* blank page — so the slot must honour the same per-user map the
* Insights slot and the More hub already do.
*
* Watched red: with the `requiresModule: "medications"` entry removed
* from PRIMARY_LEFT (the pre-fix ungated pin) the module-off test fails
* with the tab still present. Verified red against the pre-fix bar.
* Watched red: with `nav:/medications` removed from the surface map (the
* pre-fix ungated pin) the module-off test fails with the tab still
* present.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -70,4 +70,11 @@ describe("<BottomNav> mounted module gate", () => {
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
it("keeps the Insights slot with AI analysis off", () => {
mockUserRef.value = { id: "u1", modules: { insights: false } };
const html = render();
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
});
@@ -24,6 +24,11 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
}),
}));
// Every module on: this file is about the discard guard, not the module mask.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: {} } }),
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>();
return {
@@ -17,6 +17,14 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
}),
}));
// The record's resolved module map; each test sets what it needs.
const modulesRef: { value: Record<string, boolean> | undefined } = {
value: undefined,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: modulesRef.value } }),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
@@ -51,6 +59,7 @@ function render() {
describe("<CapturePicker> — offered kinds", () => {
it("offers measurement, medication and mood, and no water entry", () => {
modulesRef.value = undefined;
const html = render();
expect(html).toContain('data-testid="capture-picker-measurement"');
@@ -61,3 +70,20 @@ describe("<CapturePicker> — offered kinds", () => {
expect(html).not.toContain('data-testid="capture-picker-water"');
});
});
describe("<CapturePicker> — switched-off modules", () => {
it("drops Mood with the mood module off and Medication with medications off", () => {
modulesRef.value = { mood: false, medications: false };
const html = render();
expect(html).toContain('data-testid="capture-picker-measurement"');
expect(html).not.toContain('data-testid="capture-picker-mood"');
expect(html).not.toContain('data-testid="capture-picker-medication"');
});
it("keeps both while their modules are on", () => {
modulesRef.value = { mood: true, medications: true };
const html = render();
expect(html).toContain('data-testid="capture-picker-mood"');
expect(html).toContain('data-testid="capture-picker-medication"');
});
});
@@ -0,0 +1,101 @@
/**
* The one direct-URL gate for module pages: top-level pages and Insights
* sub-pages answer through the surface map, render the inline notice worded
* from `moduleAccess`, and never redirect.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const pathRef = { value: "/" };
vi.mock("next/navigation", () => ({
usePathname: () => pathRef.value,
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
type MockUser = {
modules: Record<string, boolean>;
moduleAccess?: Record<string, string>;
};
const userRef: { value: MockUser | null } = { value: { modules: {} } };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: userRef.value, isAuthenticated: true }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ModulePageGate, moduleOwningPath } from "../module-page-gate";
function render(pathname: string, user: MockUser) {
pathRef.value = pathname;
userRef.value = user;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ModulePageGate>
<p data-testid="page">page</p>
</ModulePageGate>
</I18nProvider>,
);
}
describe("moduleOwningPath", () => {
it.each([
["/mood", "mood"],
["/labs/123", "labs"],
["/insights/mood", "mood"],
["/insights/workouts/abc", "workouts"],
["/insights/blood-glucose", "glucose"],
["/insights/medications", "medications"],
["/insights/recovery", "recovery"],
["/insights/six-minute-walk", "recovery"],
["/insights/breathing-disturbances", "sleep"],
])("%s belongs to %s", (path, owner) => {
expect(moduleOwningPath(path)).toBe(owner);
});
it.each(["/", "/measurements", "/insights", "/insights/weight", "/checkups"])(
"%s belongs to no module",
(path) => {
expect(moduleOwningPath(path)).toBeUndefined();
},
);
});
describe("<ModulePageGate>", () => {
it("renders the page while its module is on", () => {
const html = render("/insights/mood", { modules: { mood: true } });
expect(html).toContain('data-testid="page"');
expect(html).not.toContain("module-disabled-notice");
});
it("keeps the Insights area with AI analysis off", () => {
const html = render("/insights", { modules: { insights: false } });
expect(html).toContain('data-testid="page"');
});
it("replaces an Insights sub-page with the notice, Settings link included, when the record switched it off", () => {
const html = render("/insights/mood", {
modules: { mood: false },
moduleAccess: { mood: "disabled" },
});
expect(html).not.toContain('data-testid="page"');
expect(html).toContain('data-slot="module-disabled-notice"');
expect(html).toContain('data-module="mood"');
expect(html).toContain('data-module-access="disabled"');
expect(html).toContain('data-slot="module-off-open-settings"');
});
it("offers no Settings link when the operator switched the module off", () => {
const html = render("/insights/sleep", {
modules: { sleep: false },
moduleAccess: { sleep: "unavailable" },
});
expect(html).toContain('data-module-access="unavailable"');
expect(html).not.toContain("module-off-open-settings");
});
it("covers a sub-page no nav entry names (medications)", () => {
const html = render("/insights/medications", {
modules: { medications: false },
});
expect(html).toContain('data-module="medications"');
});
});
@@ -48,16 +48,18 @@ import {
NAV_DESTINATIONS,
isDestinationInSharedRecord,
mobileMoreHubDestinations,
navDestinationModule,
visibleNavDestinations,
visibleUtilityDestinations,
} from "../nav-model";
import { surfaceModule } from "@/lib/modules/surface";
/** Everything enabled, so the module gate never masks the sharing gate. */
const ALL_MODULES = Object.fromEntries(
NAV_DESTINATIONS.filter((d) => d.requiresModule).map((d) => [
d.requiresModule as string,
true,
]),
NAV_DESTINATIONS.flatMap((d) => {
const owner = navDestinationModule(d);
return owner ? [[owner, true]] : [];
}),
);
function hrefsInSharedRecord(): string[] {
@@ -313,7 +315,10 @@ describe("the utility tail under a switch", () => {
expect(firstListed, record.recordKind).toBeDefined();
// A module-gated landing could vanish from the shell when its module is
// off, leaving the entry pointing at a section the list no longer shows.
expect(firstListed?.moduleGate, record.recordKind).toBeUndefined();
expect(
surfaceModule(`settings:${firstListed?.slug}`),
record.recordKind,
).toBeUndefined();
expect(hrefs(record)).toEqual([`/settings/${firstListed?.slug}`]);
}
});
@@ -90,7 +90,7 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
describe("visibleNavDestinations module gate", () => {
it("includes Cycle (between Mood and Medications) only when its module is enabled", () => {
// v1.18.0 — cycle is `requiresModule: "cycle"` and reads the delegated
// v1.18.0 — cycle is owned by `cycle` in the surface map and reads the delegated
// `cycle` key from the same resolved module map every other gate uses.
// v1.19.1 (S4) — cycle sits in the head block, after Mood and before the
// fixed Medications → … → Achievements spine.
@@ -102,15 +102,12 @@ describe("visibleNavDestinations module gate", () => {
expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/medications"));
});
it("drops a module-gated entry (mood / labs / coach / achievements / insights / medications) when its module is disabled", () => {
it("drops a module-gated entry (mood / labs / coach / achievements / medications) when its module is disabled", () => {
const disabled = visibleNavDestinations({
mood: false,
labs: false,
coach: false,
achievements: false,
// v1.18.0 — Insights is now `requiresModule: "insights"`, so the
// top-level /insights entry drops when the module is off.
insights: false,
// v1.18.1 (D3) — medications graduated to a toggleable module.
medications: false,
}).map((d) => d.href);
@@ -118,10 +115,16 @@ describe("visibleNavDestinations module gate", () => {
expect(disabled).not.toContain("/labs");
expect(disabled).not.toContain("/coach");
expect(disabled).not.toContain("/achievements");
expect(disabled).not.toContain("/insights");
expect(disabled).not.toContain("/medications");
});
it("keeps Insights with AI analysis off: the insights key owns no destination", () => {
const hrefs = visibleNavDestinations({ insights: false }).map(
(d) => d.href,
);
expect(hrefs).toContain("/insights");
});
it("keeps every module-gated entry when its module is enabled", () => {
const enabled = visibleNavDestinations({
mood: true,
@@ -86,8 +86,9 @@ function render({
// pure `visibleNavDestinations(modules, true)` contract verified in
// `nav-model.test.ts`; the mounted render is exercised in e2e.
// CORE destinations carry no `requiresModule` and always render.
const CORE_HREFS = ["/", "/measurements", "/checkups"] as const;
// CORE destinations have no owner in the surface map and always render.
// `/insights` is one of them: the `insights` key is AI analysis only.
const CORE_HREFS = ["/", "/measurements", "/checkups", "/insights"] as const;
// A representative slice of the module-gated entries that must stay out of
// the first-paint markup whatever the module map says.
const GATED_HREFS = [
@@ -96,7 +97,6 @@ const GATED_HREFS = [
"/medications",
"/labs",
"/illness",
"/insights",
"/coach",
"/achievements",
] as const;
+23 -38
View File
@@ -17,7 +17,7 @@ import {
isDestinationInSharedRecord,
mobileMoreHubDestinations,
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
@@ -60,34 +60,19 @@ interface NavLink {
// surfaces tell one story instead of two hand-curated ones that drift,
// and the headline invariant is a tested model function rather than
// inline bar logic.
// The Meds slot is module-gated exactly like the Insights slot below:
// `/medications` renders nothing when the medications module is off, so a
// pinned tab would be a visible control over a blank page.
const PRIMARY_LEFT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
// The Meds slot follows the medications module through the surface map
// (`nav:/medications`): a pinned tab over a module that is off would be a
// visible control over a notice.
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{
href: "/medications",
tKey: "nav.medications",
icon: Pill,
requiresModule: "medications",
},
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
// v1.18.0 — the Insights primary slot is module-gated. Insights is now a
// toggleable module (`requiresModule: "insights"` in the shared nav model),
// so the fixed slot must respect the same per-user map the More hub already
// honours rather than pinning a destination the account turned off. When
// insights is disabled the slot is dropped (it is excluded from the More hub
// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden
// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps
// the slot, mirroring the gate's default-on contract.
const PRIMARY_RIGHT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
{
href: "/insights",
tKey: "nav.insights",
icon: Lightbulb,
requiresModule: "insights",
},
// The Insights slot belongs to no module (the `insights` key is AI analysis,
// not the area), so it stays whatever the module map says; it still drops
// inside a shared record, like any destination sharing does not cover.
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
];
export function BottomNav() {
@@ -115,7 +100,8 @@ export function BottomNav() {
// The capture button opens the picker, so it shows exactly when the picker
// would offer at least one kind — the same per-section rule, asked once.
const canCapture =
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER).length > 0;
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
.length > 0;
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
@@ -132,23 +118,22 @@ export function BottomNav() {
[user?.modules, mounted, sharedRecord, sections],
);
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
// has that module disabled. Fail-closed until mounted (so a disabled
// Insights never flickers in), then the real map applies: a missing key /
// unloaded map keeps the slot, mirroring the gate + More-hub default-on
// contract.
// Every slot but Home asks two things: does sharing cover it, and does its
// module (if the surface map names one) stay on. Fail-closed until mounted,
// so SSR and the first paint agree, then the real map applies: a missing
// key / unloaded map keeps the slot, the gate's default-on contract.
const primarySlotVisible = useCallback(
(item: NavLink & { requiresModule?: ModuleKey }) => {
// An ungated slot (Home) is pinned unconditionally — the bar always
// has an anchor, whatever the module map or a record switch says.
if (!item.requiresModule) return true;
(item: NavLink) => {
// Home is pinned unconditionally — the bar always has an anchor,
// whatever the module map or a record switch says.
if (item.href === "/") return true;
return (
// v1.36.0 — the fixed slot asks the shared destination model
// whether sharing covers it, rather than carrying its own answer:
// Insights is an AI surface and drops out under a switch.
// Insights drops out under a switch.
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
mounted &&
user?.modules?.[item.requiresModule] !== false
isSurfaceVisible(`nav:${item.href}`, user?.modules)
);
},
[user?.modules, mounted, sharedRecord, sections],
+22 -5
View File
@@ -19,7 +19,9 @@ import {
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareDomain } from "@/lib/sharing/scope";
import { useAuth } from "@/hooks/use-auth";
import {
useRecordCapabilities,
type RecordCapabilities,
@@ -106,15 +108,25 @@ export function admittedCaptureKind(
}
/**
* The capture kinds to offer, given what the record allows. Exported pure so
* the delegation rule can be pinned without opening a sheet: an SSR test
* cannot tap the button that opens it.
* The capture kinds to offer, given what the record allows and which of its
* modules are on. Exported pure so both rules can be pinned without opening a
* sheet: an SSR test cannot tap the button that opens it.
*
* A kind whose module is off (`capture:<kind>` in the surface map) is not
* offered: a Mood entry with the mood module off would post to a route that
* refuses it. An absent module map (the account still loading) offers every
* kind the record allows, the gate's default-on contract.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canWriteDomain">,
kinds: ReadonlyArray<CaptureKind>,
modules?: SurfaceModuleMap | null,
): CaptureKind[] {
return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]));
return kinds.filter(
(kind) =>
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
isSurfaceVisible(`capture:${kind}`, modules),
);
}
interface CapturePickerProps {
@@ -127,7 +139,12 @@ interface CapturePickerProps {
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const { t } = useTranslations();
const capabilities = useRecordCapabilities();
const offered = visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER);
const { user } = useAuth();
const offered = visibleCaptureKinds(
capabilities,
CAPTURE_KIND_ORDER,
user?.modules,
);
const [kind, setKind] = useState<CaptureKind | null>(null);
// Re-derived on every render, never latched: the offer can shrink under a
// sheet that is already open. See `admittedCaptureKind`.
@@ -79,7 +79,11 @@ export function ModuleDisabledNotice({
const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey);
return (
<div data-slot="module-disabled-notice" data-module-access={state}>
<div
data-slot="module-disabled-notice"
data-module={moduleKey}
data-module-access={state}
>
<EmptyState
icon={icon}
title={t(copy.title, { module: moduleName })}
+42 -20
View File
@@ -8,25 +8,48 @@ import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice
import {
isNavDestinationActive,
NAV_DESTINATIONS,
navDestinationModule,
} from "@/components/layout/nav-model";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
const INSIGHTS_PREFIX = "/insights/";
/**
* v1.39 (C2) — the page of a module that is switched off answers with an
* empty state that names the switch (design spec §After the flow), in one
* place for every module page.
* The module that owns the page at `pathname`, from the one surface map.
*
* The setup flow switches modules OFF for a new record — an explicit `false`
* for every module the answers did not name — and the navigation drops the
* entry. A person who reaches the page anyway (a bookmark, a link in a
* report, the tour) used to get the page's own empty state, which said
* "nothing here yet" about a module that is not there at all. This maps the
* route to the module through the navigation model's own gate, so a module
* page and its nav entry can never disagree about which switch they follow,
* and renders the shared notice with the one action that can help: the
* Modules settings, offered only when the record's own switch is what is off.
* An Insights sub-page answers by its slug (`insights-page:<slug>`), so
* `/insights/mood` and `/insights/workouts/<id>` follow their own module while
* `/insights` itself follows none. Every other page answers through the nav
* destination it sits under (`nav:<href>`), most specific first.
*/
export function moduleOwningPath(pathname: string): ModuleKey | undefined {
if (pathname.startsWith(INSIGHTS_PREFIX)) {
const slug = pathname.slice(INSIGHTS_PREFIX.length).split("/")[0];
const owner = surfaceModule(`insights-page:${slug}`);
if (owner !== undefined) return owner;
}
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
);
return destination ? navDestinationModule(destination) : undefined;
}
/**
* A page of a module that is switched off answers with an inline notice that
* names the reason, in one place for every module page: top-level pages
* (`/mood`, `/labs` …) and Insights sub-pages (`/insights/mood`,
* `/insights/sleep`, `/insights/medications` …) alike.
*
* A person who reaches such a page anyway (a bookmark, a link in a report,
* the tour) gets the notice rather than a redirect, which would drop where
* they were, and rather than the page's own error rows, which would say
* "could not be loaded" about a module that is not there. The notice reads
* the reason from `moduleAccess` and offers Settings only when the record's
* own switch is what is off.
*
* Paint, not enforcement: every route behind a module refuses on its own
* through the server gate. Mounted inside the shell, which renders children
@@ -38,19 +61,18 @@ export function ModulePageGate({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { t } = useTranslations();
const destination = NAV_DESTINATIONS.find(
(d) => d.requiresModule && isNavDestinationActive(d.href, pathname),
);
const moduleKey = destination?.requiresModule;
const moduleKey = moduleOwningPath(pathname);
const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
if (!off) return <>{children}</>;
if (!off || !destination || !moduleKey) return <>{children}</>;
const Icon = destination.icon;
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
);
const Icon = destination?.icon;
return (
<ModuleDisabledNotice
moduleKey={moduleKey}
icon={<Icon className="size-6" />}
icon={Icon ? <Icon className="size-6" /> : undefined}
action={
<Button asChild size="sm">
<Link href="/settings/modules" data-slot="module-off-open-settings">
+29 -41
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface";
// The leaf module, not the `@/lib/record-settings` barrel, for the reason
// `auth-shell.tsx` gives: the barrel drags server-side schemas into the chrome.
import {
@@ -56,17 +57,6 @@ export interface NavDestination {
* spotlight tour — renaming silently breaks the cutout for that step.
*/
tourId?: string;
/**
* v1.18.0 — gate the entry on a per-user module toggle. When set, the
* entry is dropped unless the account's resolved module map (from
* `GET /api/auth/me`'s `modules`) has the key enabled. Core destinations
* (weight / BP / pulse + always-on pages) carry no key and always render.
* `cycle` and `coach` are delegated keys (cycle → gender +
* opt-in, coach → operator flag + per-user opt-out); the auth/me map
* already reflects that delegation, so reading them here is correct and
* not a re-derivation.
*/
requiresModule?: ModuleKey;
/**
* v1.36.0 — is this destination part of what account sharing covers.
*
@@ -119,12 +109,9 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.mood",
icon: Waves,
tourId: "nav-mood",
requiresModule: "mood",
},
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood.
// Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off)
// key from the resolved module map, so the entry is absent until the account
// turns the module on from the Modules hub. The destination is the dedicated
// Owned by `mentalHealth` in the surface map. The destination is the dedicated
// top-level `/mental-wellbeing` check-in surface (its own module page — it
// no longer borrows the Insights tab strip / layout shell).
{
@@ -133,7 +120,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.mentalWellbeing",
icon: Brain,
tourId: "nav-mental-wellbeing",
requiresModule: "mentalHealth",
},
{
href: "/cycle",
@@ -141,7 +127,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.cycle",
icon: Droplets,
tourId: "nav-cycle",
requiresModule: "cycle",
},
{
href: "/medications",
@@ -151,7 +136,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-medications",
// v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable
// module; the nav entry now drops when the account turns the module off.
requiresModule: "medications",
},
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
// clinical spine. It is a first-class tracking surface ("wann muss ich was
@@ -164,7 +148,8 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vorsorge",
icon: Stethoscope,
tourId: "nav-vorsorge",
// v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike
// v1.18.1 — deliberately owned by no module (absent from the surface
// map). Unlike
// labs / illness / cycle (opt-in clinical-spine verticals born off by
// default), preventive-care reminders are a CORE surface available to
// every account from birth: a reminder can target core vitals
@@ -178,7 +163,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.labs",
icon: FlaskConical,
tourId: "nav-labs",
requiresModule: "labs",
},
{
href: "/profile",
@@ -189,21 +173,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-profile",
},
// v1.18.1 — the illness/condition journal sits in the clinical spine
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
// opt-in `illness` key from the resolved module map, so the entry is
// absent until the account turns the module on from the Modules hub.
// next to Labs. Owned by `illness` in the surface map.
{
href: "/illness",
sharedRecord: true,
tKey: "nav.illness",
icon: Thermometer,
tourId: "nav-illness",
requiresModule: "illness",
},
// v1.37.3: the immunization log sits in the clinical spine beside Illness.
// Born-gated: `requiresModule: "vaccinations"` reads the resolved module map,
// so the entry is absent until the account keeps the module on (default-on —
// it drops only when a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// Owned by `vaccinations` in the surface map (default-on; it drops only when
// a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// data routes stay reachable so a restore / import keeps working and
// re-enabling finds every dose intact.
{
@@ -212,24 +192,23 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vaccinations",
icon: Syringe,
tourId: "nav-vaccinations",
requiresModule: "vaccinations",
},
// v1.18.0 — Workouts and Recovery both left the left-nav: each already
// surfaces as an Insights tab-strip pill (`/insights/workouts` gated on
// a workout row, `/insights/recovery` always present), so neither is a
// top-level `NAV_DESTINATIONS` entry any more.
// v1.25.0 (W-DOCS-IN) — inbound clinical documents sit in the clinical
// spine after Illness. Born-gated: `requiresModule: "inboundDocuments"`
// reads the opt-in key from the resolved module map, so the entry is absent
// until the account turns the module on from the Modules hub.
// spine after Illness. Owned by `inboundDocuments` in the surface map.
{
href: "/documents",
sharedRecord: true,
tKey: "nav.documents",
icon: FileScan,
tourId: "nav-documents",
requiresModule: "inboundDocuments",
},
// Insights belongs to no module: the `insights` key means AI analysis, and
// the area is data, so switching AI analysis off leaves this entry standing.
//
// Insights and the Coach carry no `sharedRecord` flag, so they drop out
// under a switch. The reason has changed since v1.36.0 and the old wording
// ("non-delegable, server-side and here") is no longer true of the tiles:
@@ -257,7 +236,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.insights",
icon: Lightbulb,
tourId: "nav-insights",
requiresModule: "insights",
},
// v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty
@@ -268,7 +246,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.coach",
icon: MessagesSquare,
tourId: "nav-coach",
requiresModule: "coach",
},
{
href: "/achievements",
@@ -276,7 +253,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.achievements",
icon: Trophy,
tourId: "nav-achievements",
requiresModule: "achievements",
},
];
@@ -355,10 +331,22 @@ export function visibleUtilityDestinations(
*/
export type ModuleVisibilityMap = Partial<Record<ModuleKey, boolean>>;
/**
* The module that owns a destination, from the one surface map
* (`nav:<href>` in `@/lib/modules/surface`), or `undefined` for a core
* destination. The nav bars, the direct-URL notice and the tour all ask this,
* so a page and its nav entry cannot disagree about which switch they follow.
*/
export function navDestinationModule(
d: Pick<NavDestination, "href">,
): ModuleKey | undefined {
return surfaceModule(`nav:${d.href}`);
}
/**
* Whether a destination is visible under the given module map. Core
* destinations (no `requiresModule`) always pass; a gated entry passes
* unless its module resolves to an explicit `false`.
* destinations (no owner in the surface map) always pass; an owned entry
* passes unless its module resolves to an explicit `false`.
*
* `mounted` (default `true`) is the hydration gate the nav bars thread in.
* The resolved module map rides the client-only `/api/auth/me` query, which
@@ -389,7 +377,7 @@ function isNavDestinationVisible(
// than reached only through the shared arm, which is what it relied on
// before the module gate below started applying to a shared record too.
if (!sharedRecord && d.sharedRecordOnly) return false;
if (!d.requiresModule) return true;
if (navDestinationModule(d) === undefined) return true;
if (!mounted) return false;
// The module map, and whose it is, is the whole point. A shared record used
// to skip this line: the grant's scope decided which doors existed, and the
@@ -400,7 +388,7 @@ function isNavDestinationVisible(
// it, turning Cycle off for a profile left the Cycle door standing in that
// profile's own navigation. Scope decides which doors the grant opens;
// this decides which of them the record tracks at all.
return modules?.[d.requiresModule] !== false;
return isSurfaceVisible(`nav:${d.href}`, modules);
}
/**
@@ -427,8 +415,8 @@ export function isDestinationInSharedRecord(
* The ordered destinations visible to this account — drops a module-gated
* entry (mood, cycle, labs, coach, achievements …) when its module is
* disabled in the account's resolved module map. Both bars start from this.
* v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule:
* "cycle"` and reads the delegated `cycle` key from the same map.
* Cycle reads the delegated `cycle` key from the same map; `/insights` has
* no owner (the `insights` key is AI analysis, not the area).
*
* v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing
* covers, so a delegate is not offered a door the server will shut. Paint
+1 -1
View File
@@ -60,7 +60,7 @@ import {
interface TourProps {
/**
* Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose
* `requiresModule` resolves to `false` are dropped; the counter total
* page belongs to a switched-off module are dropped; the counter total
* tracks the resolved list so "Schritt n/total" stays honest.
*/
modules?: TourModuleMap;
@@ -0,0 +1,66 @@
/**
* The overview arrange list marks every section whose module is off as
* unavailable, through the same surface map the overview renders from, so
* Settings never offers a toggle for a block that cannot appear.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const modulesRef: { value: Record<string, boolean> } = { value: {} };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { modules: modulesRef.value },
}),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ briefing: true }),
}));
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => ({
layout: { sections: [] },
isLoading: false,
}),
}));
const seen: { gated: string[] } = { gated: [] };
vi.mock("@/components/insights/insights-edit-mode", () => ({
InsightsEditMode: ({
gatedOffSectionIds,
}: {
gatedOffSectionIds: ReadonlySet<string>;
}) => {
seen.gated = [...gatedOffSectionIds].sort();
return null;
},
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightsOverviewArrangeSection } from "../insights-overview-arrange-section";
function gatedFor(modules: Record<string, boolean>): string[] {
modulesRef.value = modules;
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightsOverviewArrangeSection />
</I18nProvider>,
);
return seen.gated;
}
describe("<InsightsOverviewArrangeSection> module gate", () => {
it("offers every section with every module on", () => {
expect(gatedFor({})).toEqual([]);
});
it("marks the breathing, labs and cycle blocks unavailable with their modules off", () => {
expect(gatedFor({ sleep: false, labs: false, cycle: false })).toEqual([
"breathing",
"cycle-summary",
"labs-changes",
]);
});
it("marks nothing for AI analysis off", () => {
expect(gatedFor({ insights: false })).toEqual([]);
});
});
@@ -25,6 +25,7 @@ import {
LAYOUT_GROUPS,
LAYOUT_GROUP_IDS,
isLayoutGroupId,
layoutGroupModule,
} from "../layout-groups";
describe("layout-groups registry", () => {
@@ -59,7 +60,9 @@ describe("layout-groups registry", () => {
it("gates the toggleable tracking modules and leaves the rest always-on", () => {
const gate = (id: string) =>
LAYOUT_GROUPS.find((group) => group.id === id)?.moduleGate;
LAYOUT_GROUPS.some((group) => group.id === id)
? layoutGroupModule(id)
: "not-a-group";
expect(gate("medications")).toBe("medications");
expect(gate("mood")).toBe("mood");
expect(gate("labs")).toBe("labs");
@@ -53,10 +53,8 @@ import {
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
import { useMounted } from "@/hooks/use-mounted";
import { useAuth } from "@/hooks/use-auth";
import {
NATIVE_ONLY_WIDGET_LABEL_KEYS,
WIDGET_MODULE_BY_ID,
} from "@/lib/dashboard/widget-modules";
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -605,14 +603,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
.filter((w): w is typeof w & { id: DashboardWidgetId } =>
webWidgetIds.has(w.id),
)
// v1.18.0 — hide a widget toggle whose owning module is
// disabled. Map the widget id → ModuleKey FIRST (undefined =
// core widget = always shown), THEN check the module map.
// Fail-open: only an explicit `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
// Hide a widget toggle whose owning module is off, through the
// one surface map. A core widget has no owner and always shows;
// only an explicit `false` hides the row.
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
.filter((w) => !unavailableIds.has(w.id))
.sort((a, b) => a.order - b.order);
const sortedIds = sortedWidgets.map((w) => w.id);
@@ -685,10 +679,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
.filter((w) => nativeOnlyIds.has(w.id))
// Same fail-open module gate as the web list: only an explicit
// `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
.filter((w) => !unavailableIds.has(w.id))
.sort((a, b) => a.order - b.order);
if (nativeWidgets.length === 0) return null;
@@ -11,7 +11,11 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { SectionHeading } from "@/components/ui/section-heading";
import { SettingsCard } from "@/components/settings/settings-card";
import { type InsightsSectionId } from "@/lib/insights-layout";
import {
INSIGHTS_SECTION_IDS,
type InsightsSectionId,
} from "@/lib/insights-layout";
import { isSurfaceVisible } from "@/lib/modules/surface";
/**
* v1.15.18 — overview-arrange block for the Insights settings section.
@@ -23,8 +27,10 @@ import { type InsightsSectionId } from "@/lib/insights-layout";
* shared cache; the editor simply stays mounted).
*
* `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose
* feature flag / data gate is off renders its row disabled-with-a-hint rather
* than offering a toggle that does nothing.
* feature flag or owning module is off renders its row disabled-with-a-hint
* rather than offering a toggle that does nothing. The row stays in the list
* (not filtered out) so a save keeps the section's stored place for when the
* module comes back.
*/
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
const { t } = useTranslations();
@@ -38,11 +44,15 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
gated.add("daily-briefing");
gated.add("period-review");
}
if (!user?.cycleTrackingEnabled) {
gated.add("cycle-summary");
// A block whose module is off cannot appear on the overview, so its row
// is not offered as a live toggle. Read from the one surface map the
// overview renders from (`overview:<id>`); cycle follows the resolved
// `modules.cycle` like the overview's cycle ring, not the raw column.
for (const id of INSIGHTS_SECTION_IDS) {
if (!isSurfaceVisible(`overview:${id}`, user?.modules)) gated.add(id);
}
return gated;
}, [flags.briefing, user?.cycleTrackingEnabled]);
}, [flags.briefing, user?.modules]);
return (
<section
+11 -9
View File
@@ -1,6 +1,7 @@
import type { JSX } from "react";
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
import { DashboardSection } from "./dashboard-section";
import { InsightsSection } from "./insights-section";
import { MedicationsSection } from "./medications-section";
@@ -43,11 +44,16 @@ export interface LayoutGroup {
/** i18n key under `settings.sections.layout.<slug>.description`. */
descriptionKey: string;
Body: () => JSX.Element | null;
/**
* When set, the row / subpage renders only while the module is enabled
* (fail-open, `!== false`). Omitted = always shown.
*/
moduleGate?: ModuleKey;
}
/**
* The module a layout group belongs to, from the one surface map
* (`settings-layout:<id>`), or `undefined` for a group that is always shown.
* The row and its sub-page render only while that module is on (fail-open,
* `!== false`).
*/
export function layoutGroupModule(id: string): ModuleKey | undefined {
return surfaceModule(`settings-layout:${id}`);
}
export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
@@ -68,28 +74,24 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
titleKey: "settings.sections.layout.medications.title",
descriptionKey: "settings.sections.layout.medications.description",
Body: MedicationsSection,
moduleGate: "medications",
},
{
id: "mood",
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
Body: MoodSection,
moduleGate: "mood",
},
{
id: "labs",
titleKey: "settings.sections.layout.labs.title",
descriptionKey: "settings.sections.layout.labs.description",
Body: LabsSection,
moduleGate: "labs",
},
{
id: "illness",
titleKey: "settings.sections.layout.illness.title",
descriptionKey: "settings.sections.layout.illness.description",
Body: IllnessSection,
moduleGate: "illness",
},
{
id: "vorsorge",
@@ -22,14 +22,14 @@ import type { ModuleKey } from "@/lib/modules/registry";
* an ordinary client update. The redirect is a client-only effect, so it never
* diverges the initial render and cannot trigger a React #418 mismatch.
*
* Groups with no `moduleGate` (dashboard / insights / vorsorge) pass
* `moduleGate={undefined}` and always render.
* Groups the surface map gives no owner (dashboard / insights / vorsorge)
* pass `moduleKey={undefined}` and always render.
*/
export function LayoutModuleGate({
moduleGate,
moduleKey,
children,
}: {
moduleGate?: ModuleKey;
moduleKey?: ModuleKey;
children: React.ReactNode;
}) {
const hydrated = useMounted();
@@ -37,9 +37,7 @@ export function LayoutModuleGate({
const router = useRouter();
const disabled =
hydrated &&
moduleGate !== undefined &&
user?.modules?.[moduleGate] === false;
hydrated && moduleKey !== undefined && user?.modules?.[moduleKey] === false;
React.useEffect(() => {
if (disabled) {
+4 -2
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "./settings-card";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { LAYOUT_GROUPS } from "./layout-groups";
/**
@@ -21,7 +22,8 @@ import { LAYOUT_GROUPS } from "./layout-groups";
* `layout` slug; this body renders only the row list.
*
* Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so
* a section never silently disappears. Groups with no `moduleGate` always
* a section never silently disappears. Groups with no owner in the surface
* map (`settings-layout:<id>`) always
* render. The filter is gated on a post-mount flag (`useMounted()`) so SSR and
* the first client paint ALWAYS emit the same fail-open list — the same
* hydration-stability contract the shell nav uses, so the module filter can
@@ -35,7 +37,7 @@ export function LayoutSection() {
const modules = user?.modules;
const visibleGroups = LAYOUT_GROUPS.filter(
(group) =>
!hydrated || !group.moduleGate || modules?.[group.moduleGate] !== false,
!hydrated || isSurfaceVisible(`settings-layout:${group.id}`, modules),
);
return (
+9 -18
View File
@@ -48,7 +48,7 @@ import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification";
import {
SETTINGS_SECTION_SLUGS,
@@ -106,14 +106,6 @@ interface SettingsSection {
icon: LucideIcon;
/** 2026-07-17 UX/IA audit (M6) — which sidebar group this entry renders under. */
group: SettingsSectionGroup;
/**
* v1.18.0 (S5) — per-submodule entries are listed only when their module
* is enabled. When set, the nav entry hides if the resolved
* `useAuth().user.modules` map has the key explicitly `false`. The gate
* fails OPEN (a missing key reads as enabled) so a stale `/me` payload
* never blanks an entry. Omitted = always shown (global / CORE entries).
*/
moduleGate?: ModuleKey;
}
/**
@@ -268,13 +260,13 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
// v1.25 (W-ENV) — Environmental context: home location, travel overrides,
// and the weather/daylight backfill. Module-gated on the opt-in
// `environment` module, so the entry only appears once the user turns it on.
// `environment` module (`settings:environment` in the surface map), so the
// entry only appears once the user turns it on.
{
slug: "environment",
titleKey: "settings.sections.environment.title",
icon: CloudSun,
group: "healthProfile",
moduleGate: "environment",
},
// ── AI ──
{
@@ -284,14 +276,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
group: "ai",
},
// v1.18.0 (S5) — Coach: the Coach preference cards get their own entry,
// shown only when the coach module is enabled. The AI entry above keeps
// shown only when the coach module is enabled (`settings:coach` in the
// surface map). The AI entry above keeps
// provider / model / BYOK configuration.
{
slug: "coach",
titleKey: "settings.sections.coach.title",
icon: Bot,
group: "ai",
moduleGate: "coach",
},
// ── Connectivity ──
{
@@ -411,8 +403,9 @@ export function SettingsShell({
// is enabled. Read from the resolved `useAuth().user.modules` map (the
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
// reads as enabled so an entry never silently disappears. Entries with
// no `moduleGate` (global / CORE) are always shown.
// reads as enabled so an entry never silently disappears. A section's
// owner comes from the surface map (`settings:<slug>`); sections with no
// owner (global / CORE) are always shown.
//
// v1.25.9 — the module filter must NOT run during SSR / the first client
// paint. `useAuth().user` resolves from the `/api/auth/me` query, which is
@@ -435,9 +428,7 @@ export function SettingsShell({
const activeRecord = user?.accountAccess?.active ?? null;
const allVisibleSections = SETTINGS_SECTIONS.filter(
(section) =>
!hydrated ||
!section.moduleGate ||
modules?.[section.moduleGate] !== false,
!hydrated || isSurfaceVisible(`settings:${section.slug}`, modules),
);
// The server has already resolved which record is active, and which
@@ -1,86 +0,0 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.18.0 B1 — `useModulePageGuard` is the client-side direct-URL guard
* for the insights sub-pages of a toggleable module. The repo has no
* `@testing-library/react`, so the contract is exercised through an SSR
* Probe that renders the resolved `ready` flag. The redirect side-effect
* lives in a `useEffect` (no-op under SSR); the `ready` derivation is the
* load-bearing decision both the redirect and the loader branch read, so
* locking it covers the guard.
*/
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
const useAuthMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => useAuthMock(),
}));
import { useModulePageGuard } from "../use-module-page-guard";
function Probe({ moduleKey }: { moduleKey: "glucose" }) {
const { ready } = useModulePageGuard(moduleKey);
return <span data-testid="ready">{String(ready)}</span>;
}
function readyOf(): string {
const html = renderToStaticMarkup(<Probe moduleKey="glucose" />);
return html.includes(">true<") ? "true" : "false";
}
describe("useModulePageGuard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("is not ready while auth is loading", () => {
useAuthMock.mockReturnValue({
user: null,
isLoading: true,
isAuthenticated: false,
});
expect(readyOf()).toBe("false");
});
it("is not ready when unauthenticated", () => {
useAuthMock.mockReturnValue({
user: null,
isLoading: false,
isAuthenticated: false,
});
expect(readyOf()).toBe("false");
});
it("is not ready when the module is disabled (explicit false)", () => {
useAuthMock.mockReturnValue({
user: { modules: { glucose: false } },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("false");
});
it("is ready when authenticated and the module is enabled", () => {
useAuthMock.mockReturnValue({
user: { modules: { glucose: true } },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("true");
});
it("is ready (default-on) when the module key is absent", () => {
useAuthMock.mockReturnValue({
user: { modules: {} },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("true");
});
});
@@ -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();
});
});
-45
View File
@@ -1,45 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import type { ModuleKey } from "@/lib/modules/registry";
/**
* v1.18.0 B1 — client-side page guard for a toggleable module.
*
* Mirrors the cycle page (`src/app/cycle/page.tsx`): an unauthenticated
* visitor bounces to login; an authenticated account without the module
* enabled bounces to `/insights`. The nav entry is already hidden for a
* disabled account, so this only catches a direct URL hit. Every backing
* `/api/*` route enforces the gate server-side, so this is a UX redirect,
* not the security boundary.
*
* Returns `ready` — `true` only once auth has resolved, the visitor is
* authenticated, AND the module is enabled. While `false` the caller
* should render a calm loader / nothing rather than the (about-to-
* redirect) module surface, so a disabled-module page never half-renders.
*
* Default-on (disabled allowlist): an absent map, a missing key, or any
* non-`false` value reads as enabled — matching the server gate.
*/
export function useModulePageGuard(moduleKey: ModuleKey): {
ready: boolean;
} {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const enabled = user?.modules?.[moduleKey] !== false;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/insights");
}
}, [isLoading, isAuthenticated, enabled, router]);
return { ready: !isLoading && isAuthenticated && enabled };
}
+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);
}
-16
View File
@@ -4,7 +4,6 @@
* Single source of truth for which widgets show on /, what order, and
* what the default layout is for new users. Null / missing = default.
*/
import type { ModuleKey } from "@/lib/modules/registry";
import {
PRIORITY_ITEM_KINDS,
isPriorityItemKind,
@@ -513,21 +512,6 @@ export function resolveHeroRingOrder(
return out;
}
/**
* Ring id → owning toggleable module. Mirrors the derived routes'
* `DERIVED_MODULE` map for the three derived rings (READINESS rides the
* recovery module like the recovery/strain/stress trio); MED_COMPLIANCE
* belongs to the medications module. Client-safe (type-only ModuleKey
* import) so the Settings picker and the server snapshot resolver gate
* on the same map — the `WIDGET_MODULE_BY_ID` pattern.
*/
export const SCORE_RING_MODULE: Record<ScoreRingId, ModuleKey> = {
READINESS: "recovery",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
MED_COMPLIANCE: "medications",
};
function isScoreRingId(value: unknown): value is ScoreRingId {
return (
typeof value === "string" &&
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
const layout = {
version: 2,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "medications", visible: true, tileVisible: true, order: 1 },
{ id: "mood", visible: true, tileVisible: true, order: 2 },
{ id: "hrv", visible: true, tileVisible: true, order: 3 },
],
};
describe("hideModuleWidgets", () => {
it("hides the widgets of a switched-off module and keeps their order", () => {
const out = hideModuleWidgets(layout, { medications: false });
expect(out.widgets.map((w) => [w.id, w.visible, w.tileVisible])).toEqual([
["weight", true, true],
["medications", false, false],
["mood", true, true],
["hrv", true, true],
]);
});
it("hides the ids it is told to on top of the module mask", () => {
const out = hideModuleWidgets(layout, {}, new Set(["hrv"]));
expect(out.widgets.find((w) => w.id === "hrv")?.visible).toBe(false);
expect(out.widgets.find((w) => w.id === "mood")?.visible).toBe(true);
});
it("is a no-op before the module map is known", () => {
expect(hideModuleWidgets(layout, undefined).widgets).toEqual(
layout.widgets,
);
});
it("never writes through to the layout it was given", () => {
hideModuleWidgets(layout, { mood: false, medications: false });
expect(layout.widgets.every((w) => w.visible)).toBe(true);
});
});
+20 -12
View File
@@ -3,7 +3,8 @@
*
* The resolver is tested in isolation with the derived dispatcher
* mocked, pinning:
* - module gating (owning module + the insights gate on derived rings);
* - module gating (the owning module, from the surface map; the insights
* key, AI analysis, gates no ring);
* - the pass-through contract for derived scores (same resolvers the
* batch route calls, no recomputation, non-`ok` → no ring);
* - the dose ring: today's taken/scheduled progress off the shared
@@ -117,7 +118,11 @@ describe("buildScoreRingsBlock() — selection + module gating", () => {
);
});
it("the insights gate drops derived rings but keeps the dose ring", async () => {
it("keeps derived rings with AI analysis off: the scores are data", async () => {
computeDerivedMetric.mockResolvedValue({
status: "ok",
value: { score: 70, band: "yellow" },
});
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
@@ -126,16 +131,19 @@ describe("buildScoreRingsBlock() — selection + module gating", () => {
NOW,
medsToday(2, 2),
);
expect(computeDerivedMetric).not.toHaveBeenCalled();
expect(loadBaselineProfile).not.toHaveBeenCalled();
expect(rings).toEqual([
{
id: "MED_COMPLIANCE",
score: 100,
band: "green",
doses: { taken: 2, scheduled: 2 },
},
]);
expect(rings.map((r) => r.id)).toEqual(["READINESS", "MED_COMPLIANCE"]);
});
it("drops the dose ring with the medications module off", async () => {
const rings = await buildScoreRingsBlock(
fakePrisma,
"user-1",
["MED_COMPLIANCE"],
moduleMap({ medications: false }),
NOW,
medsToday(2, 2),
);
expect(rings).toEqual([]);
});
});
+7 -16
View File
@@ -25,22 +25,18 @@
* taken, `yellow` while doses remain — never `red`: pending doses in
* the morning are not an alert state.
*
* Module gating rides the client-safe `SCORE_RING_MODULE` map in
* `@/lib/dashboard-layout` (mirroring the derived routes'
* `DERIVED_MODULE`: readiness/recovery → recovery module, sleep score →
* sleep module, MED_COMPLIANCE → medications) plus the `insights` gate
* every derived read sits behind.
* Module gating reads the surface map (`score-ring:<id>` in
* `@/lib/modules/surface`): readiness/recovery → recovery, sleep score →
* sleep, MED_COMPLIANCE → medications. The derived scores are data, so the
* `insights` key (AI analysis) gates no ring.
*
* Every ring resolves fail-soft: a throwing engine drops its ring, never
* the snapshot.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import type { ModuleKey } from "@/lib/modules/gate";
import {
SCORE_RING_IDS,
SCORE_RING_MODULE,
type ScoreRingId,
} from "@/lib/dashboard-layout";
import { SCORE_RING_IDS, type ScoreRingId } from "@/lib/dashboard-layout";
import { isSurfaceVisible } from "@/lib/modules/surface";
import {
computeDerivedMetric,
loadBaselineProfile,
@@ -122,12 +118,7 @@ export async function buildScoreRingsBlock(
): Promise<DashboardScoreRing[]> {
const eligible = selected.filter((id) => {
if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false;
if (modules[SCORE_RING_MODULE[id]] === false) return false;
// The derived scores are an insights-layer read everywhere else
// (`/api/insights/derived*` sits behind the insights module) — the
// snapshot honours the same gate.
if (DERIVED_RING_METRIC[id] && modules.insights === false) return false;
return true;
return isSurfaceVisible(`score-ring:${id}`, modules);
});
if (eligible.length === 0) return [];
+6 -22
View File
@@ -135,8 +135,8 @@ export {
SUMMARY_TYPE_MODULE,
} from "@/lib/dashboard/widget-modules";
import {
WIDGET_MODULE_BY_ID,
gateSummariesByModules,
hideModuleWidgets,
unavailableWidgetIds,
} from "@/lib/dashboard/widget-modules";
@@ -1079,38 +1079,22 @@ function isThickPhaseWarm(coverage: RollupCoverageMap): boolean {
}
/**
* v1.18.0 — force every widget whose module is disabled to invisible on
* the resolved layout (both `visible` and `tileVisible`). Order is
* preserved so a re-enable restores the user's saved position. Pure
* projection — the persisted `dashboardWidgetsJson` is untouched; only
* what the snapshot publishes is gated.
* The layout the snapshot publishes: widgets of a switched-off module, and
* widgets that cannot paint for this account, forced invisible. Same rule the
* Settings screen hides their toggles with, so the dashboard and the switches
* never disagree. The stored layout is untouched.
*/
function gateLayoutByModules(
layout: DashboardLayout,
modules: Record<ModuleKey, boolean>,
summaries: Record<string, DataSummary>,
): DashboardLayout {
// Widgets that cannot paint for THIS account, on the same rule the
// Settings screen hides their toggle with — so the layout the dashboard
// renders and the switches the user is offered never disagree.
const unavailable = new Set(
unavailableWidgetIds(modules, {
hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0,
}),
);
return {
...layout,
widgets: layout.widgets.map((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
if (moduleKey && modules[moduleKey] === false) {
return { ...w, visible: false, tileVisible: false };
}
if (unavailable.has(w.id)) {
return { ...w, visible: false, tileVisible: false };
}
return w;
}),
};
return hideModuleWidgets(layout, modules, unavailable);
}
/**
+53 -69
View File
@@ -13,83 +13,67 @@
* imports), so this file stays browser-bundle-safe.
*/
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, surfaceModulesOfKind } from "@/lib/modules/surface";
/**
* Dashboard widget id → toggleable module key. When the user disables the
* module, the matching widget is forced invisible on both the web `layout`
* and the iOS `layoutCatalogue`, so the tile/chart never paints. Only the
* toggleable surfaces appear here; CORE widgets (weight / bp / pulse /
* bodyFat / bpInTarget and the vital-derived HealthKit metrics) carry NO
* entry and are never hidden.
* Dashboard widget id → toggleable module key. A widget whose module is off
* is forced invisible on both the web `layout` and the iOS
* `layoutCatalogue`. CORE widgets carry no entry and are never hidden.
*
* v1.18.1 (D3) — `medications` graduated from CORE to a toggleable module,
* so the medication tile now gates off it like the other toggleable widgets.
* A view of the one surface map (`widget:*` in `@/lib/modules/surface`), so
* the dashboard, the Settings list and every other surface answer from the
* same declaration.
*/
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
mood: "mood",
sleep: "sleep",
glucose: "glucose",
achievements: "achievements",
recentWorkouts: "workouts",
medications: "medications",
// v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery
// module; the per-night breathing-disturbance widget belongs to sleep.
cardioRecovery: "recovery",
sixMinuteWalk: "recovery",
stairAscentSpeed: "recovery",
stairDescentSpeed: "recovery",
breathingDisturbances: "sleep",
// v1.29 — fluid-intake strip tile, nutrients-store-backed (see
// `SUMMARY_TYPE_MODULE.NUTRIENT_WATER` below).
waterIntake: "nutrients",
};
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> =
surfaceModulesOfKind("widget");
/**
* Slim-summary keys that belong to a toggleable module. When the module
* is off the key is stripped from `tiles.summaries` /
* `tiles.lastSeenByType` (so `metricStates` and the client data-floor
* gates also drop it) before the snapshot leaves the server. Core vital
* types are absent here and always pass through.
* Summary keys that belong to a toggleable module. When the module is off the
* key is stripped from `tiles.summaries` / `tiles.lastSeenByType` (so
* `metricStates` and the client data-floor gates drop it too) before the
* snapshot leaves the server. Core vital types are absent and always pass.
*
* v1.29 — widened from `Partial<Record<MeasurementType, ModuleKey>>` to
* `Partial<Record<string, ModuleKey>>` so the synthetic `NUTRIENT_WATER`
* key (a `NutrientIntakeDay`-derived summary, not a real
* `MeasurementType` — the abandoned `feat/water` branch's parallel
* `WATER_INTAKE` enum value is deliberately NOT added) can ride the same
* gate without widening the `MeasurementType` enum itself.
* Keyed by string rather than `MeasurementType` because two keys are
* synthetic (`NUTRIENT_WATER`, `MOOD_ENTRY`: day totals derived from other
* stores). A view of `summary:*` in `@/lib/modules/surface`.
*/
export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
SLEEP_DURATION: "sleep",
BLOOD_GLUCOSE: "glucose",
// v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page +
// its tiles are the recovery module's surface; when it is off these
// device-native signals must drop from the dashboard snapshot too.
CARDIO_RECOVERY: "recovery",
SIX_MINUTE_WALK_DISTANCE: "recovery",
STAIR_ASCENT_SPEED: "recovery",
STAIR_DESCENT_SPEED: "recovery",
// The HRV fallback series. `METRIC_STATUS_MODULE_OWNERS` already owns this
// type for the metric-status / MCP reads, for the reason stated there: a
// fallback path must not serve the recovery data the primary type refuses.
// The dashboard snapshot gates off THIS map instead, and the entry was
// missing — harmless only while the dashboard HRV tile read SDNN alone.
// Now that the tile falls back to RMSSD (`pickHrvSummary`), the omission
// would hand a recovery-off account a tile built from recovery-owned rows.
// `HEART_RATE_VARIABILITY` stays deliberately ungated: SDNN is a plain
// vital, and it is what the tile shows whenever the account has it.
HRV_RMSSD: "recovery",
// Per-night breathing-disturbance index is a sleep-page signal.
BREATHING_DISTURBANCES: "sleep",
// v1.29 — fluid-intake dashboard tile summary, derived server-side from
// `NutrientIntakeDay` (nutrient="water", summed across sources). Gated
// on the `nutrients` module like the rest of that store's surfaces.
NUTRIENT_WATER: "nutrients",
// Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it
// rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the
// `mood` module: turning the module off must drop the dashboard card on
// every client, not just hide the web widget.
MOOD_ENTRY: "mood",
};
export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> =
surfaceModulesOfKind("summary");
/**
* Force every widget whose owning module is off, and every id in `alsoHide`,
* to invisible (both `visible` and `tileVisible`). Order is kept so a
* re-enable restores the saved position.
*
* A projection for RENDERING only. The stored `dashboardWidgetsJson` is never
* written through this: `GET /api/dashboard/widgets` returns the stored
* layout as saved, because Settings edits and re-saves exactly that value,
* and a masked read saved back would switch the widget off for good. The
* dashboard snapshot applies it server-side; the dashboard's legacy (no
* snapshot) path applies it in the browser, so both feeds paint the same.
*/
export function hideModuleWidgets<
L extends {
widgets: ReadonlyArray<{
id: string;
visible: boolean;
tileVisible?: boolean;
}>;
},
>(
layout: L,
modules: Partial<Record<ModuleKey, boolean>> | null | undefined,
alsoHide: ReadonlySet<string> = new Set(),
): L {
return {
...layout,
widgets: layout.widgets.map((w) =>
isSurfaceVisible(`widget:${w.id}`, modules) && !alsoHide.has(w.id)
? w
: { ...w, visible: false, tileVisible: false },
),
};
}
/**
* The summary types a module map turns off. Lifted out of
@@ -0,0 +1,137 @@
/**
* The discovery matrix leaves out every channel whose module the record
* switched off, through the surface map, so no correlation statistic is
* computed over a switched-off module.
*/
import { describe, expect, it, vi } from "vitest";
const pts = [{ day: "2026-09-01", value: 1 }];
vi.mock("@/lib/insights/correlation-channel-series", () => ({
buildMeasurementDailySeries: vi.fn(),
fetchMeasurementWindowSeries: vi.fn(),
fetchMeasurementDailySeriesTiered: vi.fn(
async (_u: string, _tz: string, _s: Date, types: string[]) => ({
byType: new Map(types.map((t) => [t, pts])),
measurementsCapped: false,
rollupTypes: [],
}),
),
fetchMoodWindowSeries: vi.fn(async () => ({
moodDaily: pts,
moodCapped: false,
})),
fetchMoodFactorWindowSeries: vi.fn(async () => ({
moodDaily: pts,
moodCapped: false,
factorSeries: new Map([["FACTOR:work", pts]]),
})),
fetchComplianceSeries: vi.fn(async () => ({
key: "MEDICATION_COMPLIANCE",
role: "behaviour",
points: pts,
})),
fetchSymptomSeries: vi.fn(async () => ({
key: "SYMPTOM_SEVERITY",
role: "outcome",
points: pts,
})),
fetchEnvironmentSeries: vi.fn(async () => [
{ key: "ENV_TEMP_MEAN", role: "behaviour", points: pts },
]),
fetchCustomMetricBehaviourSeries: vi.fn(async () => [
{ key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts },
]),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(),
}));
import {
assembleDiscoveryMatrix,
maskSeriesByModules,
} from "@/lib/insights/discovery-matrix";
const OPTS = {
tz: "UTC",
since: new Date("2026-03-01T00:00:00Z"),
fetchMode: "tiered" as const,
includeMoodFactors: true,
};
const keysOf = (series: { key: string }[]) => [
...new Set(series.map((s) => s.key)),
];
describe("assembleDiscoveryMatrix — switched-off modules", () => {
it("keeps every channel without a module map", async () => {
const { series } = await assembleDiscoveryMatrix("u1", OPTS);
const keys = keysOf(series);
for (const key of [
"MOOD",
"FACTOR:work",
"SLEEP_DURATION",
"BLOOD_GLUCOSE",
"MEDICATION_COMPLIANCE",
"SYMPTOM_SEVERITY",
"ENV_TEMP_MEAN",
"CUSTOM_METRIC:abc",
"WEIGHT",
]) {
expect(keys).toContain(key);
}
});
it("drops each module's channels, and only those, when it is off", async () => {
const { series, byMetric } = await assembleDiscoveryMatrix("u1", {
...OPTS,
modules: {
mood: false,
sleep: false,
glucose: false,
medications: false,
illness: false,
environment: false,
},
});
const keys = keysOf(series);
for (const gone of [
"MOOD",
"FACTOR:work",
"SLEEP_DURATION",
"BLOOD_GLUCOSE",
"MEDICATION_COMPLIANCE",
"SYMPTOM_SEVERITY",
"ENV_TEMP_MEAN",
]) {
expect(keys, gone).not.toContain(gone);
expect(byMetric.has(gone), gone).toBe(false);
}
// Core vitals and custom metrics belong to no module.
expect(keys).toContain("WEIGHT");
expect(keys).toContain("BLOOD_PRESSURE_SYS");
expect(keys).toContain("CUSTOM_METRIC:abc");
});
it("drops nothing for AI analysis off", async () => {
const all = await assembleDiscoveryMatrix("u1", OPTS);
const off = await assembleDiscoveryMatrix("u1", {
...OPTS,
modules: { insights: false },
});
expect(off.series.map((s) => s.key)).toEqual(all.series.map((s) => s.key));
});
});
describe("maskSeriesByModules", () => {
it("keeps the fold order of what remains", () => {
const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({
key,
role: "behaviour" as const,
points: pts,
}));
expect(
maskSeriesByModules(series, { mood: false }).map((s) => s.key),
).toEqual(["WEIGHT", "PULSE"]);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema";
import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface";
import {
selectTrendCharts,
DEFAULT_TREND_CHART_CAP,
@@ -158,23 +159,58 @@ describe("selectTrendCharts", () => {
});
});
describe("selectTrendCharts — hidden metrics", () => {
it("drops mood from the fallback triple when the mood module is off", () => {
const charts = selectTrendCharts(null, { hiddenMetrics: ["mood"] });
expect(charts.map((c) => c.metric)).not.toContain("mood");
expect(charts).toHaveLength(2);
describe("selectTrendCharts — switched-off modules", () => {
it("fills the fallback row from the next metric when mood is off", () => {
const charts = selectTrendCharts(null, { modules: { mood: false } });
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "pulse"]);
});
it("skips a hidden metric the briefing flags", () => {
it("keeps the familiar blood pressure, weight, mood row with every module on", () => {
const charts = selectTrendCharts(null, { modules: {} });
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("skips a flagged metric whose module is off and lets the next finding take the slot", () => {
const charts = selectTrendCharts(briefing(["sleep", "mood", "weight"]), {
modules: { sleep: false, mood: false },
});
expect(charts.map((c) => c.metric)).toEqual(["weight"]);
});
it("still honours an explicit hide list", () => {
const charts = selectTrendCharts(briefing(["mood", "weight"]), {
hiddenMetrics: ["mood"],
});
expect(charts.map((c) => c.metric)).not.toContain("mood");
expect(charts).toHaveLength(1);
expect(charts.map((c) => c.metric)).toEqual(["weight"]);
});
it("keeps mood when nothing is hidden", () => {
const charts = selectTrendCharts(null, { hiddenMetrics: [] });
expect(charts.map((c) => c.metric)).toContain("mood");
it("never returns a slot whose module is off, for any module map and briefing", () => {
// Exhaustive over every on/off combination of the owning modules, with
// every finding a briefing can carry, in both directions.
const owners = [
...new Set(
Object.keys(TREND_CHART_CONFIG)
.map((m) => surfaceModule(`trend:${m}`))
.filter((k): k is NonNullable<typeof k> => k !== undefined),
),
];
expect(owners.length).toBeGreaterThan(0);
const all = Object.keys(TREND_CHART_CONFIG) as Array<
keyof typeof TREND_CHART_CONFIG
>;
const briefings = [null, briefing(all), briefing([...all].reverse())];
for (let mask = 0; mask < 1 << owners.length; mask += 1) {
const modules = Object.fromEntries(
owners.map((key, i) => [key, (mask & (1 << i)) === 0]),
);
for (const b of briefings) {
for (const chart of selectTrendCharts(b, { modules })) {
expect(
isSurfaceVisible(`trend:${chart.metric}`, modules),
`${chart.metric} under ${JSON.stringify(modules)}`,
).toBe(true);
}
}
}
});
});
+34 -1
View File
@@ -57,6 +57,11 @@ import {
type MoodFactorWindowFetch,
} from "@/lib/insights/correlation-channel-series";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
correlationChannelSurfaceId,
isSurfaceVisible,
type SurfaceModuleMap,
} from "@/lib/modules/surface";
/**
* How the measurement channels are read.
@@ -117,6 +122,14 @@ export interface DiscoveryMatrixOptions {
* measurement read instead of a second one.
*/
extraMeasurementTypes?: readonly MeasurementType[];
/**
* The record's resolved module map. A channel whose module is off
* (`correlation:<key>` in the surface map: mood and its rated factors,
* sleep, glucose, medication adherence, symptoms, weather) is left out of
* `series` and `byMetric`, so no statistic is computed over a module the
* person switched off. Omitted: every channel is kept.
*/
modules?: SurfaceModuleMap;
}
/** Per-channel reach, for the callers' wide-event annotations. */
@@ -150,6 +163,20 @@ export interface DiscoveryMatrix {
diagnostics: DiscoveryMatrixDiagnostics;
}
/**
* Leave out every channel whose owning module is off. Pure, and applied after
* the fold so the order of what remains is the order it was folded in (the
* ranking stability note on {@link assembleDiscoveryMatrix}).
*/
export function maskSeriesByModules(
series: readonly NamedSeries[],
modules: SurfaceModuleMap | null | undefined,
): NamedSeries[] {
return series.filter((s) =>
isSurfaceVisible(correlationChannelSurfaceId(s.key), modules),
);
}
/**
* The mood read, normalised to the factor-carrying shape. With factors off the
* factor map is empty, so the fold below has one code path rather than two.
@@ -302,8 +329,14 @@ export async function assembleDiscoveryMatrix(
byMetric.set(key, factorPoints);
}
for (const key of [...byMetric.keys()]) {
if (!isSurfaceVisible(correlationChannelSurfaceId(key), opts.modules)) {
byMetric.delete(key);
}
}
return {
series,
series: maskSeriesByModules(series, opts.modules),
byMetric,
diagnostics: {
measurementsCapped: measurements.measurementsCapped,
+26 -6
View File
@@ -1,4 +1,5 @@
import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
/**
* v1.8.5 — derive the Insights overview "Trends" chart set from the
@@ -261,6 +262,15 @@ export const DEFAULT_TREND_METRICS: ReadonlyArray<
DailyBriefingKeyFinding["sourceMetric"]
> = ["bp", "weight", "mood"];
/**
* Metrics the fallback row reaches for, in order, when a default slot is left
* out because its module is off. Pulse is a core vital, so a row without mood
* still paints three charts rather than two.
*/
const FALLBACK_FILL_METRICS: ReadonlyArray<
DailyBriefingKeyFinding["sourceMetric"]
> = ["pulse"];
/** Max charts the row renders. Three keeps the equal-height 3-up grid. */
export const DEFAULT_TREND_CHART_CAP = 3;
@@ -268,10 +278,13 @@ export interface SelectTrendChartsOptions {
/** Max number of charts to return. Defaults to {@link DEFAULT_TREND_CHART_CAP}. */
cap?: number;
/**
* Metrics whose module is switched off. They are skipped on both the
* briefing path and the fallback triple, so a disabled module never
* charts on the overview.
* The record's resolved module map (`GET /api/auth/me` `modules`). A slot
* whose module is off (`trend:<metric>` in the surface map) is skipped on
* both the briefing path and the fallback row, and the next eligible
* metric takes its place. Absent or partial: every slot is eligible.
*/
modules?: SurfaceModuleMap | null;
/** Further metrics to leave out, on top of the module map. */
hiddenMetrics?: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>;
}
@@ -279,12 +292,14 @@ function configsFor(
metrics: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>,
cap: number,
hidden: ReadonlySet<string>,
modules: SurfaceModuleMap | null | undefined,
): TrendChartConfig[] {
const seen = new Set<string>();
const out: TrendChartConfig[] = [];
for (const metric of metrics) {
if (out.length >= cap) break;
if (hidden.has(metric)) continue; // module switched off
if (hidden.has(metric)) continue;
if (!isSurfaceVisible(`trend:${metric}`, modules)) continue; // module off
const config = TREND_CHART_CONFIG[metric];
if (!config) continue; // metric has no standalone trend chart
if (seen.has(config.metric)) continue; // dedupe
@@ -313,11 +328,16 @@ export function selectTrendCharts(
const findingMetrics =
briefing?.keyFindings?.map((f) => f.sourceMetric) ?? [];
const fromBriefing = configsFor(findingMetrics, cap, hidden);
const fromBriefing = configsFor(findingMetrics, cap, hidden, options.modules);
if (fromBriefing.length > 0) {
return fromBriefing;
}
return configsFor(DEFAULT_TREND_METRICS, cap, hidden);
return configsFor(
[...DEFAULT_TREND_METRICS, ...FALLBACK_FILL_METRICS],
cap,
hidden,
options.modules,
);
}
@@ -0,0 +1,335 @@
/**
* Structural guard on the surface map (`src/lib/modules/surface.ts`).
*
* The map is the one answer to "which module owns this surface". It only
* works if two things stay true, and each half of this file holds one:
*
* 1. Every id in the map names a surface that exists. A nav href, a slug, a
* section id, a widget id that was renamed would leave an entry that
* matches nothing, and the surface it meant would silently stop hiding.
* Each family is checked against the list the app actually renders from.
*
* 2. Every surface that shows a module's data is owned by that module. This
* half cannot come from the map itself, so it is derived from a second
* source: the measurement types each surface plots, joined against the
* summary owners the dashboard already gates on. A sleep page with no
* sleep entry, or a trends slot charting a recovery-owned series with no
* owner, fails here by name. A short table of owners that no data join
* can derive (nav pages, the add menu, overview blocks) is pinned below.
*
* Plus the retirement half: the per-surface maps and guard hooks this map
* replaced must not come back, so their names are searched for in source.
*
* Limits, stated so nobody reads more into a green run: the data join only
* sees surfaces that declare the types they plot, so a surface that reads a
* module's store without declaring a type would pass. The source search
* matches names, not behaviour; a copy under a new name passes. Every match
* set asserts a non-zero count so a matcher that stopped matching fails.
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
// The capture picker and the Settings shell pull in forms and sections; the
// guard needs only their exported lists.
vi.mock("@/components/measurements/measurement-form", () => ({
MeasurementForm: () => null,
}));
vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null }));
vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
MedicationIntakeQuickAdd: () => null,
}));
import {
walkSourceFiles,
stripComments,
} from "@/__tests__/helpers/source-files";
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker";
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
SCORE_RING_IDS,
} from "@/lib/dashboard-layout";
import { SUMMARY_TYPE_MODULE } from "@/lib/dashboard/widget-modules";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import {
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
FACTOR_CHANNEL_PREFIX,
} from "@/lib/insights/correlation-discovery";
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
import { INSIGHTS_SECTION_IDS } from "@/lib/insights-layout";
import {
SUB_PAGE_SLUGS,
TYPE_TO_SUB_PAGE_SLUG,
} from "@/lib/insights/sub-page-metric";
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
import {
SURFACE_KINDS,
SURFACE_MODULE,
surfaceModule,
surfaceModulesOfKind,
type SurfaceKind,
} from "@/lib/modules/surface";
const SRC = join(process.cwd(), "src");
function localIds(kind: SurfaceKind): string[] {
const ids = Object.keys(surfaceModulesOfKind(kind));
return ids;
}
describe("every surface id names a surface that exists", () => {
it("covers every kind with at least one entry", () => {
for (const kind of SURFACE_KINDS) {
expect(localIds(kind).length, kind).toBeGreaterThan(0);
}
});
it("nav: a destination href", () => {
const hrefs = new Set(NAV_DESTINATIONS.map((d) => d.href));
for (const id of localIds("nav")) expect(hrefs.has(id), id).toBe(true);
});
it("insights-page: a routed Insights sub-page", () => {
for (const slug of localIds("insights-page")) {
expect(
existsSync(join(SRC, "app/insights", slug)),
`src/app/insights/${slug}`,
).toBe(true);
}
});
it("overview: an overview section id, or the cycle ring inside the scores strip", () => {
const ids = new Set<string>([...INSIGHTS_SECTION_IDS, "cycle-ring"]);
for (const id of localIds("overview")) expect(ids.has(id), id).toBe(true);
});
it("trend: a trends-row slot that charts something", () => {
for (const id of localIds("trend")) {
expect(
TREND_CHART_CONFIG[id as keyof typeof TREND_CHART_CONFIG],
id,
).toBeTruthy();
}
});
it("capture: an add-menu kind", () => {
const kinds = new Set<string>(CAPTURE_KIND_ORDER);
for (const id of localIds("capture")) expect(kinds.has(id), id).toBe(true);
});
it("widget: a dashboard widget id", () => {
const ids = new Set<string>(DASHBOARD_WIDGET_CATALOGUE_IDS);
for (const id of localIds("widget")) expect(ids.has(id), id).toBe(true);
});
it("derived: a derived metric id", () => {
const ids = new Set<string>(DERIVED_METRIC_IDS);
for (const id of localIds("derived")) expect(ids.has(id), id).toBe(true);
});
it("score-ring: a hero score ring id, the derived ones owned like their score", () => {
const ids = new Set<string>(SCORE_RING_IDS);
for (const id of localIds("score-ring")) {
expect(ids.has(id), id).toBe(true);
const derivedOwner = surfaceModule(`derived:${id}`);
if (derivedOwner !== undefined) {
expect(surfaceModule(`score-ring:${id}`), id).toBe(derivedOwner);
}
}
});
it("settings: a Settings section slug", () => {
const slugs = new Set<string>(SETTINGS_SECTIONS.map((s) => s.slug));
for (const id of localIds("settings")) expect(slugs.has(id), id).toBe(true);
});
it("settings-layout: a Layout group id", () => {
const ids = new Set<string>(LAYOUT_GROUP_IDS);
for (const id of localIds("settings-layout")) {
expect(ids.has(id), id).toBe(true);
}
});
it("correlation: a discovery channel, a weather field or the lab pass", () => {
const channels = new Set<string>([
...DISCOVERY_BEHAVIOURS,
...DISCOVERY_OUTCOMES,
...ENVIRONMENT_FIELDS.map((f) => f.key),
"LAB_DRAWS",
]);
for (const id of localIds("correlation")) {
expect(channels.has(id), id).toBe(true);
}
// `correlationChannelSurfaceId` folds rated factors onto mood by this
// prefix; it has to be the prefix the engine actually writes.
expect(FACTOR_CHANNEL_PREFIX).toBe("FACTOR:");
});
});
describe("every surface that shows a module's data is owned by it", () => {
/** Summary owners are the dashboard's own gate; the join source of truth. */
const ownerOfType = (type: string) => SUMMARY_TYPE_MODULE[type];
/**
* Sub-pages a module-owned type routes to but that are not that module's.
* Each carries its reason; an entry here is a decision, not a silence.
*/
const NOT_OWNED_DESPITE_TYPE: Record<string, string> = {
// The HRV page leads with SDNN, a plain vital. RMSSD is recovery-owned
// and routes here only as the multi-metric fallback.
hrv: "SDNN page; RMSSD is only its fallback series",
};
it("Insights sub-pages follow the owner of the types they plot", () => {
let joined = 0;
for (const [type, slug] of Object.entries(TYPE_TO_SUB_PAGE_SLUG)) {
const owner = ownerOfType(type);
if (!owner || slug in NOT_OWNED_DESPITE_TYPE) continue;
joined += 1;
expect(
surfaceModule(`insights-page:${slug}`),
`${slug} plots ${type}, which ${owner} owns`,
).toBe(owner);
}
expect(joined).toBeGreaterThan(0);
});
it("trends-row slots follow the owner of the series they chart", () => {
let joined = 0;
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
if (!config) continue;
const owners = new Set(
config.types.map(ownerOfType).filter((o) => o !== undefined),
);
if (config.kind === "mood") owners.add("mood");
if (owners.size === 0) {
expect(surfaceModule(`trend:${metric}`), metric).toBeUndefined();
continue;
}
joined += 1;
expect(owners.size, metric).toBe(1);
expect(surfaceModule(`trend:${metric}`), metric).toBe([...owners][0]);
}
expect(joined).toBeGreaterThan(0);
});
it("correlation channels that are summary types follow the summary owner", () => {
let joined = 0;
for (const key of [...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]) {
const owner = ownerOfType(key);
if (!owner) continue;
joined += 1;
expect(surfaceModule(`correlation:${key}`), key).toBe(owner);
}
expect(joined).toBeGreaterThan(0);
});
it("every weather channel belongs to the environment module", () => {
for (const field of ENVIRONMENT_FIELDS) {
expect(surfaceModule(`correlation:${field.key}`), field.key).toBe(
"environment",
);
}
});
// Owners no data join can derive. Pinned by hand; each is a surface the
// audit found leaking when its module was off.
it.each([
["nav:/mood", "mood"],
["nav:/cycle", "cycle"],
["nav:/medications", "medications"],
["nav:/labs", "labs"],
["nav:/coach", "coach"],
["insights-page:mood", "mood"],
["insights-page:medications", "medications"],
["insights-page:workouts", "workouts"],
["insights-page:recovery", "recovery"],
["insights-page:nutrients", "nutrients"],
["overview:breathing", "sleep"],
["overview:labs-changes", "labs"],
["overview:cycle-summary", "cycle"],
["overview:cycle-ring", "cycle"],
["capture:mood", "mood"],
["capture:medication", "medications"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
["correlation:SYMPTOM_SEVERITY", "illness"],
["correlation:LAB_DRAWS", "labs"],
])("%s is owned by %s", (id, owner) => {
expect(surfaceModule(id)).toBe(owner);
});
it("the insights key owns nothing (it is AI analysis, not an area)", () => {
expect(Object.values(SURFACE_MODULE)).not.toContain("insights");
});
it("the tab strip covers every sub-page slug it can render", () => {
// Every owned sub-page that is a strip slug is reachable by the strip's
// lookup; recovery is the one composite pill without a slug.
const slugs = new Set<string>(SUB_PAGE_SLUGS);
for (const id of localIds("insights-page")) {
expect(slugs.has(id) || id === "recovery", id).toBe(true);
}
});
});
describe("the replaced maps and guards stay gone", () => {
const files = walkSourceFiles(SRC, { floor: 1000 }).filter(
(rel) =>
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!/\.test\.tsx?$/.test(rel),
);
const sources = files.map((rel) => ({
rel,
code: stripComments(readFileSync(join(SRC, rel), "utf8")),
}));
it.each([
["a per-destination module key", /\brequiresModule\s*[?:]/],
["a per-section module key", /\bmoduleGate\s*\?\s*:|\bmoduleGate\s*:\s*"/],
["the tab strip's own slug map", /\bSUB_PAGE_MODULE\b/],
["the redirecting page guard", /\buseModulePageGuard\b/],
])("no source declares %s", (_label, pattern) => {
const hits = sources.filter((s) => pattern.test(s.code)).map((s) => s.rel);
expect(hits).toEqual([]);
});
it("the dashboard maps are views, built from the surface map and nowhere else", () => {
const widgetModules = sources.find(
(s) => s.rel === "lib/dashboard/widget-modules.ts",
);
expect(widgetModules).toBeDefined();
expect(widgetModules!.code).toMatch(
/WIDGET_MODULE_BY_ID[^=]*=\s*surfaceModulesOfKind\(\s*"widget"\s*\)/,
);
expect(widgetModules!.code).toMatch(
/SUMMARY_TYPE_MODULE[^=]*=\s*surfaceModulesOfKind\(\s*"summary"\s*\)/,
);
});
it("the derived-score gate agrees with the surface map", () => {
// `DERIVED_MODULE` lives in the derived route, which this guard reads as
// text rather than importing (it pulls the whole server stack). Parsed
// entry by entry and compared, so a drift in either direction fails.
const route = readFileSync(
join(SRC, "app/api/insights/derived/route.ts"),
"utf8",
);
const body = /DERIVED_MODULE[^=]*=\s*\{([\s\S]*?)\}/.exec(route)?.[1];
expect(body).toBeDefined();
const entries = Object.fromEntries(
[...body!.matchAll(/([A-Z_]+)\s*:\s*"([A-Za-z]+)"/g)].map((m) => [
m[1],
m[2],
]),
);
expect(Object.keys(entries).length).toBeGreaterThan(0);
expect(entries).toEqual(surfaceModulesOfKind("derived"));
});
});
+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");
}
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* 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",
// ── 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",
// ── 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: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}`;
}
@@ -51,6 +51,19 @@ describe("tour-state", () => {
expect(ids).toContain("wrapUp");
});
it("keeps the Insights stop with AI analysis off", () => {
const stops = buildTourStops({ modules: { insights: false } });
expect(stops.map((s) => s.id)).toContain("insights");
});
it("drops the Coach and Medications stops with their modules off", () => {
const ids = buildTourStops({
modules: { coach: false, medications: false },
}).map((s) => s.id);
expect(ids).not.toContain("coach");
expect(ids).not.toContain("medications");
});
it("keeps a module stop when its key is absent or true (fail-open)", () => {
const stops = buildTourStops({ modules: { labs: true } });
expect(stops.map((s) => s.id)).toContain("labs");
+5 -16
View File
@@ -21,6 +21,7 @@
*/
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
/** Stable IDs for every stop in the tour, in presentation order. */
export type TourStopId =
@@ -69,12 +70,6 @@ export interface TourStop {
* page.
*/
route?: string;
/**
* v1.18.6 — module gate. When set, the stop is dropped unless the
* account's resolved module map has the key enabled (default-on:
* only an explicit `false` drops it, mirroring the nav gate).
*/
requiresModule?: ModuleKey;
}
/**
@@ -111,7 +106,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "medications-hero",
placement: "bottom",
route: "/medications",
requiresModule: "medications",
titleKey: "onboarding.tour.steps.medications.title",
bodyKey: "onboarding.tour.steps.medications.body",
},
@@ -120,7 +114,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "labs-hero",
placement: "bottom",
route: "/labs",
requiresModule: "labs",
titleKey: "onboarding.tour.steps.labs.title",
bodyKey: "onboarding.tour.steps.labs.body",
},
@@ -129,7 +122,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "illness-hero",
placement: "bottom",
route: "/illness",
requiresModule: "illness",
titleKey: "onboarding.tour.steps.illness.title",
bodyKey: "onboarding.tour.steps.illness.body",
},
@@ -146,7 +138,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "cycle-hero",
placement: "bottom",
route: "/cycle",
requiresModule: "cycle",
titleKey: "onboarding.tour.steps.cycle.title",
bodyKey: "onboarding.tour.steps.cycle.body",
},
@@ -155,7 +146,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "mood-hero",
placement: "bottom",
route: "/mood",
requiresModule: "mood",
titleKey: "onboarding.tour.steps.mood.title",
bodyKey: "onboarding.tour.steps.mood.body",
},
@@ -164,7 +154,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "insights-hero",
placement: "bottom",
route: "/insights",
requiresModule: "insights",
titleKey: "onboarding.tour.steps.insights.title",
bodyKey: "onboarding.tour.steps.insights.body",
},
@@ -173,7 +162,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "coach-hero",
placement: "bottom",
route: "/coach",
requiresModule: "coach",
titleKey: "onboarding.tour.steps.coach.title",
bodyKey: "onboarding.tour.steps.coach.body",
},
@@ -198,7 +186,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "achievements-hero",
placement: "bottom",
route: "/achievements",
requiresModule: "achievements",
titleKey: "onboarding.tour.steps.achievements.title",
bodyKey: "onboarding.tour.steps.achievements.body",
},
@@ -220,7 +207,7 @@ export type TourModuleMap = Partial<Record<ModuleKey, boolean>>;
/**
* Build the resolved tour-step list for the given module map. Stops
* whose `requiresModule` resolves to `false` are dropped without
* whose page belongs to a module that is `false` are dropped without
* renumbering the rest.
*
* `filterToStop` narrows the list to a single stop — the per-module
@@ -233,8 +220,10 @@ export function buildTourStops(opts?: {
filterToStop?: TourStopId;
}): TourStop[] {
const modules = opts?.modules;
// A stop follows the page it stands on, through the same surface map the
// nav reads (`nav:<route>`), so a stop and its nav entry cannot disagree.
const gated = ALL_STOPS.filter(
(s) => !s.requiresModule || modules?.[s.requiresModule] !== false,
(s) => !s.route || isSurfaceVisible(`nav:${s.route}`, modules),
);
if (opts?.filterToStop) {
return gated.filter((s) => s.id === opts.filterToStop);