Skip to content

fix(alarms): time the wind-down nudge from the sleep need the Sleep tab states - #2906

Open
UtkuDenizAltiok wants to merge 1 commit into
ryanbr:mainfrom
UtkuDenizAltiok:fix/wind-down-sleep-need
Open

UtkuDenizAltiok wants to merge 1 commit into
ryanbr:mainfrom
UtkuDenizAltiok:fix/wind-down-sleep-need

Conversation

@UtkuDenizAltiok

Copy link
Copy Markdown

What was wrong

The wind-down nudge fires at wake time − sleep need − 30 min lead. Both platforms describe it as timed from the
user's usual sleep need (Apple: "A calm nudge 8h 30m before your usual wake time"; Android: "timed from your wake
time and usual sleep need"). The need it actually used is windDown.sleepNeedMinutes, which defaults to 8 h and
which nothing on either platform ever writes (no UI, no backup key, no caller of the setter).

Every other surface uses the personal need: the Sleep tab's debt card, ledger and trend need line
(SleepModel.debtNeedMin / Kotlin SleepModelLogic), and the Rest score, all through
AnalyticsEngine.Rest.personalizedNeedHours (upper-quartile night, floored at the 8 h adult target, capped at
9.5 h). So a long sleeper whose Sleep tab says "8h 40m needed" is reminded as if they need 8 h, and the Apple
Alarms copy states a need no other screen states.

A second, smaller Apple bug in the same copy: it printed sleepNeedMinutes / 60 hours beside leadMinutes
minutes, which only adds up while the need is a whole number of hours (an 8h 40m need + 30 min lead read
"8h 30m").

The change

The stored value stays (the scheduler, and Android's boot receiver, run without the database), but the app keeps
it equal to the Sleep tab's need:

  • Apple: WindDownNudge.adoptSleepNeed(normativeNeedMin:), called from AppModel's repo.$days sink with
    SleepModel.debtNeedMin(days:).
  • Android: WindDownStore.adoptSleepNeed(normativeNeedMin), called from the recentDays collector with a new
    debtNeedMin(days) in SleepModelLogic.kt, which buildSleepModel now also uses (the same expression,
    extracted, so the two cannot drift).

Both write only when the rounded need changes (it moves over weeks) and reschedule only then, and only while the
nudge is on. An empty history emission is skipped so a launch cannot reset a learned need to 8 h and back.

Behaviour change, stated plainly: because the personal need never drops below 8 h, the nudge only ever moves
earlier, and only for people whose longer nights run past 8 h. Anyone with fewer than 7 nights, or whose
upper-quartile night is at or below 8 h, sees no change.

The Apple copy now states need + lead (WindDownNudge.offsetHoursMinutes); same strings, no new translations.
Android's existing copy ("timed from your wake time and usual sleep need") is now accurate as written.

Parity

The rounding helper (WindDownNudge.needMinutes(fromNormativeNeedMin:) / WindDownStore.needMinutes) rounds half
away from zero after clamping to the store's 5–11 h (clamping first so neither Int(_:) traps nor Math.round
saturates). Verified by oracle, not by reading: the Swift body was compiled standalone over 28 inputs (ties,
bounds, negative, huge, NaN, ±∞) and its stdout is pasted verbatim as the expected string in the Kotlin test; the
Swift test asserts the same list.

Tests

  • StrandTests/WindDownSleepNeedTests.swift: the oracle list; adopt only when the rounded need moves; ten nights
    with an 8h 40m upper quartile give the Sleep tab's 520-minute need and move a 07:00 nudge from 22:30 to 21:50;
    the copy sum (8h 40m + 30m → 9h 10m).
  • android/.../alarm/WindDownSleepNeedTest.kt: the same oracle, adopt-on-change, and the same 22:30 → 21:50 case
    through debtNeedMin.

Verification

  • NOOPiOS simulator build; Strand macOS build + StrandTests (2306, 0 failures, 2 skipped as before).
  • Fork Android CI (assembleFullDebug + testFullDebugUnitTest) green on this head: https://github.com/UtkuDenizAltiok/noop/actions/runs/42258465615
  • Tools/i18n_audit.py --ci upstream/main exit 0, Tools/doc_comment_lint.py clean, Tools unit tests 338 OK,
    parity governance acceptance 50 OK.
  • Not hardware-dependent (a local notification time); not observed firing on a device in this PR.

Related

🤖 Generated with Claude Code

…ab states

The wind-down nudge fires at wake time minus sleep need minus a 30-minute lead, and both platforms'
copy says it uses "your usual sleep need". It used a stored `windDown.sleepNeedMinutes` that defaults to
8 h and that nothing on either platform ever writes. Every other surface (the Sleep tab's debt card,
ledger and trend need line, and the Rest score) uses the personal need: the upper-quartile night,
never below the 8 h adult target, capped at 9.5 h. So a long sleeper whose Sleep tab said "8h 40m
needed" was reminded as if they needed 8 h, and the Apple Alarms copy named a need no other screen
stated.

