Skip to content

[Bug] Sleep-stage breakdown double-counts overlapping IN_BED rows #644

Description

@lutzkind

Summary

The sleep-night API can return a correct inBedMinutes headline but an inflated stages.IN_BED value for the same session. The iOS Sleep stages screen then shows contradictory numbers, for example:

  • Time in bed: 10 h 13 min
  • In bed in the stage legend: 20 h 17 min

The cause is that inBedMinutes uses interval-union logic, while reconstructSleepSessions() still sums every raw IN_BED segment into the stages map and returns all overlapping IN_BED segments in the hypnogram payload.

Affected versions

Confirmed on a self-hosted server running:

  • HealthLog v1.32.31
  • build SHA 4ed8c8f39b77

The relevant implementation is unchanged on current main (v1.32.36 at the time of investigation).

Confirmed production shape

One Apple Health night contained two IN_BED rows with distinct HealthKit external IDs but the same start:

Row Span Duration
1 2026-07-24 16:06:00Z → 2026-07-25 02:09:59Z 603.98 min
2 2026-07-24 16:06:00Z → 2026-07-25 02:18:59Z 612.98 min

These appear to be an earlier and revised/corrected envelope for the same night.

Correct interval union:

16:06:00Z → 02:18:59Z = 612.98 min → 613 min = 10 h 13 min

Raw sum:

603.98 + 612.98 = 1216.97 min → 1217 min = 20 h 17 min

This exactly matches the contradictory iOS values.

Other stage totals are correct

The same session's raw stage totals were:

CORE   319.70 min → 320 min
DEEP    94.90 min → 95 min
REM    135.90 min → 136 min
AWAKE    6.92 min → 7 min

Asleep total:

320 + 95 + 136 = 551 min = 9 h 11 min

The in-bed envelope has 55 additional unstaged minutes: 30 minutes before the first staged segment and 25 minutes after the last staged segment. That part is internally consistent and is not the defect.

Exact faulty path

src/lib/analytics/sleep-night.ts correctly computes the union envelope:

const inBedEnvelope = inBedEnvelopeMinutes(session);

But reconstructSleepSessions() builds segments from the canonical pool without normalising overlapping IN_BED rows:

const segments = pool
  .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular))
  .map(segmentOf)
  .sort(...);

It then sums every segment into stages:

if (stage) stages[stage] = (stages[stage] ?? 0) + seg.minutes;

So the returned session can contain:

{
  "inBedMinutes": 613,
  "stages": {
    "IN_BED": 1217
  }
}

src/app/api/sleep/night/route.ts serializes both fields unchanged apart from rounding. The iOS screen uses the correct session headline for Time in bed and the inflated stage map for the legend.

Expected behaviour

For every session:

stages.IN_BED === inBedMinutes

when an in-bed signal exists.

The hypnogram payload should also not contain overlapping duplicate IN_BED bars representing the same envelope.

Suggested fix

Normalise IN_BED intervals once and reuse the result for all session outputs:

  1. Merge overlapping/contiguous IN_BED intervals across the raw session.
  2. Exclude raw IN_BED rows from the canonical segments list.
  3. Add the merged interval(s) back as canonical IN_BED segment(s).
  4. Set stages.IN_BED to the merged total, matching inBedMinutes.
  5. Leave CORE/DEEP/REM/AWAKE writer selection and awakening counting unchanged.

A minimal display-only override of stages.IN_BED would fix the legend but would leave duplicate hypnogram segments in the API, so normalising the segment list is the more complete general fix.

Regression tests

Add coverage for:

  • Two same-writer IN_BED rows with the same start and revised end:
    • inBedMinutes = 613
    • stages.IN_BED = 613
    • one merged IN_BED segment
  • Two writer-specific partially overlapping envelopes:
    • interval union, not raw sum
    • stage map equals envelope total
  • Multiple disjoint in-bed intervals within one session:
    • preserve merged intervals and sum their union
  • No IN_BED signal:
    • inBedMinutes = null
    • no stages.IN_BED
  • Existing CORE/DEEP/REM/AWAKE totals and awakening count remain unchanged
  • /api/sleep/night serialization cannot emit contradictory inBedMinutes and stages.IN_BED

Classification

This is a server-side sleep-session aggregation defect exposed by the native iOS view. It is not caused by the wearable's stage estimates, timezone conversion, or the headline calculation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions