mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
133 lines
5.5 KiB
TypeScript
133 lines
5.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import { reconstructContiguousSleepTimeline } from "../reconstruct-timeline";
|
|
|
|
describe("reconstructContiguousSleepTimeline", () => {
|
|
const onset = Date.parse("2026-06-09T21:00:00.000Z");
|
|
const end = Date.parse("2026-06-10T05:00:00.000Z");
|
|
|
|
it("lays stages contiguously from onset, each ending at its own instant", () => {
|
|
const rows = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: [
|
|
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
|
|
{ durationMs: 30 * 60_000, stage: "DEEP", fieldTag: "sleep_deep" },
|
|
{ durationMs: 90 * 60_000, stage: "REM", fieldTag: "sleep_rem" },
|
|
],
|
|
externalIdFor: (tag) => `night:seg:${tag}`,
|
|
});
|
|
const core = rows.find((r) => r.sleepStage === "CORE")!;
|
|
const deep = rows.find((r) => r.sleepStage === "DEEP")!;
|
|
const rem = rows.find((r) => r.sleepStage === "REM")!;
|
|
expect(core.value).toBe(60);
|
|
expect(core.measuredAt.getTime()).toBe(onset + 60 * 60_000);
|
|
expect(deep.measuredAt.getTime()).toBe(onset + 90 * 60_000);
|
|
expect(rem.measuredAt.getTime()).toBe(onset + 180 * 60_000);
|
|
// Distinct instants → ordered hypnogram, not a shared right edge.
|
|
expect(new Set(rows.map((r) => r.measuredAt.getTime())).size).toBe(3);
|
|
});
|
|
|
|
it("flags every laid segment reconstructed and keys a stage-tagged externalId", () => {
|
|
const rows = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: [
|
|
{ durationMs: 15 * 60_000, stage: "AWAKE", fieldTag: "sleep_awake" },
|
|
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
|
|
],
|
|
externalIdFor: (tag) => `night:seg:${tag}`,
|
|
});
|
|
expect(rows.every((r) => r.reconstructed === true)).toBe(true);
|
|
expect(rows[0].externalId).toBe("night:seg:sleep_awake");
|
|
expect(rows[1].externalId).toBe("night:seg:sleep_core");
|
|
});
|
|
|
|
it("skips non-positive, null, undefined and non-finite durations", () => {
|
|
const rows = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: [
|
|
{ durationMs: 0, stage: "AWAKE", fieldTag: "sleep_awake" },
|
|
{ durationMs: null, stage: "CORE", fieldTag: "sleep_core" },
|
|
{ durationMs: undefined, stage: "DEEP", fieldTag: "sleep_deep" },
|
|
{ durationMs: Number.NaN, stage: "REM", fieldTag: "sleep_rem" },
|
|
{ durationMs: 30 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
|
|
],
|
|
externalIdFor: (tag) => `night:seg:${tag}`,
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
// The laid stage keys on its tag alone — skipped stages cannot influence
|
|
// the id of any other stage (no positional index to renumber).
|
|
expect(rows[0].externalId).toBe("night:seg:sleep_core");
|
|
expect(rows[0].measuredAt.getTime()).toBe(onset + 30 * 60_000);
|
|
});
|
|
|
|
it("keeps stable externalIds when a re-score flips a stage 0↔positive", () => {
|
|
// First scoring: no awake time. Re-score: WHOOP/Polar now report a
|
|
// positive AWAKE block. Under the retired positional index the re-score
|
|
// renumbered CORE/DEEP (0→1, 1→2), minting fresh externalIds the upsert
|
|
// then INSERTED next to the old rows — the night double-counted. The
|
|
// stage-tagged key must be identical across both scorings.
|
|
const stages = (awakeMs: number) =>
|
|
[
|
|
{ durationMs: awakeMs, stage: "AWAKE", fieldTag: "sleep_awake" },
|
|
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
|
|
{ durationMs: 30 * 60_000, stage: "DEEP", fieldTag: "sleep_deep" },
|
|
] as const;
|
|
const externalIdFor = (tag: string) => `night:seg:${tag}`;
|
|
|
|
const first = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: stages(0),
|
|
externalIdFor,
|
|
});
|
|
const rescored = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: stages(15 * 60_000),
|
|
externalIdFor,
|
|
});
|
|
|
|
const idsOf = (rows: typeof first, stage: string) =>
|
|
rows.find((r) => r.sleepStage === stage)?.externalId;
|
|
// CORE and DEEP keep their ids — the awake flip cannot renumber them.
|
|
expect(idsOf(rescored, "CORE")).toBe(idsOf(first, "CORE"));
|
|
expect(idsOf(rescored, "DEEP")).toBe(idsOf(first, "DEEP"));
|
|
expect(idsOf(rescored, "AWAKE")).toBe("night:seg:sleep_awake");
|
|
});
|
|
|
|
it("appends a single IN_BED envelope at the given END instant", () => {
|
|
const rows = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: [
|
|
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
|
|
],
|
|
inBed: {
|
|
durationMs: end - onset,
|
|
measuredAt: new Date(end),
|
|
fieldTag: "sleep_in_bed",
|
|
},
|
|
externalIdFor: (tag) => `night:seg:${tag}`,
|
|
});
|
|
const inBed = rows.find((r) => r.sleepStage === "IN_BED")!;
|
|
expect(inBed.measuredAt.getTime()).toBe(end);
|
|
expect(inBed.value).toBe(480); // 8 h
|
|
// IN_BED is an envelope, not a placed segment — no reconstructed flag / id.
|
|
expect(inBed.reconstructed).toBeUndefined();
|
|
expect(inBed.externalId).toBeUndefined();
|
|
});
|
|
|
|
it("omits IN_BED when its duration is missing or non-positive", () => {
|
|
const rows = reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: [
|
|
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
|
|
],
|
|
inBed: {
|
|
durationMs: 0,
|
|
measuredAt: new Date(end),
|
|
fieldTag: "sleep_in_bed",
|
|
},
|
|
externalIdFor: (tag) => `night:seg:${tag}`,
|
|
});
|
|
expect(rows.find((r) => r.sleepStage === "IN_BED")).toBeUndefined();
|
|
});
|
|
});
|