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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:42:17 +02:00
1 parent 91be6b2967
commit 94e07d0d90
6 files changed
+130 -133

No files matched your search

+18 -17
View File
@@ -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({
`<QueryErrorCard>` 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 ? (
<TodayHero
digest={digestQuery.data}
renderFilteredAllClear={renderFilteredHeroAllClear}
@@ -978,7 +978,8 @@ export default function DashboardPageClient({
<TodayHeroSkeleton />
) : digestQuery.isError ? (
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
) : null)}
) : null
}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
* (`AuthShell`) so its overlay survives the cross-page
+4 -9
View File
@@ -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<T>(ms: number): Promise<T | undefined> {
* 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(