From 94e07d0d90279fc9c32edacabb44069e9bcfa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:42:17 +0200 Subject: [PATCH] fix(dashboard): hide a switched-off module's widgets on every feed The dashboard widget and summary maps are now views of the surface map. The legacy dashboard feed painted the stored layout as saved, so the medications card stayed with the module off; the mask the snapshot applies now runs on that feed too, in the browser, from the same map. The widgets route keeps returning the stored layout unmasked, because Settings saves that value back. The Today digest is the day's data and no longer waits on the AI analysis switch, on the server prefetch or in the browser. --- src/app/page-client.tsx | 35 ++--- src/app/page.tsx | 13 +- .../settings/dashboard-layout-section.tsx | 23 +--- .../__tests__/hide-module-widgets.test.ts | 42 ++++++ src/lib/dashboard/snapshot.ts | 28 +--- src/lib/dashboard/widget-modules.ts | 122 ++++++++---------- 6 files changed, 130 insertions(+), 133 deletions(-) create mode 100644 src/lib/dashboard/__tests__/hide-module-widgets.test.ts diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index f26a276a1..f664db6f0 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -144,7 +144,8 @@ import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { useDailyDigest } from "@/lib/queries/use-daily-digest"; -import { useModuleEnabled } from "@/hooks/use-module-enabled"; +import { useSurfaceVisible } from "@/hooks/use-surface-visible"; +import { hideModuleWidgets } from "@/lib/dashboard/widget-modules"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { BATCH_COVERAGE_DAYS, @@ -311,17 +312,10 @@ export default function DashboardPageClient({ const snapshotQuery = useDashboardSnapshot(snapshotEnabled); // S2 — the Today hero reads the unified daily digest (the S1 DTO) from - // its own cached route. Gated on the `insights` module (the digest is - // the AI-narrative daily layer; the route 403s when insights is off) - // and the auth state so a logged-out / gated account never fires it. - // Two readings of one flag, on purpose. The FETCH gate takes the live - // module map, so an account with insights off never fires the request. The - // RENDER gate below takes the mount-pinned account, so the hydration render - // agrees with the server (which had no account, and therefore the default-on - // answer). See `useAccountOnceMounted`. - const insightsModuleEnabled = useModuleEnabled("insights"); - const insightsEnabled = user?.modules?.insights !== false; - const digestQuery = useDailyDigest(isAuthenticated && insightsModuleEnabled); + // its own cached route. It is the day's data, so no module owns it: the + // `insights` key means AI analysis, and the digest leaves its model-written + // lead out on its own when that is off. Gated on auth only. + const digestQuery = useDailyDigest(isAuthenticated); // v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web // hero (Marc, live-use: uneven, wasted tile space). The snapshot still @@ -423,9 +417,14 @@ export default function DashboardPageClient({ enabled: !snapshotEnabled && isAuthenticated, ...DASHBOARD_QUERY_OPTS, }); + // The snapshot publishes its layout with switched-off modules' widgets + // already hidden. The legacy feed is the stored layout as saved (Settings + // edits that exact value), so the same mask is applied here, from the same + // surface map, before anything paints. const layoutData = snapshotEnabled ? snapshotQuery.data?.layout - : layoutDataLegacy; + : layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules); + const moodWidgetVisible = useSurfaceVisible("widget:mood"); const { data: moodDataLegacy } = useQuery({ queryKey: queryKeys.moodAnalytics(), @@ -435,7 +434,8 @@ export default function DashboardPageClient({ summary: DataSummary; }>("/api/mood/analytics"); }, - enabled: !snapshotEnabled && isAuthenticated, + // The route refuses with the mood module off; do not ask. + enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible, ...DASHBOARD_QUERY_OPTS, }); const moodData = snapshotEnabled @@ -947,7 +947,7 @@ export default function DashboardPageClient({ `` with a retry action rather than silently falling through to nothing. The dense tile grid + charts below are untouched. */} - {insightsEnabled && + { // v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero // (the LCP element) instead of a skeleton. This is hydration-safe ONLY // because of a bound invariant: the digest is server-DEHYDRATED @@ -966,7 +966,7 @@ export default function DashboardPageClient({ // rehydrates from disk. The error / empty branches stay reachable only // post-mount. DO NOT add `["daily", …]` to that allowlist, or render // the hero from any client-only source, without revisiting this gate. - (digestQuery.data ? ( + digestQuery.data ? ( ) : digestQuery.isError ? ( digestQuery.refetch()} /> - ) : null)} + ) : null + } {/* v1.18.6 — the spotlight tour launcher moved to the app-shell * (`AuthShell`) so its overlay survives the cross-page diff --git a/src/app/page.tsx b/src/app/page.tsx index 26848887d..8f77e9367 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,7 +13,6 @@ import { type BatchWindow, } from "@/lib/dashboard/batch-chart-types"; import { resolveDashboardLayout } from "@/lib/dashboard-layout"; -import { resolveModuleMap } from "@/lib/modules/gate"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import { readSeriesBatch } from "@/lib/measurements/series-batch-read"; import { queryKeys } from "@/lib/query-keys"; @@ -62,8 +61,9 @@ function softTimeout(ms: number): Promise { * window from `computeBatchWindow(now, user.timezone)` threaded to the * client as the `batchWindow` prop. Server and client build the identical * key by construction, so the prefetched slice lands instead of refetching. - * - Module-gate parity: the digest route gates on `insights`; skip its - * prefetch when the module is off (the client hook is disabled then too). + * - The digest is the day's data and belongs to no module (the `insights` + * key is AI analysis, which the digest omits on its own), so it is + * prefetched for every account, as the client fetches it for every account. * - Record identity: the session comes from `getUnswitchedSession()`, which * answers null while the browser is acting on somebody else's record. Every * read below scopes to `user`, and under a switch `user` is the DELEGATE — @@ -117,9 +117,6 @@ export default async function DashboardPage() { batchWindow = computeBatchWindow(new Date(), user.timezone); } - const modules = await resolveModuleMap(user.id); - const insightsEnabled = modules.insights !== false; - const seriesTypes = snapshotMode ? deriveBatchChartTypes( resolveDashboardLayout(wireBody.layout), @@ -133,9 +130,7 @@ export default async function DashboardPage() { // Digest carries a soft budget (cold-day extras rebuild); series is a // warm rollup read (< 100 ms) with none. Both fail soft to `undefined`. - const digestWork = insightsEnabled - ? loadDailyDigest(user).catch(() => undefined) - : Promise.resolve(undefined); + const digestWork = loadDailyDigest(user).catch(() => undefined); const seriesWork = batchWindow && seriesTypes.length > 0 ? readSeriesBatch( diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index ef9549330..b1a2bd9df 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -53,10 +53,8 @@ import { import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch"; import { useMounted } from "@/hooks/use-mounted"; import { useAuth } from "@/hooks/use-auth"; -import { - NATIVE_ONLY_WIDGET_LABEL_KEYS, - WIDGET_MODULE_BY_ID, -} from "@/lib/dashboard/widget-modules"; +import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules"; +import { isSurfaceVisible } from "@/lib/modules/surface"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -605,14 +603,10 @@ export function DashboardLayoutSection({ id }: { id: string }) { .filter((w): w is typeof w & { id: DashboardWidgetId } => webWidgetIds.has(w.id), ) - // v1.18.0 — hide a widget toggle whose owning module is - // disabled. Map the widget id → ModuleKey FIRST (undefined = - // core widget = always shown), THEN check the module map. - // Fail-open: only an explicit `false` hides the row. - .filter((w) => { - const moduleKey = WIDGET_MODULE_BY_ID[w.id]; - return !moduleKey || modules?.[moduleKey] !== false; - }) + // Hide a widget toggle whose owning module is off, through the + // one surface map. A core widget has no owner and always shows; + // only an explicit `false` hides the row. + .filter((w) => isSurfaceVisible(`widget:${w.id}`, modules)) .filter((w) => !unavailableIds.has(w.id)) .sort((a, b) => a.order - b.order); const sortedIds = sortedWidgets.map((w) => w.id); @@ -685,10 +679,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { .filter((w) => nativeOnlyIds.has(w.id)) // Same fail-open module gate as the web list: only an explicit // `false` hides the row. - .filter((w) => { - const moduleKey = WIDGET_MODULE_BY_ID[w.id]; - return !moduleKey || modules?.[moduleKey] !== false; - }) + .filter((w) => isSurfaceVisible(`widget:${w.id}`, modules)) .filter((w) => !unavailableIds.has(w.id)) .sort((a, b) => a.order - b.order); if (nativeWidgets.length === 0) return null; diff --git a/src/lib/dashboard/__tests__/hide-module-widgets.test.ts b/src/lib/dashboard/__tests__/hide-module-widgets.test.ts new file mode 100644 index 000000000..982f88a32 --- /dev/null +++ b/src/lib/dashboard/__tests__/hide-module-widgets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { hideModuleWidgets } from "@/lib/dashboard/widget-modules"; + +const layout = { + version: 2, + widgets: [ + { id: "weight", visible: true, tileVisible: true, order: 0 }, + { id: "medications", visible: true, tileVisible: true, order: 1 }, + { id: "mood", visible: true, tileVisible: true, order: 2 }, + { id: "hrv", visible: true, tileVisible: true, order: 3 }, + ], +}; + +describe("hideModuleWidgets", () => { + it("hides the widgets of a switched-off module and keeps their order", () => { + const out = hideModuleWidgets(layout, { medications: false }); + expect(out.widgets.map((w) => [w.id, w.visible, w.tileVisible])).toEqual([ + ["weight", true, true], + ["medications", false, false], + ["mood", true, true], + ["hrv", true, true], + ]); + }); + + it("hides the ids it is told to on top of the module mask", () => { + const out = hideModuleWidgets(layout, {}, new Set(["hrv"])); + expect(out.widgets.find((w) => w.id === "hrv")?.visible).toBe(false); + expect(out.widgets.find((w) => w.id === "mood")?.visible).toBe(true); + }); + + it("is a no-op before the module map is known", () => { + expect(hideModuleWidgets(layout, undefined).widgets).toEqual( + layout.widgets, + ); + }); + + it("never writes through to the layout it was given", () => { + hideModuleWidgets(layout, { mood: false, medications: false }); + expect(layout.widgets.every((w) => w.visible)).toBe(true); + }); +}); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index da3f1f631..87771fc6f 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -130,8 +130,8 @@ export { SUMMARY_TYPE_MODULE, } from "@/lib/dashboard/widget-modules"; import { - WIDGET_MODULE_BY_ID, gateSummariesByModules, + hideModuleWidgets, unavailableWidgetIds, } from "@/lib/dashboard/widget-modules"; @@ -1065,38 +1065,22 @@ 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. + * The layout the snapshot publishes: widgets of a switched-off module, and + * widgets that cannot paint for this account, forced invisible. Same rule the + * Settings screen hides their toggles with, so the dashboard and the switches + * never disagree. The stored layout is untouched. */ function gateLayoutByModules( layout: DashboardLayout, modules: Record, summaries: Record, ): DashboardLayout { - // Widgets that cannot paint for THIS account, on the same rule the - // Settings screen hides their toggle with — so the layout the dashboard - // renders and the switches the user is offered never disagree. const unavailable = new Set( unavailableWidgetIds(modules, { hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0, }), ); - 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 }; - } - if (unavailable.has(w.id)) { - return { ...w, visible: false, tileVisible: false }; - } - return w; - }), - }; + return hideModuleWidgets(layout, modules, unavailable); } /** diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 1d2c39b44..a8b025d99 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -13,83 +13,67 @@ * imports), so this file stays browser-bundle-safe. */ import type { ModuleKey } from "@/lib/modules/registry"; +import { isSurfaceVisible, surfaceModulesOfKind } from "@/lib/modules/surface"; /** - * 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 / bpInTarget and the vital-derived HealthKit metrics) carry NO - * entry and are never hidden. + * Dashboard widget id → toggleable module key. A widget whose module is off + * is forced invisible on both the web `layout` and the iOS + * `layoutCatalogue`. CORE widgets carry no entry and are never hidden. * - * v1.18.1 (D3) — `medications` graduated from CORE to a toggleable module, - * so the medication tile now gates off it like the other toggleable widgets. + * A view of the one surface map (`widget:*` in `@/lib/modules/surface`), so + * the dashboard, the Settings list and every other surface answer from the + * same declaration. */ -export const WIDGET_MODULE_BY_ID: Partial> = { - mood: "mood", - sleep: "sleep", - glucose: "glucose", - achievements: "achievements", - recentWorkouts: "workouts", - medications: "medications", - // 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", - // v1.29 — fluid-intake strip tile, nutrients-store-backed (see - // `SUMMARY_TYPE_MODULE.NUTRIENT_WATER` below). - waterIntake: "nutrients", -}; +export const WIDGET_MODULE_BY_ID: Partial> = + surfaceModulesOfKind("widget"); /** - * 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. + * 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 drop it too) before the + * snapshot leaves the server. Core vital types are absent and always pass. * - * v1.29 — widened from `Partial>` to - * `Partial>` so the synthetic `NUTRIENT_WATER` - * key (a `NutrientIntakeDay`-derived summary, not a real - * `MeasurementType` — the abandoned `feat/water` branch's parallel - * `WATER_INTAKE` enum value is deliberately NOT added) can ride the same - * gate without widening the `MeasurementType` enum itself. + * Keyed by string rather than `MeasurementType` because two keys are + * synthetic (`NUTRIENT_WATER`, `MOOD_ENTRY`: day totals derived from other + * stores). A view of `summary:*` in `@/lib/modules/surface`. */ -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", - // The HRV fallback series. `METRIC_STATUS_MODULE_OWNERS` already owns this - // type for the metric-status / MCP reads, for the reason stated there: a - // fallback path must not serve the recovery data the primary type refuses. - // The dashboard snapshot gates off THIS map instead, and the entry was - // missing — harmless only while the dashboard HRV tile read SDNN alone. - // Now that the tile falls back to RMSSD (`pickHrvSummary`), the omission - // would hand a recovery-off account a tile built from recovery-owned rows. - // `HEART_RATE_VARIABILITY` stays deliberately ungated: SDNN is a plain - // vital, and it is what the tile shows whenever the account has it. - HRV_RMSSD: "recovery", - // Per-night breathing-disturbance index is a sleep-page signal. - BREATHING_DISTURBANCES: "sleep", - // v1.29 — fluid-intake dashboard tile summary, derived server-side from - // `NutrientIntakeDay` (nutrient="water", summed across sources). Gated - // on the `nutrients` module like the rest of that store's surfaces. - NUTRIENT_WATER: "nutrients", - // Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it - // rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the - // `mood` module: turning the module off must drop the dashboard card on - // every client, not just hide the web widget. - MOOD_ENTRY: "mood", -}; +export const SUMMARY_TYPE_MODULE: Partial> = + surfaceModulesOfKind("summary"); + +/** + * Force every widget whose owning module is off, and every id in `alsoHide`, + * to invisible (both `visible` and `tileVisible`). Order is kept so a + * re-enable restores the saved position. + * + * A projection for RENDERING only. The stored `dashboardWidgetsJson` is never + * written through this: `GET /api/dashboard/widgets` returns the stored + * layout as saved, because Settings edits and re-saves exactly that value, + * and a masked read saved back would switch the widget off for good. The + * dashboard snapshot applies it server-side; the dashboard's legacy (no + * snapshot) path applies it in the browser, so both feeds paint the same. + */ +export function hideModuleWidgets< + L extends { + widgets: ReadonlyArray<{ + id: string; + visible: boolean; + tileVisible?: boolean; + }>; + }, +>( + layout: L, + modules: Partial> | null | undefined, + alsoHide: ReadonlySet = new Set(), +): L { + return { + ...layout, + widgets: layout.widgets.map((w) => + isSurfaceVisible(`widget:${w.id}`, modules) && !alsoHide.has(w.id) + ? w + : { ...w, visible: false, tileVisible: false }, + ), + }; +} /** * The summary types a module map turns off. Lifted out of