mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge(sleep): reconstruct WHOOP per-stage timeline, fix Withings/Fitbit stamping
This commit is contained in:
commit
ed50d10556
19 files changed
+771
-87
No files matched your search
@@ -8982,6 +8982,11 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
reconstructed:
|
||||
type: boolean
|
||||
description: 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:
|
||||
type: object
|
||||
propertyNames:
|
||||
@@ -9047,6 +9052,7 @@ components:
|
||||
- inBedMinutes
|
||||
- awakeMinutes
|
||||
- awakenings
|
||||
- reconstructed
|
||||
- stages
|
||||
- segments
|
||||
additionalProperties: false
|
||||
@@ -9102,6 +9108,11 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
reconstructed:
|
||||
type: boolean
|
||||
description: 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:
|
||||
type: object
|
||||
propertyNames:
|
||||
@@ -9167,6 +9178,7 @@ components:
|
||||
- inBedMinutes
|
||||
- awakeMinutes
|
||||
- awakenings
|
||||
- reconstructed
|
||||
- stages
|
||||
- segments
|
||||
additionalProperties: false
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- v1.17.1 — one-shot sleep-timeline backfill markers.
|
||||
--
|
||||
-- WHOOP's v2 API returns only per-stage sleep DURATION totals (no onset
|
||||
-- timestamps), and the pre-fix mapper stamped all five stage totals on the one
|
||||
-- sleep-END instant. The hypnogram then reconstructed every stage as a span
|
||||
-- touching the night's right edge — the stacked-bar artefact with no clock
|
||||
-- times. The fix reconstructs an ordered, contiguous per-segment timeline.
|
||||
--
|
||||
-- Withings stamped each sleep segment with its START while every reader treats
|
||||
-- `measured_at` as the segment END, shifting each night one segment-length
|
||||
-- earlier. The fix stamps the END.
|
||||
--
|
||||
-- Both fixes change the stored row shape / instant, so existing rows must be
|
||||
-- re-synced. The boot-time backfill (per connection) deletes the affected
|
||||
-- SLEEP_DURATION rows, re-syncs with the corrected mapper, re-folds the rollup
|
||||
-- tier, and stamps `sleep_timeline_backfill_at` so the discovery query drops
|
||||
-- the connection. Null = backfill not yet run; the marker makes the one-shot
|
||||
-- idempotent across reboots.
|
||||
ALTER TABLE "whoop_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
|
||||
ALTER TABLE "withings_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
|
||||
@@ -2173,6 +2173,13 @@ model WithingsConnection {
|
||||
/// has never re-authed since v1.4.25 — the UI surfaces a banner
|
||||
/// inviting them to reconnect.
|
||||
scope String? @map("scope")
|
||||
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
|
||||
/// sleep rows were stamped with the segment START; the reader treats
|
||||
/// `measuredAt` as the END, shifting every night one segment-length earlier.
|
||||
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
|
||||
/// and re-syncs with the corrected END stamp, then sets this so the discovery
|
||||
/// query drops the connection. Null = backfill not yet run.
|
||||
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -2249,6 +2256,13 @@ model WhoopConnection {
|
||||
/// Set once the self-converging history backfill walks every collection to
|
||||
/// completion. Null = backfill still in flight (or never started).
|
||||
backfillCompletedAt DateTime? @map("backfill_completed_at")
|
||||
/// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP
|
||||
/// sleep rows stamped all five stage totals on the one sleep-END instant, so
|
||||
/// the hypnogram stacked them on the night's right edge. The boot-time
|
||||
/// backfill deletes the legacy summary rows and re-syncs the reconstructed
|
||||
/// per-segment timeline, then sets this so the discovery query drops the
|
||||
/// connection. Null = backfill not yet run.
|
||||
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
|
||||
/// Max heart rate from the WHOOP body-measurement profile — a profile
|
||||
/// constant, not a time series, so it lives here rather than as a
|
||||
/// Measurement row.
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
* priority ladder — so two sources' timelines never overlay. A view over the
|
||||
* existing per-stage `SLEEP_DURATION` rows; no schema, no new type.
|
||||
*
|
||||
* `reconstructed` (per session) is true when the winning source has no
|
||||
* per-stage onset timestamps and the server synthesised a contiguous timeline
|
||||
* (WHOOP) — the client renders the hypnogram but labels it an approximate
|
||||
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
|
||||
*
|
||||
* `date` omitted → the most recent night in the trailing-year window.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
|
||||
@@ -64,6 +69,10 @@ function serializeSession(s: SleepSession) {
|
||||
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
|
||||
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
|
||||
awakenings: s.awakenings,
|
||||
// iOS #18 — `reconstructed` is true when the source has no per-stage onset
|
||||
// timestamps and the server synthesised a contiguous timeline (WHOOP). iOS
|
||||
// labels the hypnogram as an approximate layout; it never recomputes.
|
||||
reconstructed: s.reconstructed,
|
||||
// iOS #18 — round the per-stage map too; the underlying totals sum
|
||||
// second-precision segments and otherwise serialise as e.g. 88.4999.
|
||||
stages: Object.fromEntries(
|
||||
|
||||
@@ -380,6 +380,48 @@ describe("reconstructSleepSessions", () => {
|
||||
expect(sessions[0].asleepMinutes).toBe(420);
|
||||
});
|
||||
|
||||
it("flags a WHOOP-won night as reconstructed (synthetic stage order) with clock-time-distinct segments", () => {
|
||||
// The ingest mapper reconstructs an ordered, contiguous WHOOP timeline.
|
||||
// The reconstructed segments carry distinct measuredAt instants, so the
|
||||
// hypnogram lays them at distinct clock times (no shared right edge).
|
||||
const rows: SleepStageRow[] = [
|
||||
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), // 21:00→01:00
|
||||
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "WHOOP"), // 01:00→03:00
|
||||
srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "WHOOP"), // 03:00→05:00
|
||||
];
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
expect(sessions).toHaveLength(1);
|
||||
const s = sessions[0];
|
||||
expect(s.source).toBe("WHOOP");
|
||||
expect(s.reconstructed).toBe(true);
|
||||
// No two segments end on the same instant — not stacked on the right edge.
|
||||
const ends = s.segments.map((seg) => seg.end.getTime());
|
||||
expect(new Set(ends).size).toBe(ends.length);
|
||||
});
|
||||
|
||||
it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => {
|
||||
const rows: SleepStageRow[] = [
|
||||
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
|
||||
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
|
||||
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "APPLE_HEALTH"),
|
||||
];
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].source).toBe("APPLE_HEALTH");
|
||||
expect(sessions[0].reconstructed).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT flag a Withings night as reconstructed (real per-segment series)", () => {
|
||||
const rows: SleepStageRow[] = [
|
||||
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WITHINGS"),
|
||||
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WITHINGS"),
|
||||
];
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].source).toBe("WITHINGS");
|
||||
expect(sessions[0].reconstructed).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the bare ASLEEP aggregate from segments + stages when granular exists (HIGH H1, v1.11.5)", () => {
|
||||
// Apple-Health shape: granular CORE/DEEP/REM PLUS the unspecified ASLEEP
|
||||
// aggregate covering the same period, plus IN_BED. The hypnogram must not
|
||||
|
||||
@@ -281,6 +281,19 @@ export interface SleepNight {
|
||||
/** Sentinel for rows that carry no source — collapses to one bucket. */
|
||||
const NO_SOURCE = "__none__";
|
||||
|
||||
/**
|
||||
* Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's
|
||||
* v2 API exposes only per-stage duration totals (no onset timestamps), so the
|
||||
* ingest mapper reconstructs a contiguous timeline in a fixed physiological
|
||||
* order (`whoop/client.ts` `mapSleep`). A session won by such a source carries
|
||||
* `reconstructed: true` so the UI labels it as an approximate layout. Apple
|
||||
* Health / Withings / Fitbit all store a real per-segment series and stay off
|
||||
* this set.
|
||||
*/
|
||||
const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet<string> = new Set<string>([
|
||||
"WHOOP",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Separator between the source and device-type parts of a writer key.
|
||||
* ` | ||||