merge(reconcile): b1-dataroutes audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:20:45 +02:00
commit 3e0582d284
17 files changed
+430 -14

No files matched your search

@@ -107,11 +107,13 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/mood/tags/groups/[key]/route.ts",
"src/app/api/mood/tags/[key]/hidden/route.ts",
// ── DATA LAYER (workouts / labs) ──────────────────────────────────
// Raw Workout + LabResult CRUD + the iOS batch ingest. Same data-layer
// reasoning: the workouts / labs modules gate the surfaces (Insights
// pill, dashboard tile, doctor-report section), not the row store.
"src/app/api/workouts/route.ts",
"src/app/api/workouts/[id]/route.ts",
// The workout READ surfaces (`GET /api/workouts`, `GET /api/workouts/{id}`)
// are now module-gated — they back the hidden Insights workouts surface, so
// a disabled account must not read them even over a Bearer token. Only the
// iOS batch INGEST stays exempt: synced workouts must keep landing in the
// row store while the surface is hidden, so re-enabling reveals a complete
// history rather than a gap. LabResult CRUD follows the same data-layer
// reasoning (the labs module gates the surfaces, not the row store).
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
@@ -363,6 +363,66 @@ describe("GET /api/analytics", () => {
expect(body.data.lastSeenByType.PULSE).toBeNull();
});
// v1.18.0 B1 — the default slice serves the glucose-only surfaces
// (per-context summaries + the TIR/GMI/eA1C clinical panel) only when
// the `glucose` module is enabled. A disabled-glucose account nulls
// both fields rather than 403-ing the whole core-metrics payload.
function seedGlucoseRows() {
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
vi.mocked(prisma.measurement.findMany).mockImplementation(
(async (args: { where?: { type?: string } }) => {
if (args?.where?.type === "BLOOD_GLUCOSE") {
return [
{ value: 95, measuredAt, glucoseContext: "FASTING" },
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
];
}
return [];
}) as never,
);
}
it("serves glucose clinical + per-context when glucose enabled", async () => {
seedGlucoseRows();
vi.mocked(isModuleEnabled).mockResolvedValue(true as never);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
glucoseByContext: Record<string, unknown> | null;
glucoseClinical: unknown | null;
};
};
expect(body.data.glucoseClinical).not.toBeNull();
expect(body.data.glucoseByContext).not.toBeNull();
expect(body.data.glucoseByContext?.FASTING).toBeDefined();
});
it("nulls glucose clinical + per-context when glucose disabled", async () => {
seedGlucoseRows();
vi.mocked(isModuleEnabled).mockImplementation(
(async (_userId: string, key: string) => key !== "glucose") as never,
);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
glucoseByContext: Record<string, unknown> | null;
glucoseClinical: unknown | null;
summaries: Record<string, { count: number }>;
};
};
// Core metrics still served — the route did NOT 403.
expect(body.data.summaries.WEIGHT.count).toBe(0);
// Glucose-only surfaces are nulled.
expect(body.data.glucoseClinical).toBeNull();
expect(body.data.glucoseByContext).toBeNull();
});
// v1.4.49.1 — the 15-way per-type `fetchMeasurementSeriesChunked`
// fan-out was removed entirely; the default slice now delegates to
// `computeSummariesSlice` which runs three rollup-tier `$queryRaw`
+10 -2
View File
@@ -427,6 +427,14 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
// route serves core metrics too, so a disabled-glucose account must NOT
// 403 the whole payload; instead the glucose-only surfaces (per-context
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
// never reach a hidden surface. Mirrors the mood-pillar gate above.
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
@@ -447,8 +455,8 @@ async function buildAnalyticsResponse(user: AuthedUser) {
bpInTargetPctPriorYear,
bpInTargetCount90,
bpInTargetSpanDays90,
glucoseByContext,
glucoseClinical,
glucoseByContext: glucoseByContextOut,
glucoseClinical: glucoseClinicalOut,
correlations,
healthScore,
sleepStages,
+5
View File
@@ -29,6 +29,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
type RouteParams = { params: Promise<{ id: string }> };
@@ -39,6 +40,10 @@ export const GET = apiHandler(
annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } });
// v1.18.0 B1 — gate the detail surface behind the workouts module.
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const row = await prisma.workout.findUnique({
where: { id },
include: {
@@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({
})),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () => ({})),
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
};
});
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -113,6 +123,10 @@ describe("GET /api/workouts — canonical dedup", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
// clearAllMocks wipes the factory default; re-seed the gate to pass.
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
});
it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => {
@@ -139,6 +153,20 @@ describe("GET /api/workouts — canonical dedup", () => {
expect(res.status).toBe(401);
});
it("returns 403 module.disabled when the workouts module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: new Response(
JSON.stringify({ data: null, error: "Module disabled" }),
{ status: 403 },
),
} as never);
const res = await GET(makeRequest());
expect(res.status).toBe(403);
// The list query never runs once the gate refuses.
expect(prisma.workout.findMany).not.toHaveBeenCalled();
});
it("filters by ownership — only the caller's rows enter the picker", async () => {
// The mock returns whatever the route asked Prisma for; the route's
// `where: { userId: user.id }` filter is what enforces ownership.
@@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({
})),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () => ({})),
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
};
});
import { GET } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -94,6 +104,10 @@ describe("GET /api/workouts/{id}", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
// clearAllMocks wipes the factory default; re-seed the gate to pass.
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
});
it("returns the workout with iOS-contract field names", async () => {
@@ -123,6 +137,20 @@ describe("GET /api/workouts/{id}", () => {
expect(res.status).toBe(401);
});
it("returns 403 module.disabled when the workouts module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: new Response(
JSON.stringify({ data: null, error: "Module disabled" }),
{ status: 403 },
),
} as never);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(403);
// The detail query never runs once the gate refuses.
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
+8
View File
@@ -55,6 +55,7 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { requireModuleEnabled } from "@/lib/modules/gate";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
@@ -171,6 +172,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "workouts.list" } });
// v1.18.0 B1 — the workouts surface is a toggleable module. A disabled
// account must not be able to read the list even with a valid Bearer
// token. The batch INGEST route stays ungated so synced data still
// lands while the surface is hidden.
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const { searchParams } = new URL(request.url);
const limit = Math.min(
Math.max(
+12 -1
View File
@@ -1,10 +1,11 @@
"use client";
import { Droplet } from "lucide-react";
import { Droplet, Loader2 } 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 { resolveGlucoseUnit } from "@/lib/glucose";
@@ -29,9 +30,19 @@ const MGDL_PER_MMOL = 18.0182;
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"
+13
View File
@@ -1,6 +1,9 @@
"use client";
import { Loader2 } 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";
@@ -20,6 +23,16 @@ 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
+13 -1
View File
@@ -1,9 +1,10 @@
"use client";
import Link from "next/link";
import { Moon } from "lucide-react";
import { Loader2, 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";
@@ -35,9 +36,20 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
export default function InsightsSchlafPage() {
const { t } = useTranslations();
const { user } = useAuth();
const { ready } = useModulePageGuard("sleep");
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
// /insights instead of half-rendering the sleep surface.
if (!ready) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
if (isEmpty) {
return (
<SubPageShell
+13
View File
@@ -2,8 +2,11 @@
import { use } from "react";
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 { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -32,8 +35,18 @@ export default function InsightsWorkoutDetailPage({
}) {
const { t } = useTranslations();
const { id } = use(params);
const { ready } = useModulePageGuard("workouts");
const { data, isLoading, error } = useWorkoutDetail(id);
// 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")}
+12 -1
View File
@@ -1,9 +1,10 @@
"use client";
import { Activity } from "lucide-react";
import { Activity, Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts } 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";
@@ -28,8 +29,18 @@ import { Skeleton } from "@/components/ui/skeleton";
*/
export default function InsightsWorkoutsPage() {
const { t } = useTranslations();
const { ready } = useModulePageGuard("workouts");
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
// 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")}
+8 -2
View File
@@ -523,7 +523,11 @@ export default function DashboardPage() {
const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat;
const showMoodTile = isTileVisible("mood") && hasMood;
const showSleepTile = isTileVisible("sleep") && hasSleep;
const showStepsTile = isTileVisible("steps") && hasSteps;
// v1.18.0 B1 — steps are activity data tied to the workouts module
// surface; AND the resolved module flag (fail-open: only an explicit
// `false` hides it) so a disabled-workouts account drops the tile.
const showStepsTile =
isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false;
const showVo2Tile = isTileVisible("vo2Max") && hasVo2;
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
@@ -559,7 +563,9 @@ export default function DashboardPage() {
// no data floor, so it would otherwise flash in then retract for a user
// who hid it.
const showRecentWorkoutsTile =
layoutResolved && isChartVisible("recentWorkouts");
layoutResolved &&
isChartVisible("recentWorkouts") &&
user?.modules?.workouts !== false;
// v1.16.0 — shared reveal gate for the chart row. Every data-backed
// chart mounts as soon as its visibility gate flips (so the per-chart
@@ -0,0 +1,86 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.18.0 B1 — `useModulePageGuard` is the client-side direct-URL guard
* for the insights sub-pages of a toggleable module. The repo has no
* `@testing-library/react`, so the contract is exercised through an SSR
* Probe that renders the resolved `ready` flag. The redirect side-effect
* lives in a `useEffect` (no-op under SSR); the `ready` derivation is the
* load-bearing decision both the redirect and the loader branch read, so
* locking it covers the guard.
*/
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
const useAuthMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => useAuthMock(),
}));
import { useModulePageGuard } from "../use-module-page-guard";
function Probe({ moduleKey }: { moduleKey: "glucose" }) {
const { ready } = useModulePageGuard(moduleKey);
return <span data-testid="ready">{String(ready)}</span>;
}
function readyOf(): string {
const html = renderToStaticMarkup(<Probe moduleKey="glucose" />);
return html.includes(">true<") ? "true" : "false";
}
describe("useModulePageGuard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("is not ready while auth is loading", () => {
useAuthMock.mockReturnValue({
user: null,
isLoading: true,
isAuthenticated: false,
});
expect(readyOf()).toBe("false");
});
it("is not ready when unauthenticated", () => {
useAuthMock.mockReturnValue({
user: null,
isLoading: false,
isAuthenticated: false,
});
expect(readyOf()).toBe("false");
});
it("is not ready when the module is disabled (explicit false)", () => {
useAuthMock.mockReturnValue({
user: { modules: { glucose: false } },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("false");
});
it("is ready when authenticated and the module is enabled", () => {
useAuthMock.mockReturnValue({
user: { modules: { glucose: true } },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("true");
});
it("is ready (default-on) when the module key is absent", () => {
useAuthMock.mockReturnValue({
user: { modules: {} },
isLoading: false,
isAuthenticated: true,
});
expect(readyOf()).toBe("true");
});
});
+45
View File
@@ -0,0 +1,45 @@
"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 };
}
@@ -87,9 +87,12 @@ function moduleMap(
import {
buildDashboardSnapshot,
WIDGET_MODULE_BY_ID,
SUMMARY_TYPE_MODULE,
type SnapshotUserInput,
} from "../snapshot";
import type { ModuleKey } from "@/lib/modules/gate";
import { MODULE_KEYS } from "@/lib/modules/registry";
const emptySummary = {
count: 0,
@@ -796,6 +799,67 @@ describe("buildDashboardSnapshot — additive proof", () => {
});
});
describe("module gating maps — every toggleable-domain widget is mapped (v1.18.0)", () => {
// The dashboard widget ids that belong to a toggleable module. A new
// toggleable widget MUST be added here AND to `WIDGET_MODULE_BY_ID`, or
// it would leak onto a disabled-module account's dashboard.
const TOGGLEABLE_WIDGET_IDS = [
"mood",
"sleep",
"glucose",
"achievements",
"recentWorkouts",
"cardioRecovery",
"sixMinuteWalk",
"stairAscentSpeed",
"stairDescentSpeed",
"breathingDisturbances",
] as const;
it("every toggleable-domain widget id has a module mapping", () => {
for (const id of TOGGLEABLE_WIDGET_IDS) {
expect(
WIDGET_MODULE_BY_ID[id],
`widget "${id}" is missing a WIDGET_MODULE_BY_ID entry`,
).toBeDefined();
}
});
it("every mapped widget value is a real ModuleKey", () => {
for (const [id, key] of Object.entries(WIDGET_MODULE_BY_ID)) {
expect(
MODULE_KEYS.includes(key as ModuleKey),
`widget "${id}" maps to unknown module "${key}"`,
).toBe(true);
}
});
it("every summary-type mapping value is a real ModuleKey", () => {
for (const [type, key] of Object.entries(SUMMARY_TYPE_MODULE)) {
expect(
MODULE_KEYS.includes(key as ModuleKey),
`summary type "${type}" maps to unknown module "${key}"`,
).toBe(true);
}
});
it("recovery-domain widgets resolve to the recovery module", () => {
expect(WIDGET_MODULE_BY_ID.cardioRecovery).toBe("recovery");
expect(WIDGET_MODULE_BY_ID.sixMinuteWalk).toBe("recovery");
expect(WIDGET_MODULE_BY_ID.stairAscentSpeed).toBe("recovery");
expect(WIDGET_MODULE_BY_ID.stairDescentSpeed).toBe("recovery");
expect(SUMMARY_TYPE_MODULE.CARDIO_RECOVERY).toBe("recovery");
expect(SUMMARY_TYPE_MODULE.SIX_MINUTE_WALK_DISTANCE).toBe("recovery");
expect(SUMMARY_TYPE_MODULE.STAIR_ASCENT_SPEED).toBe("recovery");
expect(SUMMARY_TYPE_MODULE.STAIR_DESCENT_SPEED).toBe("recovery");
});
it("breathing-disturbance widget + type resolve to the sleep module", () => {
expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep");
expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep");
});
});
describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
const widget = (snap: { layout: { widgets: { id: string; visible: boolean; tileVisible?: boolean }[] } }, id: string) =>
snap.layout.widgets.find((w) => w.id === id);
+18 -2
View File
@@ -91,12 +91,19 @@ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
* (weight / bp / pulse / bodyFat / medications / bpInTarget and the
* vital-derived HealthKit metrics) carry NO entry and are never hidden.
*/
const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
mood: "mood",
sleep: "sleep",
glucose: "glucose",
achievements: "achievements",
recentWorkouts: "workouts",
// 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",
};
/**
@@ -106,9 +113,18 @@ const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
* client data-floor gates also drop it) before the snapshot leaves the
* server. Core vital types are absent here and always pass through.
*/
const SUMMARY_TYPE_MODULE: Partial<Record<MeasurementType, ModuleKey>> = {
export const SUMMARY_TYPE_MODULE: Partial<Record<MeasurementType, 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",
// Per-night breathing-disturbance index is a sleep-page signal.
BREATHING_DISTURBANCES: "sleep",
};
const GLUCOSE_CONTEXTS = [