mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(sleep): the hypnogram keeps its timeline when a summary writer joins the night
A writer whose stage rows all share one timestamp carries night totals, not segments — reconstructing them as segments paints every stage flush against the night's right edge. The session picker now prefers a writer with genuinely timed rows among the granular candidates, so a stage summary backfilled for a night an actually-timed writer also recorded no longer evicts the real timeline; summary-only nights still resolve. The hypnogram itself degrades a summary-shaped session to the stage breakdown instead of drawing the false skyline.
This commit is contained in:
1 parent
4493eb2cac
commit
5859072561
3 files changed
+89
-6
No files matched your search
@@ -209,7 +209,15 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
// need any positive per-stage minutes. A legacy stage-summary night (no
|
||||
// segment spans) still shows its breakdown rather than collapsing the
|
||||
// whole card. "Unavailable" is reserved for a truly empty session.
|
||||
const hasTrack = spans.length > 0;
|
||||
// Spans must also carry more than one distinct end instant: a summary-
|
||||
// shaped writer (per-stage night totals all stamped on the sleep end)
|
||||
// reconstructs into bars that all touch the night's right edge — that
|
||||
// is a breakdown wearing a timeline costume, so paint it as the
|
||||
// breakdown footer instead.
|
||||
const hasTrack =
|
||||
spans.length > 0 &&
|
||||
(spans.length === 1 ||
|
||||
new Set(spans.map((s) => s.x2)).size > 1);
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
|
||||
return (
|
||||
|
||||
@@ -403,6 +403,52 @@ describe("reconstructSleepSessions", () => {
|
||||
expect(s.inBedMinutes).toBe(510);
|
||||
});
|
||||
|
||||
it("prefers a per-segment timeline writer over a summary-shaped writer (v1.16.11)", () => {
|
||||
// WHOOP's real wire shape: the v2 API exposes only stage_summary, so
|
||||
// every stage row of a night lands with ONE shared measuredAt — the
|
||||
// sleep END instant. Reconstructing those as segments yields five
|
||||
// spans all touching the right edge of the night. When Apple Health
|
||||
// carries genuinely timed per-segment rows for the same night, the
|
||||
// timed writer must win even though WHOOP ties (or wins) on distinct
|
||||
// stage count and outranks Apple on the default ladder.
|
||||
const end = "2026-06-04T05:00:00.000Z";
|
||||
const rows: SleepStageRow[] = [
|
||||
// WHOOP: full-house stage summary, all stamped on the sleep end.
|
||||
srcRow(end, "IN_BED", 510, "WHOOP"),
|
||||
srcRow(end, "AWAKE", 30, "WHOOP"),
|
||||
srcRow(end, "CORE", 240, "WHOOP"),
|
||||
srcRow(end, "DEEP", 120, "WHOOP"),
|
||||
srcRow(end, "REM", 120, "WHOOP"),
|
||||
// Apple Health: a partial but genuinely timed timeline (2 stages).
|
||||
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
|
||||
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"),
|
||||
];
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].source).toBe("APPLE_HEALTH");
|
||||
// The surviving segments carry a real timeline: more than one
|
||||
// distinct end instant.
|
||||
const ends = new Set(
|
||||
sessions[0].segments.map((seg) => seg.end.getTime()),
|
||||
);
|
||||
expect(ends.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("keeps a summary-shaped writer when nobody else covers the night (v1.16.11)", () => {
|
||||
// WHOOP-only night — the stage summary is all there is, so it must
|
||||
// still resolve (the timeline gate is conditional, not absolute).
|
||||
const end = "2026-06-04T05:00:00.000Z";
|
||||
const rows: SleepStageRow[] = [
|
||||
srcRow(end, "CORE", 240, "WHOOP"),
|
||||
srcRow(end, "DEEP", 120, "WHOOP"),
|
||||
srcRow(end, "REM", 120, "WHOOP"),
|
||||
];
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].source).toBe("WHOOP");
|
||||
expect(sessions[0].asleepMinutes).toBe(480);
|
||||
});
|
||||
|
||||
it("keeps the bare ASLEEP segment when NO granular stage exists", () => {
|
||||
// Legacy ASLEEP-only night — the bare aggregate IS the timeline, so it
|
||||
// must survive as a segment (otherwise the hypnogram would have nothing).
|
||||
|
||||
@@ -360,10 +360,12 @@ function pickSessionWriter(
|
||||
asleepByWriter.size > 0
|
||||
? [...asleepByWriter.keys()]
|
||||
: [...rowsByWriter.keys()];
|
||||
// Richness gate: keep only the writers tied for the MOST distinct granular
|
||||
// stages. A 3-stage hypnogram beats a 1-stage partial beats a coarse
|
||||
// ASLEEP-only export; coarse writers survive only when NO writer carries a
|
||||
// granular stage for the session.
|
||||
// Three gates, in this order:
|
||||
// 1. granular PRESENCE — writers carrying any granular stage eliminate
|
||||
// coarse ASLEEP-only writers (a phone aggregate must not mask a
|
||||
// watch's hypnogram), coarse survives only when nobody is granular;
|
||||
// 2. timeline shape — see below;
|
||||
// 3. max distinct-stage COUNT among the survivors.
|
||||
const richnessOf = new Map<string, number>();
|
||||
let maxRichness = 0;
|
||||
for (const key of writers) {
|
||||
@@ -372,7 +374,34 @@ function pickSessionWriter(
|
||||
if (score > maxRichness) maxRichness = score;
|
||||
}
|
||||
if (maxRichness > 0) {
|
||||
writers = writers.filter((key) => richnessOf.get(key) === maxRichness);
|
||||
writers = writers.filter((key) => (richnessOf.get(key) ?? 0) > 0);
|
||||
}
|
||||
// Timeline gate — BETWEEN granular presence and the max-richness count.
|
||||
// A writer whose stage rows carry ≥2 distinct measuredAt instants has
|
||||
// real per-segment timing; a summary-shaped writer stamps every stage
|
||||
// total on one instant (WHOOP's stage_summary stamps everything on the
|
||||
// sleep END), and the hypnogram would reconstruct its "segments" as
|
||||
// five spans all touching the night's right edge. When at least one
|
||||
// timeline writer covers the session, summary-only writers are out of
|
||||
// the running BEFORE the richness count — WHOOP's guaranteed full-house
|
||||
// stage count must not eliminate a partial but genuinely-timed Apple
|
||||
// night. A summary-only writer still wins nights nobody else recorded.
|
||||
const distinctInstantsOf = (writerRows: readonly SleepStageRow[]): number =>
|
||||
new Set(writerRows.map((r) => r.measuredAt.getTime())).size;
|
||||
const timelineWriters = writers.filter(
|
||||
(key) => distinctInstantsOf(rowsByWriter.get(key) ?? []) > 1,
|
||||
);
|
||||
if (timelineWriters.length > 0) {
|
||||
writers = timelineWriters;
|
||||
}
|
||||
// Richness gate among the survivors: most distinct granular stages wins.
|
||||
let maxTimedRichness = 0;
|
||||
for (const key of writers) {
|
||||
const score = richnessOf.get(key) ?? 0;
|
||||
if (score > maxTimedRichness) maxTimedRichness = score;
|
||||
}
|
||||
if (maxTimedRichness > 0) {
|
||||
writers = writers.filter((key) => richnessOf.get(key) === maxTimedRichness);
|
||||
}
|
||||
if (writers.length <= 1) return writers[0] ?? NO_SOURCE;
|
||||
|
||||
|
||||
Reference in new issue
Block a user