feat(analytics): per-stage sleep aggregation in /api/analytics

Apple Health stores sleep as one Measurement row per stage per night,
so the existing single-value SLEEP_DURATION summary would treat each
stage as its own datapoint and grossly understate the per-night total.
The route now sums per Berlin day before summarising — one summary
datapoint per night, value equal to total minutes asleep.

A new optional `sleepStages` block surfaces the per-stage breakdown
over the trailing 30 days when stage-tagged rows exist (and `null`
otherwise so the existing UI keeps painting the totals-only path).
The block reports total minutes, the night count covered, and the
per-stage minute sums so the upcoming insights surface can render
"averaged across N nights" captions truthfully.

Two integration tests cover both shapes — a four-stage night
aggregating to 880 minutes with a populated stages map, and a
manual-source no-stage row keeping `sleepStages` at null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:25:01 +02:00
1 parent 86a80c5280
commit 5470649d01
2 files changed
+269 -8

No files matched your search

+88 -8
View File
@@ -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<string, { total: number; date: Date }>();
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<string, number>;
} | 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<string, number> = {};
const dayKeys = new Set<string>();
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.
@@ -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<string, number>;
} | 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);
});
});