fix(modules): route nav entries and module pages through the surface map

Navigation destinations, the bottom bar's fixed slots and the tour stops
no longer carry their own module keys; each asks the surface map by its
href. Insights has no owner there, so switching AI analysis off leaves
the Insights entry, slot and tour stop in place.

The shell's page gate now resolves Insights sub-pages by slug as well
as top-level pages, so /insights/mood, /insights/medications and the
recovery metric pages show the module notice when their module is off,
worded from the access reason, with the Settings link only for the
record's own switch. The per-page redirect guard, the achievements
redirect and the nutrients page's own notice are gone; nothing
redirects any more.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:48:12 +02:00
1 parent a4f8e6db27
commit 39ae034b8c
22 files changed
+263 -401

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 (
+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
@@ -8,9 +8,9 @@
* blank page — so the slot must honour the same per-user map the
* Insights slot and the More hub already do.
*
* Watched red: with the `requiresModule: "medications"` entry removed
* from PRIMARY_LEFT (the pre-fix ungated pin) the module-off test fails
* with the tab still present. Verified red against the pre-fix bar.
* Watched red: with `nav:/medications` removed from the surface map (the
* pre-fix ungated pin) the module-off test fails with the tab still
* present.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -70,4 +70,11 @@ describe("<BottomNav> mounted module gate", () => {
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
it("keeps the Insights slot with AI analysis off", () => {
mockUserRef.value = { id: "u1", modules: { insights: false } };
const html = render();
expect(html).toContain('href="/insights"');
mockUserRef.value = { id: "u1", modules: {} };
});
});
@@ -0,0 +1,101 @@
/**
* The one direct-URL gate for module pages: top-level pages and Insights
* sub-pages answer through the surface map, render the inline notice worded
* from `moduleAccess`, and never redirect.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const pathRef = { value: "/" };
vi.mock("next/navigation", () => ({
usePathname: () => pathRef.value,
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
type MockUser = {
modules: Record<string, boolean>;
moduleAccess?: Record<string, string>;
};
const userRef: { value: MockUser | null } = { value: { modules: {} } };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: userRef.value, isAuthenticated: true }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ModulePageGate, moduleOwningPath } from "../module-page-gate";
function render(pathname: string, user: MockUser) {
pathRef.value = pathname;
userRef.value = user;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ModulePageGate>
<p data-testid="page">page</p>
</ModulePageGate>
</I18nProvider>,
);
}
describe("moduleOwningPath", () => {
it.each([
["/mood", "mood"],
["/labs/123", "labs"],
["/insights/mood", "mood"],
["/insights/workouts/abc", "workouts"],
["/insights/blood-glucose", "glucose"],
["/insights/medications", "medications"],
["/insights/recovery", "recovery"],
["/insights/six-minute-walk", "recovery"],
["/insights/breathing-disturbances", "sleep"],
])("%s belongs to %s", (path, owner) => {
expect(moduleOwningPath(path)).toBe(owner);
});
it.each(["/", "/measurements", "/insights", "/insights/weight", "/checkups"])(
"%s belongs to no module",
(path) => {
expect(moduleOwningPath(path)).toBeUndefined();
},
);
});
describe("<ModulePageGate>", () => {
it("renders the page while its module is on", () => {
const html = render("/insights/mood", { modules: { mood: true } });
expect(html).toContain('data-testid="page"');
expect(html).not.toContain("module-disabled-notice");
});
it("keeps the Insights area with AI analysis off", () => {
const html = render("/insights", { modules: { insights: false } });
expect(html).toContain('data-testid="page"');
});
it("replaces an Insights sub-page with the notice, Settings link included, when the record switched it off", () => {
const html = render("/insights/mood", {
modules: { mood: false },
moduleAccess: { mood: "disabled" },
});
expect(html).not.toContain('data-testid="page"');
expect(html).toContain('data-slot="module-disabled-notice"');
expect(html).toContain('data-module="mood"');
expect(html).toContain('data-module-access="disabled"');
expect(html).toContain('data-slot="module-off-open-settings"');
});
it("offers no Settings link when the operator switched the module off", () => {
const html = render("/insights/sleep", {
modules: { sleep: false },
moduleAccess: { sleep: "unavailable" },
});
expect(html).toContain('data-module-access="unavailable"');
expect(html).not.toContain("module-off-open-settings");
});
it("covers a sub-page no nav entry names (medications)", () => {
const html = render("/insights/medications", {
modules: { medications: false },
});
expect(html).toContain('data-module="medications"');
});
});
@@ -48,16 +48,17 @@ import {
NAV_DESTINATIONS,
isDestinationInSharedRecord,
mobileMoreHubDestinations,
navDestinationModule,
visibleNavDestinations,
visibleUtilityDestinations,
} from "../nav-model";
/** Everything enabled, so the module gate never masks the sharing gate. */
const ALL_MODULES = Object.fromEntries(
NAV_DESTINATIONS.filter((d) => d.requiresModule).map((d) => [
d.requiresModule as string,
true,
]),
NAV_DESTINATIONS.flatMap((d) => {
const owner = navDestinationModule(d);
return owner ? [[owner, true]] : [];
}),
);
function hrefsInSharedRecord(): string[] {
@@ -102,15 +102,12 @@ describe("visibleNavDestinations module gate", () => {
expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/medications"));
});
it("drops a module-gated entry (mood / labs / coach / achievements / insights / medications) when its module is disabled", () => {
it("drops a module-gated entry (mood / labs / coach / achievements / medications) when its module is disabled", () => {
const disabled = visibleNavDestinations({
mood: false,
labs: false,
coach: false,
achievements: false,
// v1.18.0 — Insights is now `requiresModule: "insights"`, so the
// top-level /insights entry drops when the module is off.
insights: false,
// v1.18.1 (D3) — medications graduated to a toggleable module.
medications: false,
}).map((d) => d.href);
@@ -118,10 +115,16 @@ describe("visibleNavDestinations module gate", () => {
expect(disabled).not.toContain("/labs");
expect(disabled).not.toContain("/coach");
expect(disabled).not.toContain("/achievements");
expect(disabled).not.toContain("/insights");
expect(disabled).not.toContain("/medications");
});
it("keeps Insights with AI analysis off: the insights key owns no destination", () => {
const hrefs = visibleNavDestinations({ insights: false }).map(
(d) => d.href,
);
expect(hrefs).toContain("/insights");
});
it("keeps every module-gated entry when its module is enabled", () => {
const enabled = visibleNavDestinations({
mood: true,
@@ -86,8 +86,9 @@ function render({
// pure `visibleNavDestinations(modules, true)` contract verified in
// `nav-model.test.ts`; the mounted render is exercised in e2e.
// CORE destinations carry no `requiresModule` and always render.
const CORE_HREFS = ["/", "/measurements", "/checkups"] as const;
// CORE destinations have no owner in the surface map and always render.
// `/insights` is one of them: the `insights` key is AI analysis only.
const CORE_HREFS = ["/", "/measurements", "/checkups", "/insights"] as const;
// A representative slice of the module-gated entries that must stay out of
// the first-paint markup whatever the module map says.
const GATED_HREFS = [
@@ -96,7 +97,6 @@ const GATED_HREFS = [
"/medications",
"/labs",
"/illness",
"/insights",
"/coach",
"/achievements",
] as const;
+23 -38
View File
@@ -17,7 +17,7 @@ import {
isDestinationInSharedRecord,
mobileMoreHubDestinations,
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
@@ -60,34 +60,19 @@ interface NavLink {
// surfaces tell one story instead of two hand-curated ones that drift,
// and the headline invariant is a tested model function rather than
// inline bar logic.
// The Meds slot is module-gated exactly like the Insights slot below:
// `/medications` renders nothing when the medications module is off, so a
// pinned tab would be a visible control over a blank page.
const PRIMARY_LEFT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
// The Meds slot follows the medications module through the surface map
// (`nav:/medications`): a pinned tab over a module that is off would be a
// visible control over a notice.
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{
href: "/medications",
tKey: "nav.medications",
icon: Pill,
requiresModule: "medications",
},
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
// v1.18.0 — the Insights primary slot is module-gated. Insights is now a
// toggleable module (`requiresModule: "insights"` in the shared nav model),
// so the fixed slot must respect the same per-user map the More hub already
// honours rather than pinning a destination the account turned off. When
// insights is disabled the slot is dropped (it is excluded from the More hub
// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden
// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps
// the slot, mirroring the gate's default-on contract.
const PRIMARY_RIGHT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
{
href: "/insights",
tKey: "nav.insights",
icon: Lightbulb,
requiresModule: "insights",
},
// The Insights slot belongs to no module (the `insights` key is AI analysis,
// not the area), so it stays whatever the module map says; it still drops
// inside a shared record, like any destination sharing does not cover.
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
];
export function BottomNav() {
@@ -115,7 +100,8 @@ export function BottomNav() {
// The capture button opens the picker, so it shows exactly when the picker
// would offer at least one kind — the same per-section rule, asked once.
const canCapture =
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER).length > 0;
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
.length > 0;
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
@@ -132,23 +118,22 @@ export function BottomNav() {
[user?.modules, mounted, sharedRecord, sections],
);
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
// has that module disabled. Fail-closed until mounted (so a disabled
// Insights never flickers in), then the real map applies: a missing key /
// unloaded map keeps the slot, mirroring the gate + More-hub default-on
// contract.
// Every slot but Home asks two things: does sharing cover it, and does its
// module (if the surface map names one) stay on. Fail-closed until mounted,
// so SSR and the first paint agree, then the real map applies: a missing
// key / unloaded map keeps the slot, the gate's default-on contract.
const primarySlotVisible = useCallback(
(item: NavLink & { requiresModule?: ModuleKey }) => {
// An ungated slot (Home) is pinned unconditionally — the bar always
// has an anchor, whatever the module map or a record switch says.
if (!item.requiresModule) return true;
(item: NavLink) => {
// Home is pinned unconditionally — the bar always has an anchor,
// whatever the module map or a record switch says.
if (item.href === "/") return true;
return (
// v1.36.0 — the fixed slot asks the shared destination model
// whether sharing covers it, rather than carrying its own answer:
// Insights is an AI surface and drops out under a switch.
// Insights drops out under a switch.
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
mounted &&
user?.modules?.[item.requiresModule] !== false
isSurfaceVisible(`nav:${item.href}`, user?.modules)
);
},
[user?.modules, mounted, sharedRecord, sections],
@@ -79,7 +79,11 @@ export function ModuleDisabledNotice({
const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey);
return (
<div data-slot="module-disabled-notice" data-module-access={state}>
<div
data-slot="module-disabled-notice"
data-module={moduleKey}
data-module-access={state}
>
<EmptyState
icon={icon}
title={t(copy.title, { module: moduleName })}
+42 -20
View File
@@ -8,25 +8,48 @@ import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice
import {
isNavDestinationActive,
NAV_DESTINATIONS,
navDestinationModule,
} from "@/components/layout/nav-model";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { surfaceModule } from "@/lib/modules/surface";
const INSIGHTS_PREFIX = "/insights/";
/**
* v1.39 (C2) — the page of a module that is switched off answers with an
* empty state that names the switch (design spec §After the flow), in one
* place for every module page.
* The module that owns the page at `pathname`, from the one surface map.
*
* The setup flow switches modules OFF for a new record — an explicit `false`
* for every module the answers did not name — and the navigation drops the
* entry. A person who reaches the page anyway (a bookmark, a link in a
* report, the tour) used to get the page's own empty state, which said
* "nothing here yet" about a module that is not there at all. This maps the
* route to the module through the navigation model's own gate, so a module
* page and its nav entry can never disagree about which switch they follow,
* and renders the shared notice with the one action that can help: the
* Modules settings, offered only when the record's own switch is what is off.
* An Insights sub-page answers by its slug (`insights-page:<slug>`), so
* `/insights/mood` and `/insights/workouts/<id>` follow their own module while
* `/insights` itself follows none. Every other page answers through the nav
* destination it sits under (`nav:<href>`), most specific first.
*/
export function moduleOwningPath(pathname: string): ModuleKey | undefined {
if (pathname.startsWith(INSIGHTS_PREFIX)) {
const slug = pathname.slice(INSIGHTS_PREFIX.length).split("/")[0];
const owner = surfaceModule(`insights-page:${slug}`);
if (owner !== undefined) return owner;
}
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
);
return destination ? navDestinationModule(destination) : undefined;
}
/**
* A page of a module that is switched off answers with an inline notice that
* names the reason, in one place for every module page: top-level pages
* (`/mood`, `/labs` …) and Insights sub-pages (`/insights/mood`,
* `/insights/sleep`, `/insights/medications` …) alike.
*
* A person who reaches such a page anyway (a bookmark, a link in a report,
* the tour) gets the notice rather than a redirect, which would drop where
* they were, and rather than the page's own error rows, which would say
* "could not be loaded" about a module that is not there. The notice reads
* the reason from `moduleAccess` and offers Settings only when the record's
* own switch is what is off.
*
* Paint, not enforcement: every route behind a module refuses on its own
* through the server gate. Mounted inside the shell, which renders children
@@ -38,19 +61,18 @@ export function ModulePageGate({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { t } = useTranslations();
const destination = NAV_DESTINATIONS.find(
(d) => d.requiresModule && isNavDestinationActive(d.href, pathname),
);
const moduleKey = destination?.requiresModule;
const moduleKey = moduleOwningPath(pathname);
const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
if (!off) return <>{children}</>;
if (!off || !destination || !moduleKey) return <>{children}</>;
const Icon = destination.icon;
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),
);
const Icon = destination?.icon;
return (
<ModuleDisabledNotice
moduleKey={moduleKey}
icon={<Icon className="size-6" />}
icon={Icon ? <Icon className="size-6" /> : undefined}
action={
<Button asChild size="sm">
<Link href="/settings/modules" data-slot="module-off-open-settings">
+29 -41
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface";
// The leaf module, not the `@/lib/record-settings` barrel, for the reason
// `auth-shell.tsx` gives: the barrel drags server-side schemas into the chrome.
import {
@@ -56,17 +57,6 @@ export interface NavDestination {
* spotlight tour — renaming silently breaks the cutout for that step.
*/
tourId?: string;
/**
* v1.18.0 — gate the entry on a per-user module toggle. When set, the
* entry is dropped unless the account's resolved module map (from
* `GET /api/auth/me`'s `modules`) has the key enabled. Core destinations
* (weight / BP / pulse + always-on pages) carry no key and always render.
* `cycle` and `coach` are delegated keys (cycle → gender +
* opt-in, coach → operator flag + per-user opt-out); the auth/me map
* already reflects that delegation, so reading them here is correct and
* not a re-derivation.
*/
requiresModule?: ModuleKey;
/**
* v1.36.0 — is this destination part of what account sharing covers.
*
@@ -119,12 +109,9 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.mood",
icon: Waves,
tourId: "nav-mood",
requiresModule: "mood",
},
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood.
// Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off)
// key from the resolved module map, so the entry is absent until the account
// turns the module on from the Modules hub. The destination is the dedicated
// Owned by `mentalHealth` in the surface map. The destination is the dedicated
// top-level `/mental-wellbeing` check-in surface (its own module page — it
// no longer borrows the Insights tab strip / layout shell).
{
@@ -133,7 +120,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.mentalWellbeing",
icon: Brain,
tourId: "nav-mental-wellbeing",
requiresModule: "mentalHealth",
},
{
href: "/cycle",
@@ -141,7 +127,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.cycle",
icon: Droplets,
tourId: "nav-cycle",
requiresModule: "cycle",
},
{
href: "/medications",
@@ -151,7 +136,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-medications",
// v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable
// module; the nav entry now drops when the account turns the module off.
requiresModule: "medications",
},
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
// clinical spine. It is a first-class tracking surface ("wann muss ich was
@@ -164,7 +148,8 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vorsorge",
icon: Stethoscope,
tourId: "nav-vorsorge",
// v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike
// v1.18.1 — deliberately owned by no module (absent from the surface
// map). Unlike
// labs / illness / cycle (opt-in clinical-spine verticals born off by
// default), preventive-care reminders are a CORE surface available to
// every account from birth: a reminder can target core vitals
@@ -178,7 +163,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.labs",
icon: FlaskConical,
tourId: "nav-labs",
requiresModule: "labs",
},
{
href: "/profile",
@@ -189,21 +173,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-profile",
},
// v1.18.1 — the illness/condition journal sits in the clinical spine
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
// opt-in `illness` key from the resolved module map, so the entry is
// absent until the account turns the module on from the Modules hub.
// next to Labs. Owned by `illness` in the surface map.
{
href: "/illness",
sharedRecord: true,
tKey: "nav.illness",
icon: Thermometer,
tourId: "nav-illness",
requiresModule: "illness",
},
// v1.37.3: the immunization log sits in the clinical spine beside Illness.
// Born-gated: `requiresModule: "vaccinations"` reads the resolved module map,
// so the entry is absent until the account keeps the module on (default-on —
// it drops only when a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// Owned by `vaccinations` in the surface map (default-on; it drops only when
// a user turns it off). SURFACE-gated: the `/api/vaccinations*`
// data routes stay reachable so a restore / import keeps working and
// re-enabling finds every dose intact.
{
@@ -212,24 +192,23 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.vaccinations",
icon: Syringe,
tourId: "nav-vaccinations",
requiresModule: "vaccinations",
},
// v1.18.0 — Workouts and Recovery both left the left-nav: each already
// surfaces as an Insights tab-strip pill (`/insights/workouts` gated on
// a workout row, `/insights/recovery` always present), so neither is a
// top-level `NAV_DESTINATIONS` entry any more.
// v1.25.0 (W-DOCS-IN) — inbound clinical documents sit in the clinical
// spine after Illness. Born-gated: `requiresModule: "inboundDocuments"`
// reads the opt-in key from the resolved module map, so the entry is absent
// until the account turns the module on from the Modules hub.
// spine after Illness. Owned by `inboundDocuments` in the surface map.
{
href: "/documents",
sharedRecord: true,
tKey: "nav.documents",
icon: FileScan,
tourId: "nav-documents",
requiresModule: "inboundDocuments",
},
// Insights belongs to no module: the `insights` key means AI analysis, and
// the area is data, so switching AI analysis off leaves this entry standing.
//
// Insights and the Coach carry no `sharedRecord` flag, so they drop out
// under a switch. The reason has changed since v1.36.0 and the old wording
// ("non-delegable, server-side and here") is no longer true of the tiles:
@@ -257,7 +236,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.insights",
icon: Lightbulb,
tourId: "nav-insights",
requiresModule: "insights",
},
// v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty
@@ -268,7 +246,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.coach",
icon: MessagesSquare,
tourId: "nav-coach",
requiresModule: "coach",
},
{
href: "/achievements",
@@ -276,7 +253,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tKey: "nav.achievements",
icon: Trophy,
tourId: "nav-achievements",
requiresModule: "achievements",
},
];
@@ -355,10 +331,22 @@ export function visibleUtilityDestinations(
*/
export type ModuleVisibilityMap = Partial<Record<ModuleKey, boolean>>;
/**
* The module that owns a destination, from the one surface map
* (`nav:<href>` in `@/lib/modules/surface`), or `undefined` for a core
* destination. The nav bars, the direct-URL notice and the tour all ask this,
* so a page and its nav entry cannot disagree about which switch they follow.
*/
export function navDestinationModule(
d: Pick<NavDestination, "href">,
): ModuleKey | undefined {
return surfaceModule(`nav:${d.href}`);
}
/**
* Whether a destination is visible under the given module map. Core
* destinations (no `requiresModule`) always pass; a gated entry passes
* unless its module resolves to an explicit `false`.
* destinations (no owner in the surface map) always pass; an owned entry
* passes unless its module resolves to an explicit `false`.
*
* `mounted` (default `true`) is the hydration gate the nav bars thread in.
* The resolved module map rides the client-only `/api/auth/me` query, which
@@ -389,7 +377,7 @@ function isNavDestinationVisible(
// than reached only through the shared arm, which is what it relied on
// before the module gate below started applying to a shared record too.
if (!sharedRecord && d.sharedRecordOnly) return false;
if (!d.requiresModule) return true;
if (navDestinationModule(d) === undefined) return true;
if (!mounted) return false;
// The module map, and whose it is, is the whole point. A shared record used
// to skip this line: the grant's scope decided which doors existed, and the
@@ -400,7 +388,7 @@ function isNavDestinationVisible(
// it, turning Cycle off for a profile left the Cycle door standing in that
// profile's own navigation. Scope decides which doors the grant opens;
// this decides which of them the record tracks at all.
return modules?.[d.requiresModule] !== false;
return isSurfaceVisible(`nav:${d.href}`, modules);
}
/**
@@ -427,8 +415,8 @@ export function isDestinationInSharedRecord(
* The ordered destinations visible to this account — drops a module-gated
* entry (mood, cycle, labs, coach, achievements …) when its module is
* disabled in the account's resolved module map. Both bars start from this.
* v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule:
* "cycle"` and reads the delegated `cycle` key from the same map.
* Cycle reads the delegated `cycle` key from the same map; `/insights` has
* no owner (the `insights` key is AI analysis, not the area).
*
* v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing
* covers, so a delegate is not offered a door the server will shut. Paint
+1 -1
View File
@@ -60,7 +60,7 @@ import {
interface TourProps {
/**
* Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose
* `requiresModule` resolves to `false` are dropped; the counter total
* page belongs to a switched-off module are dropped; the counter total
* tracks the resolved list so "Schritt n/total" stays honest.
*/
modules?: TourModuleMap;
@@ -1,86 +0,0 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.18.0 B1 — `useModulePageGuard` is the client-side direct-URL guard
* for the insights sub-pages of a toggleable module. The repo has no
* `@testing-library/react`, so the contract is exercised through an SSR
* Probe that renders the resolved `ready` flag. The redirect side-effect
* lives in a `useEffect` (no-op under SSR); the `ready` derivation is the
* load-bearing decision both the redirect and the loader branch read, so
* locking it covers the guard.
*/
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
const useAuthMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => useAuthMock(),
}));
import { useModulePageGuard } from "../use-module-page-guard";
function Probe({ moduleKey }: { moduleKey: "glucose" }) {
const { ready } = useModulePageGuard(moduleKey);
return <span data-testid="ready">{String(ready)}</span>;
}
function readyOf(): string {
const html = renderToStaticMarkup(<Probe moduleKey="glucose" />);
return html.includes(">true<") ? "true" : "false";
}
describe("useModulePageGuard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("is not ready while auth is loading", () => {
useAuthMock.mockReturnValue({
user: null,
isLoading: true,
isAuthenticated: false,
});
expect(readyOf()).toBe("false");
});
it("is not ready when unauthenticated", () => {
useAuthMock.mockReturnValue({
user: null,
isLoading: false,
isAuthenticated: false,
});
expect(readyOf()).toBe("false");
});
it("is not ready when the module is disabled (explicit false)", () => {
useAuthMock.mockReturnValue({
user: { modules: { glucose: false } },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("false");
});
it("is ready when authenticated and the module is enabled", () => {
useAuthMock.mockReturnValue({
user: { modules: { glucose: true } },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("true");
});
it("is ready (default-on) when the module key is absent", () => {
useAuthMock.mockReturnValue({
user: { modules: {} },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("true");
});
});
-45
View File
@@ -1,45 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import type { ModuleKey } from "@/lib/modules/registry";
/**
* v1.18.0 B1 — client-side page guard for a toggleable module.
*
* Mirrors the cycle page (`src/app/cycle/page.tsx`): an unauthenticated
* visitor bounces to login; an authenticated account without the module
* enabled bounces to `/insights`. The nav entry is already hidden for a
* disabled account, so this only catches a direct URL hit. Every backing
* `/api/*` route enforces the gate server-side, so this is a UX redirect,
* not the security boundary.
*
* Returns `ready` — `true` only once auth has resolved, the visitor is
* authenticated, AND the module is enabled. While `false` the caller
* should render a calm loader / nothing rather than the (about-to-
* redirect) module surface, so a disabled-module page never half-renders.
*
* Default-on (disabled allowlist): an absent map, a missing key, or any
* non-`false` value reads as enabled — matching the server gate.
*/
export function useModulePageGuard(moduleKey: ModuleKey): {
ready: boolean;
} {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const enabled = user?.modules?.[moduleKey] !== false;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/insights");
}
}, [isLoading, isAuthenticated, enabled, router]);
return { ready: !isLoading && isAuthenticated && enabled };
}
@@ -51,6 +51,19 @@ describe("tour-state", () => {
expect(ids).toContain("wrapUp");
});
it("keeps the Insights stop with AI analysis off", () => {
const stops = buildTourStops({ modules: { insights: false } });
expect(stops.map((s) => s.id)).toContain("insights");
});
it("drops the Coach and Medications stops with their modules off", () => {
const ids = buildTourStops({
modules: { coach: false, medications: false },
}).map((s) => s.id);
expect(ids).not.toContain("coach");
expect(ids).not.toContain("medications");
});
it("keeps a module stop when its key is absent or true (fail-open)", () => {
const stops = buildTourStops({ modules: { labs: true } });
expect(stops.map((s) => s.id)).toContain("labs");
+5 -16
View File
@@ -21,6 +21,7 @@
*/
import type { ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible } from "@/lib/modules/surface";
/** Stable IDs for every stop in the tour, in presentation order. */
export type TourStopId =
@@ -69,12 +70,6 @@ export interface TourStop {
* page.
*/
route?: string;
/**
* v1.18.6 — module gate. When set, the stop is dropped unless the
* account's resolved module map has the key enabled (default-on:
* only an explicit `false` drops it, mirroring the nav gate).
*/
requiresModule?: ModuleKey;
}
/**
@@ -111,7 +106,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "medications-hero",
placement: "bottom",
route: "/medications",
requiresModule: "medications",
titleKey: "onboarding.tour.steps.medications.title",
bodyKey: "onboarding.tour.steps.medications.body",
},
@@ -120,7 +114,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "labs-hero",
placement: "bottom",
route: "/labs",
requiresModule: "labs",
titleKey: "onboarding.tour.steps.labs.title",
bodyKey: "onboarding.tour.steps.labs.body",
},
@@ -129,7 +122,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "illness-hero",
placement: "bottom",
route: "/illness",
requiresModule: "illness",
titleKey: "onboarding.tour.steps.illness.title",
bodyKey: "onboarding.tour.steps.illness.body",
},
@@ -146,7 +138,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "cycle-hero",
placement: "bottom",
route: "/cycle",
requiresModule: "cycle",
titleKey: "onboarding.tour.steps.cycle.title",
bodyKey: "onboarding.tour.steps.cycle.body",
},
@@ -155,7 +146,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "mood-hero",
placement: "bottom",
route: "/mood",
requiresModule: "mood",
titleKey: "onboarding.tour.steps.mood.title",
bodyKey: "onboarding.tour.steps.mood.body",
},
@@ -164,7 +154,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "insights-hero",
placement: "bottom",
route: "/insights",
requiresModule: "insights",
titleKey: "onboarding.tour.steps.insights.title",
bodyKey: "onboarding.tour.steps.insights.body",
},
@@ -173,7 +162,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "coach-hero",
placement: "bottom",
route: "/coach",
requiresModule: "coach",
titleKey: "onboarding.tour.steps.coach.title",
bodyKey: "onboarding.tour.steps.coach.body",
},
@@ -198,7 +186,6 @@ const ALL_STOPS: readonly TourStop[] = [
targetId: "achievements-hero",
placement: "bottom",
route: "/achievements",
requiresModule: "achievements",
titleKey: "onboarding.tour.steps.achievements.title",
bodyKey: "onboarding.tour.steps.achievements.body",
},
@@ -220,7 +207,7 @@ export type TourModuleMap = Partial<Record<ModuleKey, boolean>>;
/**
* Build the resolved tour-step list for the given module map. Stops
* whose `requiresModule` resolves to `false` are dropped without
* whose page belongs to a module that is `false` are dropped without
* renumbering the rest.
*
* `filterToStop` narrows the list to a single stop — the per-module
@@ -233,8 +220,10 @@ export function buildTourStops(opts?: {
filterToStop?: TourStopId;
}): TourStop[] {
const modules = opts?.modules;
// A stop follows the page it stands on, through the same surface map the
// nav reads (`nav:<route>`), so a stop and its nav entry cannot disagree.
const gated = ALL_STOPS.filter(
(s) => !s.requiresModule || modules?.[s.requiresModule] !== false,
(s) => !s.route || isSurfaceVisible(`nav:${s.route}`, modules),
);
if (opts?.filterToStop) {
return gated.filter((s) => s.id === opts.filterToStop);