diff --git a/e2e/modules-off.spec.ts b/e2e/modules-off.spec.ts new file mode 100644 index 000000000..ceb79df73 --- /dev/null +++ b/e2e/modules-off.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 63dad8a79..50791808c 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -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 { ], ); + // 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 { // 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); diff --git a/e2e/setup/modules-fixture.ts b/e2e/setup/modules-fixture.ts new file mode 100644 index 000000000..7dfb8aedf --- /dev/null +++ b/e2e/setup/modules-fixture.ts @@ -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 { + 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(); + } +} diff --git a/messages/de.json b/messages/de.json index ca08bcc20..419b0f16b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" diff --git a/messages/en.json b/messages/en.json index dc8749a5e..31089bf21 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" diff --git a/messages/es.json b/messages/es.json index a1d6f8b27..287124c44 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" diff --git a/messages/fr.json b/messages/fr.json index ea84233ff..dd59b3df6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" diff --git a/messages/it.json b/messages/it.json index 26f199139..6ab0f72c0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" diff --git a/messages/ko.json b/messages/ko.json index dee6cfc36..48747e8c5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10163,8 +10163,7 @@ "dayInvalid": "기록에 서버가 받을 수 없는 날짜가 담겨 있었어요. 보내는 쪽의 시계나 시간대가 잘못됐을 때 생길 수 있으니 한번 확인해 보세요.", "upsertFailed": "기록이 서버까지 도착했지만 이쪽 문제로 저장되지 않았어요. 따로 하실 일은 없어요.", "unknown": "기록이 도착했지만 저장하지 못했어요." - }, - "moduleEnableError": "영양소 모듈을 켜지 못했어요. 다시 시도해 주세요." + } }, "hydration": { "referenceMeta": "권장 섭취량 {value} {unit}/일 · EFSA" diff --git a/messages/pl.json b/messages/pl.json index 2a1e64284..6e60f460d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" diff --git a/playwright.config.ts b/playwright.config.ts index 3d4e78b96..57cb11be7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index abee3ed10..57d1d4f8a 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -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 ; } - // 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 ( diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index c22a3e35b..a22642d60 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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>, + ); // 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>); + 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>); + 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); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 20eab84b8..784d37d2f 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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> >, - `${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>, + 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; diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index d8dcdddb2..0007fef34 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -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 ( -
- -
- ); - } - return ( apiPatch("/api/auth/me/modules", { nutrients: true }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - }, - onError: () => toast.error(t("nutrients.page.moduleEnableError")), - }); - - if (!nutrientsEnabled) { - return ( - - {/* - 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. - */} - } - action={ - - } - /> - - ); - } - if (overview.isError) { return ( - - - ); - } return ( - - - ); - } - if (isEmpty) { return ( - - - ); - } - return ( (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 ( -
- -
- ); - } - return ( ("/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({ `` 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 ? ( ) : digestQuery.isError ? ( digestQuery.refetch()} /> - ) : null)} + ) : null + } {/* v1.18.6 — the spotlight tour launcher moved to the app-shell * (`AuthShell`) so its overlay survives the cross-page diff --git a/src/app/page.tsx b/src/app/page.tsx index 26848887d..8f77e9367 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(ms: number): Promise { * 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( diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx index 257685689..b0e82d825 100644 --- a/src/app/settings/layout/[module]/page.tsx +++ b/src/app/settings/layout/[module]/page.tsx @@ -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) { >
- +
diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 3aedbc6f9..317c3d942 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -546,6 +546,28 @@ describe(" — 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( + , + ); + expect(on).toContain('href="/insights/medications"'); + const off = render( + , + ); + 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. diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 60f6613d0..0714a96ab 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -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> = { - 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> = { */ export type InsightsModuleMap = Partial>; -/** 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:`), 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`, diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 85d8255a8..a7056fd80 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -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 diff --git a/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx b/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx index 6662c5b7c..3df038b9e 100644 --- a/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx +++ b/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx @@ -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(" 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: {} }; + }); }); diff --git a/src/components/layout/__tests__/capture-picker-discard.test.tsx b/src/components/layout/__tests__/capture-picker-discard.test.tsx index f701b3a94..a62bfc7b4 100644 --- a/src/components/layout/__tests__/capture-picker-discard.test.tsx +++ b/src/components/layout/__tests__/capture-picker-discard.test.tsx @@ -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(); return { diff --git a/src/components/layout/__tests__/capture-picker.test.tsx b/src/components/layout/__tests__/capture-picker.test.tsx index ecb0d3463..20d5e86af 100644 --- a/src/components/layout/__tests__/capture-picker.test.tsx +++ b/src/components/layout/__tests__/capture-picker.test.tsx @@ -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 | 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(" — 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(" — offered kinds", () => { expect(html).not.toContain('data-testid="capture-picker-water"'); }); }); + +describe(" — 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"'); + }); +}); diff --git a/src/components/layout/__tests__/module-page-gate.test.tsx b/src/components/layout/__tests__/module-page-gate.test.tsx new file mode 100644 index 000000000..37828815d --- /dev/null +++ b/src/components/layout/__tests__/module-page-gate.test.tsx @@ -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; + moduleAccess?: Record; +}; +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( + + +

page

+
+
, + ); +} + +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("", () => { + 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"'); + }); +}); diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts index f9c6005f9..a635feb9f 100644 --- a/src/components/layout/__tests__/nav-model-shared-record.test.ts +++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts @@ -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}`]); } }); diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index e37cbd032..5852a9fe5 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -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, diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 176fdb67a..7effc6cb9 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -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; diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 8990eb73d..6200f8be4 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -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 = [ +// 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 = [ { 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 = [ - { - 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 = [ + { 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], diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index b3415e891..068dd5f46 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -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:` 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, kinds: ReadonlyArray, + 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(null); // Re-derived on every render, never latched: the offer can shrink under a // sheet that is already open. See `admittedCaptureKind`. diff --git a/src/components/layout/module-disabled-notice.tsx b/src/components/layout/module-disabled-notice.tsx index 9cb047ee3..84e5a35aa 100644 --- a/src/components/layout/module-disabled-notice.tsx +++ b/src/components/layout/module-disabled-notice.tsx @@ -79,7 +79,11 @@ export function ModuleDisabledNotice({ const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey); return ( -
+
`), so + * `/insights/mood` and `/insights/workouts/` follow their own module while + * `/insights` itself follows none. Every other page answers through the nav + * destination it sits under (`nav:`), 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 ( } + icon={Icon ? : undefined} action={