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:
Marc-André Bombeck committed 2026-06-04 18:56:20 +02:00
1 parent 4b5970fbb0
commit e4a85c58af
5 files changed
+650

No files matched your search

+286
View File
@@ -984,6 +984,35 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/api/sleep/night:
get:
tags:
- Measurements
summary: Reconstructed sleep night (hypnogram source, v1.11.5)
description: "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical
source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals,
the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the
local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date`
omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema,
no new measurement type."
parameters:
- in: query
name: date
schema:
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
responses:
"200":
description: Reconstructed night.
content:
application/json:
schema:
$ref: "#/components/schemas/SleepNightResponse"
"401": *a1
"422": *a2
"429": *a3
/api/workouts:
get:
tags:
@@ -5182,6 +5211,263 @@ components:
required:
- deletedCount
additionalProperties: false
SleepNightResponse:
type: object
properties:
data:
$ref: "#/components/schemas/SleepNightResource"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SleepNightResource:
type: object
properties:
night:
anyOf:
- type: string
- type: "null"
main:
anyOf:
- type: object
properties:
night:
type: string
description: Wake-day key (YYYY-MM-DD).
source:
anyOf:
- type: string
enum:
- MANUAL
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
- WHOOP
- type: "null"
start:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
end:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
asleepMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
inBedMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
awakeMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
awakenings:
type: integer
minimum: 0
maximum: 9007199254740991
stages:
type: object
propertyNames:
type: string
enum:
- IN_BED
- AWAKE
- ASLEEP
- REM
- CORE
- DEEP
additionalProperties:
type: number
required:
- IN_BED
- AWAKE
- ASLEEP
- REM
- CORE
- DEEP
segments:
type: array
items:
type: object
properties:
stage:
anyOf:
- type: string
enum:
- IN_BED
- AWAKE
- ASLEEP
- REM
- CORE
- DEEP
- type: "null"
start:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
end:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
minutes:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- stage
- start
- end
- minutes
additionalProperties: false
required:
- night
- source
- start
- end
- asleepMinutes
- inBedMinutes
- awakeMinutes
- awakenings
- stages
- segments
additionalProperties: false
- type: "null"
naps:
type: array
items:
type: object
properties:
night:
type: string
description: Wake-day key (YYYY-MM-DD).
source:
anyOf:
- type: string
enum:
- MANUAL
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
- WHOOP
- type: "null"
start:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
end:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
asleepMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
inBedMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
awakeMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
awakenings:
type: integer
minimum: 0
maximum: 9007199254740991
stages:
type: object
propertyNames:
type: string
enum:
- IN_BED
- AWAKE
- ASLEEP
- REM
- CORE
- DEEP
additionalProperties:
type: number
required:
- IN_BED
- AWAKE
- ASLEEP
- REM
- CORE
- DEEP
segments:
type: array
items:
type: object
properties:
stage:
anyOf:
- type: string
enum:
- IN_BED
- AWAKE
- ASLEEP
- REM
- CORE
- DEEP
- type: "null"
start:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
end:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
minutes:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- stage
- start
- end
- minutes
additionalProperties: false
required:
- night
- source
- start
- end
- asleepMinutes
- inBedMinutes
- awakeMinutes
- awakenings
- stages
- segments
additionalProperties: false
required:
- night
- main
- naps
additionalProperties: false
description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps
surfaced separately."
ListWorkoutsResponse:
type: object
properties:
@@ -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([]);
});
});
+140
View File
@@ -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),
});
});
+73
View File
@@ -578,6 +578,49 @@ const measurementResource = z
description: "Server-shaped measurement row returned by GET endpoints.",
});
// ── Sleep night (v1.11.5 hypnogram source) ──────────────────────────
const sleepStageEnum = z.enum([
"IN_BED",
"AWAKE",
"ASLEEP",
"REM",
"CORE",
"DEEP",
]);
const sleepSegmentResource = z.object({
stage: sleepStageEnum.nullable(),
start: z.iso.datetime({ offset: true }),
end: z.iso.datetime({ offset: true }),
minutes: z.number().int().nonnegative(),
});
const sleepSessionResource = z.object({
night: z.string().describe("Wake-day key (YYYY-MM-DD)."),
source: measurementSourceEnum.nullable(),
start: z.iso.datetime({ offset: true }),
end: z.iso.datetime({ offset: true }),
asleepMinutes: z.number().int().nonnegative(),
inBedMinutes: z.number().int().nonnegative().nullable(),
awakeMinutes: z.number().int().nonnegative().nullable(),
awakenings: z.number().int().nonnegative(),
stages: z.record(sleepStageEnum, z.number()),
segments: z.array(sleepSegmentResource),
});
const sleepNightResponse = z
.object({
night: z.string().nullable(),
main: sleepSessionResource.nullable(),
naps: z.array(sleepSessionResource),
})
.meta({
id: "SleepNightResource",
description:
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
});
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
const syncStateResponse = z
@@ -2537,6 +2580,36 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/sleep/night": {
get: {
tags: ["Measurements"],
summary: "Reconstructed sleep night (hypnogram source, v1.11.5)",
description:
"Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
requestParams: {
query: z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe(
"Wake-day key (YYYY-MM-DD). Omit for the most recent night.",
),
}),
},
responses: {
"200": {
description: "Reconstructed night.",
content: {
"application/json": {
schema: dataEnvelope(sleepNightResponse, "SleepNightResponse"),
},
},
},
...stdResponses,
},
},
},
"/api/workouts": {
get: {
tags: ["Measurements"],
+9
View File
@@ -41,6 +41,13 @@ export const queryKeys = {
measurements: () => ["measurements"] as const,
moodEntries: () => ["mood-entries"] as const,
/**
* v1.11.5 — last-night hypnogram (`GET /api/sleep/night`). The `date`
* discriminator lets the night-picker step back through recent nights
* without colliding caches; `undefined` is the most-recent night.
*/
sleepNight: (date?: string) => ["sleep-night", date ?? "latest"] as const,
/**
* v1.4.33 IW2 — the analytics queryKey now optionally carries a
* `slice` discriminator so the dashboard tile-strip can subscribe to
@@ -510,6 +517,8 @@ export const measurementDependentKeys = [
// v1.11.4 item J — refresh the Trends-row deterministic caption series
// when a reading changes, in lockstep with the chart row above it.
["trend-series"] as const,
// v1.11.5 — refresh the last-night hypnogram when sleep rows change.
["sleep-night"] as const,
];
/**