mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge branch 'feat/module-surfaces' into release/v1.39.0
This commit is contained in:
commit
ab0d1b4b71
67 files changed
+1975
-703
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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 +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")}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user