merge(sleep): reconstruct WHOOP per-stage timeline, fix Withings/Fitbit stamping

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:20:53 +02:00
commit ed50d10556
19 files changed
+771 -87

No files matched your search

+12
View File
@@ -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);
+14
View File
@@ -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.
+9
View File
@@ -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
+29
View File
@@ -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.
* `` cannot appear in a `MeasurementSource` enum literal or a
@@ -615,6 +628,17 @@ export interface SleepSession {
awakenings: number;
/** The canonical source's segments, sorted by start, for the hypnogram. */
segments: SleepSegment[];
/**
* `true` when the winning writer's stage ORDER is synthesised rather than
* measured. WHOOP's v2 API returns only per-stage duration totals — no onset
* timestamps — so the server reconstructs a contiguous timeline in a fixed
* physiological order (`whoop/client.ts` `mapSleep`). The segment positions
* are then approximate, not measured, and the UI / iOS must label the night
* as an approximate layout (never present it as measured stage timing).
* Sources that store a real per-segment series (Apple Health, Withings,
* Fitbit) stay `false`.
*/
reconstructed: boolean;
}
/** Resolve a stage row to its absolute span (start = end − duration). */
@@ -760,6 +784,11 @@ export function reconstructSleepSessions(
stages,
awakenings: countAwakenings(segments),
segments,
// The winning writer's stage order is synthetic when its source
// reconstructs the timeline (WHOOP). The UI labels such a night as an
// approximate layout; a real-series source (Apple/Withings/Fitbit) is
// measured.
reconstructed: RECONSTRUCTED_TIMELINE_SOURCES.has(canonicalSource),
});
}
return sessions.sort((a, b) => a.start.getTime() - b.start.getTime());
+24 -16
View File
@@ -526,7 +526,7 @@ describe("sleep-stage mapping", () => {
expect(mapFitbitSleepStage(42)).toBeNull();
});
it("maps a session into per-stage SLEEP_DURATION rows with measuredAt = stage END", () => {
it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => {
const session = {
sleep: {
startTime: "2026-05-10T22:00:00.000Z",
@@ -556,23 +556,31 @@ describe("sleep-stage mapping", () => {
},
};
const out = mapSleepSession(session);
const byStage = Object.fromEntries(out.map((m) => [m.sleepStage, m]));
// CORE = the two light segments summed (30 + 45 = 75 min).
expect(byStage.CORE!.value).toBe(75);
expect(byStage.CORE!.type).toBe("SLEEP_DURATION");
expect(byStage.CORE!.unit).toBe("minutes");
// measuredAt is the LATEST end for that stage (the second light segment).
expect(byStage.CORE!.measuredAt.toISOString()).toBe(
// One row PER segment (no collapse) — 4 segments → 4 rows.
expect(out).toHaveLength(4);
expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true);
expect(out.every((m) => m.unit === "minutes")).toBe(true);
// Each row carries its OWN end instant + duration — a real clock-time
// timeline, not a stage collapse.
const core = out.filter((m) => m.sleepStage === "CORE");
expect(core.map((m) => m.value)).toEqual([30, 45]);
expect(core.map((m) => m.measuredAt.toISOString())).toEqual([
"2026-05-10T22:30:00.000Z",
"2026-05-11T00:15:00.000Z",
);
expect(byStage.DEEP!.value).toBe(60);
expect(byStage.REM!.value).toBe(45);
]);
const deep = out.find((m) => m.sleepStage === "DEEP")!;
expect(deep.value).toBe(60);
expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z");
const rem = out.find((m) => m.sleepStage === "REM")!;
expect(rem.value).toBe(45);
// externalId field-tag is session-anchored so a re-score overwrites in place.
expect(byStage.DEEP!.fieldTag).toBe(
"2026-05-11T06:00:00.000Z:sleep_deep",
);
// Every fieldTag is distinct (session anchor + stage + segment index) so
// the several segments of one stage never collide under the dedup key.
const tags = out.map((m) => m.fieldTag);
expect(new Set(tags).size).toBe(tags.length);
expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1");
});
it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => {
@@ -592,7 +600,7 @@ describe("sleep-stage mapping", () => {
},
});
const deep = out.find((m) => m.sleepStage === "DEEP");
expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep");
expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0");
});
it("yields nothing for a session with no parseable segments", () => {
+20 -26
View File
@@ -1244,10 +1244,18 @@ function sleepSessionAnchor(point: FitbitDataPoint): string {
}
/**
* Map one Google sleep session into per-stage `SLEEP_DURATION` rows. Each
* stage's segments are summed into one row carrying the stage's `SleepStage`,
* `measuredAt = the latest segment END for that stage`, and a stage-scoped
* fieldTag so the (up to six) stage rows for one night stay distinct under the
* Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows. The
* Google sleep payload carries a real per-stage segment series (each with its
* own start/end), so one row is emitted PER SEGMENT — `measuredAt = that
* segment's END` — rather than collapsing a stage's segments onto a single
* lastEnd instant. The collapsed shape stamped every stage total on its last
* end, so the hypnogram reconstructed all of a stage's time as one block ending
* at the night's right edge (the stacked-bar artefact); per-segment rows lay
* each block at its true clock time. The timeline is MEASURED (real onsets), so
* these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets.
*
* Each segment carries a stage-scoped, INDEXED fieldTag so the several segments
* of one stage stay distinct under the `(userId, type, source, externalId)`
* dedup key. Unknown stage labels are skipped; a session with no parseable
* segment yields nothing.
*/
@@ -1257,38 +1265,24 @@ export function mapSleepSession(
const segments = readSleepSegments(point);
if (segments.length === 0) return [];
// Accumulate per-stage minutes + track the latest end instant for each stage.
const perStage = new Map<
FitbitSleepStage,
{ minutes: number; lastEnd: Date }
>();
const anchor = sleepSessionAnchor(point);
const out: FitbitMappedMeasurement[] = [];
let segIndex = 0;
for (const seg of segments) {
const stage = mapFitbitSleepStage(seg.stage);
if (!stage) continue;
const mins = minutesBetween(seg.startTime, seg.endTime);
if (mins === null) continue;
if (mins === null || !(mins > 0)) continue;
const end = new Date(seg.endTime as string);
const prev = perStage.get(stage);
if (prev) {
prev.minutes += mins;
if (end > prev.lastEnd) prev.lastEnd = end;
} else {
perStage.set(stage, { minutes: mins, lastEnd: end });
}
}
const anchor = sleepSessionAnchor(point);
const out: FitbitMappedMeasurement[] = [];
for (const [stage, agg] of perStage) {
if (!(agg.minutes > 0)) continue;
out.push({
type: "SLEEP_DURATION",
value: round2(agg.minutes),
value: round2(mins),
unit: "minutes",
measuredAt: agg.lastEnd,
fieldTag: `${anchor}:sleep_${stage.toLowerCase()}`,
measuredAt: end,
fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`,
sleepStage: stage,
});
segIndex += 1;
}
return out;
}
+11 -8
View File
@@ -2,16 +2,19 @@
* Fitbit / Google Health sleep-bundle sync (v1.12.0, W5).
*
* Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps
* each into per-stage `SLEEP_DURATION` rows via `mapSleepSession` (minutes per
* stage; `measuredAt = the stage's END instant; harmonised onto the shared
* `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the night-total +
* hypnogram readers already consume for WHOOP / Apple). Upserts as
* each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row
* per stage segment; `measuredAt = that segment's END instant; harmonised onto
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the
* night-total + hypnogram readers already consume for WHOOP / Apple). Google
* carries a real per-segment series, so the rows lay each block at its true
* clock time (a MEASURED timeline, not reconstructed). Upserts as
* `source = FITBIT`.
*
* Per-stage rows carry the `sleepStage` axis so the (up to six) stage rows for
* one night stay distinct under the `(userId, type, source, externalId)` dedup
* key. externalId = `<session-anchor>:sleep_<stage>` — a re-scored night
* overwrites in place. A 24 h overlap covers Google's after-the-fact re-score.
* Each segment carries the `sleepStage` axis and an indexed fieldTag so the
* several segments of one stage stay distinct under the
* `(userId, type, source, externalId)` dedup key. externalId =
* `<session-anchor>:sleep_<stage>:<i>` — a re-scored night overwrites in place.
* A 24 h overlap covers Google's after-the-fact re-score.
*
* A per-data-class 403 soft-skips the resource — the sleep bundle is granted
* independently of activity / metrics in the Google consent flow.
@@ -0,0 +1,149 @@
/**
* v1.17.1 — sleep-timeline backfill self-convergence tests (mocked).
* - discovery only matches connections whose sleep rows predate the fix
* (`sleepTimelineBackfillAt IS NULL`), for both WHOOP and Withings;
* - a completed pass DELETES the source's SLEEP_DURATION rows, re-syncs, and
* stamps `sleepTimelineBackfillAt` so the next discovery drops the account
* (idempotent across reboots);
* - the discovery enqueue is singleton-keyed per (provider, user).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock, bossSend, syncUserWhoop, syncWithingsSleep } = vi.hoisted(
() => ({
prismaMock: {
whoopConnection: { findMany: vi.fn(), update: vi.fn() },
withingsConnection: { findMany: vi.fn(), update: vi.fn() },
measurement: { deleteMany: vi.fn() },
},
bossSend: vi.fn(),
syncUserWhoop: vi.fn(),
syncWithingsSleep: vi.fn(),
}),
);
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => ({ send: bossSend }),
}));
vi.mock("@/lib/whoop/sync", () => ({
syncUserWhoop: (...a: unknown[]) => syncUserWhoop(...a),
}));
vi.mock("@/lib/withings/sync-sleep", () => ({
syncUserSleep: (...a: unknown[]) => syncWithingsSleep(...a),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: () => {},
getEvent: () => null,
}));
import {
enqueueBootTimeSleepTimelineBackfill,
runSleepTimelineBackfillForUser,
} from "../sleep-timeline-backfill";
beforeEach(() => {
vi.clearAllMocks();
});
describe("enqueueBootTimeSleepTimelineBackfill — discovery", () => {
it("queries un-backfilled WHOOP + Withings connections and enqueues one job per (provider, user)", async () => {
prismaMock.whoopConnection.findMany.mockResolvedValue([{ userId: "w1" }]);
prismaMock.withingsConnection.findMany.mockResolvedValue([
{ userId: "v1" },
]);
bossSend.mockResolvedValue("job-id");
const result = await enqueueBootTimeSleepTimelineBackfill();
expect(prismaMock.whoopConnection.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }),
);
expect(prismaMock.withingsConnection.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }),
);
expect(result.enqueued).toBe(2);
expect(bossSend).toHaveBeenCalledWith(
"sleep-timeline-backfill",
expect.objectContaining({ userId: "w1", provider: "WHOOP" }),
expect.objectContaining({
singletonKey: "sleep-timeline-backfill|WHOOP|w1",
}),
);
expect(bossSend).toHaveBeenCalledWith(
"sleep-timeline-backfill",
expect.objectContaining({ userId: "v1", provider: "WITHINGS" }),
expect.objectContaining({
singletonKey: "sleep-timeline-backfill|WITHINGS|v1",
}),
);
});
it("self-converges: no un-backfilled connections → nothing enqueued", async () => {
prismaMock.whoopConnection.findMany.mockResolvedValue([]);
prismaMock.withingsConnection.findMany.mockResolvedValue([]);
const result = await enqueueBootTimeSleepTimelineBackfill();
expect(result.enqueued).toBe(0);
expect(bossSend).not.toHaveBeenCalled();
});
it("never throws — surfaces a discovery error through the result value", async () => {
prismaMock.whoopConnection.findMany.mockRejectedValue(new Error("db down"));
prismaMock.withingsConnection.findMany.mockResolvedValue([]);
const result = await enqueueBootTimeSleepTimelineBackfill();
expect(result.error).toBe("db down");
expect(result.enqueued).toBe(0);
});
});
describe("runSleepTimelineBackfillForUser", () => {
it("WHOOP: deletes the source's sleep rows, full-syncs, and stamps the marker", async () => {
prismaMock.measurement.deleteMany.mockResolvedValue({ count: 5 });
syncUserWhoop.mockResolvedValue(42);
prismaMock.whoopConnection.update.mockResolvedValue({});
const { deleted, imported } = await runSleepTimelineBackfillForUser(
"w1",
"WHOOP",
);
expect(deleted).toBe(5);
expect(imported).toBe(42);
expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({
where: { userId: "w1", type: "SLEEP_DURATION", source: "WHOOP" },
});
expect(syncUserWhoop).toHaveBeenCalledWith("w1", { fullSync: true });
const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0];
expect(updateArg.where).toEqual({ userId: "w1" });
expect(updateArg.data.sleepTimelineBackfillAt).toBeInstanceOf(Date);
});
it("WITHINGS: deletes the source's sleep rows, re-syncs, and stamps the marker", async () => {
prismaMock.measurement.deleteMany.mockResolvedValue({ count: 3 });
syncWithingsSleep.mockResolvedValue(7);
prismaMock.withingsConnection.update.mockResolvedValue({});
const { deleted, imported } = await runSleepTimelineBackfillForUser(
"v1",
"WITHINGS",
);
expect(deleted).toBe(3);
expect(imported).toBe(7);
expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({
where: { userId: "v1", type: "SLEEP_DURATION", source: "WITHINGS" },
});
expect(syncWithingsSleep).toHaveBeenCalledWith("v1", { fullSync: true });
const updateArg = prismaMock.withingsConnection.update.mock.calls[0]![0];
expect(updateArg.where).toEqual({ userId: "v1" });
expect(updateArg.data.sleepTimelineBackfillAt).toBeInstanceOf(Date);
});
});
@@ -0,0 +1,38 @@
/**
* v1.17.1 — pg-boss queue registration for the one-shot sleep-timeline backfill.
*
* The reminder-worker imports heavy infrastructure, so we read the source as
* text and assert the queue constant is declared, registered in `allQueues`,
* wired to a `boss.work` handler, and enqueued at boot — the fast guard that
* catches the v1.4.37 dead-queue class (a queue declared but never registered,
* which silently never drains).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
describe("reminder-worker — sleep-timeline backfill queue", () => {
it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => {
const block = /const allQueues = \[([\s\S]*?)\];/.exec(source);
expect(block).not.toBeNull();
expect(block![1]!).toContain("SLEEP_TIMELINE_BACKFILL_QUEUE");
});
it("wires a boss.work handler to the per-user runner", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,260}SLEEP_TIMELINE_BACKFILL_QUEUE[\s\S]{0,260}runSleepTimelineBackfillForUser/,
);
});
it("wires the boot discovery", () => {
expect(source).toMatch(/enqueueBootTimeSleepTimelineBackfill\(\)/);
});
it("imports the backfill exports", () => {
expect(source).toMatch(/from\s*["']@\/lib\/jobs\/sleep-timeline-backfill["']/);
});
});
+63
View File
@@ -21,6 +21,13 @@ import {
enqueueBootTimeFitbitBackfill,
type FitbitBackfillPayload,
} from "@/lib/jobs/fitbit-backfill";
import {
SLEEP_TIMELINE_BACKFILL_QUEUE,
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
runSleepTimelineBackfillForUser,
enqueueBootTimeSleepTimelineBackfill,
type SleepTimelineBackfillPayload,
} from "@/lib/jobs/sleep-timeline-backfill";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -629,6 +636,13 @@ export async function startReminderWorker() {
FITBIT_SYNC_QUEUE,
FITBIT_BACKFILL_QUEUE,
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
// v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings.
// Discovery enqueues one job per connection whose sleep rows predate the
// stamp/shape fix; the pass deletes the affected SLEEP_DURATION rows and
// re-syncs. Idempotent across reboots. The queue MUST be registered here or
// pg-boss never provisions it and the boot enqueue silently never drains
// (the v1.4.37 dead-queue class).
SLEEP_TIMELINE_BACKFILL_QUEUE,
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no
// backfill queue — the hourly window walks the recent SGV set). The queue
// MUST be registered here or pg-boss never provisions it and the schedule
@@ -1056,6 +1070,27 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleFitbitOAuthStateCleanup,
);
// v1.17.1 — one-shot sleep-timeline backfill. The boot enqueue below sends
// one job per (user, provider) whose sleep rows predate the stamp/shape fix;
// this handler deletes the affected rows, re-syncs, and stamps the marker so
// the discovery query drops the connection.
await boss.work<SleepTimelineBackfillPayload>(
SLEEP_TIMELINE_BACKFILL_QUEUE,
{ localConcurrency: SLEEP_TIMELINE_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId, provider } = job.data;
const { deleted, imported } = await runSleepTimelineBackfillForUser(
userId,
provider,
);
workerLog(
"info",
`[sleep-timeline-backfill] user=${userId} provider=${provider} deleted=${deleted} imported=${imported}`,
);
}
},
);
// v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no
// `userId`) walks every configured instance; one user's unreachable host is
// warned, not fatal.
@@ -1882,6 +1917,34 @@ export async function startReminderWorker() {
);
}
// v1.17.1 — fire-and-forget boot discovery for the one-shot sleep-timeline
// backfill. Finds every WHOOP + Withings connection whose sleep rows predate
// the stamp/shape fix and enqueues one job per (user, provider). Idempotent
// across reboots: a completed pass stamps `sleepTimelineBackfillAt`, dropping
// the connection from the discovery set. Errors come back through the
// helper's result value — the worker boot never fails because of a miss.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeSleepTimelineBackfill();
if (error) {
workerLog(
"error",
`[sleep-timeline-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[sleep-timeline-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[sleep-timeline-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.7.0 — fire-and-forget boot discovery for the daily-mean
// consolidation pass. Finds every user holding live per-sample
// high-frequency mean-type rows and enqueues one job per account.
+169
View File
@@ -0,0 +1,169 @@
/**
* v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings.
*
* Two ingest-side stamp/shape fixes changed the stored sleep rows:
*
* - WHOOP: the old mapper stamped all five per-stage DURATION totals on the
* one sleep-END instant (WHOOP v2 exposes no onset timestamps), so the
* hypnogram reconstructed every stage as a span touching the night's right
* edge — stacked, no clock times. The fix reconstructs an ordered,
* contiguous per-segment timeline with distinct `measuredAt` per segment and
* NEW indexed externalIds (`<sleep_id>:seg:<tag>:<i>`).
* - Withings: each segment was stamped with its START while every reader
* treats `measuredAt` as the END, shifting each night one segment-length
* earlier. The fix stamps the END.
*
* Both change the row's `measuredAt` (and, for WHOOP, the externalId), and the
* unique index `(userId, type, measuredAt, source, sleepStage)` makes an
* in-place UPDATE collision-prone. So the per-connection pass DELETES the
* affected `SLEEP_DURATION` rows for that source, RE-SYNCS with the corrected
* mapper (which re-folds the rollup tier in its tail), and stamps
* `sleepTimelineBackfillAt` so the discovery query drops the connection.
*
* Idempotent across reboots: the discovery predicate is
* `sleep_timeline_backfill_at IS NULL`, and a completed pass stamps the marker.
* A reboot mid-pass re-runs delete + re-sync from scratch — the re-sync upserts
* are key-stable, so the result converges.
*
* Modelled on `whoop-backfill.ts` / `fitbit-backfill.ts`. The queue name MUST
* be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts` or pg-boss
* never provisions it and the boot enqueue silently never drains (the v1.4.37
* dead-queue class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { syncUserWhoop } from "@/lib/whoop/sync";
import { syncUserSleep as syncWithingsSleep } from "@/lib/withings/sync-sleep";
export const SLEEP_TIMELINE_BACKFILL_QUEUE = "sleep-timeline-backfill";
/**
* Serial concurrency — a pass deletes a source's sleep rows then walks a
* re-sync that is rate-bounded by the upstream API cap. Concurrency-1 keeps it
* from crowding the request pool, matching the other backfill queues.
*/
export const SLEEP_TIMELINE_BACKFILL_CONCURRENCY = 1;
export type SleepTimelineProvider = "WHOOP" | "WITHINGS";
export interface SleepTimelineBackfillPayload {
userId: string;
provider: SleepTimelineProvider;
enqueuedAt: string;
}
/**
* Delete the legacy `SLEEP_DURATION` rows for one source, then re-sync with the
* corrected mapper. WHOOP runs a full-history sync (the segment externalIds are
* new, so the deleted summary rows are not re-created); Withings re-syncs its
* default trailing window with the corrected END stamp. The re-sync re-folds
* the rollup tier in its own tail.
*/
export async function runSleepTimelineBackfillForUser(
userId: string,
provider: SleepTimelineProvider,
): Promise<{ deleted: number; imported: number }> {
const { count: deleted } = await prisma.measurement.deleteMany({
where: { userId, type: "SLEEP_DURATION", source: provider },
});
let imported = 0;
if (provider === "WHOOP") {
imported = await syncUserWhoop(userId, { fullSync: true });
await prisma.whoopConnection.update({
where: { userId },
data: { sleepTimelineBackfillAt: new Date() },
});
} else {
imported = await syncWithingsSleep(userId, { fullSync: true });
await prisma.withingsConnection.update({
where: { userId },
data: { sleepTimelineBackfillAt: new Date() },
});
}
annotate({
action: {
name: "sleep.timeline.backfill.complete",
details: { provider, deleted, imported },
},
});
return { deleted, imported };
}
/**
* Boot-time discovery. Finds every WHOOP + Withings connection not yet
* backfilled (`sleep_timeline_backfill_at IS NULL`) and enqueues one job per
* (user, provider). Idempotent across reboots: a completed pass stamps the
* marker, dropping the connection from the discovery set. pg-boss
* `singletonKey` coalesces duplicate sends so a fast restart while a job is
* queued doesn't double up.
*
* Best-effort: errors are returned through the result value so worker boot
* never fails because of a backfill miss.
*/
export async function enqueueBootTimeSleepTimelineBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const [whoop, withings] = await Promise.all([
prisma.whoopConnection.findMany({
where: { sleepTimelineBackfillAt: null },
select: { userId: true },
}),
prisma.withingsConnection.findMany({
where: { sleepTimelineBackfillAt: null },
select: { userId: true },
}),
]);
const targets: Array<{ userId: string; provider: SleepTimelineProvider }> =
[
...whoop.map((c) => ({ userId: c.userId, provider: "WHOOP" as const })),
...withings.map((c) => ({
userId: c.userId,
provider: "WITHINGS" as const,
})),
];
if (targets.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId, provider } of targets) {
const payload: SleepTimelineBackfillPayload = {
userId,
provider,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(SLEEP_TIMELINE_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `sleep-timeline-backfill|${provider}|${userId}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+5
View File
@@ -200,6 +200,11 @@ const sleepSessionResource = z.object({
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),
});
+58 -9
View File
@@ -320,15 +320,64 @@ describe("mapSleep", () => {
it("maps per-stage SLEEP_DURATION rows (ms→min) with sleepStage", () => {
const rows = mapSleep(base);
const dur = rows.filter((r) => r.type === "SLEEP_DURATION");
const byStage = Object.fromEntries(dur.map((r) => [r.sleepStage, r.value]));
expect(byStage.CORE).toBe(240); // light → CORE
expect(byStage.DEEP).toBe(90); // slow-wave → DEEP
expect(byStage.REM).toBe(120);
expect(byStage.AWAKE).toBe(30);
expect(byStage.IN_BED).toBe(480);
// One asleep/awake row per reconstructed segment + one IN_BED envelope. The
// per-stage TOTALS are unchanged (the reconstruction only reorders timing).
const minutesByStage: Record<string, number> = {};
for (const r of dur) {
minutesByStage[r.sleepStage!] =
(minutesByStage[r.sleepStage!] ?? 0) + r.value;
}
expect(minutesByStage.CORE).toBe(240); // light → CORE
expect(minutesByStage.DEEP).toBe(90); // slow-wave → DEEP
expect(minutesByStage.REM).toBe(120);
expect(minutesByStage.AWAKE).toBe(30);
expect(minutesByStage.IN_BED).toBe(480);
expect(dur.every((r) => r.unit === "minutes")).toBe(true);
});
it("reconstructs an ordered, contiguous, non-overlapping per-segment timeline from sleep ONSET", () => {
const segs = mapSleep(base)
.filter((r) => r.type === "SLEEP_DURATION" && r.sleepStage !== "IN_BED")
.map((r) => ({
stage: r.sleepStage,
end: r.measuredAt.getTime(),
start: r.measuredAt.getTime() - r.value * 60_000,
reconstructed: r.reconstructed,
}))
.sort((a, b) => a.start - b.start);
// AWAKE (lead) → CORE → DEEP → REM, laid back-to-back.
expect(segs.map((s) => s.stage)).toEqual(["AWAKE", "CORE", "DEEP", "REM"]);
// First segment starts at sleep ONSET (s.start), not the END edge.
expect(segs[0].start).toBe(new Date(base.start).getTime());
// Contiguous + non-overlapping: each segment's start == previous end.
for (let i = 1; i < segs.length; i++) {
expect(segs[i].start).toBe(segs[i - 1].end);
}
// Distinct END instants — no stacking on the night's right edge.
expect(new Set(segs.map((s) => s.end)).size).toBe(segs.length);
// Every reconstructed row is flagged so the UI labels the night approximate.
expect(segs.every((s) => s.reconstructed === true)).toBe(true);
});
it("emits IN_BED as a single un-reconstructed envelope row over [start, end]", () => {
const inBed = mapSleep(base).filter((r) => r.sleepStage === "IN_BED");
expect(inBed).toHaveLength(1);
const r = inBed[0]!;
expect(r.value).toBe(480);
// measuredAt = sleep END → segmentOf resolves the span back to the window.
expect(r.measuredAt.toISOString()).toBe(base.end);
expect(r.reconstructed).toBeUndefined();
});
it("gives each reconstructed segment a distinct indexed externalId", () => {
const ids = mapSleep(base)
.filter((r) => r.reconstructed)
.map((r) => r.externalId);
expect(ids.every((id) => id?.startsWith("sleep-uuid:seg:"))).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it("sums SLEEP_NEED components ms→min", () => {
const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED");
expect(need?.value).toBe(490); // 450 + 30 + 10 + 0
@@ -344,9 +393,9 @@ describe("mapSleep", () => {
expect(byType.RESPIRATORY_RATE?.unit).toBe("breaths/min");
});
it("uses sleep.end as measuredAt", () => {
const row = mapSleep(base)[0]!;
expect(row.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
it("stamps the non-segment scores on sleep.end", () => {
const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED")!;
expect(need.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
});
it("emits nothing for an unscored sleep", () => {
+76 -17
View File
@@ -505,20 +505,24 @@ export interface MappedMeasurement {
fieldTag: string;
/** Per-stage sleep rows carry the SleepStage; everything else omits it. */
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
/**
* Full externalId for rows the mapper must key itself rather than letting
* the sync layer build `<resource-uuid>:<fieldTag>`. The reconstructed sleep
* segments (one row per synthetic span) need an index in the key so the
* several rows of one stage stay distinct under
* `userId_type_source_externalId`. When set the sync layer uses it verbatim.
*/
externalId?: string;
/**
* `true` on the synthesised per-segment sleep rows whose ORDER is invented:
* WHOOP's v2 API returns only per-stage duration totals (no onset
* timestamps), so the timeline is reconstructed in a fixed physiological
* order. The night is flagged so the UI labels it as an approximate layout
* and never presents it as measured stage timing.
*/
reconstructed?: boolean;
}
/** WHOOP sleep stage → HealthLog SleepStage (light→CORE, slow-wave→DEEP). */
const SLEEP_STAGE_MAP: Record<
string,
{ stage: MappedMeasurement["sleepStage"]; fieldTag: string }
> = {
total_light_sleep_time_milli: { stage: "CORE", fieldTag: "sleep_core" },
total_slow_wave_sleep_time_milli: { stage: "DEEP", fieldTag: "sleep_deep" },
total_rem_sleep_time_milli: { stage: "REM", fieldTag: "sleep_rem" },
total_awake_time_milli: { stage: "AWAKE", fieldTag: "sleep_awake" },
total_in_bed_time_milli: { stage: "IN_BED", fieldTag: "sleep_in_bed" },
};
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
@@ -589,16 +593,71 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
const out: MappedMeasurement[] = [];
const stages = s.score.stage_summary;
for (const [key, mapping] of Object.entries(SLEEP_STAGE_MAP)) {
const ms = stages[key as keyof typeof stages];
if (typeof ms !== "number") continue;
// WHOOP v2 returns only per-stage DURATION totals (no per-stage onset
// timestamps, no hypnogram endpoint). Stamping every stage total on the one
// sleep-END instant makes the hypnogram reconstruct five spans that all touch
// the night's right edge (the stacked-bar artefact). Since the API never
// carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the
// asleep 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 reconstructed row is flagged
// `reconstructed: true` and the night DTO advertises it — the UI labels it as
// an approximate layout and never presents it as measured stage timing.
//
// IN_BED stays a single envelope row spanning the whole [start, end] window:
// the in-bed reader wants the union envelope, not a placed segment. AWAKE is
// laid as a leading segment from onset so the asleep stages still partition
// the remaining window.
const onset = new Date(s.start).getTime();
// Order the laid-out stages: a brief AWAKE settling-in block, then the asleep
// stages CORE → DEEP → REM. Each carries its own duration; the cursor walks
// forward so the spans are contiguous and non-overlapping.
const TIMELINE_ORDER: ReadonlyArray<{
key: keyof typeof stages;
stage: NonNullable<MappedMeasurement["sleepStage"]>;
tag: string;
}> = [
{ key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" },
{ key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" },
{ key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" },
{ key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" },
];
let cursor = onset;
let segIndex = 0;
for (const { key, stage, tag } of TIMELINE_ORDER) {
const ms = stages[key];
if (typeof ms !== "number" || ms <= 0) continue;
const segEnd = cursor + ms;
out.push({
type: "SLEEP_DURATION",
value: round2(ms * MS_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
// The sync layer keys reconstructed rows by this externalId verbatim; the
// index keeps the several rows of one night distinct under
// userId_type_source_externalId.
fieldTag: tag,
externalId: `${s.id}:seg:${tag}:${segIndex}`,
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
// IN_BED — single envelope row over the whole sleep window. `measuredAt` is
// the sleep END so `segmentOf` resolves the span back to [start, end]; the
// in-bed union-envelope reader consumes the full window.
const inBedMs = stages.total_in_bed_time_milli;
if (typeof inBedMs === "number" && inBedMs > 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBedMs * MS_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag: mapping.fieldTag,
sleepStage: mapping.stage,
fieldTag: "sleep_in_bed",
sleepStage: "IN_BED",
});
}
+15 -4
View File
@@ -4,8 +4,14 @@
* `mapSleep` (per-stage SLEEP_DURATION rows, SLEEP_NEED, the SLEEP_*
* percentages, RESPIRATORY_RATE), and upserts as `source = WHOOP`.
*
* Per-stage rows carry the `sleepStage` axis so the five stage rows for one
* night stay distinct under the dedup key. externalId = `<sleep_id>:<fieldTag>`.
* Sleep rows carry the `sleepStage` axis so the night's rows stay distinct
* under the dedup key. WHOOP exposes only per-stage DURATION totals (no onset
* timestamps), so `mapSleep` RECONSTRUCTS an ordered, contiguous per-segment
* timeline (CORE/DEEP/REM/AWAKE laid back-to-back from sleep onset) and flags
* those rows `reconstructed`; each reconstructed segment carries its own
* indexed externalId `<sleep_id>:seg:<tag>:<i>`. IN_BED stays a single
* envelope row keyed `<sleep_id>:sleep_in_bed`. The non-segment scores keep
* the `<sleep_id>:<fieldTag>` shape.
*/
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
import {
@@ -53,7 +59,10 @@ export async function syncUserSleep(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${s.id}:${m.fieldTag}`,
// Reconstructed sleep segments carry their own indexed externalId so
// the several rows of one stage stay distinct; everything else keeps
// the `<sleep_id>:<fieldTag>` shape.
externalId: m.externalId ?? `${s.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
@@ -92,7 +101,9 @@ export async function syncWhoopSleepById(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${record.id}:${m.fieldTag}`,
// Reconstructed sleep segments carry their own indexed externalId
// (see syncUserSleep).
externalId: m.externalId ?? `${record.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
@@ -194,7 +194,7 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(firstDuration.data.unit).toBe("minutes");
});
it("converts startdate (unix seconds) to a Date in measuredAt", async () => {
it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
@@ -205,7 +205,10 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { measuredAt: Date };
};
expect(arg.data.measuredAt.getTime()).toBe(1715000000 * 1000);
// measuredAt is the segment END — every reader treats it as the END and
// resolves the span as start = end − duration. Stamping the START shifted
// the night one segment-length earlier.
expect(arg.data.measuredAt.getTime()).toBe(1715003600 * 1000);
});
it("skips state 4 (synthetic marker) without throwing", async () => {
+12 -5
View File
@@ -26,10 +26,13 @@
* when Withings re-emits the night with adjusted segment boundaries.
*
* Date semantics: each segment's `measuredAt` is the segment's
* `startdate` (unix seconds → `Date`). The duration in minutes lands
* in `value`. The HealthLog analytics aggregator already groups
* stage rows under their parent night via the per-night `dayKey`
* helper, so the segment-level timestamp is the canonical sort key.
* `enddate` (unix seconds → `Date`). Every reader treats `measuredAt`
* as the segment END and resolves the span as `start = end − duration`
* (`sleep-night.ts` `segmentOf`); stamping the START shifted the whole
* night one segment-length earlier. The duration in minutes lands in
* `value`. The HealthLog analytics aggregator groups stage rows under
* their parent night via the per-night `dayKey` helper, so the
* segment-level END timestamp is the canonical sort key.
*/
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
@@ -258,7 +261,11 @@ export async function syncUserSleep(
segmentIndex++;
continue;
}
const measuredAt = new Date(segment.startdate * 1000);
// `measuredAt` is the segment END (`enddate`). Every reader treats
// `measuredAt` as the END instant — `segmentOf` resolves the span as
// `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START
// here shifted the whole night one segment-length earlier; END is correct.
const measuredAt = new Date(segment.enddate * 1000);
const durationSec = Math.max(0, segment.enddate - segment.startdate);
// Canonical SLEEP_DURATION unit is minutes (Apple Health alignment,
// v1.4.23 schema note). Round so re-sync doesn't flip values on