Merge branch 'feat/module-surfaces' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:10:11 +02:00
commit ab0d1b4b71
67 files changed
+1975 -703

No files matched your search

+3 -22
View File
@@ -34,8 +34,6 @@ import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -262,7 +260,6 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
export default function AchievementsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
const router = useRouter();
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the whole page disappears: an empty state stands in
@@ -279,29 +276,13 @@ export default function AchievementsPage() {
enabled: isAuthenticated && achievementsEnabled,
});
useEffect(() => {
if (!authLoading && isAuthenticated && !achievementsEnabled) {
router.push("/insights");
}
}, [authLoading, isAuthenticated, achievementsEnabled, router]);
if (authLoading || (achievementsEnabled && isLoading)) {
return <PageAuthGate label={t("common.loading")} />;
}
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is
// hidden by the same gate, so this only catches a direct URL hit. It used to
// render `null` and stop there, which left a blank page with no explanation
// and no route anywhere — a dead end rather than a gate. It now bounces to
// /insights, matching every other module-gated page (`useModulePageGuard`);
// the brief `null` is the pre-redirect frame, not the destination.
//
// The shared guard is not used wholesale here because it also redirects an
// unauthenticated visitor to /auth/login, and this page deliberately answers
// that case with a "sign in to see your achievements" header instead.
if (isAuthenticated && !achievementsEnabled) {
return null;
}
// Module off: the shell answers this URL with the module notice
// (`ModulePageGate`) before the page renders, so there is no branch for it
// here. `achievementsEnabled` above only keeps the query from firing.
if (!isAuthenticated) {
return (
@@ -83,7 +83,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
@@ -100,6 +100,10 @@ beforeEach(() => {
// prior test would serve a hit and skip the builder every assertion spies on.
__resetAllCachesForTests();
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
// Every module on unless a test says otherwise.
vi.mocked(resolveModuleMap).mockResolvedValue(
{} as Awaited<ReturnType<typeof resolveModuleMap>>,
);
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
@@ -249,6 +253,29 @@ describe("GET /api/insights/correlations", () => {
);
});
it("skips the lab draws with the labs module off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveModuleMap).mockResolvedValue({
labs: false,
} as Awaited<ReturnType<typeof resolveModuleMap>>);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
});
it("recomputes rather than serve a cached body after a module was switched off", async () => {
// The switched-off set is part of the cache key. Watched red with the
// suffix removed from the key: the second call is a cache hit and the
// measurement window is read once.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
vi.mocked(resolveModuleMap).mockResolvedValue({
mood: false,
} as Awaited<ReturnType<typeof resolveModuleMap>>);
await callGet(makeReq());
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+24 -5
View File
@@ -23,7 +23,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
MODULE_KEYS,
requireModuleEnabled,
resolveModuleMap,
} from "@/lib/modules/gate";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
@@ -89,12 +94,18 @@ export const GET = apiHandler(async () => {
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
// so a cache hit performs no write on the read path.
// Statistics serve only with their owning modules: a channel whose module
// is off never enters the scan. The switched-off set is part of the cache
// key, so toggling a module never serves a body computed under the other
// setting.
const modules = await resolveModuleMap(user.id);
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
const body = await cachedSwr(
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildCorrelationsResponse>>
>,
`${user.id}|correlations|${locale}`,
() => buildCorrelationsResponse(user.id, locale),
`${user.id}|correlations|${locale}|off:${modulesOff.join(",")}`,
() => buildCorrelationsResponse(user.id, locale, modules),
annotate,
);
@@ -104,6 +115,7 @@ export const GET = apiHandler(async () => {
async function buildCorrelationsResponse(
userId: string,
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
modules: SurfaceModuleMap,
) {
const profile = await prisma.user.findUnique({
where: { id: userId },
@@ -125,8 +137,15 @@ async function buildCorrelationsResponse(
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed
// a different pass over a different grain, so they are not matrix channels.
const [matrix, labDraws] = await Promise.all([
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }),
fetchLabDraws(userId, tz, since),
assembleDiscoveryMatrix(userId, {
tz,
since,
fetchMode: "tiered",
modules,
}),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
]);
const { series, diagnostics } = matrix;
+1 -12
View File
@@ -1,11 +1,10 @@
"use client";
import { Droplet, Loader2 } from "lucide-react";
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
import { useAuth } from "@/hooks/use-auth";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { useTranslations } from "@/lib/i18n/context";
import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
@@ -28,19 +27,9 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
export default function InsightsBlutzuckerPage() {
const { user } = useAuth();
const { t } = useTranslations();
const { ready } = useModulePageGuard("glucose");
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const isMmol = glucoseUnit === "mmol/L";
// v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<HealthKitMetricPage
measurementType="BLOOD_GLUCOSE"
+7 -50
View File
@@ -1,20 +1,17 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Leaf } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types";
@@ -53,12 +50,11 @@ function lastAttemptReasonKey(reason: string): string {
* (self-gates to nothing without data), micronutrients (self-gates to
* an EmptyState without data). Degradation ladder:
*
* - module off → the shared `ModuleDisabledNotice`, which names the reason
* and carries the in-context enable CTA when it is the record's own
* switch that is off. The module STAYS opt-in (2026-07-17 memo — the
* HealthKit read prompt on the device is not visible consent to a
* server / Coach holding a supplement pattern); this page just makes
* the toggle discoverable in context instead of buried in Settings.
* - module off → never reaches this page: the shell answers the URL with
* the module notice (`ModulePageGate`), like every other module page.
* The module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt on
* the device is not visible consent to a server / Coach holding a
* supplement pattern).
* - module on, the overview read failed → a `QueryErrorCard` with a
* retry. This branch has to sit ABOVE the empty-state check below:
* `overview.data` is `undefined` on a failed read exactly like it is
@@ -86,7 +82,6 @@ export default function InsightsNutrientsPage() {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const queryClient = useQueryClient();
const nutrientsEnabled = user?.modules?.nutrients === true;
@@ -97,44 +92,6 @@ export default function InsightsNutrientsPage() {
enabled: nutrientsEnabled,
});
const enableModule = useMutation({
mutationFn: () => apiPatch("/api/auth/me/modules", { nutrients: true }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
onError: () => toast.error(t("nutrients.page.moduleEnableError")),
});
if (!nutrientsEnabled) {
return (
<SubPageShell title={t("nutrients.page.title")}>
{/*
The shared notice rather than this page's own copy: "nutrients is
off" was three situations wearing one sentence, and only one of them
is fixed by the button below. A delegate whose grant does not reach
the measurements section, and an account on an instance where the
operator removed the module, both used to be handed a switch that
would refuse them. The notice reads the reason off `moduleAccess`
and drops the action for the two cases it cannot help.
*/}
<ModuleDisabledNotice
moduleKey="nutrients"
icon={<Leaf className="size-6" />}
action={
<Button
size="sm"
onClick={() => enableModule.mutate()}
disabled={enableModule.isPending}
data-slot="nutrients-enable-module"
>
{t("nutrients.page.moduleOffCta")}
</Button>
}
/>
</SubPageShell>
);
}
if (overview.isError) {
return (
<SubPageShell
+1 -12
View File
@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { ArrowRight, Loader2 } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
@@ -29,16 +28,6 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
*/
export default function InsightsRecoveryPage() {
const { t } = useTranslations();
const { ready } = useModulePageGuard("recovery");
// v1.18.0 B1 — bounce a direct URL hit on a disabled-recovery account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<SubPageShell
+1 -13
View File
@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { Loader2, Moon } from "lucide-react";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
@@ -40,7 +39,6 @@ import { useSleepRhythm } from "@/components/insights/sleep/use-sleep-rhythm";
export default function InsightsSchlafPage() {
const { t } = useTranslations();
const { user } = useAuth();
const { ready } = useModulePageGuard("sleep");
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
// The three rhythm cards share one `["sleep-rhythm"]` read, so a failure used
@@ -51,16 +49,6 @@ export default function InsightsSchlafPage() {
Boolean(user) && !isEmpty,
);
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
// /insights instead of half-rendering the sleep surface.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
if (isEmpty) {
return (
<SubPageShell
-13
View File
@@ -3,11 +3,8 @@
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkoutDetail } from "@/hooks/use-workouts";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { BackLink } from "@/components/ui/back-link";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Skeleton } from "@/components/ui/skeleton";
@@ -54,7 +51,6 @@ export default function InsightsWorkoutDetailPage({
const { t } = useTranslations();
const { id } = use(params);
const router = useRouter();
const { ready } = useModulePageGuard("workouts");
const { data, isLoading, error, refetch } = useWorkoutDetail(id);
const canonicalHref = canonicalWorkoutDetailHref(id, data?.canonicalId);
@@ -62,15 +58,6 @@ export default function InsightsWorkoutDetailPage({
if (canonicalHref) router.replace(canonicalHref);
}, [canonicalHref, router]);
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<SubPageShell
title={t("insights.workouts.title")}
+1 -12
View File
@@ -1,12 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { Activity, Loader2 } from "lucide-react";
import { Activity } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useInfiniteWorkouts } from "@/hooks/use-workouts";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
@@ -42,7 +41,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
*/
export default function InsightsWorkoutsPageClient() {
const { t, tCount } = useTranslations();
const { ready } = useModulePageGuard("workouts");
const [sportType, setSportType] = useState<string | undefined>(undefined);
const {
workouts,
@@ -76,15 +74,6 @@ export default function InsightsWorkoutsPageClient() {
return label === key ? sport : label;
};
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<SubPageShell
title={t("insights.workouts.title")}
+4 -4
View File
@@ -34,10 +34,10 @@ import InsightsWorkoutsPageClient from "./page-client";
* - The dehydrated first page is JSON-round-tripped and wrapped in TanStack's
* `{ pages, pageParams }` infinite-data shape. Prisma `Date`s therefore
* hydrate as the same ISO strings produced by the client wire response.
* - Module-gate parity: the client page bounces a workouts-off account
* (`useModulePageGuard`), and the API route refuses it server-side; skip the
* prefetch when the module is off so a disabled page never seeds a cache it
* will not read.
* - Module-gate parity: the shell answers a workouts-off account with the
* module notice instead of this page (`ModulePageGate`), and the API route
* refuses it server-side; skip the prefetch when the module is off so a
* disabled page never seeds a cache it will not read.
* - The client cell keeps its own fetch (`staleTime: 60s`): the prefetch seeds
* fresh data, so the mounted cell paints immediately and only refetches once
* the TTL lapses — the "empty then fills" flash is gone without dropping the
+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,
@@ -315,17 +316,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
@@ -427,9 +421,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(),
@@ -439,7 +438,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
@@ -963,7 +963,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
@@ -982,7 +982,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}
@@ -994,7 +994,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(
+2 -1
View File
@@ -4,6 +4,7 @@ import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
import {
LAYOUT_GROUPS,
layoutGroupModule,
LAYOUT_GROUP_IDS,
isLayoutGroupId,
} from "@/components/settings/layout-groups";
@@ -83,7 +84,7 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
>
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleGate={group.moduleGate}>
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
<Body />
</LayoutModuleGate>
</section>