diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index e382ef10e..81157df7a 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -38,19 +38,45 @@ export const GET = apiHandler(async () => { .findMany({ where: { userId: user.id, type }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true }, + select: { value: true, measuredAt: true, sleepStage: true }, }) - .then((measurements) => ({ - type, - summary: summarize( - measurements.map( + .then((measurements) => { + // v1.4.23 — Apple Health's sleep ingest stores one row per + // stage per night. Summarising the raw rows would treat each + // stage as its own datapoint and grossly understate "average + // sleep". Aggregate per Berlin day before summarising so the + // summary matches the user's intuition (one number per night + // = total minutes asleep). + let datapoints: DataPoint[]; + if (type === "SLEEP_DURATION") { + const byDay = new Map(); + for (const m of measurements) { + const key = berlinDayKey(m.measuredAt); + const slot = byDay.get(key) ?? { + total: 0, + date: m.measuredAt, + }; + slot.total += m.value; + if (m.measuredAt > slot.date) slot.date = m.measuredAt; + byDay.set(key, slot); + } + datapoints = Array.from(byDay.values()).map( + (s): DataPoint => ({ date: s.date, value: s.total }), + ); + datapoints.sort((a, b) => a.date.getTime() - b.date.getTime()); + } else { + datapoints = measurements.map( (m): DataPoint => ({ date: m.measuredAt, value: m.value, }), - ), - ), - })), + ); + } + return { + type, + summary: summarize(datapoints), + }; + }), ), ); @@ -59,6 +85,12 @@ export const GET = apiHandler(async () => { results[type] = summary; } + // v1.4.23 — sleep-stage breakdown for the trailing 30 days. Only + // included when the user has stage-tagged rows in window; null + // otherwise so the UI can render a plain total without the + // breakdown card painting empty. + const sleepStages = await computeSleepStageBreakdown(user.id); + // BMI calculation let bmi: number | null = null; if (user.heightCm && results.WEIGHT?.latest) { @@ -171,9 +203,57 @@ export const GET = apiHandler(async () => { glucoseByContext, correlations, healthScore, + sleepStages, }); }); +/** + * Per-stage sleep-minutes breakdown over the trailing 30 days. + * + * Returns `null` when the user has no stage-tagged sleep rows in + * window — the analytics consumer renders the existing + * `summaries.SLEEP_DURATION` totals without a stage card in that + * case. Returns the sum-per-stage AND the day count covered so the + * UI can render an "averaged across N nights" caption truthfully. + */ +async function computeSleepStageBreakdown(userId: string): Promise<{ + windowDays: number; + nights: number; + totalMinutes: number; + stages: Record; +} | null> { + const DAY_MS = 24 * 60 * 60 * 1000; + const since = new Date(Date.now() - 30 * DAY_MS); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + sleepStage: { not: null }, + measuredAt: { gte: since }, + }, + select: { value: true, measuredAt: true, sleepStage: true }, + }); + + if (rows.length === 0) return null; + + const stages: Record = {}; + const dayKeys = new Set(); + let totalMinutes = 0; + for (const row of rows) { + if (!row.sleepStage) continue; + stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value; + totalMinutes += row.value; + dayKeys.add(berlinDayKey(row.measuredAt)); + } + + return { + windowDays: 30, + nights: dayKeys.size, + totalMinutes, + stages, + }; +} + /** * Build inputs for the three pre-defined hypotheses + run them. * Pure-ish — only Prisma reads, no external calls. diff --git a/tests/integration/analytics-sleep-stages.test.ts b/tests/integration/analytics-sleep-stages.test.ts new file mode 100644 index 000000000..d79a0979f --- /dev/null +++ b/tests/integration/analytics-sleep-stages.test.ts @@ -0,0 +1,181 @@ +/** + * v1.4.23 — sleep-stage aggregation in `GET /api/analytics`. + * + * The Apple Health batch endpoint stores one Measurement row per sleep + * stage per night. Two contracts the analytics route now upholds: + * + * 1. `summaries.SLEEP_DURATION` aggregates per Berlin day (sums the + * stage rows) so the summary's `latest`/`mean` reflect the total + * minutes asleep on a given night, not a single stage. + * + * 2. A new `sleepStages` block surfaces the per-stage breakdown over + * the trailing 30 days when stage-tagged rows exist; it is + * `null` for a user with no per-stage rows so the existing UI + * keeps painting the totals-only path. + */ +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 AnalyticsEnvelope { + data: { + summaries: Record< + string, + { + count: number; + latest: number | null; + mean: number | null; + } + >; + sleepStages: { + windowDays: number; + nights: number; + totalMinutes: number; + stages: Record; + } | 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 — sleep-stage aggregation", () => { + it("aggregates per-stage rows into a single per-night summary + a stage breakdown", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-stage-user"); + + // One night with the four iOS-16+ stages tagged. The analytics + // route should surface a single Measurement-summary datapoint + // (~480 minutes total) plus a per-stage breakdown. + const baseDate = new Date("2026-05-09T01:00:00.000Z"); + const stages: Array<{ stage: "IN_BED" | "CORE" | "DEEP" | "REM"; min: number }> = [ + { stage: "IN_BED", min: 480 }, + { stage: "CORE", min: 220 }, + { stage: "DEEP", min: 90 }, + { stage: "REM", min: 90 }, + ]; + let i = 0; + for (const { stage, min } of stages) { + await prisma.measurement.create({ + data: { + userId: user.id, + type: "SLEEP_DURATION", + value: min, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: new Date(baseDate.getTime() + i * 60 * 1000), + externalId: `uuid-night-1-${stage}`, + sleepStage: stage, + }, + }); + i++; + } + + const { GET } = await import("@/app/api/analytics/route"); + const response = await GET( + new Request("http://localhost/api/analytics") as never, + { params: Promise.resolve({}) } as never, + ); + expect(response.status).toBe(200); + const envelope = (await response.json()) as AnalyticsEnvelope; + + // Per-stage rows aggregated into a single per-night datapoint: + // IN_BED 480 + CORE 220 + DEEP 90 + REM 90 = 880 total minutes. + // Summary count is 1 (one night), and `latest` matches the sum. + const summary = envelope.data.summaries.SLEEP_DURATION; + expect(summary.count).toBe(1); + expect(summary.latest).toBe(880); + + // Stage breakdown surfaces the per-stage minutes verbatim. + const sleepStages = envelope.data.sleepStages; + expect(sleepStages).not.toBeNull(); + expect(sleepStages!.nights).toBe(1); + expect(sleepStages!.totalMinutes).toBe(880); + expect(sleepStages!.stages.IN_BED).toBe(480); + expect(sleepStages!.stages.CORE).toBe(220); + expect(sleepStages!.stages.DEEP).toBe(90); + expect(sleepStages!.stages.REM).toBe(90); + }); + + it("returns sleepStages: null when the user has no stage-tagged sleep rows", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-no-stages"); + + // A single sleep row WITHOUT a stage — the legacy/manual path. + await prisma.measurement.create({ + data: { + userId: user.id, + type: "SLEEP_DURATION", + value: 420, + unit: "minutes", + source: "MANUAL", + measuredAt: new Date("2026-05-09T07:00:00.000Z"), + }, + }); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await GET( + new Request("http://localhost/api/analytics") as never, + { params: Promise.resolve({}) } as never, + ); + expect(response.status).toBe(200); + const envelope = (await response.json()) as AnalyticsEnvelope; + + expect(envelope.data.sleepStages).toBeNull(); + // The non-stage summary path keeps working — one row, value 420. + expect(envelope.data.summaries.SLEEP_DURATION.count).toBe(1); + expect(envelope.data.summaries.SLEEP_DURATION.latest).toBe(420); + }); +});