Files
HealthLog/src/lib/openapi/routes/measurements.ts
T
Marc-André Bombeck d570d558fa feat(insights): make sleep debt a configurable source ladder with a computed explainer
Generalise sleep debt onto the existing per-metric source-priority picker
(a new sleepDebt ladder, native-above-computed, default WHOOP > Oura > Polar >
computed) instead of assuming one provider. The rhythm read resolves the active
source off the ladder and carries it on the DTO; when the active source is our
own COMPUTED engine — every user today, since no provider ships a native
sleep-debt balance — the Sleep page explains what the figure means and that it
differs from a wearable's native number. The computed model itself is unchanged
and correct (a short rolling balance of need minus sleep, surplus-credited);
only stale comments that still referenced the old 14-night window are corrected.
2026-06-28 16:04:22 +02:00

772 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenAPI route table — measurements, batch ingest, bulk delete, sleep night.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
createMeasurementSchema,
listMeasurementsSchema,
measurementTypeEnum,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { seriesBatchQuerySchema } from "@/lib/validations/series-batch";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
const batchEntrySchema = z
.object({
hkIdentifier: z
.string()
.min(1)
.max(120)
.describe(
"HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).",
),
value: z
.number()
.finite()
.describe(
"Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides `valueMin` / `valueMax`.",
),
valueMin: z
.number()
.finite()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
),
valueMax: z
.number()
.finite()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
),
unit: z.string().min(1).max(60),
startDate: z.iso.datetime({ offset: true }),
endDate: z.iso.datetime({ offset: true }),
sleepStage: z
.number()
.int()
.min(0)
.max(20)
.optional()
.describe(
"HKCategoryValueSleepAnalysis codepoint; only for sleep samples.",
),
categoryValue: z
.number()
.int()
.min(0)
.max(20)
.optional()
.describe(
"v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers.",
),
externalId: z
.string()
.min(1)
.max(120)
.describe(
"Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" +
"• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" +
"• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" +
"• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>` — v1.19.0 (iOS #34) hourly heart-rate bucket carrying the hour's AVERAGE bpm as one PULSE row. `<bucket-start-hour>` is the ISO-8601 UTC hour boundary with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason `malformed_hr_bucket_id`.",
),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
// `APPLE_HEALTH` server-side when omitted, so legacy clients are
// unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of
// `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and
// rejected on this client-facing route.
source: z
.enum(["APPLE_HEALTH", "MANUAL"])
.optional()
.describe(
"Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.",
),
// v1.4.25 W8c — optional device-type tag fed into the canonical
// source picker's second axis. NULL is treated as `unknown`;
// legacy iOS builds that don't ship the field continue to work.
deviceType: deviceTypeEnum
.nullable()
.optional()
.describe(
"Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.",
),
})
.meta({ id: "AppleHealthBatchEntry" });
const batchPayloadSchema = z
.object({
entries: z.array(batchEntrySchema).min(1).max(500),
})
.meta({
id: "AppleHealthBatchRequest",
description:
"Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.",
});
const batchEntryResult = z
.object({
index: z.number().int().nonnegative(),
status: z
.enum(["inserted", "updated", "duplicate", "skipped"])
.describe(
"`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g. unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`.",
),
reason: z.string().optional(),
})
.meta({ id: "AppleHealthBatchEntryResult" });
const batchResponse = z
.object({
processed: z.number().int().nonnegative(),
inserted: z.number().int().nonnegative(),
updated: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
skipped: z.array(
z.object({
index: z.number().int().nonnegative(),
reason: z.string(),
}),
),
entries: z.array(batchEntryResult),
})
.meta({ id: "AppleHealthBatchResponse" });
const deleteByExternalIdsRequest = z
.object({
externalIds: z.array(z.string().min(1).max(120)).min(0).max(500),
})
.meta({
id: "MeasurementsDeleteByExternalIdsRequest",
description:
"iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard).",
});
const deleteByExternalIdsResponse = z
.object({
deletedCount: z.number().int().nonnegative(),
})
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
// v1.15.13 — multi-select bulk soft-delete for the measurements
// management list. Ids are scoped to the caller; forged / foreign ids
// are silent no-ops (no existence leak). Soft-delete (tombstone), so the
// rows surface in the `/api/sync/changes` delta feed.
const bulkDeleteMeasurementsRequest = z
.object({
ids: z.array(z.string().min(1)).min(1).max(200),
})
.meta({
id: "MeasurementsBulkDeleteRequest",
description:
"1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).",
});
const bulkDeleteMeasurementsResponse = z
.object({
deleted: z.number().int().nonnegative(),
})
.meta({ id: "MeasurementsBulkDeleteResult" });
// v1.16.4 — un-tombstone for the management list's delete-Undo
// affordance. Same id-scoping rules as the bulk delete: forged /
// foreign / not-deleted ids are silent no-ops.
const restoreMeasurementsRequest = z
.object({
ids: z.array(z.string().min(1)).min(1).max(200),
})
.meta({
id: "MeasurementsRestoreRequest",
description:
"1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no existence leak).",
});
const restoreMeasurementsResponse = z
.object({
restored: z.number().int().nonnegative(),
})
.meta({ id: "MeasurementsRestoreResult" });
export const measurementResource = z
.object({
id: z.string(),
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
measuredAt: z.iso.datetime({ offset: true }),
source: measurementSourceEnum,
notes: z.string().nullable().optional(),
})
.meta({
id: "MeasurementResource",
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(),
reconstructed: z
.boolean()
.describe(
"True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).",
),
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
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.",
});
// ── Sleep rhythm (sleep-debt + chronotype, v1.17.0) ──────────────────
//
// Server-authoritative timing signals computed off the SAME canonical night
// reconstruction the Sleep Score reads. Both carry a calm not-yet-ready state
// (`partial` / `learning`) below their night thresholds — they never assert a
// total / band off thin data.
const sleepDebtResource = z.object({
state: z
.enum(["partial", "ready"])
.describe(
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
),
debtMinutes: z
.number()
.int()
.nonnegative()
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
needMinutes: z
.number()
.int()
.nonnegative()
.describe("Age-based sleep need (minutes) used for the per-night deficit."),
nightsCounted: z.number().int().nonnegative(),
windowNights: z
.number()
.int()
.positive()
.describe("Rolling window length in nights."),
nightsUntilReady: z
.number()
.int()
.nonnegative()
.describe(
"Nights still needed before the debt is asserted (0 when ready).",
),
source: measurementSourceEnum.describe(
"v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would be a device-native debt. Clients explain the figure when it is `COMPUTED`.",
),
});
const chronotypeBandEnum = z.enum([
"extreme_early",
"early",
"intermediate",
"late",
"extreme_late",
]);
const chronotypeResource = z.object({
state: z
.enum(["learning", "ready"])
.describe(
"`learning` until enough free-day nights exist (no band asserted); `ready` once the free-day sample is large enough.",
),
msfMinutes: z
.number()
.nullable()
.describe("Mid-sleep on free days (minutes-of-day), null while learning."),
msfScMinutes: z
.number()
.nullable()
.describe(
"Sleep-debt-corrected mid-sleep on free days (MSFsc, minutes-of-day), null while learning.",
),
band: chronotypeBandEnum
.nullable()
.describe("MCTQ chronotype band off MSFsc, null while learning."),
socialJetlagMinutes: z
.number()
.nullable()
.describe(
"Social jetlag = circular |MSF_work − MSF_free| in minutes, null when one side is missing.",
),
freeNightsCounted: z.number().int().nonnegative(),
workNightsCounted: z.number().int().nonnegative(),
freeNightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Free-day nights still needed before a band is asserted."),
});
const averageSleepResource = z.object({
state: z.enum(["partial", "ready"]),
averageMinutes: z
.number()
.int()
.nonnegative()
.describe("Mean asleep minutes per scorable night (0 while partial)."),
nightsCounted: z.number().int().nonnegative(),
nightsUntilReady: z
.number()
.int()
.nonnegative()
.describe("Scorable nights still needed before the average is asserted."),
});
const sleepRhythmResponse = z
.object({
sleepDebt: sleepDebtResource,
chronotype: chronotypeResource,
averagePerNight: averageSleepResource,
})
.meta({
id: "SleepRhythmResource",
description:
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Time-series adapter (iOS chart source) ───────────────────────────
//
// v1.16.16 — documents the long-shipped `GET /api/measurements/series`
// the iOS charts read off. The response is described AS-IS: `points`
// carry `id`/`at`/`value`/`secondary` (the route's wire field names),
// not the canonical `measuredAt`. `secondary` pairs the diastolic value
// for `bloodPressure`; `sleepStages` is per-night stage hours for `sleep`
// and null otherwise. `unit` is the resolved per-kind token (glucose +
// sleep resolve at request time to the user's display unit / hours).
const seriesKindEnum = z.enum([
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"glucose",
"sleep",
"steps",
"totalBodyWater",
"boneMass",
"oxygenSaturation",
"restingHeartRate",
"heartRateVariability",
"vo2Max",
]);
const seriesQuerySchema = z.object({
kind: seriesKindEnum,
days: z.coerce
.number()
.int()
.min(1)
.max(3650)
.optional()
.default(30)
.describe(
"Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the requested range.",
),
});
const seriesPointSchema = z.object({
id: z
.string()
.describe("Measurement row id, or `sleep:<wake-day>` for a sleep night."),
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
value: z.number().describe("Primary value in the top-level `unit`."),
secondary: z
.number()
.nullable()
.describe(
"Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind.",
),
sleepStages: z
.record(z.string(), z.number())
.nullable()
.optional()
.describe(
"Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.",
),
valueMin: z
.number()
.nullable()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and absent for every other kind.",
),
valueMax: z
.number()
.nullable()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.",
),
});
const seriesResponse = z
.object({
kind: seriesKindEnum,
unit: z
.string()
.describe(
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).",
),
points: z.array(seriesPointSchema),
stats: z
.object({
mean: z.number(),
min: z.number(),
max: z.number(),
stdDev: z.number(),
count: z.number().int().nonnegative(),
})
.describe("Summary over the returned points; all-zero when empty."),
})
.meta({
id: "MeasurementsSeriesResponse",
description:
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block.",
});
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
//
// Returns every requested MeasurementType's rollup-backed daily series
// in one response so the dashboard chart row does a single round-trip
// instead of one per chart. Each row mirrors the daily-aggregate shape
// `GET /api/measurements?aggregate=daily&source=rollup` emits.
const seriesBatchRowSchema = z.object({
type: measurementTypeEnum,
value: z.number().describe("Daily mean (spot metrics) or SUM (cumulative)."),
measuredAt: z.iso
.datetime({ offset: true })
.describe("Day-bucket start (ISO-8601)."),
count: z
.number()
.int()
.nonnegative()
.optional()
.describe("Underlying raw-row count for the bucket."),
minValue: z
.number()
.nullable()
.optional()
.describe("Per-day minimum (spot metrics only; absent for cumulative)."),
maxValue: z
.number()
.nullable()
.optional()
.describe("Per-day maximum (spot metrics only; absent for cumulative)."),
});
const seriesBatchResponse = z
.object({
series: z
.record(measurementTypeEnum, z.array(seriesBatchRowSchema))
.describe("Per-type daily series, keyed by MeasurementType."),
})
.meta({
id: "MeasurementsSeriesBatchResponse",
description:
"Batched daily series — one rollup-backed series per requested type.",
});
export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/measurements/series-batch": {
get: {
tags: ["Measurements"],
summary: "Batched daily series (dashboard fetch coalescing)",
description:
"Returns the rollup-backed daily series for every requested MeasurementType in ONE response, so the dashboard chart row does a single round-trip instead of one self-fetch per chart. Each per-type series is byte-identical with `GET /api/measurements?aggregate=daily&source=rollup` for the same window. SLEEP_DURATION is not served here (it rides the per-night reconstruction at `/api/measurements/series?kind=sleep`). Auth via cookie or Bearer.",
requestParams: {
query: seriesBatchQuerySchema,
},
responses: {
"200": {
description: "Per-type daily series.",
content: {
"application/json": {
schema: dataEnvelope(
seriesBatchResponse,
"GetMeasurementsSeriesBatchResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/series": {
get: {
tags: ["Measurements"],
summary: "Per-kind time series (iOS chart source)",
description:
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.",
requestParams: {
query: seriesQuerySchema,
},
responses: {
"200": {
description: "Resolved series.",
content: {
"application/json": {
schema: dataEnvelope(
seriesResponse,
"GetMeasurementsSeriesResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements": {
get: {
tags: ["Measurements"],
summary: "List measurements",
description:
"Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.",
requestParams: {
query: listMeasurementsSchema,
},
responses: {
"200": {
description: "Measurement page.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
measurements: z.array(measurementResource),
total: z.number().int().nonnegative(),
}),
"ListMeasurementsResponse",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Measurements"],
summary: "Create one measurement",
description:
"Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.",
requestBody: {
required: true,
content: { "application/json": { schema: createMeasurementSchema } },
},
responses: {
"201": {
description: "Created.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
"CreateMeasurementResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/batch": {
post: {
tags: ["Measurements"],
summary: "Apple Health batch ingest",
description:
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
requestBody: {
required: true,
content: { "application/json": { schema: batchPayloadSchema } },
},
responses: {
"200": {
description: "Batch processed.",
content: {
"application/json": {
schema: dataEnvelope(batchResponse, "BatchMeasurementsResponse"),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/by-external-ids": {
delete: {
tags: ["Measurements"],
summary: "Delete measurements by external ID (iOS deletion-sync)",
description:
"Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.",
requestBody: {
required: true,
content: {
"application/json": { schema: deleteByExternalIdsRequest },
},
},
responses: {
"200": {
description: "Delete batch processed.",
content: {
"application/json": {
schema: dataEnvelope(
deleteByExternalIdsResponse,
"MeasurementsDeleteByExternalIdsResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/bulk-delete": {
post: {
tags: ["Measurements"],
summary: "Bulk soft-delete measurements",
description:
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
requestBody: {
required: true,
content: {
"application/json": { schema: bulkDeleteMeasurementsRequest },
},
},
responses: {
"200": {
description: "Bulk delete processed.",
content: {
"application/json": {
schema: dataEnvelope(
bulkDeleteMeasurementsResponse,
"MeasurementsBulkDeleteResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/restore": {
post: {
tags: ["Measurements"],
summary: "Restore soft-deleted measurements",
description:
"Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.",
requestBody: {
required: true,
content: {
"application/json": { schema: restoreMeasurementsRequest },
},
},
responses: {
"200": {
description: "Restore processed.",
content: {
"application/json": {
schema: dataEnvelope(
restoreMeasurementsResponse,
"MeasurementsRestoreResponse",
),
},
},
},
...stdResponses,
},
},
},
"/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"),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
"/api/sleep/rhythm": {
get: {
tags: ["Measurements"],
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
description:
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
responses: {
"200": {
description: "Sleep-debt + chronotype DTO.",
content: {
"application/json": {
schema: dataEnvelope(sleepRhythmResponse, "SleepRhythmResponse"),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
};