From 5bd19573ea6f2aacf8aa6f041dd45cb219bb4ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:17:14 +0200 Subject: [PATCH 1/2] fix(insights): pass the record's module map to every correlation scan The Coach correlations tool, the per-metric card and the period narrative scanned every channel, so a switched-off module still fed their statistics and the text a model was grounded on. The matrix's modules option is now required, each caller resolves the record's map, and the Coach skips the lab pass with labs off. A registry guard holds every caller to resolving the map itself. The mask's doc comment now states what it does to the false discovery correction: fewer channels is a smaller family, so q-values of the remaining pairs move and a borderline pair can cross the threshold. --- .../correlations-module-gate.test.ts | 2 + .../coach/__tests__/correlations-read.test.ts | 55 ++++++++++++ src/lib/ai/coach/tools/correlations-read.ts | 12 ++- .../discovery-matrix-modules.test.ts | 3 +- .../metric-correlation-context.test.ts | 36 ++++++++ .../__tests__/coach-read-locale.test.ts | 2 + src/lib/insights/discovery-matrix.ts | 23 ++++- .../insights/metric-correlation-context.ts | 4 + .../__tests__/period-narrative-db.test.ts | 85 +++++++++++++++++++ .../insights/narrative/period-narrative.ts | 4 + .../__tests__/surface-module-registry.test.ts | 19 +++++ 11 files changed, 238 insertions(+), 7 deletions(-) diff --git a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts index 50aef2e7c..98866173a 100644 --- a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts @@ -16,6 +16,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), + // Every module on: the record's map feeds the matrix mask. + resolveModuleMap: vi.fn(async () => ({})), })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index b5c324aaa..f020b98c1 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi, beforeEach } from "vitest"; +const moduleMap = vi.hoisted(() => ({ value: {} as Record })); const measurementFindMany = vi.fn(); const moodFindMany = vi.fn(); const userFindUnique = vi.fn(); @@ -17,6 +18,8 @@ const environmentContextFindMany = vi.fn(); const customMetricFindMany = vi.fn(); vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), + // The record's map feeds the matrix mask; every module on by default. + resolveModuleMap: vi.fn(async () => moduleMap.value), })); vi.mock("@/lib/db", () => ({ prisma: { @@ -67,6 +70,7 @@ import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; describe("readCoachCorrelations", () => { beforeEach(() => { + moduleMap.value = {}; measurementFindMany.mockReset().mockResolvedValue([]); moodFindMany.mockReset().mockResolvedValue([]); userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); @@ -363,4 +367,55 @@ describe("readCoachCorrelations", () => { // The channel key must never leak into prose the Coach reads out. expect(pressureDriver?.behaviour).not.toContain("ENV_"); }); + it("never hands the Coach a weather driver with the environment module off", async () => { + moduleMap.value = { environment: false }; + const real = await vi.importActual< + typeof import("@/lib/insights/correlation-discovery") + >("@/lib/insights/correlation-discovery"); + discoverCorrelations.mockImplementation((series) => + real.discoverCorrelations( + series as Parameters[0], + { locale: "en" }, + ), + ); + + // 45 consecutive days. Today's barometric pressure (behaviour, day D) + // tracks the NEXT day's sleep (outcome, day D+1) with a deterministic + // linear relationship, so the pair clears n ≥ 20 / p < 0.05 / FDR / the + // effect-size floor rather than riding on noise. + const DAYS = 45; + const base = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → tz-stable day key + const dayKey = (offset: number): string => + new Date(base + offset * 86_400_000).toISOString().slice(0, 10); + + const envRows: Array<{ date: string; pressureMean: number }> = []; + const measurements: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let i = 0; i < DAYS; i++) { + const swing = i % 5; // 0..4, real variance across the window + envRows.push({ date: dayKey(i), pressureMean: 1000 + swing }); + // Higher pressure today → shorter sleep tomorrow (a clean negative r). + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - swing * 0.5, + measuredAt: new Date(base + (i + 1) * 86_400_000), + }); + } + + measurementFindMany.mockResolvedValue(measurements); + // Every other env column is null, so only the pressure channel carries + // points and the assertion below cannot be satisfied by a sibling field. + environmentContextFindMany.mockResolvedValue(envRows); + + const result = await readCoachCorrelations("u1", "en"); + + // The same data that surfaces a pressure driver above surfaces none here: + // the channel never enters the scan. + expect( + result.drivers?.find((d) => d.behaviour === "barometric pressure"), + ).toBeUndefined(); + }); }); diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 2ad9c1716..9f7d8a8d3 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -38,7 +38,8 @@ import { prisma } from "@/lib/db"; import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { isSurfaceVisible } from "@/lib/modules/surface"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, @@ -217,14 +218,19 @@ export async function readCoachCorrelations( // // v1.22 — the lab draws feed a separate point-vs-window pass, not the // matrix, so they are fetched alongside rather than assembled in. + // Switched-off modules never reach the Coach: their channels are left out + // of the scan and the lab pass is skipped with labs off. + const modules = await resolveModuleMap(userId); const [matrix, coincidentDerived, labDraws] = await Promise.all([ - assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "raw" }), + assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "raw", modules }), // Coincident-deviation is its own derived metric — fail-soft to null so a // baseline hiccup never sinks the whole correlations read. D2-8: pass the // user's tz so the "today" grouping matches the user's calendar day, not // UTC's, before the fired flag is narrated as "out of band TODAY". computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), - fetchLabDraws(userId, tz, since), + isSurfaceVisible("correlation:LAB_DRAWS", modules) + ? fetchLabDraws(userId, tz, since) + : Promise.resolve([]), ]); const { series, diagnostics } = matrix; diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 882e89f0a..e13d77393 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -57,6 +57,7 @@ const OPTS = { since: new Date("2026-03-01T00:00:00Z"), fetchMode: "tiered" as const, includeMoodFactors: true, + modules: {}, }; const keysOf = (series: { key: string }[]) => [ @@ -64,7 +65,7 @@ const keysOf = (series: { key: string }[]) => [ ]; describe("assembleDiscoveryMatrix — switched-off modules", () => { - it("keeps every channel without a module map", async () => { + it("keeps every channel with every module on", async () => { const { series } = await assembleDiscoveryMatrix("u1", OPTS); const keys = keysOf(series); for (const key of [ diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 5f4f411a5..f77052076 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -14,6 +14,12 @@ vi.mock("@/lib/db", () => ({ }, })); +// The record's module map; every module on unless a test switches one off. +const moduleMap = vi.hoisted(() => ({ value: {} as Record })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn(async () => moduleMap.value), +})); + vi.mock("@/lib/insights/correlation-patterns", () => ({ PATTERN_FAMILIES: { discoveryRetrospective: "DISCOVERY_RETROSPECTIVE" }, syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()), @@ -25,6 +31,7 @@ import { getRelevantCorrelationsForMetric } from "../metric-correlation-context" beforeEach(() => { vi.resetAllMocks(); + moduleMap.value = {}; vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", } as never); @@ -98,6 +105,35 @@ describe("getRelevantCorrelationsForMetric", () => { } }); + it("never grounds a card on a module that is switched off", async () => { + // Blood glucose today tracks tomorrow's weight. With the glucose module on, + // the weight card cites the pair (the positive control); with it off, the + // glucose channel never enters the scan and the card cites nothing. + const rows: Array<{ type: string; value: number; measuredAt: Date }> = []; + const base = new Date("2026-01-01T12:00:00Z"); + for (let d = 0; d < 60; d++) { + const glucose = 80 + (d % 7) * 5; + rows.push({ + type: "BLOOD_GLUCOSE", + value: glucose, + measuredAt: new Date(base.getTime() + d * 86_400_000), + }); + rows.push({ + type: "WEIGHT", + value: 60 + glucose * 0.1, + measuredAt: new Date(base.getTime() + (d + 1) * 86_400_000), + }); + } + vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never); + + const on = await getRelevantCorrelationsForMetric("u-1", "WEIGHT", "en"); + expect(on.length).toBeGreaterThan(0); + + moduleMap.value = { glucose: false }; + const off = await getRelevantCorrelationsForMetric("u-1", "WEIGHT", "en"); + expect(off).toEqual([]); + }); + it("is best-effort: a DB failure resolves to [] rather than throwing", async () => { vi.mocked(prisma.measurement.findMany).mockRejectedValue( new Error("db down"), diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index c861ffce9..1c2208411 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -29,6 +29,8 @@ const customMetricFindMany = vi.fn(); vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), + // Every module on: the record's map feeds the matrix mask. + resolveModuleMap: vi.fn(async () => ({})), })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index 49cde8d4a..7e86c64ea 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -126,10 +126,16 @@ export interface DiscoveryMatrixOptions { * The record's resolved module map. A channel whose module is off * (`correlation:` 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. + * `series` and `byMetric`, so no statistic is computed, and no prompt is + * grounded, on a module the person switched off. + * + * Required, not optional: a caller that forgot it would scan every channel + * and nothing would notice, which is how the Coach tool, the metric card + * and the period narrative kept scanning switched-off modules after the + * route stopped. Omitting it is now a compile error. Pass + * `resolveModuleMap(userId)` for the record being read. */ - modules?: SurfaceModuleMap; + modules: SurfaceModuleMap; } /** Per-channel reach, for the callers' wide-event annotations. */ @@ -167,6 +173,17 @@ export interface DiscoveryMatrix { * 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}). + * + * This changes the statistics of the pairs that remain, not only which pairs + * exist. Benjamini-Hochberg controls the false discovery rate across every + * pair tested, so a smaller channel set is a smaller family: each surviving + * pair's q-value is computed against fewer tests and is usually lower (never + * higher for the same p-values and rank order). Switching a module off can + * therefore let a borderline pair between two unrelated channels cross the + * FDR threshold, and switching it back on can push that pair back under. + * That is the correct correction for the question actually asked (the pairs + * over what the person tracks); it is also why a persisted pattern's q-value + * is only comparable to another computed under the same module set. */ export function maskSeriesByModules( series: readonly NamedSeries[], diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index f626b5ef3..811e8d452 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -33,6 +33,7 @@ import { DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, } from "@/lib/insights/correlation-discovery"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { decisionForEvidence, @@ -98,10 +99,13 @@ export async function getRelevantCorrelationsForMetric( // scans, so a card can never be missing a pair the page would show. The // measurement read stays `"raw"` (the route's rollup read-swap is a // read-cost choice, not a channel-set one; see the option's doc comment). + // A channel whose module is off never enters the scan, so the card is + // never grounded on a module the person switched off. const { series } = await assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "raw", + modules: await resolveModuleMap(userId), }); const result = discoverCorrelations(series, { locale }); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index 2b24fcada..b742c12ac 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -30,6 +30,12 @@ const illnessEpisodeFindMany = vi.fn(); const illnessDayLogFindMany = vi.fn(); const environmentContextFindMany = vi.fn(); const customMetricFindMany = vi.fn(); +// The record's module map; every module on unless a test switches one off. +const moduleMap = vi.hoisted(() => ({ value: {} as Record })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn(async () => moduleMap.value), +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: (a: unknown) => userFindUnique(a) }, @@ -51,6 +57,7 @@ import { buildPeriodNarrativeContext } from "../period-narrative"; const DAY_MS = 24 * 60 * 60 * 1000; beforeEach(() => { + moduleMap.value = {}; userFindUnique.mockReset().mockResolvedValue({ timezone: "UTC", sourcePriorityJson: null, @@ -66,6 +73,84 @@ beforeEach(() => { }); describe("buildPeriodNarrativeContext — grain (QA F3)", () => { + it("never narrates a module that is switched off", async () => { + // The fixture of the test above, with the sleep module off: the sleep + // night is in the data, and the narrative must neither delta it nor scan + // it. Mutation-checked against a caller passing an empty module map. + moduleMap.value = { sleep: false }; + // `now` deep enough into the week that a full current + prior 7-day + // span (plus the +1 day lag slack) has clean, unambiguous UTC days. + const now = new Date("2026-06-30T12:00:00.000Z"); + + // WEIGHT + PULSE: 14 daily readings each (current + prior week) so both + // clear the >=3-covered-day floor and the >=2-metrics-covered gate. + const dailyMetricRows: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let i = 0; i < 14; i++) { + const at = new Date(now.getTime() - i * DAY_MS - 6 * 60 * 60 * 1000); + dailyMetricRows.push({ type: "WEIGHT", value: 80, measuredAt: at }); + dailyMetricRows.push({ type: "PULSE", value: 60, measuredAt: at }); + } + + // One night THIS week: CORE 240 + DEEP 90 + REM 80 = 410 minutes total + // asleep. `measuredAt` is the END of each segment (reconstructor derives + // the start from `measuredAt - value minutes`), matching + // `sleep-night.test.ts` / `correlation-channel-series.test.ts`. + const nightEnd = new Date(now.getTime() - 1 * DAY_MS); + const sleepRows = [ + { + type: "SLEEP_DURATION", + value: 240, + measuredAt: new Date(nightEnd.getTime() - 3 * 60 * 60 * 1000), + source: "APPLE_HEALTH", + deviceType: null, + sleepStage: "CORE", + }, + { + type: "SLEEP_DURATION", + value: 90, + measuredAt: new Date(nightEnd.getTime() - 1.5 * 60 * 60 * 1000), + source: "APPLE_HEALTH", + deviceType: null, + sleepStage: "DEEP", + }, + { + type: "SLEEP_DURATION", + value: 80, + measuredAt: nightEnd, + source: "APPLE_HEALTH", + deviceType: null, + sleepStage: "REM", + }, + ]; + + measurementFindMany.mockResolvedValue([ + ...dailyMetricRows.map((r) => ({ + ...r, + source: "MANUAL", + deviceType: null, + sleepStage: null, + })), + ...sleepRows, + ]); + + const result = await buildPeriodNarrativeContext("u1", { + period: "week", + now, + locale: "en", + }); + + expect(result.status).toBe("ready"); + if (result.status !== "ready") return; + expect( + result.metricDeltas.find((d) => d.type === "SLEEP_DURATION"), + ).toBeUndefined(); + expect(result.metricDeltas.length).toBeGreaterThan(0); + }); + it("sums a night's per-stage segments into the SLEEP_DURATION delta instead of averaging them", async () => { // `now` deep enough into the week that a full current + prior 7-day // span (plus the +1 day lag slack) has clean, unambiguous UTC days. diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 34a600fd1..abe9d5c57 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -54,6 +54,7 @@ import { import { buildBaselineBand, median } from "@/lib/insights/derived/baseline"; import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; +import { resolveModuleMap } from "@/lib/modules/gate"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -514,6 +515,9 @@ export async function buildPeriodNarrativeContext( tz, since, fetchMode: "raw", + // Switched-off modules are left out of the scan AND of `byMetric`, so the + // narrative never cites a delta or a pattern from a module that is off. + modules: await resolveModuleMap(userId), // The one surface that admits RATED mood factors — see the option's doc // comment for why it is one and not four. includeMoodFactors: true, diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 61ff4d15f..d271b6f09 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -300,6 +300,25 @@ describe("the replaced maps and guards stay gone", () => { expect(hits).toEqual([]); }); + it("every discovery-matrix caller passes the record's module map", () => { + // `modules` is a required option, so omitting it fails to compile; this + // catches the other way to get it wrong, a hand-built map, by requiring + // each calling file to resolve the record's own. A file that forwards a + // map it received from its caller would need an entry here. + const callers = sources.filter( + (s) => + /\bassembleDiscoveryMatrix\s*\(/.test(s.code) && + s.rel !== "lib/insights/discovery-matrix.ts", + ); + expect(callers.length).toBeGreaterThanOrEqual(4); + for (const caller of callers) { + expect(caller.code, caller.rel).toMatch(/\bresolveModuleMap\s*\(/); + expect(caller.code, caller.rel).toMatch( + /assembleDiscoveryMatrix\s*\([\s\S]*?\bmodules\b/, + ); + } + }); + 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", From d8dc6ca8a74fa9a1533e45139df6be31a2706376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:18:04 +0200 Subject: [PATCH 2/2] fix(settings): answer a switched-off layout subpage with the module notice /settings/layout/ is a URL a person can land on, so with its module off it now shows the module notice in place, like every module page, instead of bouncing to the Layout hub without saying why. --- .../__tests__/layout-module-gate.test.tsx | 57 +++++++++++++++++++ .../settings/layout-module-gate.tsx | 50 +++++++++------- 2 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 src/components/settings/__tests__/layout-module-gate.test.tsx diff --git a/src/components/settings/__tests__/layout-module-gate.test.tsx b/src/components/settings/__tests__/layout-module-gate.test.tsx new file mode 100644 index 000000000..d0920458d --- /dev/null +++ b/src/components/settings/__tests__/layout-module-gate.test.tsx @@ -0,0 +1,57 @@ +/** + * `/settings/layout/` is a real URL (a bookmark, the hub's own link, + * a shared screenshot), so with its module off it answers in place with the + * module notice, like every module page, rather than bouncing to the hub. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const replace = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +const userRef: { + value: { + modules: Record; + moduleAccess?: Record; + }; +} = { value: { modules: {} } }; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: userRef.value }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { LayoutModuleGate } from "../layout-module-gate"; + +function render(moduleKey: "mood" | undefined) { + return renderToStaticMarkup( + + +

