From 367fcd5332a14082b0d3c0c10ccb55847cbaa916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:56:16 +0100 Subject: [PATCH] fix(insights): gate the tab strip on one presence read The tab strip on every Insights page read the whole overview (22 KB), a workouts page, the ECG list and the analytics summaries to learn which pills to show. GET /api/insights/presence answers just that, for the record on screen: the reading count per type after the record's module switches, and whether it has mood entries in the last 90 days, a tracked scheduled medication, workouts and ECG recordings. A flag whose page a switched-off module hides is false and its rows are not read. The strip now reads this and the saved layout; each page keeps loading its own data. --- docs/api/openapi.yaml | 79 ++++++++ .../baselines/version-stamp-comments.json | 4 +- .../client-poll-inventory-guard.test.ts | 5 - src/__tests__/sharing-surface-guard.test.ts | 14 +- src/app/api/insights/presence/route.ts | 97 +++++++++ .../insights-layout-shell-reads.test.tsx | 41 ++-- .../insights/insights-layout-shell.tsx | 128 ++++-------- src/lib/insights/metric-availability.ts | 18 +- src/lib/openapi/routes/insights/paths.ts | 24 +++ src/lib/openapi/routes/insights/schemas.ts | 30 +++ src/lib/query-keys/insights.ts | 2 + tests/integration/insights-presence.test.ts | 184 ++++++++++++++++++ 12 files changed, 497 insertions(+), 129 deletions(-) create mode 100644 src/app/api/insights/presence/route.ts create mode 100644 tests/integration/insights-presence.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 443b3d55a..7e17a0f95 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13115,6 +13115,27 @@ paths: "403": *a20 "422": *a4 "429": *a5 + /api/insights/presence: + get: + tags: + - Insights + summary: Which Insights pages have data + description: "What the Insights tab strip gates its pills on, for the record on screen: the reading count per + measurement type (after the record's module switches) and whether the record has mood entries in the last 90 + days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts + and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at + MANAGE level over the whole record. Cookie or Bearer auth." + responses: + "200": + description: The presence answer. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsPresenceEnvelope" + "401": *a1 + "403": *a20 + "422": *a4 + "429": *a5 /api/insights/cards: get: tags: @@ -34933,6 +34954,7 @@ components: - BLOOD_PRESSURE_SYS - PULSE - BLOOD_GLUCOSE + - BLOOD_KETONES - OXYGEN_SATURATION - BODY_TEMPERATURE - BODY_FAT @@ -68518,6 +68540,63 @@ components: additionalProperties: false description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant. + InsightsPresenceEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsPresence" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsPresence: + type: object + properties: + metrics: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - count + additionalProperties: false + description: "Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's + module switches. The same counts `GET /api/analytics?slice=summaries` reports." + hasMood: + type: boolean + description: A mood entry in the last 90 days. False while the mood module is off. + hasMedication: + type: boolean + description: An active medication with a schedule whose intake is tracked. False while the medications module is off. + hasWorkouts: + type: boolean + description: At least one workout. False while the workouts module is off. + hasEcgRecordings: + type: boolean + description: At least one ECG recording. + required: + - metrics + - hasMood + - hasMedication + - hasWorkouts + - hasEcgRecordings + additionalProperties: false + description: "Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts + and flags only, no values." InsightsCardsEnvelope: type: object properties: diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index bd8617a0b..10a8c80c5 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2876, - "total": 9172, + "total": 9164, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -982,7 +982,7 @@ "components/insights/hero-strip.tsx": 3, "components/insights/insight-status-card.tsx": 12, "components/insights/insights-edit-mode.tsx": 5, - "components/insights/insights-layout-shell.tsx": 11, + "components/insights/insights-layout-shell.tsx": 3, "components/insights/insights-tab-strip.tsx": 35, "components/insights/intraday-pulse-chart.tsx": 1, "components/insights/labs-changes-card.tsx": 1, diff --git a/src/__tests__/client-poll-inventory-guard.test.ts b/src/__tests__/client-poll-inventory-guard.test.ts index 48d16b993..95d17ae01 100644 --- a/src/__tests__/client-poll-inventory-guard.test.ts +++ b/src/__tests__/client-poll-inventory-guard.test.ts @@ -136,11 +136,6 @@ const INVENTORY: Readonly> = { cadence: "25 s, nextAdvisorPollInterval, attempts from the response timeout so the warm can land (advisor); 10 while settling", }, - "components/insights/insights-layout-shell.tsx": { - sites: 1, - shape: "bounded", - cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)", - }, }; const SITE = /\brefetchInterval\s*:/g; diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d86d5e959..0609e0dcc 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1511,6 +1511,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/insights/presence/route.ts": { + domain: "record", + why: "Which Insights pages have data: reading counts per type and four presence flags, the gate of the tab strip over the overview. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/insights/correlations/route.ts": { domain: "record", why: "Correlations over the record's own series. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -2049,6 +2053,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path.", }, + "app/api/insights/presence/route.ts": { + domain: "record", + conditions: [], + why: "Counts and flags only, no values, from the cells the overview and the summaries slice already serve; no provider on the path.", + }, "app/api/insights/correlations/route.ts": { domain: "record", conditions: [], @@ -2304,8 +2313,11 @@ const ACTOR_ROUTES: Record = { * list: a standing credential to a third-party calendar is the owner's to * make, or a managed profile's guardian's, never an adult record's delegate's. * 276 -> 278. + * + * The Insights presence read joins the record list and the manage literal: + * the tab strip's gate over the overview, counts and flags only. 278 -> 280. */ -const FROZEN_ENTRY_COUNT = 278; +const FROZEN_ENTRY_COUNT = 280; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/insights/presence/route.ts b/src/app/api/insights/presence/route.ts new file mode 100644 index 000000000..541ca3d9c --- /dev/null +++ b/src/app/api/insights/presence/route.ts @@ -0,0 +1,97 @@ +/** + * GET /api/insights/presence — which Insights pages have something to show. + * + * The tab strip on every Insights page gates its pills on this answer alone: + * the reading count per measurement type, and whether the record has mood + * entries, a scheduled medication, workouts and ECG recordings. The counts are + * the summaries slice's cell, after the record's module switches; mood and + * medication mean what the overview means by them. A flag whose page a + * switched-off module hides (`src/lib/modules/surface.ts`) is false, and its + * rows are not read. + * + * No AI gate: every field is computed from the record. MANAGE-level read over + * the whole record, like the overview the strip sits on. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { gateSummariesByModules } from "@/lib/dashboard/widget-modules"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { isSurfaceVisible } from "@/lib/modules/surface"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; + +export const dynamic = "force-dynamic"; + +/** The overview's mood window: a mood pill needs an entry inside it. */ +const MOOD_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; + +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("manage", "record"); + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const modules = await resolveModuleMap(user.id); + const shows = (slug: string) => + isSurfaceVisible(`insights-page:${slug}`, modules); + const userId = user.id; + const [slice, mood, medication, workout, ecg] = await Promise.all([ + readSummariesSlice(userId, { serveStale: true }), + shows("mood") + ? prisma.moodEntry.findFirst({ + where: { + userId, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.now() - MOOD_WINDOW_MS) }, + }, + select: { id: true }, + }) + : null, + // A medication with no schedule, an as-needed one or one whose intake is + // not tracked expects no dose, so the overview lists no rate for it. + shows("medications") + ? prisma.medication.findFirst({ + where: { + userId, + active: true, + asNeeded: false, + ...TRACKED_INTAKE_WHERE, + schedules: { some: {} }, + }, + select: { id: true }, + }) + : null, + shows("workouts") + ? prisma.workout.findFirst({ where: { userId }, select: { id: true } }) + : null, + prisma.ecgRecording.findFirst({ where: { userId }, select: { id: true } }), + ]); + + const { summaries } = gateSummariesByModules( + slice.summaries, + slice.lastSeenByType, + modules, + ); + const metrics: Record = {}; + for (const [type, summary] of Object.entries(summaries)) { + if (summary.count > 0) metrics[type] = { count: summary.count }; + } + + annotate({ + action: { name: "insights.presence.read" }, + meta: { metrics: Object.keys(metrics).length }, + }); + + return apiSuccess({ + metrics, + hasMood: mood !== null, + hasMedication: medication !== null, + hasWorkouts: workout !== null, + hasEcgRecordings: ecg !== null, + }); +}); diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx index 1d6bfb37c..d50c4da31 100644 --- a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx +++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx @@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server"; * on the tab strip but shows no briefing, so it must not read the briefing * on entry: on a metric page that read was a request (and the schema chunk * that parses its answer) for a button. The button itself stays. + * + * The strip's pills are gated on one small presence read and the saved + * layout. The overview read, a workouts page and the ECG list it used to + * send on every page for four flags are gone. */ const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = []; @@ -29,23 +33,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({ useAiCapability: () => ({ available: true }), useAiProviderState: () => ({ responseTimeoutMs: null }), })); -vi.mock("@/hooks/use-surface-visible", () => ({ - useSurfaceVisible: () => true, -})); -vi.mock("@/hooks/use-workouts", () => ({ - useWorkouts: () => ({ data: undefined }), -})); -vi.mock("@/lib/queries/use-analytics-query", () => ({ - useAnalyticsQuery: () => ({ data: undefined }), -})); +const layoutReads: unknown[] = []; vi.mock("@/hooks/use-insights-layout", () => ({ - useInsightsLayoutQuery: () => ({ - layout: { tiles: [] }, - isSuccess: false, - }), -})); -vi.mock("@/components/insights/use-ecg-list", () => ({ - useEcgList: () => ({ data: undefined }), + useInsightsLayoutQuery: (enabled: unknown) => { + layoutReads.push(enabled); + return { layout: { tiles: [] }, isSuccess: false }; + }, })); vi.mock("@/components/insights/insights-tab-strip", () => ({ InsightsTabStrip: (props: { onRegenerate?: unknown }) => { @@ -73,4 +66,20 @@ describe("InsightsLayoutShell — what it reads on entry", () => { expect(advisor[0].enabled).toBe(false); expect(typeof stripProps[0]?.onRegenerate).toBe("function"); }); + + it("reads the presence answer and the layout, nothing else", () => { + queries.length = 0; + layoutReads.length = 0; + renderToStaticMarkup( + +
+ , + ); + + const sent = queries + .filter((q) => q.enabled !== false) + .map((q) => JSON.stringify(q.queryKey)); + expect(sent).toEqual([JSON.stringify(queryKeys.insightsPresence())]); + expect(layoutReads).toEqual([true]); + }); }); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 7bba6a385..4e0481c73 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -6,20 +6,12 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useAiCapability } from "@/hooks/use-ai-capability"; -import { useSurfaceVisible } from "@/hooks/use-surface-visible"; -import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; -import { - nextAdvisorPollInterval, - useInsightsAdvisorQuery, -} from "@/components/insights/use-insights-advisor"; -import { pollAttemptCount } from "@/lib/queries/refetch-interval"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { queryKeys } from "@/lib/query-keys"; import type { InsightInputs } from "@/lib/insights/metric-availability"; import { apiGet } from "@/lib/api/api-fetch"; -import { useEcgList } from "@/components/insights/use-ecg-list"; /** * v1.4.25 — client shell for `src/app/insights/layout.tsx`. @@ -31,26 +23,22 @@ import { useEcgList } from "@/components/insights/use-ecg-list"; * page used to send the briefing read (and load the schema chunk that * parses it) for a button. * - * v1.4.27 — also owns the analytics + comprehensive reads that - * power the tab-strip availability gate. The analytics consumer goes - * through the shared `useAnalyticsQuery({ slice: "summaries" })` hook - * (v1.4.33 IW2); the comprehensive consumer still owns its bespoke - * fetch but shares its cache key with the sub-page consumers so the - * payload lands once per route, dedup-shared across consumers. + * The tab-strip gate reads one small answer, `/api/insights/presence` + * (reading counts per type, and whether mood, medication, workout and ECG + * rows exist), plus the saved layout. Each page loads its own data; the + * strip used to read the overview, a workouts page and the ECG list on + * every page for four flags. * * CRITICAL — the `` does NOT mount here. It lives only in * `src/app/insights/page.tsx` body so navigating into a sub-page * unmounts the drawer. */ -interface ComprehensivePayload { - moodSummary: { count: number } | null; - medications: Array<{ id: string }>; - /** - * v1.16.7 — true when the route served a stale cache body while a - * background rebuild runs (stale-while-revalidate). The query polls - * (bounded) while set so the fresh aggregate lands in-session. - */ - revalidating?: boolean; +interface InsightsPresence { + metrics: Record; + hasMood: boolean; + hasMedication: boolean; + hasWorkouts: boolean; + hasEcgRecordings: boolean; } export function InsightsLayoutShell({ children }: { children: ReactNode }) { @@ -70,83 +58,33 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const advisor = useInsightsAdvisorQuery(false); - // Shared analytics fetch — the layout shell only reads - // `summaries[METRIC].count` for the tab-strip availability gate, so - // it lands on IW1's slim `?slice=summaries` branch (2 SQL passes, - // no correlations / health-score / bp-in-target tail). - const analyticsQuery = useAnalyticsQuery({ slice: "summaries" }); - - // Shared comprehensive fetch — mood + medication signals for the - // event-driven gating branches. Sub-pages read the same key so the - // payload lands once per route. - const comprehensiveQuery = useQuery({ - queryKey: queryKeys.insightsComprehensive(), - queryFn: async () => { - return apiGet("/api/insights/comprehensive"); - }, + // Answered for the record on screen. A page whose module is off reads + // false here, and the strip hides its pill through the surface map anyway. + const presenceQuery = useQuery({ + queryKey: queryKeys.insightsPresence(), + queryFn: () => apiGet("/api/insights/presence"), enabled: isAuthenticated, - // v1.16.7 — converge a stale-served (SWR) body in-session: while the - // route reports `revalidating: true`, poll on the same bounded - // cadence the advisor query uses until a fresh body lands. - refetchInterval: (query) => - nextAdvisorPollInterval( - query.state.data?.revalidating, - pollAttemptCount(query.state), - ), }); + const presence = presenceQuery.data; - // v1.4.32 — workout-presence gate. The workouts pill gates on at - // least one canonical row in the list-endpoint response. The - // single-row probe shares its cache slot with the list page + - // dashboard tile so navigation between surfaces is a free cache hit. - // Only while the Workouts module shows: a switched-off module is never - // probed (its pill is hidden by the surface map regardless). - const workoutsVisible = useSurfaceVisible("insights-page:workouts"); - const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible }); + // Nutrients is opt-in, so its pill follows the module toggle alone: the + // in-context enable CTA lives on `/insights/nutrients` itself, and gating + // on a first row as well left that page unreachable before data existed. + const hasNutrients = user?.modules?.nutrients === true; + const hasEcgRecordings = presence?.hasEcgRecordings === true; - // v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike - // the always-on metric clusters, so the pill's floor is the module - // toggle alone — not a data-presence probe. Gating on BOTH the toggle - // AND a first row was a catch-22: the in-context enable CTA lives on - // `/insights/nutrients` itself, and that page was only reachable once - // data already existed. A user who just flipped the module on (or - // wants to) can now always reach the page; the page itself still - // degrades from "enable" → "no rows yet" → the three-card spine. - const nutrientsEnabled = user?.modules?.nutrients === true; - - // v1.30 — ECG recording presence for the Heart-group ECG - // pill. ECG carries no `MeasurementType`, so the metric `availability` - // model can't gate it; it rides its own `hasRecordings` probe instead. The - // query shares `queryKeys.insightsEcgList()` with `` and the - // ECG page, so the flag lands once per route (dedup-shared, no extra - // traffic). Undefined until it resolves → the pill stays off on first paint, - // exactly like the metric pills light up as their summaries land. - const ecgProbe = useEcgList(); - const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true; - - // v1.4.31 — memoise the `availability` prop so an unchanged - // payload doesn't recreate the object on every cache-write of - // analytics or comprehensive. The strip is wrapped in - // `React.memo` and would still rerun `buildTabs(availability)` on - // every parent render if this prop kept arriving as a fresh - // reference. Per - // `.planning/research/v15-insights-blocking-bug.md` fix 2. - const summaries = analyticsQuery.data?.summaries; - const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0; - const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0; - const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0; - const hasNutrients = nutrientsEnabled; + // Memoised so an unchanged answer keeps the same reference: the strip is + // wrapped in `React.memo` and reruns `buildTabs` on a new object. const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; - return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients }; - }, [ - isAuthenticated, - summaries, - hasMood, - hasMedication, - hasWorkouts, - hasNutrients, - ]); + return { + summaries: presence?.metrics, + hasMood: presence?.hasMood === true, + hasMedication: presence?.hasMedication === true, + hasWorkouts: presence?.hasWorkouts === true, + hasNutrients, + }; + }, [isAuthenticated, presence, hasNutrients]); // v1.15.14 — couple the tab strip to the saved insights layout. // A sub-page pill now shows iff its metric has data AND its slug is diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 5ec96d5b0..06ec69979 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -19,9 +19,8 @@ * `summaries[METRIC].count` from the analytics endpoint. * - `BMI` is derived from `WEIGHT` so it inherits the weight count. * - Event-driven metrics (`MOOD`, `MEDICATION`) read the boolean - * `hasMood` / `hasMedication` flags the caller threads in from - * `/api/insights/comprehensive` (mood summary count > 0, - * medications array length > 0). + * `hasMood` / `hasMedication` flags the caller threads in (the tab + * strip from `/api/insights/presence`). * * Auto-light-up behaviour: once the iOS native client uploads its * first Apple-Health measurement, `summaries[METRIC].count` flips @@ -29,7 +28,6 @@ * pill, sub-page, and dashboard tile — no separate feature flag * needed. (F18.) */ -import type { DataSummary } from "@/lib/analytics/trends"; /** * Metric keys the gating helper distinguishes. Mirrors the @@ -133,14 +131,14 @@ export type InsightMetric = | "NUTRIENTS"; /** - * Inputs the gating helper consumes. The `summaries` shape mirrors - * the `/api/analytics` response (`Record`) - * — undefined means the caller hasn't loaded analytics yet, which - * the helper treats as "not available" so loading shells aren't - * accidentally lit up. + * Inputs the gating helper consumes. `summaries` is keyed by + * `MeasurementType` and only its `count` is read, so an analytics + * summaries map and the presence read's `metrics` both fit. Undefined + * means the caller hasn't loaded it yet, which the helper treats as + * "not available" so loading shells aren't accidentally lit up. */ export interface InsightInputs { - summaries: Record | undefined; + summaries: Readonly> | undefined; /** Whether the user has logged at least one mood entry. */ hasMood: boolean; /** Whether the user has at least one active medication. */ diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index d66169878..2bcd08eec 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -22,6 +22,7 @@ import { import { insightsCardsResponse, insightsComprehensiveResponse, + insightsPresenceResponse, metricStatusQuery, metricStatusResponse, biomarkerAssessmentQuery, @@ -126,6 +127,29 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/presence": { + get: { + tags: ["Insights"], + summary: "Which Insights pages have data", + description: + "What the Insights tab strip gates its pills on, for the record on screen: the reading count per measurement type (after the record's module switches) and whether the record has mood entries in the last 90 days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", + responses: { + "200": { + description: "The presence answer.", + content: { + "application/json": { + schema: dataEnvelope( + insightsPresenceResponse, + "InsightsPresenceEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...recordRefusal(), + }, + }, + }, "/api/insights/cards": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 8e2baeb9f..cfc928786 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1691,6 +1691,36 @@ export const ecgListResponse = z "Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a HealthLog interpretation.", }); +export const insightsPresenceResponse = z + .object({ + metrics: z + .record(z.string(), z.object({ count: z.number().int() })) + .describe( + "Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's module switches. The same counts `GET /api/analytics?slice=summaries` reports.", + ), + hasMood: z + .boolean() + .describe( + "A mood entry in the last 90 days. False while the mood module is off.", + ), + hasMedication: z + .boolean() + .describe( + "An active medication with a schedule whose intake is tracked. False while the medications module is off.", + ), + hasWorkouts: z + .boolean() + .describe( + "At least one workout. False while the workouts module is off.", + ), + hasEcgRecordings: z.boolean().describe("At least one ECG recording."), + }) + .meta({ + id: "InsightsPresence", + description: + "Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts and flags only, no values.", + }); + export const ecgDetailQuery = z .object({ full: z diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d216f4acc..19854e014 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -6,6 +6,8 @@ export const insightsKeys = { insightsRoot: () => ["insights"] as const, insightsComprehensive: () => ["insights", "comprehensive"] as const, + /** Which Insights pages have data (`/api/insights/presence`), for the tab strip. */ + insightsPresence: () => ["insights", "presence"] as const, insightsTargets: () => ["insights", "targets"] as const, /** * Shared cache key for the rich `/api/insights/generate` advisor diff --git a/tests/integration/insights-presence.test.ts b/tests/integration/insights-presence.test.ts new file mode 100644 index 000000000..e4d84bdd8 --- /dev/null +++ b/tests/integration/insights-presence.test.ts @@ -0,0 +1,184 @@ +/** + * `GET /api/insights/presence`, against Postgres. + * + * The Insights tab strip gates its pills on this one answer: the reading + * count per type and whether the record has mood entries, a scheduled + * medication, workouts and ECG recordings. It must answer for the record on + * screen, a delegate's switched record included, and read nothing a + * switched-off module hides. Every call goes through the contract check. All + * data invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { GET as getPresence } from "@/app/api/insights/presence/route"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { + makeGrant, + makeMeasurement, + makeMedication, + makeSession, + makeUser, + makeWorkout, +} from "../kit/factories"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY_MS = 86_400_000; + +interface PresenceBody { + metrics: Record; + hasMood: boolean; + hasMedication: boolean; + hasWorkouts: boolean; + hasEcgRecordings: boolean; +} + +async function presence(auth: RouteAuth) { + const res = await callRoute(getPresence, { + method: "GET", + route: "/api/insights/presence", + auth, + }); + expect(res.status).toBe(200); + return res.data; +} + +/** Two weight readings, a recent mood, a medication, a workout and an ECG. */ +async function seedEverything(user: Pick) { + for (const daysAgo of [3, 2]) { + await makeMeasurement(user, { + measuredAt: new Date(Date.now() - daysAgo * DAY_MS), + }); + } + const loggedAt = new Date(Date.now() - 5 * DAY_MS); + await prisma.moodEntry.create({ + data: { + userId: user.id, + date: loggedAt.toISOString().slice(0, 10), + mood: "GUT", + score: 4, + moodLoggedAt: loggedAt, + }, + }); + await makeMedication(user); + await makeWorkout(user); + await prisma.ecgRecording.create({ + data: { + userId: user.id, + source: "APPLE_HEALTH", + externalRecordingId: `ecg-${user.id}`, + recordedAt: new Date(Date.now() - DAY_MS), + waveformEncrypted: Buffer.from([0]), + samplingFrequency: 512, + sampleCount: 0, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(prisma); + __resetAllCachesForTests(); +}); + +describe("GET /api/insights/presence", () => { + it("answers what the record holds", async () => { + const user = await makeUser({ timezone: "Europe/Berlin" }); + await seedEverything(user); + + expect(await presence(await makeSession(user))).toEqual({ + metrics: { WEIGHT: { count: 2 } }, + hasMood: true, + hasMedication: true, + hasWorkouts: true, + hasEcgRecordings: true, + }); + }); + + it("answers nothing for an empty record", async () => { + const user = await makeUser(); + expect(await presence(await makeSession(user))).toEqual({ + metrics: {}, + hasMood: false, + hasMedication: false, + hasWorkouts: false, + hasEcgRecordings: false, + }); + }); + + it("counts no mood older than the overview's 90 days and no medication that expects no dose", async () => { + const user = await makeUser(); + const loggedAt = new Date(Date.now() - 120 * DAY_MS); + await prisma.moodEntry.create({ + data: { + userId: user.id, + date: loggedAt.toISOString().slice(0, 10), + mood: "GUT", + score: 4, + moodLoggedAt: loggedAt, + }, + }); + await makeMedication(user, { asNeeded: true }); + await makeMedication(user, { trackIntake: false }); + await makeMedication(user, {}, []); + + const body = await presence(await makeSession(user)); + expect(body.hasMood).toBe(false); + expect(body.hasMedication).toBe(false); + }); + + it("reads nothing a switched-off module hides", async () => { + const user = await makeUser({ + modulePreferencesJson: { + mood: false, + medications: false, + workouts: false, + }, + }); + await seedEverything(user); + + expect(await presence(await makeSession(user))).toMatchObject({ + metrics: { WEIGHT: { count: 2 } }, + hasMood: false, + hasMedication: false, + hasWorkouts: false, + hasEcgRecordings: true, + }); + }); + + it("answers for the record a delegate has switched to", async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + await seedEverything(owner); + await makeGrant(owner, delegate, { access: "MANAGE" }); + const auth = await makeSession(delegate); + + expect((await presence(auth)).hasMood).toBe(false); + + await switchSessionTo(auth.sessionId, owner.id); + expect(await presence(auth)).toEqual({ + metrics: { WEIGHT: { count: 2 } }, + hasMood: true, + hasMedication: true, + hasWorkouts: true, + hasEcgRecordings: true, + }); + }); + + it("refuses a delegate whose grant only reads", async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + await makeGrant(owner, delegate); + const auth = await makeSession(delegate); + await switchSessionTo(auth.sessionId, owner.id); + + const res = await callRoute(getPresence, { + method: "GET", + route: "/api/insights/presence", + auth, + }); + expect(res.status).toBe(403); + }); +});