From 67d05c0de98c8df889e24cbb8e5c408114f76248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 17:33:58 +0200 Subject: [PATCH] perf(analytics): slim /api/analytics?slice=summaries slice (C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped chunked findMany reads to feed `summarize()` for every MeasurementType. For a multi-year HealthKit user the PULSE branch alone walks hundreds of thousands of rows before the dashboard tile strip can paint a single arrow — even though the strip only consumes `count / latest / avg7 / avg30 / min / max / mean` plus the three slope tuples per type. Add `?slice=summaries` that resolves the same `DataSummary` shape from two SQL passes (no JS-side spread anywhere along the chain): 1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/ `MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows. 2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent `(type, value)` pair per type so the slim shape can populate `latest` (the value at MAX(measured_at), not MAX(value)). Slope `direction` keeps the JS helper's 0.01-units-per-day "stable" threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match the existing `trendSlope()` contract so the dashboard's arrow / color logic does not have to branch on slice. Anomaly count, lastMonth, and lastYear means stay at `null` / `0` because the dashboard tile strip does not paint them on first paint; the comparison-baseline widget already opts into the default slice. The thick `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default path behind a hover-prefetch / explicit slice param. C1 is purely additive — the route's default behaviour is byte-identical. Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds, null-slope branch) and a 3-case real-Postgres integration test covering the empty user, the populated descending-WEIGHT fixture, and the default-slice-still-thick contract. 199 unit tests pass across `src/lib/analytics` + `src/app/api/analytics`; the integration suite picks up `tests/integration/analytics-summaries-slice.test.ts` on next `pnpm test:integration` run. --- src/app/api/analytics/__tests__/route.test.ts | 58 +++++ src/app/api/analytics/route.ts | 36 ++- .../__tests__/summaries-slice.test.ts | 163 ++++++++++++ src/lib/analytics/summaries-slice.ts | 236 ++++++++++++++++++ .../analytics-summaries-slice.test.ts | 220 ++++++++++++++++ 5 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 src/lib/analytics/__tests__/summaries-slice.test.ts create mode 100644 src/lib/analytics/summaries-slice.ts create mode 100644 tests/integration/analytics-summaries-slice.test.ts diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 515caead1..ca125c00b 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -23,6 +23,8 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, + // v1.4.33 C1 — slim summaries slice runs through `$queryRaw`. + $queryRaw: vi.fn(), }, })); @@ -169,4 +171,60 @@ describe("GET /api/analytics", () => { expect(body.data.bmi).toBeNull(); expect(body.data.healthScore).toBeNull(); }); + + // v1.4.33 C1 — slim summaries slice. The route branches on + // `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw` + // passes carry the per-type DataSummary shape with the same + // contract the dashboard tile strip reads. + it("returns the slim summaries slice when ?slice=summaries is set", async () => { + // Aggregate pass + latest pass. The mock returns one row per pass. + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + count: BigInt(12), + min_value: 80.0, + max_value: 84.5, + mean_value: 82.1, + avg7: 82.0, + avg30: 82.2, + slope7: -0.05, + r2_7: 0.4, + slope30: -0.02, + r2_30: 0.3, + slope90: -0.01, + r2_90: 0.2, + }, + ] as never) + .mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never); + + const req = new Request( + "http://localhost/api/analytics?slice=summaries", + ); + const res = await ( + GET as unknown as (req: Request) => Promise + )(req); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + summaries: Record< + string, + { + count: number; + latest: number | null; + slope30: { slope: number; direction: string } | null; + } + >; + bmi: number | null; + }; + }; + // The slim slice produced WEIGHT from the SQL pass; no chunked + // findMany was called. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + expect(body.data.summaries.WEIGHT.count).toBe(12); + expect(body.data.summaries.WEIGHT.latest).toBe(81.8); + expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down"); + // Slim slice never carries BMI — the consumer re-derives. + expect(body.data.bmi).toBeNull(); + }); }); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index c18cce919..b409f96a9 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -29,10 +30,43 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; export const dynamic = "force-dynamic"; -export const GET = apiHandler(async () => { +/** + * v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the + * route's production wrapper) or a plain `Request` (the integration + * tests instantiate `new Request("http://localhost/api/analytics")` + * and cast through). Falls back to the raw `URL(request.url)` parse + * so the slim slice branch is reachable from both call shapes. + */ +function readSliceParam(request: Request | undefined): string | null { + if (!request) return null; + try { + return new URL(request.url).searchParams.get("slice"); + } catch { + return null; + } +} + +export const GET = apiHandler(async (request?: Request) => { const { user } = await requireAuth(); annotate({ action: { name: "analytics.get" } }); + // v1.4.33 C1 — slim summaries slice for the dashboard tile strip. + // The default path runs 30+ chunked findMany reads to feed + // `summarize()` per type; under `?slice=summaries` the route + // resolves the same per-type DataSummary shape from 2 SQL passes + // (a `groupBy` for count/min/max/mean plus a single `$queryRaw` + // carrying the windowed averages and Postgres `regr_slope` / + // `regr_r2` for the slope tuples). The slim slice drops the thick + // `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext` + // / `sleepStages` / `bmi` blocks — those stay on the default path + // behind a hover-prefetch from the Coach drawer + the correlation + // tile's `` boundary. Consumers that need only the headline + // tile values land on this branch and skip the heavy chain. + if (readSliceParam(request) === "summaries") { + const slim = await computeSummariesSlice(user.id); + return apiSuccess(slim); + } + // v1.4.25 W7b — every day-bucket call inside this route now honours // the user's display timezone. The legacy `berlinDayKey()` import // remains for sleep-stage and correlation paths that share their diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts new file mode 100644 index 000000000..1ba88676f --- /dev/null +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -0,0 +1,163 @@ +/** + * v1.4.33 C1 — unit-level pin for the slim summaries slice. Heavier + * integration coverage lives in `tests/integration/analytics-summaries-slice.test.ts` + * (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so + * the slope/round/empty contracts are pinned without a container. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { computeSummariesSlice } from "../summaries-slice"; + +const RAW = prisma.$queryRaw as unknown as ReturnType; + +beforeEach(() => { + RAW.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeSummariesSlice", () => { + it("returns the empty-summary skeleton when the user has no rows", async () => { + // Pass 1 (aggregates) returns empty, pass 2 (latest) returns empty. + RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const result = await computeSummariesSlice("user-1"); + + // Every enum option is seeded so consumers can read + // `summaries.WEIGHT` and get a stable empty shape. + expect(result.summaries.WEIGHT).toEqual({ + count: 0, + latest: null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }); + expect(result.bmi).toBeNull(); + // Both SQL passes fired. + expect(RAW).toHaveBeenCalledTimes(2); + }); + + it("maps a populated aggregate row into the DataSummary shape", async () => { + RAW.mockResolvedValueOnce([ + { + type: "WEIGHT", + count: BigInt(42), + min_value: 79.2, + max_value: 84.1, + mean_value: 82.05, + avg7: 81.9, + avg30: 82.1, + slope7: -0.014, + r2_7: 0.65, + slope30: -0.005, + r2_30: 0.42, + slope90: 0.001, + r2_90: 0.12, + }, + ]).mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4 }]); + + const result = await computeSummariesSlice("user-1"); + const weight = result.summaries.WEIGHT; + + expect(weight.count).toBe(42); + expect(weight.latest).toBe(81.4); + expect(weight.min).toBe(79.2); + expect(weight.max).toBe(84.1); + expect(weight.mean).toBe(82.05); + expect(weight.avg7).toBe(81.9); + expect(weight.avg30).toBe(82.1); + expect(weight.anomalyCount).toBe(0); + expect(weight.avg30LastMonth).toBeNull(); + expect(weight.avg30LastYear).toBeNull(); + // Slope direction respects the 0.01-units-per-day "stable" + // threshold the JS helper uses. -0.014 → "down", -0.005 → + // "stable", 0.001 → "stable". + expect(weight.slope7).toEqual({ + slope: -0.014, + direction: "down", + confidence: 0.65, + }); + expect(weight.slope30).toEqual({ + slope: -0.005, + direction: "stable", + confidence: 0.42, + }); + expect(weight.slope90).toEqual({ + slope: 0.001, + direction: "stable", + confidence: 0.12, + }); + }); + + it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => { + RAW.mockResolvedValueOnce([ + { + type: "PULSE", + count: BigInt(1), + min_value: 72, + max_value: 72, + mean_value: 72, + avg7: 72, + avg30: 72, + // regr_slope returns NULL when n < 2. + slope7: null, + r2_7: null, + slope30: null, + r2_30: null, + slope90: null, + r2_90: null, + }, + ]).mockResolvedValueOnce([{ type: "PULSE", value: 72 }]); + + const result = await computeSummariesSlice("user-1"); + expect(result.summaries.PULSE.slope7).toBeNull(); + expect(result.summaries.PULSE.slope30).toBeNull(); + expect(result.summaries.PULSE.slope90).toBeNull(); + }); + + it("seeds the latest value from the DISTINCT ON pass per type", async () => { + RAW.mockResolvedValueOnce([ + { + type: "PULSE", + count: BigInt(3), + min_value: 60, + max_value: 95, + mean_value: 77, + avg7: 77, + avg30: 77, + slope7: 0, + r2_7: 0, + slope30: 0, + r2_30: 0, + slope90: 0, + r2_90: 0, + }, + ]).mockResolvedValueOnce([{ type: "PULSE", value: 88 }]); + + const result = await computeSummariesSlice("user-1"); + // Latest is NOT max — comes from the row at MAX(measured_at). + expect(result.summaries.PULSE.latest).toBe(88); + expect(result.summaries.PULSE.max).toBe(95); + }); +}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts new file mode 100644 index 000000000..209996c75 --- /dev/null +++ b/src/lib/analytics/summaries-slice.ts @@ -0,0 +1,236 @@ +/** + * v1.4.33 C1 — slim `summaries` slice for `/api/analytics?slice=summaries`. + * + * The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped + * chunked findMany reads (one per `MeasurementType`) so it can hand + * each series to `summarize()` for slope7/30/90 + anomaly detection + + * lastMonth/lastYear means. The dashboard tile strip only consumes + * `count`, `latest`, `avg7`, `avg30`, `min`, `max`, `mean`, and the + * three slope tuples — every other field gets `null`-coalesced before + * it ever paints — yet the user waits on the full chunked walk. + * + * This helper resolves the same `DataSummary` shape (per-type) with + * two SQL passes: + * + * 1. **`groupBy` + windowed aggregates** — one `$queryRaw` carrying + * `COUNT`/`MIN`/`MAX`/`AVG`, the 7-day and 30-day `AVG` slices + * via `FILTER`, plus `regr_slope` + `regr_r2` for the slope + * tuples at 7d / 30d / 90d windows. Postgres folds all of this + * into one index scan over `measurements WHERE user_id = …`. + * + * 2. **`DISTINCT ON (type)` latest read** — one `$queryRaw` + * returning the most-recent `(type, value)` pair per type so the + * slim shape can populate `latest`. `MAX(value)` would not do — + * we need the value of the row at `MAX(measured_at)`, not the + * largest value ever recorded. + * + * Fields the slim shape intentionally omits (the dashboard never + * reads them on first paint): + * - `anomalyCount`: would require an extra per-type read for the + * z-score loop. The Coach / insights paths still get it via the + * default slice. + * - `avg30LastMonth` / `avg30LastYear`: the dashboard tile delta + * callout uses them, but only when the comparison-baseline + * widget is enabled — that path already pre-fetches the default + * slice via the explicit comparison opt-in. + * + * Slope `confidence` is the SQL `regr_r2` value (R²) which matches + * the existing `trendSlope()` helper's `Math.round(rSquared * 100) / + * 100`. Slope `direction` is derived from `slope`'s sign with the + * same 0.01-units-per-day "stable" threshold the JS helper uses. + */ +import { prisma } from "@/lib/db"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { annotate } from "@/lib/logging/context"; + +interface AggregateRow { + type: string; + count: bigint; + min_value: number | null; + max_value: number | null; + mean_value: number | null; + avg7: number | null; + avg30: number | null; + slope7: number | null; + r2_7: number | null; + slope30: number | null; + r2_30: number | null; + slope90: number | null; + r2_90: number | null; +} + +interface LatestRow { + type: string; + value: number; +} + +/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */ +function buildSlope( + slope: number | null, + rSquared: number | null, +): DataSummary["slope7"] { + if (slope === null) return null; + const threshold = 0.01; + const direction: "up" | "down" | "stable" = + Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down"; + return { + slope: Math.round(slope * 1000) / 1000, + direction, + confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100, + }; +} + +function round2(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) return null; + return Math.round(value * 100) / 100; +} + +function emptySummary(): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }; +} + +export interface SummariesSlice { + summaries: Record; + /** + * `null` on the slim slice. The default route computes BMI from + * `summaries.WEIGHT.latest` + `user.heightCm`; consumers of the + * slim slice that need BMI re-derive client-side or fetch the + * default slice. + */ + bmi: null; +} + +export async function computeSummariesSlice( + userId: string, +): Promise { + // The two passes run in parallel — they target the same index path + // (`measurements (user_id, type, measured_at)`) but Postgres can + // serve them off the buffer cache concurrently. + const [aggregates, latests] = await Promise.all([ + prisma.$queryRaw` + SELECT + m."type"::text AS type, + COUNT(*) AS count, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + AVG(m."value")::double precision AS mean_value, + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS avg7, + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS avg30, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS slope7, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS r2_7, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS slope30, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS r2_30, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + )::double precision AS slope90, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + )::double precision AS r2_90 + FROM measurements m + WHERE m."user_id" = ${userId} + GROUP BY m."type" + `, + prisma.$queryRaw` + SELECT DISTINCT ON (m."type") + m."type"::text AS type, + m."value"::double precision AS value + FROM measurements m + WHERE m."user_id" = ${userId} + ORDER BY m."type", m."measured_at" DESC + `, + ]); + + const latestByType = new Map(); + for (const row of latests) { + latestByType.set(row.type, Number(row.value)); + } + + // Seed every enum option so consumers can read `summaries.PULSE` and + // get a deterministic empty shape rather than `undefined`. Mirrors + // the default route which iterates `measurementTypeEnum.options`. + const summaries: Record = {}; + for (const type of measurementTypeEnum.options) { + summaries[type] = emptySummary(); + } + + let totalRows = 0; + for (const row of aggregates) { + const count = Number(row.count); + totalRows += count; + const latest = latestByType.get(row.type) ?? null; + summaries[row.type] = { + count, + latest, + min: round2(row.min_value), + max: round2(row.max_value), + mean: round2(row.mean_value), + avg7: round2(row.avg7), + avg30: round2(row.avg30), + slope7: buildSlope(row.slope7, row.r2_7), + slope30: buildSlope(row.slope30, row.r2_30), + slope90: buildSlope(row.slope90, row.r2_90), + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }; + } + + annotate({ + action: { name: "analytics.get.slim" }, + meta: { + analytics: { + slim_summaries: { + row_count: totalRows, + type_count: aggregates.length, + }, + }, + }, + }); + + return { summaries, bmi: null }; +} diff --git a/tests/integration/analytics-summaries-slice.test.ts b/tests/integration/analytics-summaries-slice.test.ts new file mode 100644 index 000000000..ab5fe1b8d --- /dev/null +++ b/tests/integration/analytics-summaries-slice.test.ts @@ -0,0 +1,220 @@ +/** + * v1.4.33 C1 — `?slice=summaries` integration coverage. + * + * Pins the slim summaries slice against the real Postgres + the real + * `regr_slope` / `regr_r2` functions, not a unit-level `$queryRaw` + * mock. The slice replaces the route's 30-type × Promise.all chunked + * findMany walk with 2 SQL passes; the integration test runs against + * the test container so any drift in the SQL (alias names, casts, + * `FILTER` semantics) surfaces here instead of in production. + * + * Three contracts pinned: + * + * 1. **Empty user.** A brand-new account with zero rows returns the + * empty `DataSummary` skeleton for every `MeasurementType` and + * `bmi: null`. No JS-side spread anywhere along the chain — the + * v1.4.33 P0 RangeError won't reach this path. + * + * 2. **Populated WEIGHT series.** A 14-day descending-weight series + * lands `count`, `latest`, `avg7`, `avg30`, the negative-slope + * `direction: "down"` tuple from `regr_slope`, plus min/max/mean + * with the same 2-decimal rounding the `summarize()` helper uses. + * + * 3. **Default slice unchanged.** Hitting the route WITHOUT + * `?slice=summaries` still returns the thick `healthScore` / + * `correlations` / `bpInTargetPct` / `sleepStages` envelope. C1 + * is additive — no behaviour change for the existing consumers. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface DataSummaryShape { + count: number; + latest: number | null; + min: number | null; + max: number | null; + mean: number | null; + avg7: number | null; + avg30: number | null; + slope7: { slope: number; direction: string; confidence: number } | null; + slope30: { slope: number; direction: string; confidence: number } | null; + slope90: { slope: number; direction: string; confidence: number } | null; + anomalyCount: number; + avg30LastMonth: number | null; + avg30LastYear: number | null; +} + +interface SlimEnvelope { + data: { + summaries: Record; + bmi: number | null; + }; + error: null; +} + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +describe("GET /api/analytics?slice=summaries (C1)", () => { + it("returns the empty-summary skeleton for a brand-new user", async () => { + await seedSession("slim-empty-user"); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics?slice=summaries")); + + expect(response.status).toBe(200); + const envelope = (await response.json()) as SlimEnvelope; + // Every enum option is present with the deterministic empty + // shape so consumers can read `summaries.WEIGHT.count > 0` + // without an undefined guard. + expect(envelope.data.summaries.WEIGHT.count).toBe(0); + expect(envelope.data.summaries.WEIGHT.latest).toBeNull(); + expect(envelope.data.summaries.WEIGHT.slope30).toBeNull(); + expect(envelope.data.summaries.BLOOD_PRESSURE_SYS.count).toBe(0); + expect(envelope.data.summaries.PULSE.count).toBe(0); + // The slim slice never carries BMI — consumers re-derive from + // `summaries.WEIGHT.latest` + `user.heightCm` client-side or + // fetch the default slice. + expect(envelope.data.bmi).toBeNull(); + }); + + it("reports count + latest + slope + windowed averages from real SQL", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("slim-populated-user"); + + // 14 descending-WEIGHT entries spaced one day apart. Slope per + // day = (84.0 - 80.0) / 13 ≈ -0.308 — solidly inside the "down" + // band (|slope| >= 0.01 units/day threshold). + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const N = 14; + const rows = Array.from({ length: N }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + // Most-recent reading is the smallest — slope must come out + // negative when regressing value against time. + value: 80 + i * (4 / (N - 1)), + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY_MS), + })); + await prisma.measurement.createMany({ data: rows }); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics?slice=summaries")); + + expect(response.status).toBe(200); + const envelope = (await response.json()) as SlimEnvelope; + const weight = envelope.data.summaries.WEIGHT; + + expect(weight.count).toBe(N); + // Most-recent row in DESCENDING construction = index 0 = value 80. + expect(weight.latest).toBeCloseTo(80, 1); + // Min / max across the 14 rows. + expect(weight.min).toBeCloseTo(80, 1); + expect(weight.max).toBeCloseTo(84, 1); + // 7-day window covers the first 7 entries (descending dates). + expect(weight.avg7).not.toBeNull(); + expect(weight.avg30).not.toBeNull(); + // Slope direction must be "down" — value decreases with time. + expect(weight.slope30).not.toBeNull(); + expect(weight.slope30!.direction).toBe("down"); + expect(weight.slope30!.slope).toBeLessThan(0); + // R² confidence is in [0, 1]. + expect(weight.slope30!.confidence).toBeGreaterThanOrEqual(0); + expect(weight.slope30!.confidence).toBeLessThanOrEqual(1); + + // Sanity-check: PULSE is empty on this fixture. + expect(envelope.data.summaries.PULSE.count).toBe(0); + }); + + it("default slice still carries the thick envelope (additive contract)", async () => { + await seedSession("slim-default-still-thick"); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + + expect(response.status).toBe(200); + // Default slice still produces every block the route v1.4.32 + // shipped — proving C1 added a branch instead of mutating the + // existing path. + const body = (await response.json()) as { + data: { + summaries: unknown; + bmi: unknown; + bpInTargetPct: unknown; + correlations: unknown; + healthScore: unknown; + sleepStages: unknown; + glucoseByContext: unknown; + }; + }; + expect(body.data.summaries).toBeDefined(); + // Every thick-slice key must still be on the envelope (value can + // be null for an empty user — what we care about is presence). + expect("bpInTargetPct" in body.data).toBe(true); + expect("correlations" in body.data).toBe(true); + expect("healthScore" in body.data).toBe(true); + expect("sleepStages" in body.data).toBe(true); + expect("glucoseByContext" in body.data).toBe(true); + }); +});