The stored value stays (the scheduler and Android's boot receiver have no database), but the app now
keeps it equal to the Sleep tab's need:

- Apple: `WindDownNudge.adoptSleepNeed(normativeNeedMin:)`, called from AppModel's `repo.$days` sink with
  `SleepModel.debtNeedMin(days:)`.
- Android: `WindDownStore.adoptSleepNeed(normativeNeedMin)`, called from the recentDays collector with
  the new `debtNeedMin(days)`, which SleepModelLogic now also uses (same expression, extracted).

It writes only when the rounded need changes (it moves over weeks) and reschedules only then and only
while the nudge is on; an empty history emission is skipped so a launch cannot reset a learned need to
8 h and back. Rounding is half away from zero, clamped to the store's 5-11 h before converting, and
byte-identical on both platforms: the Kotlin test's expected values are the stdout of the Swift body
compiled standalone over 28 inputs including ties, bounds and non-finite values. The need never drops
below 8 h, so the nudge only ever moves earlier, and only for people whose longer nights run past 8 h.

The Apple Alarms copy ("A calm nudge %lldh %lldm before your usual wake time", "The nudge fires
%lldh %lldm before this") printed the need's whole hours beside the lead's minutes, which only added
up for a whole-hour need; it now states need + lead (same strings, no new translations).

Tests: WindDownSleepNeedTests (Swift) and WindDownSleepNeedTest (Kotlin): the rounding oracle, adopt
only on change, the 07:00 nudge moving from 22:30 to 21:50 for an 8h 40m Sleep-tab need, the copy sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Nov 6, 2026
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Nov 6, 2026
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Nov 6, 2026
Today's sleep card gains a "Tonight" row while the wind-down reminder is on: "Aim to be asleep by
22:20", then "That gives you 8h 40m of sleep before your 07:00 wake. Your wind-down reminder comes at
21:50." It answers the evening question on the screen people open, from the reminder's own inputs.

- SimpleDayPlan.tonightPlan reads exactly what WindDownNudge schedules from (the wake time for the
  weekday being woken on, the sleep need, the lead) with the reminder's own wall-clock arithmetic, so
  "wind down at" is the minute the notification fires (tested over a spread of wakes and needs, per-day
  wake times, and ryanbr#1863's early-wake case landing on the evening before).
- Shown only while the reminder is on (its wake time is otherwise an unused setting, ryanbr#2353) and only
  while the fall-asleep time is still ahead: not late in the evening, not after midnight.
- The card's last-night line can state WHOOP's imported need for that night while tonight uses NOOP's;
  when the two differ, tonight's row leaves out its duration rather than print a second need.
- Today observes the reminder's settings (SimpleDayPlan.ReminderKeys, pinned to WindDownNudge's keys by
  WindDownMirrorKeyTests), so switching it off on the Alarms screen removes the row at once.

With upstream ryanbr#2906 (in the testing stack) the reminder's need is the Sleep tab's need, so the row and
the Sleep tab agree. Presentation only; the full view is unchanged. 3 new strings, nine locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@UtkuDenizAltiok

Copy link
Copy Markdown
Author

The Strand (macOS) leg of App build shows as cancelled, but it never ran: GitHub could not assign a macOS runner ("The job was not acquired by Runner of type hosted even after multiple attempts", no steps executed). Every leg that ran passed, including NOOPiOS. On this head (eb0391a0) the Strand macOS build and StrandTests pass locally (2306 tests, 0 failures). A re-run of that leg should clear it.

UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Nov 6, 2026
Today's sleep card gains a "Tonight" row while the wind-down reminder is on: "Aim to be asleep by
22:20", then "That gives you 8h 40m of sleep before your 07:00 wake. Your wind-down reminder comes at
21:50." It answers the evening question on the screen people open, from the reminder's own inputs.

- SimpleDayPlan.tonightPlan reads exactly what WindDownNudge schedules from (the wake time for the
  weekday being woken on, the sleep need, the lead) with the reminder's own wall-clock arithmetic, so
  "wind down at" is the minute the notification fires (tested over a spread of wakes and needs, per-day
  wake times, and ryanbr#1863's early-wake case landing on the evening before).
- Shown only while the reminder is on (its wake time is otherwise an unused setting, ryanbr#2353) and only
  while the fall-asleep time is still ahead: not late in the evening, not after midnight.
- The card's last-night line can state WHOOP's imported need for that night while tonight uses NOOP's;
  when the two differ, tonight's row leaves out its duration rather than print a second need.
- Today observes the reminder's settings (SimpleDayPlan.ReminderKeys, pinned to WindDownNudge's keys by
  WindDownMirrorKeyTests), so switching it off on the Alarms screen removes the row at once.

With upstream ryanbr#2906 (in the testing stack) the reminder's need is the Sleep tab's need, so the row and
the Sleep tab agree. Presentation only; the full view is unchanged. 3 new strings, nine locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Nov 6, 2026
DX23876 pushed a commit to DX23876/noop that referenced this pull request Nov 8, 2026
…ab states

Adopted from ryanbr#2906 (by @UtkuDenizAltiok). The wind-down nudge,
and with it Forge's planned bedtime and evening start, counted back from
`windDown.sleepNeedMinutes`, a fixed 8 h that nothing ever wrote. The
Sleep tab, the debt ledger and the Rest score use the personal need
(`SleepModel.debtNeedMin`, upper-quartile night, at least 8 h).

AppModel now adopts that need whenever the daily history changes
(rounded, clamped to 5-11 h, written and rescheduled only when it moves;
an empty history is skipped). Before seven nights it stays 8 h, and the
need is capped at 9.5 h, so the nudge cannot land at an odd time. The
Alarms copy adds need and lead correctly when the need is not a whole
number of hours.

Fork adaptation:
- the Forge Sleep card no longer names a second need ("Bedtime counts
  X h, as set in Alarms"), since bedtime and card now use the same one
- the fork's own "You'll be reminded around ..." line is kept; the
  upstream wake-time picker hunk does not apply here
- the Android (Kotlin) hunks are left out: Android logic belongs in the
  shared core, so the Android wind-down still uses 8 h until that move

Not compiled here (no Swift toolchain in this environment).

Analysis migration required: no

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AWc14WNvMmSF4RHP6ww4d
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.

1 participant