mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
86a80c5280
commit
5470649d01
2 files changed
+269
-8
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user