mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(sleep): add the read-only sleep-night hypnogram endpoint
GET /api/sleep/night?date=YYYY-MM-DD returns one reconstructed night for the phase-progression view: the canonical source's stage segments (each with an absolute start/end span), the per-stage breakdown, the asleep/ in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, wake-day-keyed, one canonical source per night so two sources never overlay. date omitted returns the most recent night. A view over existing per-stage rows — no schema, no new measurement type. OpenAPI regenerated.
This commit is contained in:
1 parent
4b5970fbb0
commit
e4a85c58af
5 files changed
+650
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
|
||||
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(query: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/sleep/night?${query}`);
|
||||
}
|
||||
|
||||
function stage(iso: string, s: string, minutes: number, source = "APPLE_HEALTH") {
|
||||
return {
|
||||
value: minutes,
|
||||
measuredAt: new Date(iso),
|
||||
sleepStage: s,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/sleep/night", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(req(""));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("422s on a malformed date", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("date=2026-13-40"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns the requested night's hypnogram segments", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
stage("2026-06-04T01:00:00.000Z", "CORE", 240),
|
||||
stage("2026-06-04T03:00:00.000Z", "DEEP", 120),
|
||||
stage("2026-06-04T05:00:00.000Z", "REM", 120),
|
||||
stage("2026-06-04T05:00:00.000Z", "IN_BED", 510),
|
||||
] as never);
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.night).toBe("2026-06-04");
|
||||
expect(body.data.main.asleepMinutes).toBe(480);
|
||||
expect(body.data.main.inBedMinutes).toBe(510);
|
||||
// Segments carry absolute start/end spans (start = end − duration).
|
||||
const core = body.data.main.segments.find(
|
||||
(seg: { stage: string }) => seg.stage === "CORE",
|
||||
);
|
||||
expect(core.start).toBe("2026-06-03T21:00:00.000Z");
|
||||
expect(core.end).toBe("2026-06-04T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("defaults to the most recent scorable night when date omitted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
stage("2026-06-02T03:00:00.000Z", "CORE", 200),
|
||||
stage("2026-06-04T03:00:00.000Z", "CORE", 300),
|
||||
] as never);
|
||||
const res = await GET(req(""));
|
||||
const body = await res.json();
|
||||
expect(body.data.night).toBe("2026-06-04");
|
||||
expect(body.data.main.asleepMinutes).toBe(300);
|
||||
});
|
||||
|
||||
it("surfaces a daytime nap separately from the main night", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
// Nap on Jun 4 (12:00 → 12:40 UTC).
|
||||
stage("2026-06-04T12:40:00.000Z", "CORE", 40),
|
||||
// Overnight ending Jun 4 morning.
|
||||
stage("2026-06-04T01:00:00.000Z", "CORE", 240),
|
||||
stage("2026-06-04T05:00:00.000Z", "DEEP", 240),
|
||||
] as never);
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
const body = await res.json();
|
||||
expect(body.data.main.asleepMinutes).toBe(480);
|
||||
expect(body.data.naps).toHaveLength(1);
|
||||
expect(body.data.naps[0].asleepMinutes).toBe(40);
|
||||
});
|
||||
|
||||
it("returns a null main when the night has no asleep minutes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
const body = await res.json();
|
||||
expect(body.data.main).toBeNull();
|
||||
expect(body.data.naps).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* GET /api/sleep/night?date=YYYY-MM-DD
|
||||
*
|
||||
* v1.11.5 — read-only hypnogram source. Returns ONE night's reconstructed
|
||||
* sleep session: the canonical source's stage SEGMENTS (each with an
|
||||
* absolute start / end), the per-stage breakdown, the asleep / in-bed /
|
||||
* awake totals, the mid-sleep awakenings count, and the same-wake-day naps
|
||||
* surfaced separately (the nap convention).
|
||||
*
|
||||
* The night is the canonical per-night reconstruction — session-clustered,
|
||||
* keyed by the LOCAL WAKE DAY, collapsed to one source via the user's sleep
|
||||
* priority ladder — so two sources' timelines never overlay. A view over the
|
||||
* existing per-stage `SLEEP_DURATION` rows; no schema, no new type.
|
||||
*
|
||||
* `date` omitted → the most recent night in the trailing-year window.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
reconstructSleepSessions,
|
||||
pickMainNightAndNaps,
|
||||
type SleepSession,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
/** Sleep is read row-per-stage, so the window is bounded to one year. */
|
||||
const SLEEP_NIGHT_MAX_DAYS = 365;
|
||||
|
||||
const querySchema = z.object({
|
||||
// Optional wake-day key; when omitted the latest night is returned.
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
|
||||
.refine((s) => {
|
||||
const parsed = new Date(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(parsed.getTime())) return false;
|
||||
return s === parsed.toISOString().slice(0, 10);
|
||||
}, "date must be a real calendar date (YYYY-MM-DD)")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function serializeSession(s: SleepSession) {
|
||||
return {
|
||||
night: s.night,
|
||||
source: s.source,
|
||||
start: s.start.toISOString(),
|
||||
end: s.end.toISOString(),
|
||||
asleepMinutes: s.asleepMinutes,
|
||||
inBedMinutes: s.inBedMinutes,
|
||||
awakeMinutes: s.awakeMinutes,
|
||||
awakenings: s.awakenings,
|
||||
stages: s.stages,
|
||||
segments: s.segments.map((seg) => ({
|
||||
stage: seg.stage,
|
||||
start: seg.start.toISOString(),
|
||||
end: seg.end.toISOString(),
|
||||
minutes: Math.round(seg.minutes),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "sleep.night.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { date } = parsed.data;
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
|
||||
const since = new Date(Date.now() - SLEEP_NIGHT_MAX_DAYS * 86_400_000);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = reconstructSleepSessions(rows, tz, priorityJson);
|
||||
// Resolve the target wake day: the requested `date`, else the most
|
||||
// recent wake day that has a scorable session.
|
||||
const scorable = sessions.filter((s) => s.asleepMinutes > 0);
|
||||
const targetDay =
|
||||
date ??
|
||||
(scorable.length > 0 ? scorable[scorable.length - 1].night : undefined);
|
||||
|
||||
const daySessions = sessions.filter((s) => s.night === targetDay);
|
||||
const { main, naps } = pickMainNightAndNaps(daySessions);
|
||||
|
||||
annotate({
|
||||
action: { name: "sleep.night" },
|
||||
meta: {
|
||||
night: targetDay ?? null,
|
||||
has_main: main != null,
|
||||
nap_count: naps.length,
|
||||
},
|
||||
});
|
||||
|
||||
if (!main) {
|
||||
return apiSuccess({ night: targetDay ?? null, main: null, naps: [] });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
night: main.night,
|
||||
main: serializeSession(main),
|
||||
naps: naps.map(serializeSession),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user