From e4a85c58af4c8845cc984df450043a04dbaedb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 18:44:15 +0200 Subject: [PATCH] feat(sleep): add the read-only sleep-night hypnogram endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 286 ++++++++++++++++++ .../api/sleep/night/__tests__/route.test.ts | 142 +++++++++ src/app/api/sleep/night/route.ts | 140 +++++++++ src/lib/openapi/routes.ts | 73 +++++ src/lib/query-keys.ts | 9 + 5 files changed, 650 insertions(+) create mode 100644 src/app/api/sleep/night/__tests__/route.test.ts create mode 100644 src/app/api/sleep/night/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3a8e16515..0937cd97d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts new file mode 100644 index 000000000..0be2ab6b5 --- /dev/null +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -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(); + 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(); + 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([]); + }); +}); diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts new file mode 100644 index 000000000..fa9c6a863 --- /dev/null +++ b/src/app/api/sleep/night/route.ts @@ -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), + }); +}); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 5e146fedb..6d08d06d5 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 3977bc0f4..2dffcd2eb 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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, ]; /**