From c7f7a2925fcf05d897fa97a17183933de7a2cea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 14 Sep 2026 16:50:55 +0200 Subject: [PATCH 1/2] fix(insights): keep the overview reads open with the assistant switched off With the assistant switched off on the server, the Insights overview did not load. Its main read, /api/insights/comprehensive, was gated on the Coach surface, and the derived tiles, the ECG list, strip and live ingest, and the rhythm events were gated on the status-card surface. The master flag forces every surface off, so all of them answered 403 and the page replaced the whole overview with an error. None of these handlers calls a provider or returns assistant prose. They now gate on the insights module only, like health-status, labs-changes and patterns already do. comprehensive had no module gate of its own, only the Coach gate, so it gains the insights module gate the other five already call. Routes that serve generated text keep their gate. The route-gate inventory moves the six files onto the not-Coach-owned list, four OpenAPI descriptions drop the gate they no longer have, and a comprehensive test pins a 200 with the master flag off. Refs #975 --- docs/api/openapi.yaml | 16 +++---- .../coach-route-gate-inventory.test.ts | 30 ++++++------- .../comprehensive/__tests__/route.test.ts | 42 ++++++++++++++++++- src/app/api/insights/comprehensive/route.ts | 11 +++-- src/app/api/insights/derived/batch/route.ts | 4 +- src/app/api/insights/derived/route.ts | 4 +- src/app/api/insights/ecg/[id]/route.ts | 3 +- src/app/api/insights/ecg/route.ts | 7 ++-- src/app/api/insights/rhythm-events/route.ts | 3 +- src/lib/openapi/routes/insights/paths.ts | 8 ++-- 10 files changed, 82 insertions(+), 46 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c0de7bf46..a2de22e9a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10997,8 +10997,8 @@ paths: NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns - `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM - call. Auth via cookie or Bearer." + `hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie + or Bearer." responses: "200": description: The ECG recording list (possibly empty). @@ -11022,8 +11022,8 @@ paths: or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. - Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the - operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer." + Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no + assistant-surface gate and no LLM call. Auth via cookie or Bearer." requestBody: required: true content: @@ -11058,8 +11058,8 @@ paths: foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, - annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` - assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer. + annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM + call. `no-store`. Auth via cookie or Bearer. parameters: - in: path name: id @@ -11107,8 +11107,8 @@ paths: it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account - with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` - assistant surface; no LLM call. Auth via cookie or Bearer." + with no event rows returns `hasEvents: false`. Module-gated on `insights`; no assistant-surface gate and no LLM + call. Auth via cookie or Bearer." responses: "200": description: The device-flagged event timeline (possibly empty). diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 6c0d67024..8e822ae5d 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -42,13 +42,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", "src/app/api/insights/correlations/route.ts", - // v1.10.0 — generic derived-wellness-metric route. Pure compute over - // the rollup tier; gates on the same `insightStatus` sub-flag as the - // assessment routes (no Coach prose). - "src/app/api/insights/derived/route.ts", - // v1.10.0 — batched derived-metric route (the dashboard fan-out fix). - // Same pure compute + `insightStatus` sub-flag as the single route. - "src/app/api/insights/derived/batch/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", // v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the // same `insightStatus` sub-flag as the seven specialised status routes. @@ -62,17 +55,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // `coach`: a user with assessments enabled but Coach disabled can warm. "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", - // v1.10.0 — device-flagged event awareness timeline (categorical - // events, WX-B). Pure DB read of the device's own verdicts; gates on - // the same `insightStatus` sub-flag as the assessment routes (no Coach - // prose). - "src/app/api/insights/rhythm-events/route.ts", - // v1.28.50 — ECG recording surface (list + per-recording waveform). Pure - // DB read of the device's own recordings + verdicts; gates on the same - // `insightStatus` sub-flag as the assessment routes (no Coach prose — the - // waveform is never interpreted). - "src/app/api/insights/ecg/route.ts", - "src/app/api/insights/ecg/[id]/route.ts", "src/app/api/insights/weight-status/route.ts", ]; @@ -133,6 +115,18 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ "src/app/api/insights/chat/fenced/route.ts", "src/app/api/insights/chat/[id]/attachments/route.ts", "src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts", + // Deterministic reads behind the Insights overview: the comprehensive + // overview query, the derived-metric tiles, the ECG list, strip and live + // ingest, and the device-flagged rhythm events. None of them carries + // assistant prose or calls a provider; they gate on the `insights` module + // only. Switching the assistant off, the master or a sub-flag, must not + // refuse them, or the overview fails to load. + "src/app/api/insights/comprehensive/route.ts", + "src/app/api/insights/derived/route.ts", + "src/app/api/insights/derived/batch/route.ts", + "src/app/api/insights/ecg/route.ts", + "src/app/api/insights/ecg/[id]/route.ts", + "src/app/api/insights/rhythm-events/route.ts", ]; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index e6dde951a..082cc649e 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -88,8 +88,18 @@ vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn(async () => ({})), })); +// The route gates on the `insights` module. Mock it default-enabled so the +// envelope assertions ride through; the module-off coverage lives in the +// module route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; @@ -113,6 +123,8 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); + // resetAllMocks drops the module gate's default; restore it. + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); __resetAllCachesForTests(); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ @@ -121,7 +133,7 @@ beforeEach(() => { remaining: 119, resetAt: Date.now() + 60_000, }); - // Default to assistant-on so the gate doesn't 403 every test. + // No stored settings row: the provider probe falls back to defaults. (prisma.appSettings.findUnique as ReturnType).mockResolvedValue( null, ); @@ -210,6 +222,34 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { expect(body.data.dataSpanDays).toBe(0); }); + it("answers with the assistant switched off on the server", async () => { + // The overview's main read carries no assistant prose, so an operator who + // switches the assistant off (master flag) must still get the overview. + ( + prisma.appSettings.findUnique as ReturnType + ).mockResolvedValue({ + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: false, + }); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], + dailyByType: {}, + firstMeasurementAt: null, + totalMeasurements: 0, + }, + ); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + }); + it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); (buildComprehensiveAggregate as ReturnType).mockResolvedValue( diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a063ea1c5..607537676 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -28,8 +28,8 @@ import { requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { cachedSwrWithMeta, @@ -49,6 +49,8 @@ export const GET = apiHandler(async () => { // v1.37.0 — MANAGE-level read: computed over the whole record, with no // provider anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); @@ -56,9 +58,10 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } - // v1.4.31 — comprehensive feeds the hero strip narration and the - // recommendations grid that share the Coach gate. - await requireAssistantSurface("coach"); + // No assistant-surface gate. Every field is computed from the record + // (the provider chain is only probed for `hasProvider`), and this is the + // main read of the Insights overview: an operator who switches the + // assistant off still gets the overview. // v1.4.35 — read-through the analytics cache keyed on // (userId, "comprehensive"). The /insights page mount routinely diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 54cd3b294..e7d45d745 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -28,7 +28,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { MeasurementType } from "@/generated/prisma/client"; import { @@ -120,7 +119,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user, actor } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // Same deterministic compute as the single route, so no + // assistant-surface gate. // Per-caller limiter, same posture as the compliance routes: the cold // build fans out up to 24 rollup walks, so an unthrottled caller could diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 4d904aa4c..d51926c9e 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -24,7 +24,6 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { @@ -98,7 +97,8 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError("Too many analytics requests. Please retry later.", 429); } - await requireAssistantSurface("insightStatus"); + // Pure compute over the rollup tier, so no assistant-surface gate: + // switching the assistant off does not blank the derived tiles. const parsed = derivedQuerySchema.safeParse({ metric: request.nextUrl.searchParams.get("metric"), diff --git a/src/app/api/insights/ecg/[id]/route.ts b/src/app/api/insights/ecg/[id]/route.ts index 1be6a5351..7b05217be 100644 --- a/src/app/api/insights/ecg/[id]/route.ts +++ b/src/app/api/insights/ecg/[id]/route.ts @@ -28,7 +28,6 @@ import { NextRequest } from "next/server"; import { apiError, apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec"; @@ -48,7 +47,7 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // The waveform is never interpreted, so no assistant-surface gate. const { id } = await params; const full = request.nextUrl.searchParams.get("full") === "1"; diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 7cdf4a65f..18c28e6fa 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -42,7 +42,6 @@ import { } from "@/lib/api-response"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -113,7 +112,8 @@ export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // A pure read of the device's own recordings, so no assistant-surface + // gate. const rows = await prisma.ecgRecording.findMany({ where: { userId: user.id }, @@ -181,7 +181,8 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // A device ingest carries no assistant prose, so switching the assistant + // off must not refuse a recording. const rl = await checkRateLimit( `insights:ecg:ingest:${user.id}`, diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 8ea59813a..db7901301 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -27,7 +27,6 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; @@ -49,7 +48,7 @@ export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // A pure read of the device's own verdicts, so no assistant-surface gate. const rows = await prisma.measurement.findMany({ where: { diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 4d4f0d0f5..5f1705961 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -879,7 +879,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "ECG recording list (metadata only)", description: - "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.", responses: { "200": { description: "The ECG recording list (possibly empty).", @@ -897,7 +897,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Ingest one ECG recording", description: - "The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + "The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.", requestBody: { required: true, content: { @@ -938,7 +938,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "One ECG recording with waveform", description: - "v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.", + "v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM call. `no-store`. Auth via cookie or Bearer.", requestParams: { path: z.object({ id: z.string().describe("The ECG recording id (cuid)."), @@ -973,7 +973,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Device-flagged rhythm/HR/steadiness event timeline", description: - "v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + "v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.", responses: { "200": { description: "The device-flagged event timeline (possibly empty).", From 2b512501db4130632f341c3e9bec25275e7dcfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 14 Sep 2026 16:51:33 +0200 Subject: [PATCH 2/2] fix(insights): leave mood out of the trends row when its module is off The tab strip already hides the Mood pill when the mood module is off, but the trends row still charted mood, both from a briefing finding and in its blood pressure / weight / mood fallback. selectTrendCharts now takes the metrics to leave out, and the overview passes mood when the module is off. Refs #975 --- src/app/insights/page-client.tsx | 6 ++++++ src/components/insights/trends-row.tsx | 9 +++++++- .../__tests__/trend-chart-select.test.ts | 21 +++++++++++++++++++ src/lib/insights/trend-chart-select.ts | 13 ++++++++++-- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 936a33c09..d161b00e6 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -74,6 +74,9 @@ function BlockSkeleton({ ); } +/** Stable reference so the trends row does not re-select on every render. */ +const HIDDEN_MOOD = ["mood"] as const; + const DailyBriefing = dynamic( () => import("@/components/insights/daily-briefing").then((mod) => ({ @@ -513,6 +516,9 @@ export default function InsightsPageClient() { trends: ( diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 7ed4e976d..85d8255a8 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -13,6 +13,7 @@ import { selectTrendCharts, type TrendAnnotationKey, type TrendChartConfig, + type SelectTrendChartsOptions, } from "@/lib/insights/trend-chart-select"; import { TrendAnnotation, @@ -65,6 +66,11 @@ const MoodChart = dynamic( ); interface TrendsRowProps { + /** + * Metrics to leave out because their module is switched off (for + * example `mood`). Passed straight to `selectTrendCharts`. + */ + hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"]; /** * Daily briefing payload. Drives the chart set: the row charts the * metrics the briefing flags, in order, deduped + capped. `null` / @@ -132,13 +138,14 @@ export function TrendsRow({ annotations, confidence, loading = false, + hiddenMetrics, }: TrendsRowProps) { const { t } = useTranslations(); // 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); + const charts = selectTrendCharts(briefing, { hiddenMetrics }); // v1.4.36 W2 T3 — derive the tri-state status per metric from the // advisor's loading flag + the annotation presence. Pending wins diff --git a/src/lib/insights/__tests__/trend-chart-select.test.ts b/src/lib/insights/__tests__/trend-chart-select.test.ts index 209422697..eb812dddb 100644 --- a/src/lib/insights/__tests__/trend-chart-select.test.ts +++ b/src/lib/insights/__tests__/trend-chart-select.test.ts @@ -157,3 +157,24 @@ describe("selectTrendCharts", () => { expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps"); }); }); + +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); + }); + + it("skips a hidden metric the briefing flags", () => { + const charts = selectTrendCharts(briefing(["mood", "weight"]), { + hiddenMetrics: ["mood"], + }); + expect(charts.map((c) => c.metric)).not.toContain("mood"); + expect(charts).toHaveLength(1); + }); + + it("keeps mood when nothing is hidden", () => { + const charts = selectTrendCharts(null, { hiddenMetrics: [] }); + expect(charts.map((c) => c.metric)).toContain("mood"); + }); +}); diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index 31e04a6aa..2c3114ecb 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -267,16 +267,24 @@ export const DEFAULT_TREND_CHART_CAP = 3; 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. + */ + hiddenMetrics?: ReadonlyArray; } function configsFor( metrics: ReadonlyArray, cap: number, + hidden: ReadonlySet, ): TrendChartConfig[] { const seen = new Set(); const out: TrendChartConfig[] = []; for (const metric of metrics) { if (out.length >= cap) break; + if (hidden.has(metric)) continue; // module switched off const config = TREND_CHART_CONFIG[metric]; if (!config) continue; // metric has no standalone trend chart if (seen.has(config.metric)) continue; // dedupe @@ -301,14 +309,15 @@ export function selectTrendCharts( options: SelectTrendChartsOptions = {}, ): TrendChartConfig[] { const cap = Math.max(1, options.cap ?? DEFAULT_TREND_CHART_CAP); + const hidden = new Set(options.hiddenMetrics ?? []); const findingMetrics = briefing?.keyFindings?.map((f) => f.sourceMetric) ?? []; - const fromBriefing = configsFor(findingMetrics, cap); + const fromBriefing = configsFor(findingMetrics, cap, hidden); if (fromBriefing.length > 0) { return fromBriefing; } - return configsFor(DEFAULT_TREND_METRICS, cap); + return configsFor(DEFAULT_TREND_METRICS, cap, hidden); }