Skip to content

Save Android sleep time edits as one validated window - #525

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
kavemang:fix/atomic-sleep-editor-515
Jul 17, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
kavemang:fix/atomic-sleep-editor-515

Conversation

@kavemang

Copy link
Copy Markdown

What changed

  • replace Android's two immediate sleep-time writes with one in-memory start/end draft
  • let bedtime and wake-up pickers return to the same editor so both values can be reviewed together
  • validate and persist the complete window only when the user taps Save
  • make Cancel discard the draft without changing the recorded session
  • derive the wake date from the drafted bedtime, preserving cross-midnight edits
  • add focused tests for the reported split-night correction, cross-midnight windows, wake rollover, and invalid intermediate spans

Why

The Android editor previously committed bedtime and wake-up independently. Changing bedtime first wrote it against the session's old wake time; changing wake first did the inverse. That intermediate write could produce an inflated or inverted duration and trip the save guard before the user had entered the complete intended window.

The editor now matches the existing Apple behavior: both endpoints remain draft values, and one validated (start, end) pair reaches persistence. Existing disjoint-window confirmation and future/inverted-window guards remain in place.

Addresses the manual sleep-editor bug described in #515 (item 3). The issue's separate re-detection and split-night work remains open.

Validation

  • ./gradlew :app:testFullDebugUnitTest --tests 'com.noop.ui.SleepTimeEditDraftTest' --tests 'com.noop.analytics.SleepEditGuardTest'
  • Android full-debug Kotlin compilation completed as part of the test task
  • python3 Tools/i18n_audit.py --ci upstream/main
  • git diff --check

@kavemang
kavemang marked this pull request as ready for review July 16, 2026 19:04
@ryanbr
ryanbr merged commit d4438a7 into ryanbr:main Jul 17, 2026
1 check passed
ryanbr added a commit that referenced this pull request Jul 17, 2026
Numbers are main's current 292/203 +1, matching the previous staging commit's
pattern, so the .staging app upgrades cleanly over the last testing build (290)
and stays above main's released high-water.

