Files
HealthLog/src/lib/sleep/reconstruct-timeline.ts
T
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00

138 lines
5.0 KiB
TypeScript

/**
* Shared reconstructed-sleep-timeline builder.
*
* Some wearables (WHOOP v2, Polar) expose only per-stage DURATION totals for a
* night — no per-stage onset timestamps and no hypnogram endpoint. Stamping
* every stage total on the one sleep-END instant collapses the hypnogram into
* overlapping spans that all touch the night's right edge. Since the API never
* carries an order, both vendors RECONSTRUCT an ordered, contiguous timeline:
* lay the stages back-to-back from sleep ONSET in a fixed physiological order,
* emitting one timed row per segment with `measuredAt = that segment's END`.
*
* The ORDER is synthetic, so every laid segment is flagged `reconstructed:
* true`; the night DTO advertises it and the UI labels such a night as an
* approximate layout, never as measured stage timing. The reader's honesty
* flag (`analytics/sleep-night.ts` `RECONSTRUCTED_TIMELINE_SOURCES`) carries
* the same contract on the read side.
*
* This helper is the single source of the algorithm; each vendor `mapSleep`
* keeps only its field-name → tuple normalisation (ms vs sec, source field
* names) so the segment shape and externalId scheme can never drift between the
* two synthetic-timeline vendors. Oura's measured 5-min hypnogram is a
* different algorithm and does NOT use this helper.
*/
/** The sleep-row shape both vendor mappers emit. Kept structurally identical to
* each vendor's local `MappedMeasurement`; the helper returns rows assignable to
* either. */
export interface ReconstructedSleepRow {
type: "SLEEP_DURATION";
value: number;
unit: "minutes";
measuredAt: Date;
fieldTag: string;
externalId?: string;
sleepStage: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
reconstructed?: boolean;
}
/** One asleep/awake stage to lay onto the reconstructed timeline. `durationMs`
* is the stage total in milliseconds; non-positive or non-finite durations are
* skipped. */
export interface ReconstructedStage {
durationMs: number | null | undefined;
stage: "CORE" | "DEEP" | "REM" | "AWAKE";
fieldTag: string;
}
/** Optional single IN_BED envelope row over the whole sleep window. The in-bed
* reader consumes the union envelope, so this stays one row stamped at the
* sleep END (`measuredAt`) — NOT a placed segment. */
export interface ReconstructedInBed {
durationMs: number | null | undefined;
/** Sleep END instant; the in-bed reader resolves the span back to its window. */
measuredAt: Date;
fieldTag: string;
}
export interface ReconstructTimelineOptions {
/** Sleep ONSET instant; the contiguous walk starts here. */
startMs: number;
/** Stages in physiological lay-out order (e.g. AWAKE → CORE → DEEP → REM). */
stages: ReadonlyArray<ReconstructedStage>;
/** Optional IN_BED envelope row. */
inBed?: ReconstructedInBed;
/**
* Builds the externalId for a laid segment so the several rows of one night
* stay distinct under `userId_type_source_externalId`. Called with the
* segment's fieldTag ONLY — the builder emits at most one segment per stage,
* so the tag alone is unique within a night. A positional index was
* deliberately dropped from the key: it skipped zero-duration stages, so a
* 0↔positive flip on a vendor re-score renumbered every following stage,
* minted fresh externalIds, and the upsert double-counted the night instead
* of overwriting (the Google Health v1.28.18 bug class).
*/
externalIdFor: (fieldTag: string) => string;
}
const MS_TO_MIN = 1 / 60_000;
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Lay the asleep/awake stages contiguously from `startMs` in the given order,
* emitting one timed `SLEEP_DURATION` row per stage (`measuredAt` = that
* segment's END), each flagged `reconstructed: true` and keyed by a
* stage-tagged externalId. Appends a single IN_BED envelope row when `inBed`
* is provided.
*/
export function reconstructContiguousSleepTimeline(
opts: ReconstructTimelineOptions,
): ReconstructedSleepRow[] {
const { startMs, stages, inBed, externalIdFor } = opts;
const out: ReconstructedSleepRow[] = [];
let cursor = startMs;
for (const { durationMs, stage, fieldTag } of stages) {
if (
typeof durationMs !== "number" ||
!Number.isFinite(durationMs) ||
durationMs <= 0
) {
continue;
}
const segEnd = cursor + durationMs;
out.push({
type: "SLEEP_DURATION",
value: round2(durationMs * MS_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
fieldTag,
externalId: externalIdFor(fieldTag),
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
}
if (
inBed &&
typeof inBed.durationMs === "number" &&
Number.isFinite(inBed.durationMs) &&
inBed.durationMs > 0
) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBed.durationMs * MS_TO_MIN),
unit: "minutes",
measuredAt: inBed.measuredAt,
fieldTag: inBed.fieldTag,
sleepStage: "IN_BED",
});
}
return out;
}