merge(modules): hide dashboard tiles for disabled modules

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:05:14 +02:00
commit 0467f15b6c
2 files changed
+314 -16

No files matched your search

@@ -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<ModuleKey, boolean>> = {},
): Record<ModuleKey, boolean> {
const base: Record<ModuleKey, boolean> = {
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);
});
});
+145 -16
View File
@@ -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<Record<string, ModuleKey>> = {
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<Record<MeasurementType, ModuleKey>> = {
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<ModuleKey, boolean>,
): 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<string, DataSummary>,
lastSeenByType: Record<string, { lastSeenAt: string } | null>,
modules: Record<ModuleKey, boolean>,
): {
summaries: Record<string, DataSummary>;
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
} {
const dropped = new Set<string>();
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<string, DataSummary> = {};
for (const [type, summary] of Object.entries(summaries)) {
if (!dropped.has(type)) outSummaries[type] = summary;
}
const outLastSeen: Record<string, { lastSeenAt: string } | null> = {};
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<boolean>;
/**
* 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<Record<ModuleKey, boolean>>;
} = {},
): Promise<DashboardSnapshot> {
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)),
);