section

+
+
, + ); +} + +describe("", () => { + it("renders the section while its module is on, and always for an unowned group", () => { + userRef.value = { modules: { mood: true } }; + expect(render("mood")).toContain('data-testid="section"'); + userRef.value = { modules: { mood: false } }; + expect(render(undefined)).toContain('data-testid="section"'); + }); + + it("answers in place with the module notice when its module is off, never redirecting", () => { + userRef.value = { + modules: { mood: false }, + moduleAccess: { mood: "disabled" }, + }; + const html = render("mood"); + expect(html).not.toContain('data-testid="section"'); + expect(html).toContain('data-slot="module-disabled-notice"'); + expect(html).toContain('data-module="mood"'); + expect(html).toContain('data-slot="module-off-open-settings"'); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/layout-module-gate.tsx b/src/components/settings/layout-module-gate.tsx index 0f3e73a03..1e5bb3465 100644 --- a/src/components/settings/layout-module-gate.tsx +++ b/src/components/settings/layout-module-gate.tsx @@ -1,26 +1,29 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice"; +import { Button } from "@/components/ui/button"; 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"; /** - * v1.25.11 (#148) — client gate for an Appearance subpage - * (`/settings/layout/`). + * Client gate for an Appearance subpage (`/settings/layout/`). * - * A module's subpage may be reached by direct URL even when the user has the - * module turned off. This wrapper fails OPEN: it renders the section unless the - * resolved `useAuth().user.modules` map has the gate key explicitly `false`, - * in which case it bounces back to the hub (`/settings/layout`). + * The subpage is a real URL a person can land on (a bookmark, the hub's own + * link, a link somebody sent) with its module switched off. It answers in + * place with the module notice, like every module page (`ModulePageGate`), + * rather than bouncing to the hub, which dropped the reader somewhere else + * without saying why. The notice names the reason from `moduleAccess` and + * offers Settings, Modules only when the record's own switch is off. * - * The gate is hydration-stable: `useMounted()` returns `false` during SSR AND - * the first client paint, so the first render ALWAYS shows the section - * (matching the server HTML); the real check applies once, after hydration, as - * 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. + * Fails OPEN and is hydration-stable: `useMounted()` is `false` during SSR + * and the first client paint, so the first render always shows the section + * (matching the server HTML); the module check applies once, after + * hydration, as an ordinary client update. * * Groups the surface map gives no owner (dashboard / insights / vorsorge) * pass `moduleKey={undefined}` and always render. @@ -34,17 +37,22 @@ export function LayoutModuleGate({ }) { const hydrated = useMounted(); const { user } = useAuth(); - const router = useRouter(); + const { t } = useTranslations(); const disabled = hydrated && moduleKey !== undefined && user?.modules?.[moduleKey] === false; - React.useEffect(() => { - if (disabled) { - router.replace("/settings/layout"); - } - }, [disabled, router]); - - if (disabled) return null; - return <>{children}; + if (!disabled || moduleKey === undefined) return <>{children}; + return ( + + + {t("moduleOff.openModules")} + + + } + /> + ); }