fix(dashboard): gate the workouts + recovery surfaces by module

The dashboard's recent-workouts and steps tiles painted regardless of
the workouts module state; AND the resolved per-account flag into both
gates (fail-open — only an explicit false hides the tile).

Extend the snapshot module maps to cover the recovery domain: the
cardioRecovery / sixMinuteWalk / stairAscentSpeed / stairDescentSpeed
widgets and their HealthKit measurement types now resolve to the
recovery module, and the per-night breathing-disturbance widget + type
resolve to sleep. A new guard test asserts every toggleable-domain
widget id carries a mapping and every mapped value is a real module
key, so a future device widget cannot leak onto a disabled account.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:05:37 +02:00
1 parent a524cad4c4
commit f966ae884a
3 files changed
+90 -4

No files matched your search

+8 -2
View File
@@ -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
@@ -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);
+18 -2
View File
@@ -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<Record<string, ModuleKey>> = {
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
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<Record<string, ModuleKey>> = {
* 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>> = {
export const SUMMARY_TYPE_MODULE: Partial<Record<MeasurementType, ModuleKey>> = {
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 = [