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:
- Merge overlapping/contiguous
IN_BED intervals across the raw session.
- Exclude raw
IN_BED rows from the canonical segments list.
- Add the merged interval(s) back as canonical
IN_BED segment(s).
- Set
stages.IN_BED to the merged total, matching inBedMinutes.
- 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.
Summary
The sleep-night API can return a correct
inBedMinutesheadline but an inflatedstages.IN_BEDvalue for the same session. The iOS Sleep stages screen then shows contradictory numbers, for example:The cause is that
inBedMinutesuses interval-union logic, whilereconstructSleepSessions()still sums every rawIN_BEDsegment into thestagesmap and returns all overlappingIN_BEDsegments in the hypnogram payload.Affected versions
Confirmed on a self-hosted server running:
v1.32.314ed8c8f39b77The relevant implementation is unchanged on current
main(v1.32.36at the time of investigation).Confirmed production shape
One Apple Health night contained two
IN_BEDrows with distinct HealthKit external IDs but the same start:These appear to be an earlier and revised/corrected envelope for the same night.
Correct interval union:
Raw sum:
This exactly matches the contradictory iOS values.
Other stage totals are correct
The same session's raw stage totals were:
Asleep total:
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.tscorrectly computes the union envelope:But
reconstructSleepSessions()buildssegmentsfrom the canonical pool without normalising overlappingIN_BEDrows:It then sums every segment into
stages:So the returned session can contain:
{ "inBedMinutes": 613, "stages": { "IN_BED": 1217 } }src/app/api/sleep/night/route.tsserializes both fields unchanged apart from rounding. The iOS screen uses the correct session headline forTime in bedand the inflated stage map for the legend.Expected behaviour
For every session:
when an in-bed signal exists.
The hypnogram payload should also not contain overlapping duplicate
IN_BEDbars representing the same envelope.Suggested fix
Normalise
IN_BEDintervals once and reuse the result for all session outputs:IN_BEDintervals across the raw session.IN_BEDrows from the canonicalsegmentslist.IN_BEDsegment(s).stages.IN_BEDto the merged total, matchinginBedMinutes.A minimal display-only override of
stages.IN_BEDwould 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:
IN_BEDrows with the same start and revised end:inBedMinutes = 613stages.IN_BED = 613IN_BEDsegmentIN_BEDsignal:inBedMinutes = nullstages.IN_BED/api/sleep/nightserialization cannot emit contradictoryinBedMinutesandstages.IN_BEDClassification
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.