mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -546,6 +546,28 @@ describe("<InsightsTabStrip> — module enable/disable gate (v1.18.0)", () => {
|
||||
expect(enabled).toContain('href="/insights/recovery"');
|
||||
});
|
||||
|
||||
it("hides the Medications pill with the medications module off", () => {
|
||||
const withMeds: InsightInputs = {
|
||||
...moduleAvailability,
|
||||
hasMedication: true,
|
||||
};
|
||||
const on = render(
|
||||
<InsightsTabStrip
|
||||
availability={withMeds}
|
||||
modules={{ medications: true }}
|
||||
/>,
|
||||
);
|
||||
expect(on).toContain('href="/insights/medications"');
|
||||
const off = render(
|
||||
<InsightsTabStrip
|
||||
availability={withMeds}
|
||||
modules={{ medications: false }}
|
||||
/>,
|
||||
);
|
||||
expect(off).not.toContain('href="/insights/medications"');
|
||||
expect(off).toContain(">Overview<");
|
||||
});
|
||||
|
||||
it("fails open: an empty / omitted module map keeps every module pill", () => {
|
||||
// Default-on contract — a stale /me payload (no module map) must not
|
||||
// blank the strip. An empty map and an omitted prop both keep pills.
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type InsightMetric,
|
||||
} from "@/lib/insights/metric-availability";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — routed tab strip for `/insights`.
|
||||
@@ -392,32 +393,6 @@ export const SUB_PAGE_TABS: Record<
|
||||
nutrients: { labelKey: "insights.navNutrients", metric: "NUTRIENTS" },
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.18.0 — slug → module gate. A sub-page pill belonging to a toggleable
|
||||
* module is hidden when that module is disabled in the account's resolved
|
||||
* module map (from `GET /api/auth/me`'s `modules`), on TOP of the data +
|
||||
* layout gates. Slugs absent from this map are core metrics (BP, pulse,
|
||||
* weight, body composition, activity, cardio, hearing, environment,
|
||||
* medications …) and never gate on a module — only on data availability.
|
||||
*
|
||||
* `labs` has no metric pill; it surfaces via the left nav only. The
|
||||
* Recovery pill is gated separately below (it has no slug — it is a
|
||||
* composite always-present link, so it reads the `recovery` module key
|
||||
* directly in `buildTabs`).
|
||||
*/
|
||||
const SUB_PAGE_MODULE: Partial<Record<SubPageSlug, ModuleKey>> = {
|
||||
mood: "mood",
|
||||
sleep: "sleep",
|
||||
"breathing-disturbances": "sleep",
|
||||
"blood-glucose": "glucose",
|
||||
workouts: "workouts",
|
||||
// v1.29 — the nutrients pill gates on the opt-in `nutrients` module
|
||||
// alone; there is no data floor on top (see `hasNutrients` in
|
||||
// `insights-layout-shell.tsx` — reachability, not row count, is the
|
||||
// gate once the user has opted in).
|
||||
nutrients: "nutrients",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.18.0 — a partial `ModuleKey → enabled` map (the `modules` field
|
||||
* `GET /api/auth/me` returns; cycle + coach already delegated). A pill
|
||||
@@ -427,14 +402,17 @@ const SUB_PAGE_MODULE: Partial<Record<SubPageSlug, ModuleKey>> = {
|
||||
*/
|
||||
export type InsightsModuleMap = Partial<Record<ModuleKey, boolean>>;
|
||||
|
||||
/** True unless the slug's module is explicitly disabled in the map. */
|
||||
/**
|
||||
* True unless the slug's module is explicitly off. A sub-page's owner comes
|
||||
* from the one surface map (`insights-page:<slug>`), the same entry the
|
||||
* direct-URL notice reads, so a pill and its page cannot disagree. Slugs with
|
||||
* no entry are core metrics and gate on data only.
|
||||
*/
|
||||
function isSlugModuleEnabled(
|
||||
slug: SubPageSlug,
|
||||
modules: InsightsModuleMap | undefined,
|
||||
): boolean {
|
||||
const key = SUB_PAGE_MODULE[slug];
|
||||
if (!key) return true;
|
||||
return modules?.[key] !== false;
|
||||
return isSurfaceVisible(`insights-page:${slug}`, modules);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,7 +488,7 @@ function buildTabs(
|
||||
// group child `order` follows the layout (via `orderedSlugs` below),
|
||||
// falling back to `SUB_PAGE_GROUP_ORDER` when the layout is absent.
|
||||
// v1.18.0 — a THIRD gate on top of data + layout: a pill belonging to a
|
||||
// toggleable module (mood / sleep / glucose / workouts) is hidden when
|
||||
// toggleable module (the `insights-page:*` surfaces) is hidden when
|
||||
// that module is disabled in the account's resolved module map. Core
|
||||
// metric pills carry no module key and pass this gate unconditionally.
|
||||
const visibleSlugs = availability
|
||||
@@ -551,7 +529,7 @@ function buildTabs(
|
||||
// `summaries[METRIC].count` to gate on — the page data-gates every block
|
||||
// and falls back to a calm empty note. The pill is otherwise present like
|
||||
// Overview, but it is hidden when the `recovery` module is disabled.
|
||||
if (modules?.recovery !== false) {
|
||||
if (isSurfaceVisible("insights-page:recovery", modules)) {
|
||||
entries.push({
|
||||
kind: "link",
|
||||
href: `${INSIGHTS_OVERVIEW_PATH}/recovery`,
|
||||
|
||||
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, TrendingUp } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
@@ -67,8 +67,8 @@ const MoodChart = dynamic(
|
||||
|
||||
interface TrendsRowProps {
|
||||
/**
|
||||
* Metrics to leave out because their module is switched off (for
|
||||
* example `mood`). Passed straight to `selectTrendCharts`.
|
||||
* Further metrics to leave out. Switched-off modules need no entry here:
|
||||
* the row reads the record's module map itself.
|
||||
*/
|
||||
hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"];
|
||||
/**
|
||||
@@ -145,7 +145,15 @@ export function TrendsRow({
|
||||
// v1.8.5 — derive the chart set from the briefing. No new fetch: the
|
||||
// briefing payload is already on the page (advisor cache), so this is
|
||||
// a pure read that respects the v1.8.3 anti-freeze contract.
|
||||
const charts = selectTrendCharts(briefing, { hiddenMetrics });
|
||||
//
|
||||
// A slot whose module is off is left out and the next eligible metric fills
|
||||
// it (`trend:*` in the surface map). The mount-pinned account keeps the
|
||||
// hydration render identical to the server's, which had no account.
|
||||
const account = useAccountOnceMounted();
|
||||
const charts = selectTrendCharts(briefing, {
|
||||
modules: account?.modules,
|
||||
hiddenMetrics,
|
||||
});
|
||||
|
||||
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
|
||||
// advisor's loading flag + the annotation presence. Pending wins
|
||||
|
||||
@@ -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: {} };
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Every module on: this file is about the discard guard, not the module mask.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { modules: {} } }),
|
||||
}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ReactModule>();
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,14 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The record's resolved module map; each test sets what it needs.
|
||||
const modulesRef: { value: Record<string, boolean> | undefined } = {
|
||||
value: undefined,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { modules: modulesRef.value } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
@@ -51,6 +59,7 @@ function render() {
|
||||
|
||||
describe("<CapturePicker> — offered kinds", () => {
|
||||
it("offers measurement, medication and mood, and no water entry", () => {
|
||||
modulesRef.value = undefined;
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="capture-picker-measurement"');
|
||||
@@ -61,3 +70,20 @@ describe("<CapturePicker> — offered kinds", () => {
|
||||
expect(html).not.toContain('data-testid="capture-picker-water"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CapturePicker> — switched-off modules", () => {
|
||||
it("drops Mood with the mood module off and Medication with medications off", () => {
|
||||
modulesRef.value = { mood: false, medications: false };
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="capture-picker-measurement"');
|
||||
expect(html).not.toContain('data-testid="capture-picker-mood"');
|
||||
expect(html).not.toContain('data-testid="capture-picker-medication"');
|
||||
});
|
||||
|
||||
it("keeps both while their modules are on", () => {
|
||||
modulesRef.value = { mood: true, medications: true };
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="capture-picker-mood"');
|
||||
expect(html).toContain('data-testid="capture-picker-medication"');
|
||||
});
|
||||
});
|
||||
@@ -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,18 @@ import {
|
||||
NAV_DESTINATIONS,
|
||||
isDestinationInSharedRecord,
|
||||
mobileMoreHubDestinations,
|
||||
navDestinationModule,
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "../nav-model";
|
||||
import { surfaceModule } from "@/lib/modules/surface";
|
||||
|
||||
/** 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[] {
|
||||
@@ -313,7 +315,10 @@ describe("the utility tail under a switch", () => {
|
||||
expect(firstListed, record.recordKind).toBeDefined();
|
||||
// A module-gated landing could vanish from the shell when its module is
|
||||
// off, leaving the entry pointing at a section the list no longer shows.
|
||||
expect(firstListed?.moduleGate, record.recordKind).toBeUndefined();
|
||||
expect(
|
||||
surfaceModule(`settings:${firstListed?.slug}`),
|
||||
record.recordKind,
|
||||
).toBeUndefined();
|
||||
expect(hrefs(record)).toEqual([`/settings/${firstListed?.slug}`]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
|
||||
|
||||
describe("visibleNavDestinations module gate", () => {
|
||||
it("includes Cycle (between Mood and Medications) only when its module is enabled", () => {
|
||||
// v1.18.0 — cycle is `requiresModule: "cycle"` and reads the delegated
|
||||
// v1.18.0 — cycle is owned by `cycle` in the surface map and reads the delegated
|
||||
// `cycle` key from the same resolved module map every other gate uses.
|
||||
// v1.19.1 (S4) — cycle sits in the head block, after Mood and before the
|
||||
// fixed Medications → … → Achievements spine.
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
@@ -106,15 +108,25 @@ export function admittedCaptureKind(
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture kinds to offer, given what the record allows. Exported pure so
|
||||
* the delegation rule can be pinned without opening a sheet: an SSR test
|
||||
* cannot tap the button that opens it.
|
||||
* The capture kinds to offer, given what the record allows and which of its
|
||||
* modules are on. Exported pure so both rules can be pinned without opening a
|
||||
* sheet: an SSR test cannot tap the button that opens it.
|
||||
*
|
||||
* A kind whose module is off (`capture:<kind>` in the surface map) is not
|
||||
* offered: a Mood entry with the mood module off would post to a route that
|
||||
* refuses it. An absent module map (the account still loading) offers every
|
||||
* kind the record allows, the gate's default-on contract.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
modules?: SurfaceModuleMap | null,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]));
|
||||
return kinds.filter(
|
||||
(kind) =>
|
||||
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
|
||||
isSurfaceVisible(`capture:${kind}`, modules),
|
||||
);
|
||||
}
|
||||
|
||||
interface CapturePickerProps {
|
||||
@@ -127,7 +139,12 @@ interface CapturePickerProps {
|
||||
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const offered = visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER);
|
||||
const { user } = useAuth();
|
||||
const offered = visibleCaptureKinds(
|
||||
capabilities,
|
||||
CAPTURE_KIND_ORDER,
|
||||
user?.modules,
|
||||
);
|
||||
const [kind, setKind] = useState<CaptureKind | null>(null);
|
||||
// Re-derived on every render, never latched: the offer can shrink under a
|
||||
// sheet that is already open. See `admittedCaptureKind`.
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The overview arrange list marks every section whose module is off as
|
||||
* unavailable, through the same surface map the overview renders from, so
|
||||
* Settings never offers a toggle for a block that cannot appear.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const modulesRef: { value: Record<string, boolean> } = { value: {} };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { modules: modulesRef.value },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => ({ briefing: true }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout", () => ({
|
||||
useInsightsLayoutQuery: () => ({
|
||||
layout: { sections: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
const seen: { gated: string[] } = { gated: [] };
|
||||
vi.mock("@/components/insights/insights-edit-mode", () => ({
|
||||
InsightsEditMode: ({
|
||||
gatedOffSectionIds,
|
||||
}: {
|
||||
gatedOffSectionIds: ReadonlySet<string>;
|
||||
}) => {
|
||||
seen.gated = [...gatedOffSectionIds].sort();
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightsOverviewArrangeSection } from "../insights-overview-arrange-section";
|
||||
|
||||
function gatedFor(modules: Record<string, boolean>): string[] {
|
||||
modulesRef.value = modules;
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightsOverviewArrangeSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
return seen.gated;
|
||||
}
|
||||
|
||||
describe("<InsightsOverviewArrangeSection> module gate", () => {
|
||||
it("offers every section with every module on", () => {
|
||||
expect(gatedFor({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks the breathing, labs and cycle blocks unavailable with their modules off", () => {
|
||||
expect(gatedFor({ sleep: false, labs: false, cycle: false })).toEqual([
|
||||
"breathing",
|
||||
"cycle-summary",
|
||||
"labs-changes",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks nothing for AI analysis off", () => {
|
||||
expect(gatedFor({ insights: false })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LAYOUT_GROUPS,
|
||||
LAYOUT_GROUP_IDS,
|
||||
isLayoutGroupId,
|
||||
layoutGroupModule,
|
||||
} from "../layout-groups";
|
||||
|
||||
describe("layout-groups registry", () => {
|
||||
@@ -59,7 +60,9 @@ describe("layout-groups registry", () => {
|
||||
|
||||
it("gates the toggleable tracking modules and leaves the rest always-on", () => {
|
||||
const gate = (id: string) =>
|
||||
LAYOUT_GROUPS.find((group) => group.id === id)?.moduleGate;
|
||||
LAYOUT_GROUPS.some((group) => group.id === id)
|
||||
? layoutGroupModule(id)
|
||||
: "not-a-group";
|
||||
expect(gate("medications")).toBe("medications");
|
||||
expect(gate("mood")).toBe("mood");
|
||||
expect(gate("labs")).toBe("labs");
|
||||
|
||||
@@ -53,10 +53,8 @@ import {
|
||||
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
NATIVE_ONLY_WIDGET_LABEL_KEYS,
|
||||
WIDGET_MODULE_BY_ID,
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
@@ -605,14 +603,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
.filter((w): w is typeof w & { id: DashboardWidgetId } =>
|
||||
webWidgetIds.has(w.id),
|
||||
)
|
||||
// v1.18.0 — hide a widget toggle whose owning module is
|
||||
// disabled. Map the widget id → ModuleKey FIRST (undefined =
|
||||
// core widget = always shown), THEN check the module map.
|
||||
// Fail-open: only an explicit `false` hides the row.
|
||||
.filter((w) => {
|
||||
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
|
||||
return !moduleKey || modules?.[moduleKey] !== false;
|
||||
})
|
||||
// Hide a widget toggle whose owning module is off, through the
|
||||
// one surface map. A core widget has no owner and always shows;
|
||||
// only an explicit `false` hides the row.
|
||||
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
|
||||
.filter((w) => !unavailableIds.has(w.id))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const sortedIds = sortedWidgets.map((w) => w.id);
|
||||
@@ -685,10 +679,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
.filter((w) => nativeOnlyIds.has(w.id))
|
||||
// Same fail-open module gate as the web list: only an explicit
|
||||
// `false` hides the row.
|
||||
.filter((w) => {
|
||||
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
|
||||
return !moduleKey || modules?.[moduleKey] !== false;
|
||||
})
|
||||
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
|
||||
.filter((w) => !unavailableIds.has(w.id))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
if (nativeWidgets.length === 0) return null;
|
||||
|
||||
@@ -11,7 +11,11 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { type InsightsSectionId } from "@/lib/insights-layout";
|
||||
import {
|
||||
INSIGHTS_SECTION_IDS,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* v1.15.18 — overview-arrange block for the Insights settings section.
|
||||
@@ -23,8 +27,10 @@ import { type InsightsSectionId } from "@/lib/insights-layout";
|
||||
* shared cache; the editor simply stays mounted).
|
||||
*
|
||||
* `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose
|
||||
* feature flag / data gate is off renders its row disabled-with-a-hint rather
|
||||
* than offering a toggle that does nothing.
|
||||
* feature flag or owning module is off renders its row disabled-with-a-hint
|
||||
* rather than offering a toggle that does nothing. The row stays in the list
|
||||
* (not filtered out) so a save keeps the section's stored place for when the
|
||||
* module comes back.
|
||||
*/
|
||||
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
const { t } = useTranslations();
|
||||
@@ -38,11 +44,15 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
gated.add("daily-briefing");
|
||||
gated.add("period-review");
|
||||
}
|
||||
if (!user?.cycleTrackingEnabled) {
|
||||
gated.add("cycle-summary");
|
||||
// A block whose module is off cannot appear on the overview, so its row
|
||||
// is not offered as a live toggle. Read from the one surface map the
|
||||
// overview renders from (`overview:<id>`); cycle follows the resolved
|
||||
// `modules.cycle` like the overview's cycle ring, not the raw column.
|
||||
for (const id of INSIGHTS_SECTION_IDS) {
|
||||
if (!isSurfaceVisible(`overview:${id}`, user?.modules)) gated.add(id);
|
||||
}
|
||||
return gated;
|
||||
}, [flags.briefing, user?.cycleTrackingEnabled]);
|
||||
}, [flags.briefing, user?.modules]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { surfaceModule } from "@/lib/modules/surface";
|
||||
import { DashboardSection } from "./dashboard-section";
|
||||
import { InsightsSection } from "./insights-section";
|
||||
import { MedicationsSection } from "./medications-section";
|
||||
@@ -43,11 +44,16 @@ export interface LayoutGroup {
|
||||
/** i18n key under `settings.sections.layout.<slug>.description`. */
|
||||
descriptionKey: string;
|
||||
Body: () => JSX.Element | null;
|
||||
/**
|
||||
* When set, the row / subpage renders only while the module is enabled
|
||||
* (fail-open, `!== false`). Omitted = always shown.
|
||||
*/
|
||||
moduleGate?: ModuleKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The module a layout group belongs to, from the one surface map
|
||||
* (`settings-layout:<id>`), or `undefined` for a group that is always shown.
|
||||
* The row and its sub-page render only while that module is on (fail-open,
|
||||
* `!== false`).
|
||||
*/
|
||||
export function layoutGroupModule(id: string): ModuleKey | undefined {
|
||||
return surfaceModule(`settings-layout:${id}`);
|
||||
}
|
||||
|
||||
export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
|
||||
@@ -68,28 +74,24 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
|
||||
titleKey: "settings.sections.layout.medications.title",
|
||||
descriptionKey: "settings.sections.layout.medications.description",
|
||||
Body: MedicationsSection,
|
||||
moduleGate: "medications",
|
||||
},
|
||||
{
|
||||
id: "mood",
|
||||
titleKey: "settings.sections.layout.mood.title",
|
||||
descriptionKey: "settings.sections.layout.mood.description",
|
||||
Body: MoodSection,
|
||||
moduleGate: "mood",
|
||||
},
|
||||
{
|
||||
id: "labs",
|
||||
titleKey: "settings.sections.layout.labs.title",
|
||||
descriptionKey: "settings.sections.layout.labs.description",
|
||||
Body: LabsSection,
|
||||
moduleGate: "labs",
|
||||
},
|
||||
{
|
||||
id: "illness",
|
||||
titleKey: "settings.sections.layout.illness.title",
|
||||
descriptionKey: "settings.sections.layout.illness.description",
|
||||
Body: IllnessSection,
|
||||
moduleGate: "illness",
|
||||
},
|
||||
{
|
||||
id: "vorsorge",
|
||||
|
||||
@@ -22,14 +22,14 @@ import type { ModuleKey } from "@/lib/modules/registry";
|
||||
* an ordinary client update. The redirect is a client-only effect, so it never
|
||||
* diverges the initial render and cannot trigger a React #418 mismatch.
|
||||
*
|
||||
* Groups with no `moduleGate` (dashboard / insights / vorsorge) pass
|
||||
* `moduleGate={undefined}` and always render.
|
||||
* Groups the surface map gives no owner (dashboard / insights / vorsorge)
|
||||
* pass `moduleKey={undefined}` and always render.
|
||||
*/
|
||||
export function LayoutModuleGate({
|
||||
moduleGate,
|
||||
moduleKey,
|
||||
children,
|
||||
}: {
|
||||
moduleGate?: ModuleKey;
|
||||
moduleKey?: ModuleKey;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const hydrated = useMounted();
|
||||
@@ -37,9 +37,7 @@ export function LayoutModuleGate({
|
||||
const router = useRouter();
|
||||
|
||||
const disabled =
|
||||
hydrated &&
|
||||
moduleGate !== undefined &&
|
||||
user?.modules?.[moduleGate] === false;
|
||||
hydrated && moduleKey !== undefined && user?.modules?.[moduleKey] === false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disabled) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCard } from "./settings-card";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { LAYOUT_GROUPS } from "./layout-groups";
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,8 @@ import { LAYOUT_GROUPS } from "./layout-groups";
|
||||
* `layout` slug; this body renders only the row list.
|
||||
*
|
||||
* Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so
|
||||
* a section never silently disappears. Groups with no `moduleGate` always
|
||||
* a section never silently disappears. Groups with no owner in the surface
|
||||
* map (`settings-layout:<id>`) always
|
||||
* render. The filter is gated on a post-mount flag (`useMounted()`) so SSR and
|
||||
* the first client paint ALWAYS emit the same fail-open list — the same
|
||||
* hydration-stability contract the shell nav uses, so the module filter can
|
||||
@@ -35,7 +37,7 @@ export function LayoutSection() {
|
||||
const modules = user?.modules;
|
||||
const visibleGroups = LAYOUT_GROUPS.filter(
|
||||
(group) =>
|
||||
!hydrated || !group.moduleGate || modules?.[group.moduleGate] !== false,
|
||||
!hydrated || isSurfaceVisible(`settings-layout:${group.id}`, modules),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -48,7 +48,7 @@ import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
@@ -106,14 +106,6 @@ interface SettingsSection {
|
||||
icon: LucideIcon;
|
||||
/** 2026-07-17 UX/IA audit (M6) — which sidebar group this entry renders under. */
|
||||
group: SettingsSectionGroup;
|
||||
/**
|
||||
* v1.18.0 (S5) — per-submodule entries are listed only when their module
|
||||
* is enabled. When set, the nav entry hides if the resolved
|
||||
* `useAuth().user.modules` map has the key explicitly `false`. The gate
|
||||
* fails OPEN (a missing key reads as enabled) so a stale `/me` payload
|
||||
* never blanks an entry. Omitted = always shown (global / CORE entries).
|
||||
*/
|
||||
moduleGate?: ModuleKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,13 +260,13 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
},
|
||||
// v1.25 (W-ENV) — Environmental context: home location, travel overrides,
|
||||
// and the weather/daylight backfill. Module-gated on the opt-in
|
||||
// `environment` module, so the entry only appears once the user turns it on.
|
||||
// `environment` module (`settings:environment` in the surface map), so the
|
||||
// entry only appears once the user turns it on.
|
||||
{
|
||||
slug: "environment",
|
||||
titleKey: "settings.sections.environment.title",
|
||||
icon: CloudSun,
|
||||
group: "healthProfile",
|
||||
moduleGate: "environment",
|
||||
},
|
||||
// ── AI ──
|
||||
{
|
||||
@@ -284,14 +276,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
group: "ai",
|
||||
},
|
||||
// v1.18.0 (S5) — Coach: the Coach preference cards get their own entry,
|
||||
// shown only when the coach module is enabled. The AI entry above keeps
|
||||
// shown only when the coach module is enabled (`settings:coach` in the
|
||||
// surface map). The AI entry above keeps
|
||||
// provider / model / BYOK configuration.
|
||||
{
|
||||
slug: "coach",
|
||||
titleKey: "settings.sections.coach.title",
|
||||
icon: Bot,
|
||||
group: "ai",
|
||||
moduleGate: "coach",
|
||||
},
|
||||
// ── Connectivity ──
|
||||
{
|
||||
@@ -411,8 +403,9 @@ export function SettingsShell({
|
||||
// is enabled. Read from the resolved `useAuth().user.modules` map (the
|
||||
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
|
||||
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
|
||||
// reads as enabled so an entry never silently disappears. Entries with
|
||||
// no `moduleGate` (global / CORE) are always shown.
|
||||
// reads as enabled so an entry never silently disappears. A section's
|
||||
// owner comes from the surface map (`settings:<slug>`); sections with no
|
||||
// owner (global / CORE) are always shown.
|
||||
//
|
||||
// v1.25.9 — the module filter must NOT run during SSR / the first client
|
||||
// paint. `useAuth().user` resolves from the `/api/auth/me` query, which is
|
||||
@@ -435,9 +428,7 @@ export function SettingsShell({
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const allVisibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
!hydrated ||
|
||||
!section.moduleGate ||
|
||||
modules?.[section.moduleGate] !== false,
|
||||
!hydrated || isSurfaceVisible(`settings:${section.slug}`, modules),
|
||||
);
|
||||
|
||||
// The server has already resolved which record is active, and which
|
||||
|
||||
Reference in new issue
Block a user