mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user