mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* A module switched off disappears everywhere, and a page of it answers with
|
||||
* the notice instead of errors.
|
||||
*
|
||||
* One journey on its own account (`E2E_MODULES`): mood and medications are
|
||||
* switched on, the surfaces they own are proven present (the positive control
|
||||
* every "is gone" assertion below leans on), then both are switched off
|
||||
* through the real module route and the same surfaces are proven gone:
|
||||
*
|
||||
* - the Mood nav entry,
|
||||
* - the Mood and Medications pills in the Insights tab strip,
|
||||
* - the mood slot in the Insights trends row (the row fills from the next
|
||||
* metric instead),
|
||||
* - Mood and Medication in the add menu,
|
||||
* - `/insights/mood` renders the module notice, not the page,
|
||||
*
|
||||
* and no chart on the overview paints its error state.
|
||||
*
|
||||
* Every assertion is addressed to a stable `data-*` attribute, never to copy.
|
||||
* The journey moves its account's module switches, so it runs in one project
|
||||
* and serially (see `playwright.config.ts`).
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { MODULES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { modulesSeedState } from "./setup/modules-fixture";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
test.use({ storageState: MODULES_STORAGE_STATE_PATH });
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
/** A same-origin JSON write from the page, so the session cookie rides along. */
|
||||
async function send(
|
||||
page: Page,
|
||||
method: "POST" | "PATCH",
|
||||
path: string,
|
||||
body: unknown,
|
||||
): Promise<number> {
|
||||
return page.evaluate(
|
||||
async ({ method, path, body }) => {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
{ method, path, body },
|
||||
);
|
||||
}
|
||||
|
||||
async function setModules(page: Page, on: boolean): Promise<void> {
|
||||
expect(
|
||||
await send(page, "PATCH", "/api/auth/me/modules", {
|
||||
mood: on,
|
||||
medications: on,
|
||||
}),
|
||||
).toBe(200);
|
||||
}
|
||||
|
||||
/** One mood entry and one medication, created once, through the app. */
|
||||
async function seed(page: Page): Promise<void> {
|
||||
const state = await modulesSeedState();
|
||||
if (state.moodEntries === 0) {
|
||||
expect(
|
||||
await send(page, "POST", "/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
).toBe(201);
|
||||
}
|
||||
if (state.medications === 0) {
|
||||
expect(
|
||||
await send(page, "POST", "/api/medications", {
|
||||
name: "Module journey",
|
||||
dose: "5 mg",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
).toBe(201);
|
||||
}
|
||||
}
|
||||
|
||||
const moodPill = '[data-slot="insights-tab-strip-pill"][href="/insights/mood"]';
|
||||
const medsPill =
|
||||
'[data-slot="insights-tab-strip-pill"][href="/insights/medications"]';
|
||||
const trendCard = (metric: string) =>
|
||||
`[data-slot="trends-row-card"][data-metric="${metric}"]`;
|
||||
|
||||
async function openCapturePicker(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
const capture = page.locator('[data-testid="bottom-nav-capture"]');
|
||||
await expect(capture).toBeVisible({ timeout: 15_000 });
|
||||
await capture.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="capture-picker-measurement"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test("mood and medications, switched off, leave every surface they own", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await setModules(page, true);
|
||||
await seed(page);
|
||||
|
||||
// ── Positive control: with both modules on, each surface is there. ──
|
||||
await page.goto("/insights");
|
||||
await expect(page.locator(moodPill)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(medsPill)).toBeVisible();
|
||||
await expect(page.locator(trendCard("mood"))).toHaveCount(1);
|
||||
await expect(page.locator('[data-tour-id="nav-mood"]')).toHaveCount(1);
|
||||
|
||||
await openCapturePicker(page);
|
||||
await expect(page.locator('[data-testid="capture-picker-mood"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="capture-picker-medication"]'),
|
||||
).toHaveCount(1);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
// ── Switch both off through the real route. ──
|
||||
await setModules(page, false);
|
||||
|
||||
// Insights overview: no Mood or Medications pill, no mood trend slot, the
|
||||
// row still fills three slots from the next metric, and nothing errors.
|
||||
await page.goto("/insights");
|
||||
await expect(
|
||||
page.locator('[data-slot="insights-tab-strip-pill"][href="/insights"]'),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('[data-slot="trends-row"]')).toBeVisible();
|
||||
await expect(page.locator(moodPill)).toHaveCount(0);
|
||||
await expect(page.locator(medsPill)).toHaveCount(0);
|
||||
await expect(page.locator(trendCard("mood"))).toHaveCount(0);
|
||||
await expect(page.locator(trendCard("pulse"))).toHaveCount(1);
|
||||
await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount(0);
|
||||
|
||||
// The nav entry is gone; Insights itself is not a module's and stays.
|
||||
await expect(page.locator('[data-tour-id="nav-mood"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-tour-id="nav-insights"]')).toHaveCount(1);
|
||||
|
||||
// A direct visit: the notice, named for mood and for the record's own
|
||||
// switch (so it offers Settings), at the same URL, never the page.
|
||||
await page.goto("/insights/mood");
|
||||
await expect(page).toHaveURL(/\/insights\/mood$/);
|
||||
const notice = page.locator(
|
||||
'[data-slot="module-disabled-notice"][data-module="mood"]',
|
||||
);
|
||||
await expect(notice).toBeVisible({ timeout: 15_000 });
|
||||
await expect(notice).toHaveAttribute("data-module-access", "disabled");
|
||||
await expect(
|
||||
page.locator('[data-slot="module-off-open-settings"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount(0);
|
||||
|
||||
// The add menu offers a measurement and nothing the modules owned.
|
||||
await openCapturePicker(page);
|
||||
await expect(page.locator('[data-testid="capture-picker-mood"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="capture-picker-medication"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Leave the account as the next run expects to find it.
|
||||
await setModules(page, true);
|
||||
});
|
||||
@@ -145,6 +145,26 @@ export const E2E_MEDICATION = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The module-surfaces journey's own account (`modules-off.spec.ts`).
|
||||
*
|
||||
* It switches mood and medications off and on again, and those switches are
|
||||
* read by every surface of the account. On the shared account a parallel spec
|
||||
* reading a mood chart or the add menu would see them flip underneath it, so
|
||||
* the journey gets a record nobody else reads.
|
||||
*/
|
||||
export const E2E_MODULES = {
|
||||
email: "e2e-modules@healthlog.test",
|
||||
username: "e2e-modules",
|
||||
password: "Qm4!Vr8tK2wZp7Ls",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const MODULES_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateModules.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The notification-dispatch journey's own account.
|
||||
*
|
||||
@@ -724,6 +744,28 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The module-surfaces journey's account. Its module switches are set by
|
||||
// `e2e/setup/modules-fixture.ts` before every test.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_MODULES.username,
|
||||
E2E_MODULES.email,
|
||||
await hashPassword(E2E_MODULES.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// The notification-dispatch journey's account. Seeded like the others;
|
||||
// its channels, devices, ledger rows and preferences are reset by
|
||||
// `e2e/setup/notification-fixture.ts` before every test, because the
|
||||
@@ -1257,6 +1299,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// login — see `E2E_NOTIFY` for why the ledger counts need one.
|
||||
await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH);
|
||||
|
||||
// The module-surfaces journey's jar — see `E2E_MODULES`.
|
||||
await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH);
|
||||
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Fixture for the module-surfaces journey (`e2e/modules-off.spec.ts`).
|
||||
*
|
||||
* The journey runs on its own account (`E2E_MODULES`). This reads what that
|
||||
* account holds straight from Postgres; every WRITE goes through the app
|
||||
* (the module switch and the seed rows), so the caches the app keeps are
|
||||
* evicted by the app's own paths rather than read stale.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import { E2E_MODULES } from "./global-setup";
|
||||
|
||||
/** Rows the journey needs so the surfaces it hides would otherwise paint. */
|
||||
export interface ModulesSeedState {
|
||||
moodEntries: number;
|
||||
medications: number;
|
||||
}
|
||||
|
||||
export async function modulesSeedState(): Promise<ModulesSeedState> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[modules-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const user = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[E2E_MODULES.username],
|
||||
);
|
||||
const userId = user.rows[0]?.id;
|
||||
if (!userId) {
|
||||
throw new Error(
|
||||
"[modules-fixture] modules account not seeded — global-setup must run first",
|
||||
);
|
||||
}
|
||||
const [mood, meds] = await Promise.all([
|
||||
pool.query<{ n: string }>(
|
||||
"SELECT count(*)::text AS n FROM mood_entries WHERE user_id = $1",
|
||||
[userId],
|
||||
),
|
||||
pool.query<{ n: string }>(
|
||||
"SELECT count(*)::text AS n FROM medications WHERE user_id = $1 AND active = true",
|
||||
[userId],
|
||||
),
|
||||
]);
|
||||
return {
|
||||
moodEntries: Number(mood.rows[0]?.n ?? 0),
|
||||
medications: Number(meds.rows[0]?.n ?? 0),
|
||||
};
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.",
|
||||
"upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.",
|
||||
"unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden."
|
||||
},
|
||||
"moduleEnableError": "Das Nährstoffmodul konnte nicht aktiviert werden. Versuch es noch mal."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA"
|
||||
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.",
|
||||
"upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.",
|
||||
"unknown": "These entries arrived but couldn't be stored."
|
||||
},
|
||||
"moduleEnableError": "The nutrients module couldn't be turned on. Please try again."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Reference intake {value} {unit}/day · EFSA"
|
||||
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "Las entradas llevaban una fecha que el servidor no pudo aceptar. Esto puede ocurrir cuando el reloj o la zona horaria del lado que envía los datos están mal configurados; merece la pena revisarlo.",
|
||||
"upsertFailed": "Estas entradas llegaron al servidor, pero un problema de nuestro lado impidió guardarlas. No tienes que hacer nada.",
|
||||
"unknown": "Estas entradas llegaron, pero no se pudieron guardar."
|
||||
},
|
||||
"moduleEnableError": "No se pudo activar el módulo de nutrientes. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA"
|
||||
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.",
|
||||
"upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.",
|
||||
"unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées."
|
||||
},
|
||||
"moduleEnableError": "Le module nutriments n'a pas pu être activé. Réessaie."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA"
|
||||
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "Le voci riportavano una data che il server non ha potuto accettare. Questo può succedere quando l'orologio o il fuso orario dal lato che invia i dati non sono corretti: vale la pena controllare.",
|
||||
"upsertFailed": "Queste voci hanno raggiunto il server, ma un problema da questa parte ne ha impedito il salvataggio. Non devi fare nulla.",
|
||||
"unknown": "Queste voci sono arrivate ma non è stato possibile salvarle."
|
||||
},
|
||||
"moduleEnableError": "Non è stato possibile attivare il modulo nutrienti. Riprova."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA"
|
||||
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "기록에 서버가 받을 수 없는 날짜가 담겨 있었어요. 보내는 쪽의 시계나 시간대가 잘못됐을 때 생길 수 있으니 한번 확인해 보세요.",
|
||||
"upsertFailed": "기록이 서버까지 도착했지만 이쪽 문제로 저장되지 않았어요. 따로 하실 일은 없어요.",
|
||||
"unknown": "기록이 도착했지만 저장하지 못했어요."
|
||||
},
|
||||
"moduleEnableError": "영양소 모듈을 켜지 못했어요. 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "권장 섭취량 {value} {unit}/일 · EFSA"
|
||||
|
||||
+1
-2
@@ -10163,8 +10163,7 @@
|
||||
"dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.",
|
||||
"upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.",
|
||||
"unknown": "Te wpisy dotarły, ale nie udało się ich zapisać."
|
||||
},
|
||||
"moduleEnableError": "Nie udało się włączyć modułu składników odżywczych. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA"
|
||||
|
||||
@@ -246,6 +246,11 @@ export default defineConfig({
|
||||
// share the five-per-fifteen-minutes budget on `auth:register`, so
|
||||
// the second one meets a rate limit rather than the form.
|
||||
"invite-registration.spec.ts",
|
||||
// The module-surfaces journey switches its account's mood and
|
||||
// medications modules off and on; a second project would flip them
|
||||
// under the first mid-assertion. It sets the mobile width itself for
|
||||
// the add menu, so it runs in one project.
|
||||
"modules-off.spec.ts",
|
||||
],
|
||||
use: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
|
||||
@@ -34,8 +34,6 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -262,7 +260,6 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
export default function AchievementsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the whole page disappears: an empty state stands in
|
||||
@@ -279,29 +276,13 @@ export default function AchievementsPage() {
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated && !achievementsEnabled) {
|
||||
router.push("/insights");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, achievementsEnabled, router]);
|
||||
|
||||
if (authLoading || (achievementsEnabled && isLoading)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is
|
||||
// hidden by the same gate, so this only catches a direct URL hit. It used to
|
||||
// render `null` and stop there, which left a blank page with no explanation
|
||||
// and no route anywhere — a dead end rather than a gate. It now bounces to
|
||||
// /insights, matching every other module-gated page (`useModulePageGuard`);
|
||||
// the brief `null` is the pre-redirect frame, not the destination.
|
||||
//
|
||||
// The shared guard is not used wholesale here because it also redirects an
|
||||
// unauthenticated visitor to /auth/login, and this page deliberately answers
|
||||
// that case with a "sign in to see your achievements" header instead.
|
||||
if (isAuthenticated && !achievementsEnabled) {
|
||||
return null;
|
||||
}
|
||||
// Module off: the shell answers this URL with the module notice
|
||||
// (`ModulePageGate`) before the page renders, so there is no branch for it
|
||||
// here. `achievementsEnabled` above only keeps the query from firing.
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
|
||||
@@ -83,7 +83,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -100,6 +100,10 @@ beforeEach(() => {
|
||||
// prior test would serve a hit and skip the builder every assertion spies on.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
// Every module on unless a test says otherwise.
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof resolveModuleMap>>,
|
||||
);
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -249,6 +253,29 @@ describe("GET /api/insights/correlations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the lab draws with the labs module off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({
|
||||
labs: false,
|
||||
} as Awaited<ReturnType<typeof resolveModuleMap>>);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recomputes rather than serve a cached body after a module was switched off", async () => {
|
||||
// The switched-off set is part of the cache key. Watched red with the
|
||||
// suffix removed from the key: the second call is a cache hit and the
|
||||
// measurement window is read once.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq());
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({
|
||||
mood: false,
|
||||
} as Awaited<ReturnType<typeof resolveModuleMap>>);
|
||||
await callGet(makeReq());
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// v1.18.0 (B2) — the route now also requires the `insights` module.
|
||||
it("returns 403 + module.disabled when the insights module is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -23,7 +23,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
requireModuleEnabled,
|
||||
resolveModuleMap,
|
||||
} from "@/lib/modules/gate";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
@@ -89,12 +94,18 @@ export const GET = apiHandler(async () => {
|
||||
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
|
||||
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
|
||||
// so a cache hit performs no write on the read path.
|
||||
// Statistics serve only with their owning modules: a channel whose module
|
||||
// is off never enters the scan. The switched-off set is part of the cache
|
||||
// key, so toggling a module never serves a body computed under the other
|
||||
// setting.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildCorrelationsResponse>>
|
||||
>,
|
||||
`${user.id}|correlations|${locale}`,
|
||||
() => buildCorrelationsResponse(user.id, locale),
|
||||
`${user.id}|correlations|${locale}|off:${modulesOff.join(",")}`,
|
||||
() => buildCorrelationsResponse(user.id, locale, modules),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -104,6 +115,7 @@ export const GET = apiHandler(async () => {
|
||||
async function buildCorrelationsResponse(
|
||||
userId: string,
|
||||
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
|
||||
modules: SurfaceModuleMap,
|
||||
) {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -125,8 +137,15 @@ async function buildCorrelationsResponse(
|
||||
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed
|
||||
// a different pass over a different grain, so they are not matrix channels.
|
||||
const [matrix, labDraws] = await Promise.all([
|
||||
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }),
|
||||
fetchLabDraws(userId, tz, since),
|
||||
assembleDiscoveryMatrix(userId, {
|
||||
tz,
|
||||
since,
|
||||
fetchMode: "tiered",
|
||||
modules,
|
||||
}),
|
||||
isSurfaceVisible("correlation:LAB_DRAWS", modules)
|
||||
? fetchLabDraws(userId, tz, since)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const { series, diagnostics } = matrix;
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Droplet, Loader2 } from "lucide-react";
|
||||
import { Droplet } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
@@ -28,19 +27,9 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
export default function InsightsBlutzuckerPage() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("glucose");
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const isMmol = glucoseUnit === "mmol/L";
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_GLUCOSE"
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Leaf } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
|
||||
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
|
||||
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types";
|
||||
|
||||
@@ -53,12 +50,11 @@ function lastAttemptReasonKey(reason: string): string {
|
||||
* (self-gates to nothing without data), micronutrients (self-gates to
|
||||
* an EmptyState without data). Degradation ladder:
|
||||
*
|
||||
* - module off → the shared `ModuleDisabledNotice`, which names the reason
|
||||
* and carries the in-context enable CTA when it is the record's own
|
||||
* switch that is off. The module STAYS opt-in (2026-07-17 memo — the
|
||||
* HealthKit read prompt on the device is not visible consent to a
|
||||
* server / Coach holding a supplement pattern); this page just makes
|
||||
* the toggle discoverable in context instead of buried in Settings.
|
||||
* - module off → never reaches this page: the shell answers the URL with
|
||||
* the module notice (`ModulePageGate`), like every other module page.
|
||||
* The module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt on
|
||||
* the device is not visible consent to a server / Coach holding a
|
||||
* supplement pattern).
|
||||
* - module on, the overview read failed → a `QueryErrorCard` with a
|
||||
* retry. This branch has to sit ABOVE the empty-state check below:
|
||||
* `overview.data` is `undefined` on a failed read exactly like it is
|
||||
@@ -86,7 +82,6 @@ export default function InsightsNutrientsPage() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const nutrientsEnabled = user?.modules?.nutrients === true;
|
||||
|
||||
@@ -97,44 +92,6 @@ export default function InsightsNutrientsPage() {
|
||||
enabled: nutrientsEnabled,
|
||||
});
|
||||
|
||||
const enableModule = useMutation({
|
||||
mutationFn: () => apiPatch("/api/auth/me/modules", { nutrients: true }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
onError: () => toast.error(t("nutrients.page.moduleEnableError")),
|
||||
});
|
||||
|
||||
if (!nutrientsEnabled) {
|
||||
return (
|
||||
<SubPageShell title={t("nutrients.page.title")}>
|
||||
{/*
|
||||
The shared notice rather than this page's own copy: "nutrients is
|
||||
off" was three situations wearing one sentence, and only one of them
|
||||
is fixed by the button below. A delegate whose grant does not reach
|
||||
the measurements section, and an account on an instance where the
|
||||
operator removed the module, both used to be handed a switch that
|
||||
would refuse them. The notice reads the reason off `moduleAccess`
|
||||
and drops the action for the two cases it cannot help.
|
||||
*/}
|
||||
<ModuleDisabledNotice
|
||||
moduleKey="nutrients"
|
||||
icon={<Leaf className="size-6" />}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => enableModule.mutate()}
|
||||
disabled={enableModule.isPending}
|
||||
data-slot="nutrients-enable-module"
|
||||
>
|
||||
{t("nutrients.page.moduleOffCta")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (overview.isError) {
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Loader2 } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
|
||||
|
||||
@@ -29,16 +28,6 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
|
||||
*/
|
||||
export default function InsightsRecoveryPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("recovery");
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-recovery account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Loader2, Moon } from "lucide-react";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,7 +39,6 @@ import { useSleepRhythm } from "@/components/insights/sleep/use-sleep-rhythm";
|
||||
export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const { ready } = useModulePageGuard("sleep");
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
// The three rhythm cards share one `["sleep-rhythm"]` read, so a failure used
|
||||
@@ -51,16 +49,6 @@ export default function InsightsSchlafPage() {
|
||||
Boolean(user) && !isEmpty,
|
||||
);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
|
||||
// /insights instead of half-rendering the sleep surface.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -54,7 +51,6 @@ export default function InsightsWorkoutDetailPage({
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, error, refetch } = useWorkoutDetail(id);
|
||||
const canonicalHref = canonicalWorkoutDetailHref(id, data?.canonicalId);
|
||||
|
||||
@@ -62,15 +58,6 @@ export default function InsightsWorkoutDetailPage({
|
||||
if (canonicalHref) router.replace(canonicalHref);
|
||||
}, [canonicalHref, router]);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInfiniteWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
@@ -42,7 +41,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
*/
|
||||
export default function InsightsWorkoutsPageClient() {
|
||||
const { t, tCount } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const [sportType, setSportType] = useState<string | undefined>(undefined);
|
||||
const {
|
||||
workouts,
|
||||
@@ -76,15 +74,6 @@ export default function InsightsWorkoutsPageClient() {
|
||||
return label === key ? sport : label;
|
||||
};
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
@@ -34,10 +34,10 @@ import InsightsWorkoutsPageClient from "./page-client";
|
||||
* - The dehydrated first page is JSON-round-tripped and wrapped in TanStack's
|
||||
* `{ pages, pageParams }` infinite-data shape. Prisma `Date`s therefore
|
||||
* hydrate as the same ISO strings produced by the client wire response.
|
||||
* - Module-gate parity: the client page bounces a workouts-off account
|
||||
* (`useModulePageGuard`), and the API route refuses it server-side; skip the
|
||||
* prefetch when the module is off so a disabled page never seeds a cache it
|
||||
* will not read.
|
||||
* - Module-gate parity: the shell answers a workouts-off account with the
|
||||
* module notice instead of this page (`ModulePageGate`), and the API route
|
||||
* refuses it server-side; skip the prefetch when the module is off so a
|
||||
* disabled page never seeds a cache it will not read.
|
||||
* - The client cell keeps its own fetch (`staleTime: 60s`): the prefetch seeds
|
||||
* fresh data, so the mounted cell paints immediately and only refetches once
|
||||
* the TTL lapses — the "empty then fills" flash is gone without dropping the
|
||||
|
||||
+18
-17
@@ -144,7 +144,8 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
|
||||
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import {
|
||||
BATCH_COVERAGE_DAYS,
|
||||
@@ -315,17 +316,10 @@ export default function DashboardPageClient({
|
||||
const snapshotQuery = useDashboardSnapshot(snapshotEnabled);
|
||||
|
||||
// S2 — the Today hero reads the unified daily digest (the S1 DTO) from
|
||||
// its own cached route. Gated on the `insights` module (the digest is
|
||||
// the AI-narrative daily layer; the route 403s when insights is off)
|
||||
// and the auth state so a logged-out / gated account never fires it.
|
||||
// Two readings of one flag, on purpose. The FETCH gate takes the live
|
||||
// module map, so an account with insights off never fires the request. The
|
||||
// RENDER gate below takes the mount-pinned account, so the hydration render
|
||||
// agrees with the server (which had no account, and therefore the default-on
|
||||
// answer). See `useAccountOnceMounted`.
|
||||
const insightsModuleEnabled = useModuleEnabled("insights");
|
||||
const insightsEnabled = user?.modules?.insights !== false;
|
||||
const digestQuery = useDailyDigest(isAuthenticated && insightsModuleEnabled);
|
||||
// its own cached route. It is the day's data, so no module owns it: the
|
||||
// `insights` key means AI analysis, and the digest leaves its model-written
|
||||
// lead out on its own when that is off. Gated on auth only.
|
||||
const digestQuery = useDailyDigest(isAuthenticated);
|
||||
|
||||
// v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web
|
||||
// hero (Marc, live-use: uneven, wasted tile space). The snapshot still
|
||||
@@ -427,9 +421,14 @@ export default function DashboardPageClient({
|
||||
enabled: !snapshotEnabled && isAuthenticated,
|
||||
...DASHBOARD_QUERY_OPTS,
|
||||
});
|
||||
// The snapshot publishes its layout with switched-off modules' widgets
|
||||
// already hidden. The legacy feed is the stored layout as saved (Settings
|
||||
// edits that exact value), so the same mask is applied here, from the same
|
||||
// surface map, before anything paints.
|
||||
const layoutData = snapshotEnabled
|
||||
? snapshotQuery.data?.layout
|
||||
: layoutDataLegacy;
|
||||
: layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules);
|
||||
const moodWidgetVisible = useSurfaceVisible("widget:mood");
|
||||
|
||||
const { data: moodDataLegacy } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
@@ -439,7 +438,8 @@ export default function DashboardPageClient({
|
||||
summary: DataSummary;
|
||||
}>("/api/mood/analytics");
|
||||
},
|
||||
enabled: !snapshotEnabled && isAuthenticated,
|
||||
// The route refuses with the mood module off; do not ask.
|
||||
enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible,
|
||||
...DASHBOARD_QUERY_OPTS,
|
||||
});
|
||||
const moodData = snapshotEnabled
|
||||
@@ -963,7 +963,7 @@ export default function DashboardPageClient({
|
||||
`<QueryErrorCard>` with a retry action rather than silently
|
||||
falling through to nothing. The dense tile grid + charts below
|
||||
are untouched. */}
|
||||
{insightsEnabled &&
|
||||
{
|
||||
// v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero
|
||||
// (the LCP element) instead of a skeleton. This is hydration-safe ONLY
|
||||
// because of a bound invariant: the digest is server-DEHYDRATED
|
||||
@@ -982,7 +982,7 @@ export default function DashboardPageClient({
|
||||
// rehydrates from disk. The error / empty branches stay reachable only
|
||||
// post-mount. DO NOT add `["daily", …]` to that allowlist, or render
|
||||
// the hero from any client-only source, without revisiting this gate.
|
||||
(digestQuery.data ? (
|
||||
digestQuery.data ? (
|
||||
<TodayHero
|
||||
digest={digestQuery.data}
|
||||
renderFilteredAllClear={renderFilteredHeroAllClear}
|
||||
@@ -994,7 +994,8 @@ export default function DashboardPageClient({
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.isError ? (
|
||||
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
|
||||
) : null)}
|
||||
) : null
|
||||
}
|
||||
|
||||
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
|
||||
* (`AuthShell`) so its overlay survives the cross-page
|
||||
|
||||
+4
-9
@@ -13,7 +13,6 @@ import {
|
||||
type BatchWindow,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -62,8 +61,9 @@ function softTimeout<T>(ms: number): Promise<T | undefined> {
|
||||
* window from `computeBatchWindow(now, user.timezone)` threaded to the
|
||||
* client as the `batchWindow` prop. Server and client build the identical
|
||||
* key by construction, so the prefetched slice lands instead of refetching.
|
||||
* - Module-gate parity: the digest route gates on `insights`; skip its
|
||||
* prefetch when the module is off (the client hook is disabled then too).
|
||||
* - The digest is the day's data and belongs to no module (the `insights`
|
||||
* key is AI analysis, which the digest omits on its own), so it is
|
||||
* prefetched for every account, as the client fetches it for every account.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record. Every
|
||||
* read below scopes to `user`, and under a switch `user` is the DELEGATE —
|
||||
@@ -117,9 +117,6 @@ export default async function DashboardPage() {
|
||||
batchWindow = computeBatchWindow(new Date(), user.timezone);
|
||||
}
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const insightsEnabled = modules.insights !== false;
|
||||
|
||||
const seriesTypes = snapshotMode
|
||||
? deriveBatchChartTypes(
|
||||
resolveDashboardLayout(wireBody.layout),
|
||||
@@ -133,9 +130,7 @@ export default async function DashboardPage() {
|
||||
|
||||
// Digest carries a soft budget (cold-day extras rebuild); series is a
|
||||
// warm rollup read (< 100 ms) with none. Both fail soft to `undefined`.
|
||||
const digestWork = insightsEnabled
|
||||
? loadDailyDigest(user).catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
const digestWork = loadDailyDigest(user).catch(() => undefined);
|
||||
const seriesWork =
|
||||
batchWindow && seriesTypes.length > 0
|
||||
? readSeriesBatch(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import {
|
||||
LAYOUT_GROUPS,
|
||||
layoutGroupModule,
|
||||
LAYOUT_GROUP_IDS,
|
||||
isLayoutGroupId,
|
||||
} from "@/components/settings/layout-groups";
|
||||
@@ -83,7 +84,7 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
|
||||
>
|
||||
<RecordSettingsSectionGate section="layout">
|
||||
<section aria-labelledby={headingId} className="space-y-6">
|
||||
<LayoutModuleGate moduleGate={group.moduleGate}>
|
||||
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
|
||||
<Body />
|
||||
</LayoutModuleGate>
|
||||
</section>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const useAuthMock = vi.fn();
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
const hasClientMock = vi.fn(() => true);
|
||||
vi.mock("@/hooks/_internal/use-query-client-safe", () => ({
|
||||
useQueryClientMounted: () => hasClientMock(),
|
||||
}));
|
||||
|
||||
import { useSurfaceVisible } from "../use-surface-visible";
|
||||
|
||||
function Probe({ id }: { id: string }) {
|
||||
return <span>{String(useSurfaceVisible(id))}</span>;
|
||||
}
|
||||
|
||||
const visible = (id: string) =>
|
||||
renderToStaticMarkup(<Probe id={id} />).includes(">true<");
|
||||
|
||||
describe("useSurfaceVisible", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hasClientMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("hides a surface whose module the record switched off", () => {
|
||||
useAuthMock.mockReturnValue({ user: { modules: { mood: false } } });
|
||||
expect(visible("capture:mood")).toBe(false);
|
||||
expect(visible("capture:medication")).toBe(true);
|
||||
});
|
||||
|
||||
it("is on before the account resolves", () => {
|
||||
useAuthMock.mockReturnValue({ user: null });
|
||||
expect(visible("capture:mood")).toBe(true);
|
||||
});
|
||||
|
||||
it("is on without a query client, and never reads the account then", () => {
|
||||
hasClientMock.mockReturnValue(false);
|
||||
expect(visible("capture:mood")).toBe(true);
|
||||
expect(useAuthMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
||||
import { isSurfaceVisible, type StaticSurfaceId } from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* Whether a surface shows for the record this session is on, read through the
|
||||
* one surface map (`src/lib/modules/surface.ts`) against the resolved
|
||||
* `modules` of `GET /api/auth/me`.
|
||||
*
|
||||
* SSR-safe the way `useModuleEnabled` is: a presentational component rendered
|
||||
* without a `QueryClientProvider` gets the default-on answer instead of a
|
||||
* crash. The branch is stable for a component's lifetime, so the conditional
|
||||
* hook call is safe.
|
||||
*
|
||||
* Default-on: before the account resolves, and for every surface no module
|
||||
* owns, the answer is `true`. A caller whose first paint must match the server
|
||||
* render (nav lists, anything behind a hydration gate) keeps its own mounted
|
||||
* check on top.
|
||||
*/
|
||||
export function useSurfaceVisible(
|
||||
surfaceId: StaticSurfaceId | (string & {}),
|
||||
): boolean {
|
||||
const hasClient = useQueryClientMounted();
|
||||
if (!hasClient) return true;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useSurfaceVisibleInner(surfaceId);
|
||||
}
|
||||
|
||||
function useSurfaceVisibleInner(surfaceId: string): boolean {
|
||||
const { user } = useAuth();
|
||||
return isSurfaceVisible(surfaceId, user?.modules);
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
* Single source of truth for which widgets show on /, what order, and
|
||||
* what the default layout is for new users. Null / missing = default.
|
||||
*/
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import {
|
||||
PRIORITY_ITEM_KINDS,
|
||||
isPriorityItemKind,
|
||||
@@ -513,21 +512,6 @@ export function resolveHeroRingOrder(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring id → owning toggleable module. Mirrors the derived routes'
|
||||
* `DERIVED_MODULE` map for the three derived rings (READINESS rides the
|
||||
* recovery module like the recovery/strain/stress trio); MED_COMPLIANCE
|
||||
* belongs to the medications module. Client-safe (type-only ModuleKey
|
||||
* import) so the Settings picker and the server snapshot resolver gate
|
||||
* on the same map — the `WIDGET_MODULE_BY_ID` pattern.
|
||||
*/
|
||||
export const SCORE_RING_MODULE: Record<ScoreRingId, ModuleKey> = {
|
||||
READINESS: "recovery",
|
||||
RECOVERY_SCORE: "recovery",
|
||||
SLEEP_SCORE: "sleep",
|
||||
MED_COMPLIANCE: "medications",
|
||||
};
|
||||
|
||||
function isScoreRingId(value: unknown): value is ScoreRingId {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
|
||||
|
||||
const layout = {
|
||||
version: 2,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "medications", visible: true, tileVisible: true, order: 1 },
|
||||
{ id: "mood", visible: true, tileVisible: true, order: 2 },
|
||||
{ id: "hrv", visible: true, tileVisible: true, order: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("hideModuleWidgets", () => {
|
||||
it("hides the widgets of a switched-off module and keeps their order", () => {
|
||||
const out = hideModuleWidgets(layout, { medications: false });
|
||||
expect(out.widgets.map((w) => [w.id, w.visible, w.tileVisible])).toEqual([
|
||||
["weight", true, true],
|
||||
["medications", false, false],
|
||||
["mood", true, true],
|
||||
["hrv", true, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the ids it is told to on top of the module mask", () => {
|
||||
const out = hideModuleWidgets(layout, {}, new Set(["hrv"]));
|
||||
expect(out.widgets.find((w) => w.id === "hrv")?.visible).toBe(false);
|
||||
expect(out.widgets.find((w) => w.id === "mood")?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("is a no-op before the module map is known", () => {
|
||||
expect(hideModuleWidgets(layout, undefined).widgets).toEqual(
|
||||
layout.widgets,
|
||||
);
|
||||
});
|
||||
|
||||
it("never writes through to the layout it was given", () => {
|
||||
hideModuleWidgets(layout, { mood: false, medications: false });
|
||||
expect(layout.widgets.every((w) => w.visible)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* The resolver is tested in isolation with the derived dispatcher
|
||||
* mocked, pinning:
|
||||
* - module gating (owning module + the insights gate on derived rings);
|
||||
* - module gating (the owning module, from the surface map; the insights
|
||||
* key, AI analysis, gates no ring);
|
||||
* - the pass-through contract for derived scores (same resolvers the
|
||||
* batch route calls, no recomputation, non-`ok` → no ring);
|
||||
* - the dose ring: today's taken/scheduled progress off the shared
|
||||
@@ -117,7 +118,11 @@ describe("buildScoreRingsBlock() — selection + module gating", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("the insights gate drops derived rings but keeps the dose ring", async () => {
|
||||
it("keeps derived rings with AI analysis off: the scores are data", async () => {
|
||||
computeDerivedMetric.mockResolvedValue({
|
||||
status: "ok",
|
||||
value: { score: 70, band: "yellow" },
|
||||
});
|
||||
const rings = await buildScoreRingsBlock(
|
||||
fakePrisma,
|
||||
"user-1",
|
||||
@@ -126,16 +131,19 @@ describe("buildScoreRingsBlock() — selection + module gating", () => {
|
||||
NOW,
|
||||
medsToday(2, 2),
|
||||
);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
expect(loadBaselineProfile).not.toHaveBeenCalled();
|
||||
expect(rings).toEqual([
|
||||
{
|
||||
id: "MED_COMPLIANCE",
|
||||
score: 100,
|
||||
band: "green",
|
||||
doses: { taken: 2, scheduled: 2 },
|
||||
},
|
||||
]);
|
||||
expect(rings.map((r) => r.id)).toEqual(["READINESS", "MED_COMPLIANCE"]);
|
||||
});
|
||||
|
||||
it("drops the dose ring with the medications module off", async () => {
|
||||
const rings = await buildScoreRingsBlock(
|
||||
fakePrisma,
|
||||
"user-1",
|
||||
["MED_COMPLIANCE"],
|
||||
moduleMap({ medications: false }),
|
||||
NOW,
|
||||
medsToday(2, 2),
|
||||
);
|
||||
expect(rings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,22 +25,18 @@
|
||||
* taken, `yellow` while doses remain — never `red`: pending doses in
|
||||
* the morning are not an alert state.
|
||||
*
|
||||
* Module gating rides the client-safe `SCORE_RING_MODULE` map in
|
||||
* `@/lib/dashboard-layout` (mirroring the derived routes'
|
||||
* `DERIVED_MODULE`: readiness/recovery → recovery module, sleep score →
|
||||
* sleep module, MED_COMPLIANCE → medications) plus the `insights` gate
|
||||
* every derived read sits behind.
|
||||
* Module gating reads the surface map (`score-ring:<id>` in
|
||||
* `@/lib/modules/surface`): readiness/recovery → recovery, sleep score →
|
||||
* sleep, MED_COMPLIANCE → medications. The derived scores are data, so the
|
||||
* `insights` key (AI analysis) gates no ring.
|
||||
*
|
||||
* Every ring resolves fail-soft: a throwing engine drops its ring, never
|
||||
* the snapshot.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
SCORE_RING_IDS,
|
||||
SCORE_RING_MODULE,
|
||||
type ScoreRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { SCORE_RING_IDS, type ScoreRingId } from "@/lib/dashboard-layout";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
loadBaselineProfile,
|
||||
@@ -122,12 +118,7 @@ export async function buildScoreRingsBlock(
|
||||
): Promise<DashboardScoreRing[]> {
|
||||
const eligible = selected.filter((id) => {
|
||||
if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false;
|
||||
if (modules[SCORE_RING_MODULE[id]] === false) return false;
|
||||
// The derived scores are an insights-layer read everywhere else
|
||||
// (`/api/insights/derived*` sits behind the insights module) — the
|
||||
// snapshot honours the same gate.
|
||||
if (DERIVED_RING_METRIC[id] && modules.insights === false) return false;
|
||||
return true;
|
||||
return isSurfaceVisible(`score-ring:${id}`, modules);
|
||||
});
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
|
||||
@@ -135,8 +135,8 @@ export {
|
||||
SUMMARY_TYPE_MODULE,
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
WIDGET_MODULE_BY_ID,
|
||||
gateSummariesByModules,
|
||||
hideModuleWidgets,
|
||||
unavailableWidgetIds,
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
|
||||
@@ -1079,38 +1079,22 @@ function isThickPhaseWarm(coverage: RollupCoverageMap): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.0 — force every widget whose module is disabled to invisible on
|
||||
* the resolved layout (both `visible` and `tileVisible`). Order is
|
||||
* preserved so a re-enable restores the user's saved position. Pure
|
||||
* projection — the persisted `dashboardWidgetsJson` is untouched; only
|
||||
* what the snapshot publishes is gated.
|
||||
* The layout the snapshot publishes: widgets of a switched-off module, and
|
||||
* widgets that cannot paint for this account, forced invisible. Same rule the
|
||||
* Settings screen hides their toggles with, so the dashboard and the switches
|
||||
* never disagree. The stored layout is untouched.
|
||||
*/
|
||||
function gateLayoutByModules(
|
||||
layout: DashboardLayout,
|
||||
modules: Record<ModuleKey, boolean>,
|
||||
summaries: Record<string, DataSummary>,
|
||||
): DashboardLayout {
|
||||
// Widgets that cannot paint for THIS account, on the same rule the
|
||||
// Settings screen hides their toggle with — so the layout the dashboard
|
||||
// renders and the switches the user is offered never disagree.
|
||||
const unavailable = new Set(
|
||||
unavailableWidgetIds(modules, {
|
||||
hasSdnn: (summaries.HEART_RATE_VARIABILITY?.count ?? 0) > 0,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) => {
|
||||
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
|
||||
if (moduleKey && modules[moduleKey] === false) {
|
||||
return { ...w, visible: false, tileVisible: false };
|
||||
}
|
||||
if (unavailable.has(w.id)) {
|
||||
return { ...w, visible: false, tileVisible: false };
|
||||
}
|
||||
return w;
|
||||
}),
|
||||
};
|
||||
return hideModuleWidgets(layout, modules, unavailable);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,83 +13,67 @@
|
||||
* imports), so this file stays browser-bundle-safe.
|
||||
*/
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSurfaceVisible, surfaceModulesOfKind } from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* Dashboard widget id → toggleable module key. When the user disables the
|
||||
* module, the matching widget is forced invisible on both the web `layout`
|
||||
* and the iOS `layoutCatalogue`, so the tile/chart never paints. Only the
|
||||
* toggleable surfaces appear here; CORE widgets (weight / bp / pulse /
|
||||
* bodyFat / bpInTarget and the vital-derived HealthKit metrics) carry NO
|
||||
* entry and are never hidden.
|
||||
* Dashboard widget id → toggleable module key. A widget whose module is off
|
||||
* is forced invisible on both the web `layout` and the iOS
|
||||
* `layoutCatalogue`. CORE widgets carry no entry and are never hidden.
|
||||
*
|
||||
* v1.18.1 (D3) — `medications` graduated from CORE to a toggleable module,
|
||||
* so the medication tile now gates off it like the other toggleable widgets.
|
||||
* A view of the one surface map (`widget:*` in `@/lib/modules/surface`), so
|
||||
* the dashboard, the Settings list and every other surface answer from the
|
||||
* same declaration.
|
||||
*/
|
||||
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
|
||||
mood: "mood",
|
||||
sleep: "sleep",
|
||||
glucose: "glucose",
|
||||
achievements: "achievements",
|
||||
recentWorkouts: "workouts",
|
||||
medications: "medications",
|
||||
// v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery
|
||||
// module; the per-night breathing-disturbance widget belongs to sleep.
|
||||
cardioRecovery: "recovery",
|
||||
sixMinuteWalk: "recovery",
|
||||
stairAscentSpeed: "recovery",
|
||||
stairDescentSpeed: "recovery",
|
||||
breathingDisturbances: "sleep",
|
||||
// v1.29 — fluid-intake strip tile, nutrients-store-backed (see
|
||||
// `SUMMARY_TYPE_MODULE.NUTRIENT_WATER` below).
|
||||
waterIntake: "nutrients",
|
||||
};
|
||||
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> =
|
||||
surfaceModulesOfKind("widget");
|
||||
|
||||
/**
|
||||
* Slim-summary keys that belong to a toggleable module. When the module
|
||||
* is off the key is stripped from `tiles.summaries` /
|
||||
* `tiles.lastSeenByType` (so `metricStates` and the client data-floor
|
||||
* gates also drop it) before the snapshot leaves the server. Core vital
|
||||
* types are absent here and always pass through.
|
||||
* Summary keys that belong to a toggleable module. When the module is off the
|
||||
* key is stripped from `tiles.summaries` / `tiles.lastSeenByType` (so
|
||||
* `metricStates` and the client data-floor gates drop it too) before the
|
||||
* snapshot leaves the server. Core vital types are absent and always pass.
|
||||
*
|
||||
* v1.29 — widened from `Partial<Record<MeasurementType, ModuleKey>>` to
|
||||
* `Partial<Record<string, ModuleKey>>` so the synthetic `NUTRIENT_WATER`
|
||||
* key (a `NutrientIntakeDay`-derived summary, not a real
|
||||
* `MeasurementType` — the abandoned `feat/water` branch's parallel
|
||||
* `WATER_INTAKE` enum value is deliberately NOT added) can ride the same
|
||||
* gate without widening the `MeasurementType` enum itself.
|
||||
* Keyed by string rather than `MeasurementType` because two keys are
|
||||
* synthetic (`NUTRIENT_WATER`, `MOOD_ENTRY`: day totals derived from other
|
||||
* stores). A view of `summary:*` in `@/lib/modules/surface`.
|
||||
*/
|
||||
export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
SLEEP_DURATION: "sleep",
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
// v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page +
|
||||
// its tiles are the recovery module's surface; when it is off these
|
||||
// device-native signals must drop from the dashboard snapshot too.
|
||||
CARDIO_RECOVERY: "recovery",
|
||||
SIX_MINUTE_WALK_DISTANCE: "recovery",
|
||||
STAIR_ASCENT_SPEED: "recovery",
|
||||
STAIR_DESCENT_SPEED: "recovery",
|
||||
// The HRV fallback series. `METRIC_STATUS_MODULE_OWNERS` already owns this
|
||||
// type for the metric-status / MCP reads, for the reason stated there: a
|
||||
// fallback path must not serve the recovery data the primary type refuses.
|
||||
// The dashboard snapshot gates off THIS map instead, and the entry was
|
||||
// missing — harmless only while the dashboard HRV tile read SDNN alone.
|
||||
// Now that the tile falls back to RMSSD (`pickHrvSummary`), the omission
|
||||
// would hand a recovery-off account a tile built from recovery-owned rows.
|
||||
// `HEART_RATE_VARIABILITY` stays deliberately ungated: SDNN is a plain
|
||||
// vital, and it is what the tile shows whenever the account has it.
|
||||
HRV_RMSSD: "recovery",
|
||||
// Per-night breathing-disturbance index is a sleep-page signal.
|
||||
BREATHING_DISTURBANCES: "sleep",
|
||||
// v1.29 — fluid-intake dashboard tile summary, derived server-side from
|
||||
// `NutrientIntakeDay` (nutrient="water", summed across sources). Gated
|
||||
// on the `nutrients` module like the rest of that store's surfaces.
|
||||
NUTRIENT_WATER: "nutrients",
|
||||
// Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it
|
||||
// rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the
|
||||
// `mood` module: turning the module off must drop the dashboard card on
|
||||
// every client, not just hide the web widget.
|
||||
MOOD_ENTRY: "mood",
|
||||
};
|
||||
export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> =
|
||||
surfaceModulesOfKind("summary");
|
||||
|
||||
/**
|
||||
* Force every widget whose owning module is off, and every id in `alsoHide`,
|
||||
* to invisible (both `visible` and `tileVisible`). Order is kept so a
|
||||
* re-enable restores the saved position.
|
||||
*
|
||||
* A projection for RENDERING only. The stored `dashboardWidgetsJson` is never
|
||||
* written through this: `GET /api/dashboard/widgets` returns the stored
|
||||
* layout as saved, because Settings edits and re-saves exactly that value,
|
||||
* and a masked read saved back would switch the widget off for good. The
|
||||
* dashboard snapshot applies it server-side; the dashboard's legacy (no
|
||||
* snapshot) path applies it in the browser, so both feeds paint the same.
|
||||
*/
|
||||
export function hideModuleWidgets<
|
||||
L extends {
|
||||
widgets: ReadonlyArray<{
|
||||
id: string;
|
||||
visible: boolean;
|
||||
tileVisible?: boolean;
|
||||
}>;
|
||||
},
|
||||
>(
|
||||
layout: L,
|
||||
modules: Partial<Record<ModuleKey, boolean>> | null | undefined,
|
||||
alsoHide: ReadonlySet<string> = new Set(),
|
||||
): L {
|
||||
return {
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) =>
|
||||
isSurfaceVisible(`widget:${w.id}`, modules) && !alsoHide.has(w.id)
|
||||
? w
|
||||
: { ...w, visible: false, tileVisible: false },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary types a module map turns off. Lifted out of
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The discovery matrix leaves out every channel whose module the record
|
||||
* switched off, through the surface map, so no correlation statistic is
|
||||
* computed over a switched-off module.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pts = [{ day: "2026-09-01", value: 1 }];
|
||||
|
||||
vi.mock("@/lib/insights/correlation-channel-series", () => ({
|
||||
buildMeasurementDailySeries: vi.fn(),
|
||||
fetchMeasurementWindowSeries: vi.fn(),
|
||||
fetchMeasurementDailySeriesTiered: vi.fn(
|
||||
async (_u: string, _tz: string, _s: Date, types: string[]) => ({
|
||||
byType: new Map(types.map((t) => [t, pts])),
|
||||
measurementsCapped: false,
|
||||
rollupTypes: [],
|
||||
}),
|
||||
),
|
||||
fetchMoodWindowSeries: vi.fn(async () => ({
|
||||
moodDaily: pts,
|
||||
moodCapped: false,
|
||||
})),
|
||||
fetchMoodFactorWindowSeries: vi.fn(async () => ({
|
||||
moodDaily: pts,
|
||||
moodCapped: false,
|
||||
factorSeries: new Map([["FACTOR:work", pts]]),
|
||||
})),
|
||||
fetchComplianceSeries: vi.fn(async () => ({
|
||||
key: "MEDICATION_COMPLIANCE",
|
||||
role: "behaviour",
|
||||
points: pts,
|
||||
})),
|
||||
fetchSymptomSeries: vi.fn(async () => ({
|
||||
key: "SYMPTOM_SEVERITY",
|
||||
role: "outcome",
|
||||
points: pts,
|
||||
})),
|
||||
fetchEnvironmentSeries: vi.fn(async () => [
|
||||
{ key: "ENV_TEMP_MEAN", role: "behaviour", points: pts },
|
||||
]),
|
||||
fetchCustomMetricBehaviourSeries: vi.fn(async () => [
|
||||
{ key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts },
|
||||
]),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
assembleDiscoveryMatrix,
|
||||
maskSeriesByModules,
|
||||
} from "@/lib/insights/discovery-matrix";
|
||||
|
||||
const OPTS = {
|
||||
tz: "UTC",
|
||||
since: new Date("2026-03-01T00:00:00Z"),
|
||||
fetchMode: "tiered" as const,
|
||||
includeMoodFactors: true,
|
||||
};
|
||||
|
||||
const keysOf = (series: { key: string }[]) => [
|
||||
...new Set(series.map((s) => s.key)),
|
||||
];
|
||||
|
||||
describe("assembleDiscoveryMatrix — switched-off modules", () => {
|
||||
it("keeps every channel without a module map", async () => {
|
||||
const { series } = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
const keys = keysOf(series);
|
||||
for (const key of [
|
||||
"MOOD",
|
||||
"FACTOR:work",
|
||||
"SLEEP_DURATION",
|
||||
"BLOOD_GLUCOSE",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"SYMPTOM_SEVERITY",
|
||||
"ENV_TEMP_MEAN",
|
||||
"CUSTOM_METRIC:abc",
|
||||
"WEIGHT",
|
||||
]) {
|
||||
expect(keys).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops each module's channels, and only those, when it is off", async () => {
|
||||
const { series, byMetric } = await assembleDiscoveryMatrix("u1", {
|
||||
...OPTS,
|
||||
modules: {
|
||||
mood: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
medications: false,
|
||||
illness: false,
|
||||
environment: false,
|
||||
},
|
||||
});
|
||||
const keys = keysOf(series);
|
||||
for (const gone of [
|
||||
"MOOD",
|
||||
"FACTOR:work",
|
||||
"SLEEP_DURATION",
|
||||
"BLOOD_GLUCOSE",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"SYMPTOM_SEVERITY",
|
||||
"ENV_TEMP_MEAN",
|
||||
]) {
|
||||
expect(keys, gone).not.toContain(gone);
|
||||
expect(byMetric.has(gone), gone).toBe(false);
|
||||
}
|
||||
// Core vitals and custom metrics belong to no module.
|
||||
expect(keys).toContain("WEIGHT");
|
||||
expect(keys).toContain("BLOOD_PRESSURE_SYS");
|
||||
expect(keys).toContain("CUSTOM_METRIC:abc");
|
||||
});
|
||||
|
||||
it("drops nothing for AI analysis off", async () => {
|
||||
const all = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
const off = await assembleDiscoveryMatrix("u1", {
|
||||
...OPTS,
|
||||
modules: { insights: false },
|
||||
});
|
||||
expect(off.series.map((s) => s.key)).toEqual(all.series.map((s) => s.key));
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSeriesByModules", () => {
|
||||
it("keeps the fold order of what remains", () => {
|
||||
const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({
|
||||
key,
|
||||
role: "behaviour" as const,
|
||||
points: pts,
|
||||
}));
|
||||
expect(
|
||||
maskSeriesByModules(series, { mood: false }).map((s) => s.key),
|
||||
).toEqual(["WEIGHT", "PULSE"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema";
|
||||
import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface";
|
||||
import {
|
||||
selectTrendCharts,
|
||||
DEFAULT_TREND_CHART_CAP,
|
||||
@@ -158,23 +159,58 @@ describe("selectTrendCharts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectTrendCharts — hidden metrics", () => {
|
||||
it("drops mood from the fallback triple when the mood module is off", () => {
|
||||
const charts = selectTrendCharts(null, { hiddenMetrics: ["mood"] });
|
||||
expect(charts.map((c) => c.metric)).not.toContain("mood");
|
||||
expect(charts).toHaveLength(2);
|
||||
describe("selectTrendCharts — switched-off modules", () => {
|
||||
it("fills the fallback row from the next metric when mood is off", () => {
|
||||
const charts = selectTrendCharts(null, { modules: { mood: false } });
|
||||
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "pulse"]);
|
||||
});
|
||||
|
||||
it("skips a hidden metric the briefing flags", () => {
|
||||
it("keeps the familiar blood pressure, weight, mood row with every module on", () => {
|
||||
const charts = selectTrendCharts(null, { modules: {} });
|
||||
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
|
||||
});
|
||||
|
||||
it("skips a flagged metric whose module is off and lets the next finding take the slot", () => {
|
||||
const charts = selectTrendCharts(briefing(["sleep", "mood", "weight"]), {
|
||||
modules: { sleep: false, mood: false },
|
||||
});
|
||||
expect(charts.map((c) => c.metric)).toEqual(["weight"]);
|
||||
});
|
||||
|
||||
it("still honours an explicit hide list", () => {
|
||||
const charts = selectTrendCharts(briefing(["mood", "weight"]), {
|
||||
hiddenMetrics: ["mood"],
|
||||
});
|
||||
expect(charts.map((c) => c.metric)).not.toContain("mood");
|
||||
expect(charts).toHaveLength(1);
|
||||
expect(charts.map((c) => c.metric)).toEqual(["weight"]);
|
||||
});
|
||||
|
||||
it("keeps mood when nothing is hidden", () => {
|
||||
const charts = selectTrendCharts(null, { hiddenMetrics: [] });
|
||||
expect(charts.map((c) => c.metric)).toContain("mood");
|
||||
it("never returns a slot whose module is off, for any module map and briefing", () => {
|
||||
// Exhaustive over every on/off combination of the owning modules, with
|
||||
// every finding a briefing can carry, in both directions.
|
||||
const owners = [
|
||||
...new Set(
|
||||
Object.keys(TREND_CHART_CONFIG)
|
||||
.map((m) => surfaceModule(`trend:${m}`))
|
||||
.filter((k): k is NonNullable<typeof k> => k !== undefined),
|
||||
),
|
||||
];
|
||||
expect(owners.length).toBeGreaterThan(0);
|
||||
const all = Object.keys(TREND_CHART_CONFIG) as Array<
|
||||
keyof typeof TREND_CHART_CONFIG
|
||||
>;
|
||||
const briefings = [null, briefing(all), briefing([...all].reverse())];
|
||||
for (let mask = 0; mask < 1 << owners.length; mask += 1) {
|
||||
const modules = Object.fromEntries(
|
||||
owners.map((key, i) => [key, (mask & (1 << i)) === 0]),
|
||||
);
|
||||
for (const b of briefings) {
|
||||
for (const chart of selectTrendCharts(b, { modules })) {
|
||||
expect(
|
||||
isSurfaceVisible(`trend:${chart.metric}`, modules),
|
||||
`${chart.metric} under ${JSON.stringify(modules)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,11 @@ import {
|
||||
type MoodFactorWindowFetch,
|
||||
} from "@/lib/insights/correlation-channel-series";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
correlationChannelSurfaceId,
|
||||
isSurfaceVisible,
|
||||
type SurfaceModuleMap,
|
||||
} from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* How the measurement channels are read.
|
||||
@@ -117,6 +122,14 @@ export interface DiscoveryMatrixOptions {
|
||||
* measurement read instead of a second one.
|
||||
*/
|
||||
extraMeasurementTypes?: readonly MeasurementType[];
|
||||
/**
|
||||
* The record's resolved module map. A channel whose module is off
|
||||
* (`correlation:<key>` in the surface map: mood and its rated factors,
|
||||
* sleep, glucose, medication adherence, symptoms, weather) is left out of
|
||||
* `series` and `byMetric`, so no statistic is computed over a module the
|
||||
* person switched off. Omitted: every channel is kept.
|
||||
*/
|
||||
modules?: SurfaceModuleMap;
|
||||
}
|
||||
|
||||
/** Per-channel reach, for the callers' wide-event annotations. */
|
||||
@@ -150,6 +163,20 @@ export interface DiscoveryMatrix {
|
||||
diagnostics: DiscoveryMatrixDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave out every channel whose owning module is off. Pure, and applied after
|
||||
* the fold so the order of what remains is the order it was folded in (the
|
||||
* ranking stability note on {@link assembleDiscoveryMatrix}).
|
||||
*/
|
||||
export function maskSeriesByModules(
|
||||
series: readonly NamedSeries[],
|
||||
modules: SurfaceModuleMap | null | undefined,
|
||||
): NamedSeries[] {
|
||||
return series.filter((s) =>
|
||||
isSurfaceVisible(correlationChannelSurfaceId(s.key), modules),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mood read, normalised to the factor-carrying shape. With factors off the
|
||||
* factor map is empty, so the fold below has one code path rather than two.
|
||||
@@ -302,8 +329,14 @@ export async function assembleDiscoveryMatrix(
|
||||
byMetric.set(key, factorPoints);
|
||||
}
|
||||
|
||||
for (const key of [...byMetric.keys()]) {
|
||||
if (!isSurfaceVisible(correlationChannelSurfaceId(key), opts.modules)) {
|
||||
byMetric.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
series,
|
||||
series: maskSeriesByModules(series, opts.modules),
|
||||
byMetric,
|
||||
diagnostics: {
|
||||
measurementsCapped: measurements.measurementsCapped,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* v1.8.5 — derive the Insights overview "Trends" chart set from the
|
||||
@@ -261,6 +262,15 @@ export const DEFAULT_TREND_METRICS: ReadonlyArray<
|
||||
DailyBriefingKeyFinding["sourceMetric"]
|
||||
> = ["bp", "weight", "mood"];
|
||||
|
||||
/**
|
||||
* Metrics the fallback row reaches for, in order, when a default slot is left
|
||||
* out because its module is off. Pulse is a core vital, so a row without mood
|
||||
* still paints three charts rather than two.
|
||||
*/
|
||||
const FALLBACK_FILL_METRICS: ReadonlyArray<
|
||||
DailyBriefingKeyFinding["sourceMetric"]
|
||||
> = ["pulse"];
|
||||
|
||||
/** Max charts the row renders. Three keeps the equal-height 3-up grid. */
|
||||
export const DEFAULT_TREND_CHART_CAP = 3;
|
||||
|
||||
@@ -268,10 +278,13 @@ export interface SelectTrendChartsOptions {
|
||||
/** Max number of charts to return. Defaults to {@link DEFAULT_TREND_CHART_CAP}. */
|
||||
cap?: number;
|
||||
/**
|
||||
* Metrics whose module is switched off. They are skipped on both the
|
||||
* briefing path and the fallback triple, so a disabled module never
|
||||
* charts on the overview.
|
||||
* The record's resolved module map (`GET /api/auth/me` `modules`). A slot
|
||||
* whose module is off (`trend:<metric>` in the surface map) is skipped on
|
||||
* both the briefing path and the fallback row, and the next eligible
|
||||
* metric takes its place. Absent or partial: every slot is eligible.
|
||||
*/
|
||||
modules?: SurfaceModuleMap | null;
|
||||
/** Further metrics to leave out, on top of the module map. */
|
||||
hiddenMetrics?: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>;
|
||||
}
|
||||
|
||||
@@ -279,12 +292,14 @@ function configsFor(
|
||||
metrics: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>,
|
||||
cap: number,
|
||||
hidden: ReadonlySet<string>,
|
||||
modules: SurfaceModuleMap | null | undefined,
|
||||
): TrendChartConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const out: TrendChartConfig[] = [];
|
||||
for (const metric of metrics) {
|
||||
if (out.length >= cap) break;
|
||||
if (hidden.has(metric)) continue; // module switched off
|
||||
if (hidden.has(metric)) continue;
|
||||
if (!isSurfaceVisible(`trend:${metric}`, modules)) continue; // module off
|
||||
const config = TREND_CHART_CONFIG[metric];
|
||||
if (!config) continue; // metric has no standalone trend chart
|
||||
if (seen.has(config.metric)) continue; // dedupe
|
||||
@@ -313,11 +328,16 @@ export function selectTrendCharts(
|
||||
|
||||
const findingMetrics =
|
||||
briefing?.keyFindings?.map((f) => f.sourceMetric) ?? [];
|
||||
const fromBriefing = configsFor(findingMetrics, cap, hidden);
|
||||
const fromBriefing = configsFor(findingMetrics, cap, hidden, options.modules);
|
||||
|
||||
if (fromBriefing.length > 0) {
|
||||
return fromBriefing;
|
||||
}
|
||||
|
||||
return configsFor(DEFAULT_TREND_METRICS, cap, hidden);
|
||||
return configsFor(
|
||||
[...DEFAULT_TREND_METRICS, ...FALLBACK_FILL_METRICS],
|
||||
cap,
|
||||
hidden,
|
||||
options.modules,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Structural guard on the surface map (`src/lib/modules/surface.ts`).
|
||||
*
|
||||
* The map is the one answer to "which module owns this surface". It only
|
||||
* works if two things stay true, and each half of this file holds one:
|
||||
*
|
||||
* 1. Every id in the map names a surface that exists. A nav href, a slug, a
|
||||
* section id, a widget id that was renamed would leave an entry that
|
||||
* matches nothing, and the surface it meant would silently stop hiding.
|
||||
* Each family is checked against the list the app actually renders from.
|
||||
*
|
||||
* 2. Every surface that shows a module's data is owned by that module. This
|
||||
* half cannot come from the map itself, so it is derived from a second
|
||||
* source: the measurement types each surface plots, joined against the
|
||||
* summary owners the dashboard already gates on. A sleep page with no
|
||||
* sleep entry, or a trends slot charting a recovery-owned series with no
|
||||
* owner, fails here by name. A short table of owners that no data join
|
||||
* can derive (nav pages, the add menu, overview blocks) is pinned below.
|
||||
*
|
||||
* Plus the retirement half: the per-surface maps and guard hooks this map
|
||||
* replaced must not come back, so their names are searched for in source.
|
||||
*
|
||||
* Limits, stated so nobody reads more into a green run: the data join only
|
||||
* sees surfaces that declare the types they plot, so a surface that reads a
|
||||
* module's store without declaring a type would pass. The source search
|
||||
* matches names, not behaviour; a copy under a new name passes. Every match
|
||||
* set asserts a non-zero count so a matcher that stopped matching fails.
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The capture picker and the Settings shell pull in forms and sections; the
|
||||
// guard needs only their exported lists.
|
||||
vi.mock("@/components/measurements/measurement-form", () => ({
|
||||
MeasurementForm: () => null,
|
||||
}));
|
||||
vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null }));
|
||||
vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
|
||||
MedicationIntakeQuickAdd: () => null,
|
||||
}));
|
||||
|
||||
import {
|
||||
walkSourceFiles,
|
||||
stripComments,
|
||||
} from "@/__tests__/helpers/source-files";
|
||||
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
|
||||
import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker";
|
||||
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
|
||||
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
|
||||
import {
|
||||
DASHBOARD_WIDGET_CATALOGUE_IDS,
|
||||
SCORE_RING_IDS,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { SUMMARY_TYPE_MODULE } from "@/lib/dashboard/widget-modules";
|
||||
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
|
||||
import {
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
FACTOR_CHANNEL_PREFIX,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
|
||||
import { INSIGHTS_SECTION_IDS } from "@/lib/insights-layout";
|
||||
import {
|
||||
SUB_PAGE_SLUGS,
|
||||
TYPE_TO_SUB_PAGE_SLUG,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
|
||||
import {
|
||||
SURFACE_KINDS,
|
||||
SURFACE_MODULE,
|
||||
surfaceModule,
|
||||
surfaceModulesOfKind,
|
||||
type SurfaceKind,
|
||||
} from "@/lib/modules/surface";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function localIds(kind: SurfaceKind): string[] {
|
||||
const ids = Object.keys(surfaceModulesOfKind(kind));
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("every surface id names a surface that exists", () => {
|
||||
it("covers every kind with at least one entry", () => {
|
||||
for (const kind of SURFACE_KINDS) {
|
||||
expect(localIds(kind).length, kind).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("nav: a destination href", () => {
|
||||
const hrefs = new Set(NAV_DESTINATIONS.map((d) => d.href));
|
||||
for (const id of localIds("nav")) expect(hrefs.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("insights-page: a routed Insights sub-page", () => {
|
||||
for (const slug of localIds("insights-page")) {
|
||||
expect(
|
||||
existsSync(join(SRC, "app/insights", slug)),
|
||||
`src/app/insights/${slug}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("overview: an overview section id, or the cycle ring inside the scores strip", () => {
|
||||
const ids = new Set<string>([...INSIGHTS_SECTION_IDS, "cycle-ring"]);
|
||||
for (const id of localIds("overview")) expect(ids.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("trend: a trends-row slot that charts something", () => {
|
||||
for (const id of localIds("trend")) {
|
||||
expect(
|
||||
TREND_CHART_CONFIG[id as keyof typeof TREND_CHART_CONFIG],
|
||||
id,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("capture: an add-menu kind", () => {
|
||||
const kinds = new Set<string>(CAPTURE_KIND_ORDER);
|
||||
for (const id of localIds("capture")) expect(kinds.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("widget: a dashboard widget id", () => {
|
||||
const ids = new Set<string>(DASHBOARD_WIDGET_CATALOGUE_IDS);
|
||||
for (const id of localIds("widget")) expect(ids.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("derived: a derived metric id", () => {
|
||||
const ids = new Set<string>(DERIVED_METRIC_IDS);
|
||||
for (const id of localIds("derived")) expect(ids.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("score-ring: a hero score ring id, the derived ones owned like their score", () => {
|
||||
const ids = new Set<string>(SCORE_RING_IDS);
|
||||
for (const id of localIds("score-ring")) {
|
||||
expect(ids.has(id), id).toBe(true);
|
||||
const derivedOwner = surfaceModule(`derived:${id}`);
|
||||
if (derivedOwner !== undefined) {
|
||||
expect(surfaceModule(`score-ring:${id}`), id).toBe(derivedOwner);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("settings: a Settings section slug", () => {
|
||||
const slugs = new Set<string>(SETTINGS_SECTIONS.map((s) => s.slug));
|
||||
for (const id of localIds("settings")) expect(slugs.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("settings-layout: a Layout group id", () => {
|
||||
const ids = new Set<string>(LAYOUT_GROUP_IDS);
|
||||
for (const id of localIds("settings-layout")) {
|
||||
expect(ids.has(id), id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("correlation: a discovery channel, a weather field or the lab pass", () => {
|
||||
const channels = new Set<string>([
|
||||
...DISCOVERY_BEHAVIOURS,
|
||||
...DISCOVERY_OUTCOMES,
|
||||
...ENVIRONMENT_FIELDS.map((f) => f.key),
|
||||
"LAB_DRAWS",
|
||||
]);
|
||||
for (const id of localIds("correlation")) {
|
||||
expect(channels.has(id), id).toBe(true);
|
||||
}
|
||||
// `correlationChannelSurfaceId` folds rated factors onto mood by this
|
||||
// prefix; it has to be the prefix the engine actually writes.
|
||||
expect(FACTOR_CHANNEL_PREFIX).toBe("FACTOR:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("every surface that shows a module's data is owned by it", () => {
|
||||
/** Summary owners are the dashboard's own gate; the join source of truth. */
|
||||
const ownerOfType = (type: string) => SUMMARY_TYPE_MODULE[type];
|
||||
|
||||
/**
|
||||
* Sub-pages a module-owned type routes to but that are not that module's.
|
||||
* Each carries its reason; an entry here is a decision, not a silence.
|
||||
*/
|
||||
const NOT_OWNED_DESPITE_TYPE: Record<string, string> = {
|
||||
// The HRV page leads with SDNN, a plain vital. RMSSD is recovery-owned
|
||||
// and routes here only as the multi-metric fallback.
|
||||
hrv: "SDNN page; RMSSD is only its fallback series",
|
||||
};
|
||||
|
||||
it("Insights sub-pages follow the owner of the types they plot", () => {
|
||||
let joined = 0;
|
||||
for (const [type, slug] of Object.entries(TYPE_TO_SUB_PAGE_SLUG)) {
|
||||
const owner = ownerOfType(type);
|
||||
if (!owner || slug in NOT_OWNED_DESPITE_TYPE) continue;
|
||||
joined += 1;
|
||||
expect(
|
||||
surfaceModule(`insights-page:${slug}`),
|
||||
`${slug} plots ${type}, which ${owner} owns`,
|
||||
).toBe(owner);
|
||||
}
|
||||
expect(joined).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("trends-row slots follow the owner of the series they chart", () => {
|
||||
let joined = 0;
|
||||
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
|
||||
if (!config) continue;
|
||||
const owners = new Set(
|
||||
config.types.map(ownerOfType).filter((o) => o !== undefined),
|
||||
);
|
||||
if (config.kind === "mood") owners.add("mood");
|
||||
if (owners.size === 0) {
|
||||
expect(surfaceModule(`trend:${metric}`), metric).toBeUndefined();
|
||||
continue;
|
||||
}
|
||||
joined += 1;
|
||||
expect(owners.size, metric).toBe(1);
|
||||
expect(surfaceModule(`trend:${metric}`), metric).toBe([...owners][0]);
|
||||
}
|
||||
expect(joined).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("correlation channels that are summary types follow the summary owner", () => {
|
||||
let joined = 0;
|
||||
for (const key of [...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]) {
|
||||
const owner = ownerOfType(key);
|
||||
if (!owner) continue;
|
||||
joined += 1;
|
||||
expect(surfaceModule(`correlation:${key}`), key).toBe(owner);
|
||||
}
|
||||
expect(joined).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("every weather channel belongs to the environment module", () => {
|
||||
for (const field of ENVIRONMENT_FIELDS) {
|
||||
expect(surfaceModule(`correlation:${field.key}`), field.key).toBe(
|
||||
"environment",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Owners no data join can derive. Pinned by hand; each is a surface the
|
||||
// audit found leaking when its module was off.
|
||||
it.each([
|
||||
["nav:/mood", "mood"],
|
||||
["nav:/cycle", "cycle"],
|
||||
["nav:/medications", "medications"],
|
||||
["nav:/labs", "labs"],
|
||||
["nav:/coach", "coach"],
|
||||
["insights-page:mood", "mood"],
|
||||
["insights-page:medications", "medications"],
|
||||
["insights-page:workouts", "workouts"],
|
||||
["insights-page:recovery", "recovery"],
|
||||
["insights-page:nutrients", "nutrients"],
|
||||
["overview:breathing", "sleep"],
|
||||
["overview:labs-changes", "labs"],
|
||||
["overview:cycle-summary", "cycle"],
|
||||
["overview:cycle-ring", "cycle"],
|
||||
["capture:mood", "mood"],
|
||||
["capture:medication", "medications"],
|
||||
["trend:mood", "mood"],
|
||||
["widget:medications", "medications"],
|
||||
["correlation:MEDICATION_COMPLIANCE", "medications"],
|
||||
["correlation:SYMPTOM_SEVERITY", "illness"],
|
||||
["correlation:LAB_DRAWS", "labs"],
|
||||
])("%s is owned by %s", (id, owner) => {
|
||||
expect(surfaceModule(id)).toBe(owner);
|
||||
});
|
||||
|
||||
it("the insights key owns nothing (it is AI analysis, not an area)", () => {
|
||||
expect(Object.values(SURFACE_MODULE)).not.toContain("insights");
|
||||
});
|
||||
|
||||
it("the tab strip covers every sub-page slug it can render", () => {
|
||||
// Every owned sub-page that is a strip slug is reachable by the strip's
|
||||
// lookup; recovery is the one composite pill without a slug.
|
||||
const slugs = new Set<string>(SUB_PAGE_SLUGS);
|
||||
for (const id of localIds("insights-page")) {
|
||||
expect(slugs.has(id) || id === "recovery", id).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the replaced maps and guards stay gone", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 1000 }).filter(
|
||||
(rel) =>
|
||||
!rel.startsWith("generated/") &&
|
||||
!rel.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(rel),
|
||||
);
|
||||
const sources = files.map((rel) => ({
|
||||
rel,
|
||||
code: stripComments(readFileSync(join(SRC, rel), "utf8")),
|
||||
}));
|
||||
|
||||
it.each([
|
||||
["a per-destination module key", /\brequiresModule\s*[?:]/],
|
||||
["a per-section module key", /\bmoduleGate\s*\?\s*:|\bmoduleGate\s*:\s*"/],
|
||||
["the tab strip's own slug map", /\bSUB_PAGE_MODULE\b/],
|
||||
["the redirecting page guard", /\buseModulePageGuard\b/],
|
||||
])("no source declares %s", (_label, pattern) => {
|
||||
const hits = sources.filter((s) => pattern.test(s.code)).map((s) => s.rel);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("the dashboard maps are views, built from the surface map and nowhere else", () => {
|
||||
const widgetModules = sources.find(
|
||||
(s) => s.rel === "lib/dashboard/widget-modules.ts",
|
||||
);
|
||||
expect(widgetModules).toBeDefined();
|
||||
expect(widgetModules!.code).toMatch(
|
||||
/WIDGET_MODULE_BY_ID[^=]*=\s*surfaceModulesOfKind\(\s*"widget"\s*\)/,
|
||||
);
|
||||
expect(widgetModules!.code).toMatch(
|
||||
/SUMMARY_TYPE_MODULE[^=]*=\s*surfaceModulesOfKind\(\s*"summary"\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the derived-score gate agrees with the surface map", () => {
|
||||
// `DERIVED_MODULE` lives in the derived route, which this guard reads as
|
||||
// text rather than importing (it pulls the whole server stack). Parsed
|
||||
// entry by entry and compared, so a drift in either direction fails.
|
||||
const route = readFileSync(
|
||||
join(SRC, "app/api/insights/derived/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const body = /DERIVED_MODULE[^=]*=\s*\{([\s\S]*?)\}/.exec(route)?.[1];
|
||||
expect(body).toBeDefined();
|
||||
const entries = Object.fromEntries(
|
||||
[...body!.matchAll(/([A-Z_]+)\s*:\s*"([A-Za-z]+)"/g)].map((m) => [
|
||||
m[1],
|
||||
m[2],
|
||||
]),
|
||||
);
|
||||
expect(Object.keys(entries).length).toBeGreaterThan(0);
|
||||
expect(entries).toEqual(surfaceModulesOfKind("derived"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import {
|
||||
SURFACE_KINDS,
|
||||
SURFACE_MODULE,
|
||||
isSurfaceVisible,
|
||||
surfaceModule,
|
||||
surfaceModulesOfKind,
|
||||
} from "@/lib/modules/surface";
|
||||
|
||||
describe("SURFACE_MODULE", () => {
|
||||
it("owns every surface by a real module key", () => {
|
||||
const keys = new Set<string>(MODULE_KEYS);
|
||||
for (const [id, owner] of Object.entries(SURFACE_MODULE)) {
|
||||
expect(keys.has(owner), `${id} → ${owner}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("names every surface as <kind>:<local id> with a known kind", () => {
|
||||
const kinds = new Set<string>(SURFACE_KINDS);
|
||||
for (const id of Object.keys(SURFACE_MODULE)) {
|
||||
const kind = id.slice(0, id.indexOf(":"));
|
||||
expect(kinds.has(kind), id).toBe(true);
|
||||
expect(id.length).toBeGreaterThan(kind.length + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the Insights area to no module (the insights key is AI analysis only)", () => {
|
||||
expect(surfaceModule("nav:/insights")).toBeUndefined();
|
||||
expect(Object.values(SURFACE_MODULE)).not.toContain("insights");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSurfaceVisible", () => {
|
||||
it("hides an owned surface only on an explicit false", () => {
|
||||
expect(isSurfaceVisible("trend:mood", { mood: false })).toBe(false);
|
||||
expect(isSurfaceVisible("trend:mood", { mood: true })).toBe(true);
|
||||
expect(isSurfaceVisible("trend:mood", {})).toBe(true);
|
||||
expect(isSurfaceVisible("trend:mood", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("never hides a surface no module owns", () => {
|
||||
expect(isSurfaceVisible("trend:bp", { mood: false, sleep: false })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSurfaceVisible("nav:/measurements", { mood: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the owner's key, not the surface's name", () => {
|
||||
expect(isSurfaceVisible("capture:medication", { medications: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isSurfaceVisible("insights-page:blood-glucose", { glucose: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surfaceModulesOfKind", () => {
|
||||
it("strips the kind prefix and keeps only that kind", () => {
|
||||
const widgets = surfaceModulesOfKind("widget");
|
||||
expect(widgets.mood).toBe("mood");
|
||||
expect(widgets.recentWorkouts).toBe("workouts");
|
||||
expect(Object.keys(widgets).some((k) => k.includes(":"))).toBe(false);
|
||||
expect(widgets["/mood"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty view for a kind with no entries rather than throwing", () => {
|
||||
for (const kind of SURFACE_KINDS) {
|
||||
expect(typeof surfaceModulesOfKind(kind)).toBe("object");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Which module owns which surface, in one declarative map.
|
||||
*
|
||||
* A module a person switches off has to disappear everywhere at once: the nav
|
||||
* entry, the Insights pill, the overview card, the trends slot, the add menu,
|
||||
* the dashboard tile, the Settings row, the statistics. Each of those used to
|
||||
* keep its own little map (or none), and the ones with none were exactly where
|
||||
* a switched-off module kept showing up. This file is the one answer. Every
|
||||
* surface a toggleable module owns is listed here as `<kind>:<local id>`, and a
|
||||
* surface that is absent belongs to no module and is never hidden by one.
|
||||
*
|
||||
* The older per-surface maps are views of this one (`surfaceModulesOfKind`),
|
||||
* so they cannot drift from it. `src/lib/modules/__tests__/
|
||||
* surface-module-registry.test.ts` holds every list of surfaces the app renders
|
||||
* against this map.
|
||||
*
|
||||
* The `insights` key is deliberately absent. It means "AI analysis" (model
|
||||
* written text) and owns no surface: the Insights area, its charts and its
|
||||
* statistics are data and stay whatever their own module says. The AI
|
||||
* capability resolver reads that key; surfaces never do.
|
||||
*
|
||||
* Pure data plus two pure functions, no server or React dependency, so the
|
||||
* server builders and the browser read the same file. The React accessor is
|
||||
* `useSurfaceVisible` in `src/hooks/use-surface-visible.ts`.
|
||||
*
|
||||
* Paint, not enforcement: the routes behind a module refuse on their own
|
||||
* through `requireModuleEnabled`. Hiding a surface spares a click that would
|
||||
* end in a refusal or an empty chart.
|
||||
*/
|
||||
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/**
|
||||
* The surface families. The part of an id before the first `:`.
|
||||
*
|
||||
* - `nav`: a navigation destination, by its href (sidebar, bottom bar, the
|
||||
* More hub, the tour stop on that page, and the direct-URL notice).
|
||||
* - `insights-page`: an Insights sub-page, by its slug (the tab-strip pill
|
||||
* and the direct-URL notice under `/insights/<slug>`).
|
||||
* - `overview`: a block on the Insights overview, by its section id.
|
||||
* - `trend`: a trends-row chart slot, by its briefing `sourceMetric`.
|
||||
* - `capture`: an entry in the add menu, by its capture kind.
|
||||
* - `widget`: a dashboard widget, by its layout id.
|
||||
* - `summary`: a dashboard summary key (a `MeasurementType` or a synthetic
|
||||
* key such as `MOOD_ENTRY`), stripped from every summaries feed.
|
||||
* - `derived`: a derived score, by its `DerivedMetricId`.
|
||||
* - `settings`: a Settings section, by its slug.
|
||||
* - `settings-layout`: a Settings, Layout group, by its id.
|
||||
* - `correlation`: a correlation-discovery channel, by its channel key.
|
||||
* - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`.
|
||||
*/
|
||||
export const SURFACE_KINDS = [
|
||||
"nav",
|
||||
"insights-page",
|
||||
"overview",
|
||||
"trend",
|
||||
"capture",
|
||||
"widget",
|
||||
"summary",
|
||||
"derived",
|
||||
"settings",
|
||||
"settings-layout",
|
||||
"correlation",
|
||||
"score-ring",
|
||||
] as const;
|
||||
|
||||
export type SurfaceKind = (typeof SURFACE_KINDS)[number];
|
||||
|
||||
const STATIC_SURFACE_MODULE = {
|
||||
// ── Navigation destinations ──
|
||||
// `/insights` carries no owner: the area is data, not AI analysis.
|
||||
"nav:/mood": "mood",
|
||||
"nav:/mental-wellbeing": "mentalHealth",
|
||||
"nav:/cycle": "cycle",
|
||||
"nav:/medications": "medications",
|
||||
"nav:/labs": "labs",
|
||||
"nav:/illness": "illness",
|
||||
"nav:/vaccinations": "vaccinations",
|
||||
"nav:/documents": "inboundDocuments",
|
||||
"nav:/coach": "coach",
|
||||
"nav:/achievements": "achievements",
|
||||
|
||||
// ── Insights sub-pages ──
|
||||
"insights-page:mood": "mood",
|
||||
"insights-page:sleep": "sleep",
|
||||
// The per-night breathing-disturbance index is a sleep-page signal.
|
||||
"insights-page:breathing-disturbances": "sleep",
|
||||
"insights-page:blood-glucose": "glucose",
|
||||
"insights-page:workouts": "workouts",
|
||||
"insights-page:nutrients": "nutrients",
|
||||
// Medications is surface-gated by design: its data routes stay open so an
|
||||
// import or a sync keeps working, and these surfaces are what the switch
|
||||
// hides.
|
||||
"insights-page:medications": "medications",
|
||||
// Recovery is a composite page, not a slug with a series; the four
|
||||
// HealthKit recovery metrics below are the same module's, as on the
|
||||
// dashboard (`widget:cardioRecovery` …).
|
||||
"insights-page:recovery": "recovery",
|
||||
"insights-page:cardio-recovery": "recovery",
|
||||
"insights-page:six-minute-walk": "recovery",
|
||||
"insights-page:stair-ascent-speed": "recovery",
|
||||
"insights-page:stair-descent-speed": "recovery",
|
||||
|
||||
// ── Insights overview blocks ──
|
||||
"overview:cycle-summary": "cycle",
|
||||
// The cycle ring rides the wellness-scores strip rather than being a
|
||||
// section of its own; it follows the same key as the cycle summary, so the
|
||||
// two cannot disagree.
|
||||
"overview:cycle-ring": "cycle",
|
||||
// Breathing disturbances are measured overnight; the screening card is the
|
||||
// sleep module's.
|
||||
"overview:breathing": "sleep",
|
||||
"overview:labs-changes": "labs",
|
||||
|
||||
// ── Trends row ──
|
||||
"trend:mood": "mood",
|
||||
"trend:sleep": "sleep",
|
||||
|
||||
// ── Add menu ──
|
||||
"capture:mood": "mood",
|
||||
"capture:medication": "medications",
|
||||
|
||||
// ── Dashboard widgets ──
|
||||
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
|
||||
// HealthKit metrics) carry no entry and are never hidden.
|
||||
"widget:mood": "mood",
|
||||
"widget:sleep": "sleep",
|
||||
"widget:glucose": "glucose",
|
||||
"widget:achievements": "achievements",
|
||||
"widget:recentWorkouts": "workouts",
|
||||
"widget:medications": "medications",
|
||||
"widget:cardioRecovery": "recovery",
|
||||
"widget:sixMinuteWalk": "recovery",
|
||||
"widget:stairAscentSpeed": "recovery",
|
||||
"widget:stairDescentSpeed": "recovery",
|
||||
"widget:breathingDisturbances": "sleep",
|
||||
// The fluid-intake tile reads the nutrient store.
|
||||
"widget:waterIntake": "nutrients",
|
||||
|
||||
// ── Dashboard summary keys ──
|
||||
"summary:SLEEP_DURATION": "sleep",
|
||||
"summary:BLOOD_GLUCOSE": "glucose",
|
||||
"summary:CARDIO_RECOVERY": "recovery",
|
||||
"summary:SIX_MINUTE_WALK_DISTANCE": "recovery",
|
||||
"summary:STAIR_ASCENT_SPEED": "recovery",
|
||||
"summary:STAIR_DESCENT_SPEED": "recovery",
|
||||
// The nightly RMSSD series is the recovery module's. The dashboard HRV tile
|
||||
// falls back to it when an account has no SDNN, and a fallback must not
|
||||
// serve recovery data the primary type refuses. `HEART_RATE_VARIABILITY`
|
||||
// (SDNN) is a plain vital and stays unowned.
|
||||
"summary:HRV_RMSSD": "recovery",
|
||||
"summary:BREATHING_DISTURBANCES": "sleep",
|
||||
// Synthetic keys: derived from `NutrientIntakeDay` and `MoodEntry` rather
|
||||
// than a `MeasurementType`, riding the same gate.
|
||||
"summary:NUTRIENT_WATER": "nutrients",
|
||||
"summary:MOOD_ENTRY": "mood",
|
||||
|
||||
// ── Derived scores ──
|
||||
// READINESS equals the computed recovery score, so it follows recovery.
|
||||
// Composite baselines built on core vitals (VITALS_BASELINE, HRV_BALANCE …)
|
||||
// carry no entry.
|
||||
"derived:SLEEP_SCORE": "sleep",
|
||||
"derived:RECOVERY_SCORE": "recovery",
|
||||
"derived:STRAIN_SCORE": "recovery",
|
||||
"derived:STRESS_SCORE": "recovery",
|
||||
"derived:READINESS": "recovery",
|
||||
|
||||
// ── Dashboard hero score rings ──
|
||||
// The three derived rings follow their derived score; the dose ring is
|
||||
// medications'.
|
||||
"score-ring:READINESS": "recovery",
|
||||
"score-ring:RECOVERY_SCORE": "recovery",
|
||||
"score-ring:SLEEP_SCORE": "sleep",
|
||||
"score-ring:MED_COMPLIANCE": "medications",
|
||||
|
||||
// ── Settings sections ──
|
||||
"settings:environment": "environment",
|
||||
"settings:coach": "coach",
|
||||
|
||||
// ── Settings, Layout groups ──
|
||||
"settings-layout:medications": "medications",
|
||||
"settings-layout:mood": "mood",
|
||||
"settings-layout:labs": "labs",
|
||||
"settings-layout:illness": "illness",
|
||||
|
||||
// ── Correlation channels ──
|
||||
// Rated mood factors (`FACTOR:<key>`) are mood's too; see
|
||||
// `correlationChannelSurfaceId`.
|
||||
"correlation:MOOD": "mood",
|
||||
"correlation:SLEEP_DURATION": "sleep",
|
||||
"correlation:BLOOD_GLUCOSE": "glucose",
|
||||
"correlation:MEDICATION_COMPLIANCE": "medications",
|
||||
// Symptom burden is read from the illness journal.
|
||||
"correlation:SYMPTOM_SEVERITY": "illness",
|
||||
// The labs ↔ outcome pass over lab draws.
|
||||
"correlation:LAB_DRAWS": "labs",
|
||||
} as const satisfies Record<string, ModuleKey>;
|
||||
|
||||
/** A surface id the map declares literally. */
|
||||
export type StaticSurfaceId = keyof typeof STATIC_SURFACE_MODULE;
|
||||
|
||||
/**
|
||||
* The map. The weather channels are spread in from the environment field
|
||||
* table so a field added there is owned the moment it exists.
|
||||
*/
|
||||
export const SURFACE_MODULE: Readonly<Record<string, ModuleKey>> =
|
||||
Object.freeze({
|
||||
...STATIC_SURFACE_MODULE,
|
||||
...Object.fromEntries(
|
||||
ENVIRONMENT_FIELDS.map((field) => [
|
||||
`correlation:${field.key}`,
|
||||
"environment" as const,
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* A partial `ModuleKey → enabled` map: the `modules` field of
|
||||
* `GET /api/auth/me`, or `resolveModuleMap()` on the server. Only an explicit
|
||||
* `false` hides; a missing key or a missing map reads as on, matching the
|
||||
* gate's default-on contract.
|
||||
*/
|
||||
export type SurfaceModuleMap = Partial<Record<ModuleKey, boolean>>;
|
||||
|
||||
/** The module that owns a surface, or `undefined` for an unowned one. */
|
||||
export function surfaceModule(surfaceId: string): ModuleKey | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(SURFACE_MODULE, surfaceId)
|
||||
? SURFACE_MODULE[surfaceId]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a surface shows under a module map. An unowned surface always
|
||||
* shows; an owned one hides only when its module is explicitly `false`.
|
||||
*
|
||||
* Takes any string so callers can build ids from runtime values
|
||||
* (`widget:${id}`); the guard test is what keeps those ids honest.
|
||||
*/
|
||||
export function isSurfaceVisible(
|
||||
surfaceId: StaticSurfaceId | (string & {}),
|
||||
modules: SurfaceModuleMap | null | undefined,
|
||||
): boolean {
|
||||
const owner = surfaceModule(surfaceId);
|
||||
if (owner === undefined) return true;
|
||||
return modules?.[owner] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One family of the map with the `<kind>:` prefix stripped: `{ mood: "mood",
|
||||
* recentWorkouts: "workouts", … }` for `"widget"`. This is how the older
|
||||
* per-surface maps (`WIDGET_MODULE_BY_ID`, `SUMMARY_TYPE_MODULE`, …) are now
|
||||
* built, so they are views of this file rather than copies of it.
|
||||
*/
|
||||
export function surfaceModulesOfKind(
|
||||
kind: SurfaceKind,
|
||||
): Partial<Record<string, ModuleKey>> {
|
||||
const prefix = `${kind}:`;
|
||||
const out: Partial<Record<string, ModuleKey>> = {};
|
||||
for (const [id, owner] of Object.entries(SURFACE_MODULE)) {
|
||||
if (id.startsWith(prefix)) out[id.slice(prefix.length)] = owner;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface id of a correlation channel. Rated mood factors carry their own
|
||||
* `FACTOR:<key>` channel names but are mood entries all the same, so they
|
||||
* resolve to the mood channel.
|
||||
*/
|
||||
export function correlationChannelSurfaceId(channelKey: string): string {
|
||||
return channelKey.startsWith("FACTOR:")
|
||||
? "correlation:MOOD"
|
||||
: `correlation:${channelKey}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user