Carries everything merged since the 9.0.1 release:
 - Workouts read HR under the recording strap, not a hardcoded id (#512 Kotlin,
   #513 Swift twin)
 - Oura 0x60/0x80 IBI decoders corrected to the ring's real byte layout (#511)
 - Whoop5Config enable_sig12 corrected to ASCII '1' (#522)
 - Sleep time edits save as one validated window (#525)
 - "Recompute this night" for deleted sleep windows (#526)
 - Stress marker values stay readable (#507)
 - Faster history sync + faster Bluetooth link, both EXPERIMENTAL and default-off
   (#536/#537/#538) - the whole point of this build is field data on those
 - i18n changelog-title fix + main-push audit (#514/#535)

versionName stays 9.0.2 here for the staging identity only; the real 9.0.2
release still bumps main separately (and must be raised past 293 first).
mohdarsal9-sudo pushed a commit to mohdarsal9-sudo/noop that referenced this pull request Jul 19, 2026
…'t exceed time-in-bed (ryanbr#259) (ryanbr#270)

* sleep: clamp edited-night stages to the effective onset so asleep can't exceed time-in-bed (ryanbr#259)

A WHOOP 4.0 morning sleep reported "asleep 6h41m" over "4h33m in bed" — an
impossible card. Root: after a bed edit moved the effective onset (01:58 → 06:15),
the raw motion was too sparse to re-stage (4.0), so the stored stagesJSON kept its
pre-onset segments. dailyAggregateHonoringEdits summed the FULL window while
time-in-bed used the edited onset, so asleep exceeded in-bed (and inflated Rest/score).

Trim each SELECTED block's stages to its effective onset before summing:
clampStagesToOnset drops/trims segments that start before onsetByStart[startTs].
Main-night SELECTION still runs on the original blocks (no ryanbr#525/ryanbr#547 pick change) —
only the summed total is clamped. A block already staged from its onset (the common
case, and every non-edited night, where onset == start) is unchanged: a no-op.

startTsAdjusted is only ever set by a user edit, so effectiveStartTs != startTs
implies an edited night — exactly where this applies. Byte-parity Swift + Kotlin.

Not addressed (deferred root cause): why 4.0 over-stages a long low-motion pre-onset
stretch as sleep in the first place — the sparse-motion staging problem.

Tests: Issue259PreOnsetClampTest (Kotlin) + SleepStageTotalsTests twins (Swift) —
pre-onset trim + non-edited no-op. Full com.noop.analytics suite green.

* sleep: also clamp the DISPLAYED stage minutes to the effective onset (ryanbr#259)

The analytics clamp fixed the score, but the card's asleep number is Stages.asleep
(light+deep+rem) sourced from the session's stagesJSON directly (sessionStageMins /
sumGroupStages on Android, decodeSegments on iOS), with the clamped analytics
daily fields only a fallback — so the impossible "6h41m asleep / 4h33m in bed"
text was still shown.

Trim each session/fragment's stages to its effectiveStartTs at the display parse,
reusing SleepStageTotals.clampStagesToOnset (Android) and clamping the segment
start in decodeSegments (iOS, which also stops the hypnogram drawing bars before
the onset). No-op for non-edited nights (segments already start at the onset).

Now the card asleep, the stage bars, the hypnogram, and the score all honor the
same effective window.

* sleep: clamp the Android hypnogram timeline to the effective onset too (ryanbr#259)

The stage-minute clamp fixed the asleep NUMBER on Android, but the hypnogram strip
(segments / realSegments) still parsed the raw stagesJSON, so it drew bars for the
pre-onset stretch — contradicting the clamped total and diverging from iOS (whose
decodeSegments clamps the timeline in the same pass). Clamp the fragment/session
segment parse to effectiveStartTs so the chart starts where the edited bedtime does.
No-op for non-edited nights. Card number, stage bars, hypnogram + score now all agree
on both platforms.
@ryanbr ryanbr mentioned this pull request Jul 20, 2026
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…'t exceed time-in-bed (ryanbr#259) (ryanbr#270)

* sleep: clamp edited-night stages to the effective onset so asleep can't exceed time-in-bed (ryanbr#259)

A WHOOP 4.0 morning sleep reported "asleep 6h41m" over "4h33m in bed" — an
impossible card. Root: after a bed edit moved the effective onset (01:58 → 06:15),
the raw motion was too sparse to re-stage (4.0), so the stored stagesJSON kept its
pre-onset segments. dailyAggregateHonoringEdits summed the FULL window while
time-in-bed used the edited onset, so asleep exceeded in-bed (and inflated Rest/score).

Trim each SELECTED block's stages to its effective onset before summing:
clampStagesToOnset drops/trims segments that start before onsetByStart[startTs].
Main-night SELECTION still runs on the original blocks (no ryanbr#525/ryanbr#547 pick change) —
only the summed total is clamped. A block already staged from its onset (the common
case, and every non-edited night, where onset == start) is unchanged: a no-op.

startTsAdjusted is only ever set by a user edit, so effectiveStartTs != startTs
implies an edited night — exactly where this applies. Byte-parity Swift + Kotlin.

Not addressed (deferred root cause): why 4.0 over-stages a long low-motion pre-onset
stretch as sleep in the first place — the sparse-motion staging problem.

Tests: Issue259PreOnsetClampTest (Kotlin) + SleepStageTotalsTests twins (Swift) —
pre-onset trim + non-edited no-op. Full com.noop.analytics suite green.

* sleep: also clamp the DISPLAYED stage minutes to the effective onset (ryanbr#259)

The analytics clamp fixed the score, but the card's asleep number is Stages.asleep
(light+deep+rem) sourced from the session's stagesJSON directly (sessionStageMins /
sumGroupStages on Android, decodeSegments on iOS), with the clamped analytics
daily fields only a fallback — so the impossible "6h41m asleep / 4h33m in bed"
text was still shown.

Trim each session/fragment's stages to its effectiveStartTs at the display parse,
reusing SleepStageTotals.clampStagesToOnset (Android) and clamping the segment
start in decodeSegments (iOS, which also stops the hypnogram drawing bars before
the onset). No-op for non-edited nights (segments already start at the onset).

Now the card asleep, the stage bars, the hypnogram, and the score all honor the
same effective window.

* sleep: clamp the Android hypnogram timeline to the effective onset too (ryanbr#259)

The stage-minute clamp fixed the asleep NUMBER on Android, but the hypnogram strip
(segments / realSegments) still parsed the raw stagesJSON, so it drew bars for the
pre-onset stretch — contradicting the clamped total and diverging from iOS (whose
decodeSegments clamps the timeline in the same pass). Clamp the fragment/session
segment parse to effectiveStartTs so the chart starts where the edited bedtime does.
No-op for non-edited nights. Card number, stage bars, hypnogram + score now all agree
on both platforms.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
@ryanbr ryanbr mentioned this pull request Aug 2, 2026
5 of 12 tasks
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…'t exceed time-in-bed (ryanbr#259) (ryanbr#270)

* sleep: clamp edited-night stages to the effective onset so asleep can't exceed time-in-bed (ryanbr#259)

A WHOOP 4.0 morning sleep reported "asleep 6h41m" over "4h33m in bed" — an
impossible card. Root: after a bed edit moved the effective onset (01:58 → 06:15),
the raw motion was too sparse to re-stage (4.0), so the stored stagesJSON kept its
pre-onset segments. dailyAggregateHonoringEdits summed the FULL window while
time-in-bed used the edited onset, so asleep exceeded in-bed (and inflated Rest/score).

Trim each SELECTED block's stages to its effective onset before summing:
clampStagesToOnset drops/trims segments that start before onsetByStart[startTs].
Main-night SELECTION still runs on the original blocks (no ryanbr#525/ryanbr#547 pick change) —
only the summed total is clamped. A block already staged from its onset (the common
case, and every non-edited night, where onset == start) is unchanged: a no-op.

startTsAdjusted is only ever set by a user edit, so effectiveStartTs != startTs
implies an edited night — exactly where this applies. Byte-parity Swift + Kotlin.

Not addressed (deferred root cause): why 4.0 over-stages a long low-motion pre-onset
stretch as sleep in the first place — the sparse-motion staging problem.

Tests: Issue259PreOnsetClampTest (Kotlin) + SleepStageTotalsTests twins (Swift) —
pre-onset trim + non-edited no-op. Full com.noop.analytics suite green.

* sleep: also clamp the DISPLAYED stage minutes to the effective onset (ryanbr#259)

The analytics clamp fixed the score, but the card's asleep number is Stages.asleep
(light+deep+rem) sourced from the session's stagesJSON directly (sessionStageMins /
sumGroupStages on Android, decodeSegments on iOS), with the clamped analytics
daily fields only a fallback — so the impossible "6h41m asleep / 4h33m in bed"
text was still shown.

Trim each session/fragment's stages to its effectiveStartTs at the display parse,
reusing SleepStageTotals.clampStagesToOnset (Android) and clamping the segment
start in decodeSegments (iOS, which also stops the hypnogram drawing bars before
the onset). No-op for non-edited nights (segments already start at the onset).

Now the card asleep, the stage bars, the hypnogram, and the score all honor the
same effective window.

* sleep: clamp the Android hypnogram timeline to the effective onset too (ryanbr#259)

The stage-minute clamp fixed the asleep NUMBER on Android, but the hypnogram strip
(segments / realSegments) still parsed the raw stagesJSON, so it drew bars for the
pre-onset stretch — contradicting the clamped total and diverging from iOS (whose
decodeSegments clamps the timeline in the same pass). Clamp the fragment/session
segment parse to effectiveStartTs so the chart starts where the edited bedtime does.
No-op for non-edited nights. Card number, stage bars, hypnogram + score now all agree
on both platforms.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
mohamedchnafy-ds pushed a commit to mohamedchnafy-ds/noop that referenced this pull request Oct 31, 2026
The day lines' rest figure is the main night only (ryanbr#525), so a nap logged in the
Sleep tab never reached the Coach. A NAPS block now lists each recent day's naps
(clock span, minutes asleep) and the credited total night + naps, the figure the
sleep-debt ledger counts, split from the night by the same selector the Naps
card uses. Rest and Charge still score the night, as on Android; the block says
so, so the Coach does not read a nap into them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbxaRoQdLqN4Wg4yfikbo6
DX23876 added a commit to DX23876/noop that referenced this pull request Nov 1, 2026
The day lines' rest figure is the main night only (ryanbr#525), so a nap logged
in the Sleep tab never reached the Coach. A NAPS block now lists each of
the last 14 days' naps (clock span, minutes asleep) and the credited
total night plus naps, split from the night by SleepView.mainNightGroup,
the selector the Naps card uses. It says that Rest and Charge score the
night only, so the Coach does not read a nap into them. The block rides
along with the detailed biometric context and the get_sleep_detail tool
result, and is empty for a wearer with no naps.

CoachNapContextTests (4 tests).

Adopted from mohamedchnafy-ds/noop (042b03c, de84669, written there
with Claude) by @mohamedchnafy-ds, wired into the fork's context planner
and sleep tool.

Analysis migration required: no

Co-authored-by: mohamedchnafy-ds <175178324+mohamedchnafy-ds@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants