From 1e02af998bf37e0c7ae4c91551a14ac92f51f226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:04:40 +0200 Subject: [PATCH] feat(modules): hide dashboard tiles for disabled modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the per-user module map in the dashboard snapshot builder and strip every toggleable module's tile + data before the payload leaves the server, so a disabled module never reaches the client. - mood / sleep / glucose / achievements / workouts widgets are forced invisible on both the web layout and the iOS catalogue when their module is off; the persisted layout is untouched. - sleep / glucose summary types drop out of the slim slice, so the derived metricStates and the client data-floor gates also drop them. - the glucose clinical panel and per-context block are cleared when glucose is off; the mood block is emptied when mood is off. - the Daily Briefing falls back to its disabled state when the insights module is off (alongside the existing operator flag + coach opt-out); raw vitals stay untouched — insights is the narrative layer. Core widgets (weight, BP, pulse, body-fat, medications, BP-in-target) carry no module mapping and always render. --- src/lib/dashboard/__tests__/snapshot.test.ts | 169 +++++++++++++++++++ src/lib/dashboard/snapshot.ts | 161 ++++++++++++++++-- 2 files changed, 314 insertions(+), 16 deletions(-) diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index d621e45a8..4590f0082 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -57,11 +57,39 @@ vi.mock("@/lib/analytics/health-score-fast-path", () => ({ computeUserHealthScoreFastPath: (...a: unknown[]) => computeUserHealthScoreFastPath(...a), })); +// v1.18.0 — module gate. Default-mocked all-on so the existing envelope +// tests keep their full tile set; the module-gating suite below passes +// an explicit map through the `modules` option instead. +const resolveModuleMap = vi.fn(); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), +})); + +/** All eleven toggleable keys on, with optional per-key overrides. */ +function moduleMap( + overrides: Partial> = {}, +): Record { + const base: Record = { + cycle: true, + mood: true, + sleep: true, + glucose: true, + workouts: true, + recovery: true, + labs: true, + achievements: true, + coach: true, + insights: true, + doctorReport: true, + }; + return { ...base, ...overrides }; +} import { buildDashboardSnapshot, type SnapshotUserInput, } from "../snapshot"; +import type { ModuleKey } from "@/lib/modules/gate"; const emptySummary = { count: 0, @@ -133,6 +161,7 @@ beforeEach(() => { nextDueMedicationName: null, }); computeUserHealthScoreFastPath.mockResolvedValue(null); + resolveModuleMap.mockResolvedValue(moduleMap()); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -766,3 +795,143 @@ describe("buildDashboardSnapshot — additive proof", () => { expect(snap.tiles).toHaveProperty("mood"); }); }); + +describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { + const widget = (snap: { layout: { widgets: { id: string; visible: boolean; tileVisible?: boolean }[] } }, id: string) => + snap.layout.widgets.find((w) => w.id === id); + const cat = (snap: { layoutCatalogue: { id: string; visible: boolean }[] }, id: string) => + snap.layoutCatalogue.find((w) => w.id === id); + + beforeEach(() => { + // A warm, data-rich account so every toggleable surface would + // otherwise render — the assertions then prove the disabled ones drop. + probeRollupCoverage.mockResolvedValue( + new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]), + ); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + }); + computeSummariesSlice.mockResolvedValue({ + summaries: { + WEIGHT: { ...emptySummary, count: 3, latest: 80 }, + SLEEP_DURATION: { ...emptySummary, count: 5, latest: 420 }, + BLOOD_GLUCOSE: { ...emptySummary, count: 9, latest: 95 }, + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: new Date().toISOString() }, + SLEEP_DURATION: { lastSeenAt: new Date().toISOString() }, + BLOOD_GLUCOSE: { lastSeenAt: new Date().toISOString() }, + }, + bmi: null, + }); + // Glucose rows so `extras.glucoseByContext` + `glucoseClinical` populate. + fakePrisma.measurement.findMany.mockResolvedValue([ + { value: 95, measuredAt: new Date(), glucoseContext: "FASTING" }, + { value: 110, measuredAt: new Date(), glucoseContext: "FASTING" }, + ]); + // Mood entries so the tile would otherwise carry a summary. + fakePrisma.moodEntry.findMany.mockResolvedValue([ + { date: new Date(), score: 4 }, + ]); + }); + + it("all modules on: every toggleable tile + its data is present", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap()), + }); + expect(widget(snap, "mood")!.visible).toBe(true); + expect(widget(snap, "sleep")).toBeDefined(); + expect(widget(snap, "glucose")).toBeDefined(); + expect(widget(snap, "achievements")!.visible).toBe(true); + expect(widget(snap, "recentWorkouts")!.visible).toBe(true); + expect(snap.tiles.summaries.SLEEP_DURATION).toBeDefined(); + expect(snap.tiles.summaries.BLOOD_GLUCOSE).toBeDefined(); + expect(snap.tiles.mood.entries.length).toBeGreaterThan(0); + expect(Object.keys(snap.extras!.glucoseByContext).length).toBeGreaterThan(0); + }); + + it("mood disabled: tile hidden + mood block emptied", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ mood: false })), + }); + expect(widget(snap, "mood")!.visible).toBe(false); + expect(widget(snap, "mood")!.tileVisible).toBe(false); + expect(cat(snap, "mood")!.visible).toBe(false); + expect(snap.tiles.mood.entries).toEqual([]); + expect(snap.tiles.mood.summary).toBeNull(); + // Core tile unaffected. + expect(widget(snap, "weight")!.visible).toBe(true); + }); + + it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ sleep: false })), + }); + expect(widget(snap, "sleep")!.visible).toBe(false); + expect(snap.tiles.summaries.SLEEP_DURATION).toBeUndefined(); + expect(snap.tiles.lastSeenByType.SLEEP_DURATION).toBeUndefined(); + expect(snap.metricStates.sleep).toBeUndefined(); + // Glucose untouched. + expect(snap.tiles.summaries.BLOOD_GLUCOSE).toBeDefined(); + }); + + it("glucose disabled: tile hidden + summary stripped + clinical panel cleared", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ glucose: false })), + }); + expect(widget(snap, "glucose")!.visible).toBe(false); + expect(snap.tiles.summaries.BLOOD_GLUCOSE).toBeUndefined(); + expect(snap.metricStates.bloodGlucose).toBeUndefined(); + expect(snap.extras!.glucoseByContext).toEqual({}); + // Sleep untouched. + expect(snap.tiles.summaries.SLEEP_DURATION).toBeDefined(); + }); + + it("workouts disabled: recent-workouts tile hidden", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ workouts: false })), + }); + expect(widget(snap, "recentWorkouts")!.visible).toBe(false); + expect(widget(snap, "recentWorkouts")!.tileVisible).toBe(false); + expect(cat(snap, "recentWorkouts")!.visible).toBe(false); + }); + + it("achievements disabled: recent-unlocks tile hidden", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ achievements: false })), + }); + expect(widget(snap, "achievements")!.visible).toBe(false); + }); + + it("insights disabled: Daily Briefing surface goes to disabled state", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedText: JSON.stringify({ + dailyBriefing: { headline: "x", body: "y", generatedAt: new Date().toISOString() }, + }), + insightsCachedAt: new Date(), + }), + { modules: () => Promise.resolve(moduleMap({ insights: false })) }, + ); + expect(snap.briefingState).toBe("disabled"); + expect(snap.briefing).toBeNull(); + }); + + it("defaults to resolveModuleMap when no override is injected", async () => { + resolveModuleMap.mockResolvedValue(moduleMap({ mood: false })); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + expect(resolveModuleMap).toHaveBeenCalledWith("user-1"); + expect(widget(snap, "mood")!.visible).toBe(false); + }); +}); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index ebf86b95d..944177e84 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -78,10 +78,39 @@ import { } from "@/lib/dashboard/meds-today"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; import type { HealthScoreBand } from "@/lib/analytics/health-score"; +import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; +/** + * v1.18.0 — 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 / medications / bpInTarget and the + * vital-derived HealthKit metrics) carry NO entry and are never hidden. + */ +const WIDGET_MODULE_BY_ID: Partial> = { + mood: "mood", + sleep: "sleep", + glucose: "glucose", + achievements: "achievements", + recentWorkouts: "workouts", +}; + +/** + * v1.18.0 — `MeasurementType` 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. + */ +const SUMMARY_TYPE_MODULE: Partial> = { + SLEEP_DURATION: "sleep", + BLOOD_GLUCOSE: "glucose", +}; + const GLUCOSE_CONTEXTS = [ "FASTING", "POSTPRANDIAL", @@ -745,6 +774,59 @@ 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. + */ +function gateLayoutByModules( + layout: DashboardLayout, + modules: Record, +): DashboardLayout { + 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 }; + } + return w; + }), + }; +} + +/** + * v1.18.0 — strip disabled-module measurement types from the slim slice + * so neither `tiles.summaries` / `tiles.lastSeenByType` nor the derived + * `metricStates` carry data for a module the user turned off. Returns a + * shallow copy; the inputs are not mutated. + */ +function gateSummariesByModules( + summaries: Record, + lastSeenByType: Record, + modules: Record, +): { + summaries: Record; + lastSeenByType: Record; +} { + const dropped = new Set(); + for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) { + if (moduleKey && modules[moduleKey] === false) dropped.add(type); + } + if (dropped.size === 0) return { summaries, lastSeenByType }; + const outSummaries: Record = {}; + for (const [type, summary] of Object.entries(summaries)) { + if (!dropped.has(type)) outSummaries[type] = summary; + } + const outLastSeen: Record = {}; + for (const [type, slot] of Object.entries(lastSeenByType)) { + if (!dropped.has(type)) outLastSeen[type] = slot; + } + return { summaries: outSummaries, lastSeenByType: outLastSeen }; +} + /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is * timed via the optional `time` wrapper so a regression is attributable @@ -762,6 +844,14 @@ export async function buildDashboardSnapshot( * `@/lib/ai/provider` (no decrypt, no network). */ hasProvider?: () => Promise; + /** + * v1.18.0 — resolved per-user module map. Injectable for tests; + * defaults to `resolveModuleMap(user.id)` (memoised per request). + * Disabled toggleable modules have their dashboard tiles / data + * stripped from the snapshot at the build layer so nothing leaks to + * the client. + */ + modules?: () => Promise>; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -780,25 +870,64 @@ export async function buildDashboardSnapshot( const coverage = await time("coverage", () => probeRollupCoverage(user.id)); const warm = isThickPhaseWarm(coverage); - const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([ - // A5 — reuse the coverage map already probed above so the slice - // doesn't re-run the identical `probeRollupCoverage` query. - time("summaries", () => computeSummariesSlice(user.id, coverage)), - time("mood", () => buildMoodBlock(prisma, user.id)), - warm - ? time("extras", () => - buildExtras(prisma, user, userTz, coverage, now, time), - ) - : Promise.resolve(null), - time("flags", () => getAssistantFlags()), - // Fast phase — projection-backed today tally + earliest next-due. - time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), - ]); + const [slimRaw, moodRaw, extrasResult, flags, medsToday, modules] = + await Promise.all([ + // A5 — reuse the coverage map already probed above so the slice + // doesn't re-run the identical `probeRollupCoverage` query. + time("summaries", () => computeSummariesSlice(user.id, coverage)), + time("mood", () => buildMoodBlock(prisma, user.id)), + warm + ? time("extras", () => + buildExtras(prisma, user, userTz, coverage, now, time), + ) + : Promise.resolve(null), + time("flags", () => getAssistantFlags()), + // Fast phase — projection-backed today tally + earliest next-due. + time("medsToday", () => + buildMedsTodayBlock(prisma, user.id, userTz, now), + ), + // v1.18.0 — resolved module map (memoised per request); gates the + // toggleable tiles below at the build layer. + time("modules", () => + (options.modules ?? (() => resolveModuleMap(user.id)))(), + ), + ]); - const layout = resolveDashboardLayout(user.dashboardWidgetsJson); + // v1.18.0 — strip disabled-module data before it leaves the server. + // Mood is blanked when the mood module is off; sleep / glucose summary + // types are dropped from the slim slice; the glucose clinical panel + + // per-context block are cleared when glucose is off; the layout + + // catalogue hide every disabled-module widget. + const slim = gateSummariesByModules( + slimRaw.summaries, + slimRaw.lastSeenByType, + modules, + ); + const mood = + modules.mood === false + ? { summary: null, entries: [] as DashboardSnapshotMoodEntry[] } + : moodRaw; + if (extrasResult && modules.glucose === false) { + extrasResult.extras.glucoseByContext = {}; + extrasResult.extras.glucoseClinical = computeGlucoseClinicalMetrics([], { + windowDays: GLUCOSE_PANEL_WINDOW_DAYS, + now, + }); + } + + const layout = gateLayoutByModules( + resolveDashboardLayout(user.dashboardWidgetsJson), + modules, + ); + // v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface, + // so the `insights` module gates it alongside the operator briefing + // flag + per-user coach opt-out. Disabling `insights` yields the same + // `briefingState: "disabled"` empty surface the existing flags produce + // (the raw weight/BP/pulse data stays untouched — `insights` is the + // narrative layer, not the data layer). const briefing = await liftBriefing( user, - flags.briefing, + flags.briefing && modules.insights !== false, options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), );