mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
a524cad4c4
commit
f966ae884a
3 files changed
+90
-4
No files matched your search
+8
-2
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in new issue
Block a user