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