diff --git a/src/app/page.tsx b/src/app/page.tsx index 836045263..5808a63a6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -523,7 +523,11 @@ export default function DashboardPage() { const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat; const showMoodTile = isTileVisible("mood") && hasMood; const showSleepTile = isTileVisible("sleep") && hasSleep; - const showStepsTile = isTileVisible("steps") && hasSteps; + // v1.18.0 B1 — steps are activity data tied to the workouts module + // surface; AND the resolved module flag (fail-open: only an explicit + // `false` hides it) so a disabled-workouts account drops the tile. + const showStepsTile = + isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false; const showVo2Tile = isTileVisible("vo2Max") && hasVo2; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; @@ -559,7 +563,9 @@ export default function DashboardPage() { // no data floor, so it would otherwise flash in then retract for a user // who hid it. const showRecentWorkoutsTile = - layoutResolved && isChartVisible("recentWorkouts"); + layoutResolved && + isChartVisible("recentWorkouts") && + user?.modules?.workouts !== false; // v1.16.0 — shared reveal gate for the chart row. Every data-backed // chart mounts as soon as its visibility gate flips (so the per-chart diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 4590f0082..c1728d649 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -87,9 +87,12 @@ function moduleMap( import { buildDashboardSnapshot, + WIDGET_MODULE_BY_ID, + SUMMARY_TYPE_MODULE, type SnapshotUserInput, } from "../snapshot"; import type { ModuleKey } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; const emptySummary = { count: 0, @@ -796,6 +799,67 @@ describe("buildDashboardSnapshot — additive proof", () => { }); }); +describe("module gating maps — every toggleable-domain widget is mapped (v1.18.0)", () => { + // The dashboard widget ids that belong to a toggleable module. A new + // toggleable widget MUST be added here AND to `WIDGET_MODULE_BY_ID`, or + // it would leak onto a disabled-module account's dashboard. + const TOGGLEABLE_WIDGET_IDS = [ + "mood", + "sleep", + "glucose", + "achievements", + "recentWorkouts", + "cardioRecovery", + "sixMinuteWalk", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", + ] as const; + + it("every toggleable-domain widget id has a module mapping", () => { + for (const id of TOGGLEABLE_WIDGET_IDS) { + expect( + WIDGET_MODULE_BY_ID[id], + `widget "${id}" is missing a WIDGET_MODULE_BY_ID entry`, + ).toBeDefined(); + } + }); + + it("every mapped widget value is a real ModuleKey", () => { + for (const [id, key] of Object.entries(WIDGET_MODULE_BY_ID)) { + expect( + MODULE_KEYS.includes(key as ModuleKey), + `widget "${id}" maps to unknown module "${key}"`, + ).toBe(true); + } + }); + + it("every summary-type mapping value is a real ModuleKey", () => { + for (const [type, key] of Object.entries(SUMMARY_TYPE_MODULE)) { + expect( + MODULE_KEYS.includes(key as ModuleKey), + `summary type "${type}" maps to unknown module "${key}"`, + ).toBe(true); + } + }); + + it("recovery-domain widgets resolve to the recovery module", () => { + expect(WIDGET_MODULE_BY_ID.cardioRecovery).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.sixMinuteWalk).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.stairAscentSpeed).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.stairDescentSpeed).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.CARDIO_RECOVERY).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.SIX_MINUTE_WALK_DISTANCE).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.STAIR_ASCENT_SPEED).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.STAIR_DESCENT_SPEED).toBe("recovery"); + }); + + it("breathing-disturbance widget + type resolve to the sleep module", () => { + expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep"); + expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep"); + }); +}); + 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); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 944177e84..30835ef12 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -91,12 +91,19 @@ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; * (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> = { +export const WIDGET_MODULE_BY_ID: Partial> = { mood: "mood", sleep: "sleep", glucose: "glucose", achievements: "achievements", recentWorkouts: "workouts", + // v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery + // module; the per-night breathing-disturbance widget belongs to sleep. + cardioRecovery: "recovery", + sixMinuteWalk: "recovery", + stairAscentSpeed: "recovery", + stairDescentSpeed: "recovery", + breathingDisturbances: "sleep", }; /** @@ -106,9 +113,18 @@ const WIDGET_MODULE_BY_ID: Partial> = { * 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> = { +export const SUMMARY_TYPE_MODULE: Partial> = { SLEEP_DURATION: "sleep", BLOOD_GLUCOSE: "glucose", + // v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page + + // its tiles are the recovery module's surface; when it is off these + // device-native signals must drop from the dashboard snapshot too. + CARDIO_RECOVERY: "recovery", + SIX_MINUTE_WALK_DISTANCE: "recovery", + STAIR_ASCENT_SPEED: "recovery", + STAIR_DESCENT_SPEED: "recovery", + // Per-night breathing-disturbance index is a sleep-page signal. + BREATHING_DISTURBANCES: "sleep", }; const GLUCOSE_CONTEXTS = [