mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
merge(modules): hide dashboard tiles for disabled modules
This commit is contained in:
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
@@ -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)),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user