Skip to content

Fix double rest-timer alert on native (OS notification + client beep firing together) - #78

Merged
willchan merged 3 commits into
mainfrom
claude/double-beep-pullups-xvv86h
Aug 10, 2026
Merged

Fix double rest-timer alert on native (OS notification + client beep firing together)#78
willchan merged 3 commits into
mainfrom
claude/double-beep-pullups-xvv86h

Conversation

@willchan

Copy link
Copy Markdown
Owner

Problem

On the native iOS app, completing a rest timer produced two separate alerts: the OS-scheduled local notification (its own sound + system alert vibration) and the app's own client-side haptic + 3-tone beep, essentially every time — not just as a rare race.

The scheduled native notification is driven by the OS's own clock and fires independent of whether the app's JS is even running. notifyTimerExpired() only tried to cancel it after detecting expiry client-side (via a 250ms poll or a visibilitychange reconciler) — i.e. at or after the same instant the OS notification was due — so that cancel essentially never won the race.

Fix

  • fireTimerNotification() now checks whether the OS will actually alert (querying LocalNotifications state directly — getPending()/getDeliveredNotifications(), not an in-memory flag, since that survives reload but a flag wouldn't) before playing the client-side haptic/beep, skipping it when the OS already has it covered.
  • scheduleBackgroundTimerNotification() clears stale delivered notifications before scheduling a new one, so a leftover delivered entry from a previous timer (the app reuses one fixed notification id) can't permanently mask a later schedule() failure.
  • fireTimerNotification() now reports whether cancellation is still warranted, and skips it when the OS is trusted to alert — unconditionally cancelling a still-pending notification we just decided to trust would silently prevent it from ever firing.
  • cancelBackgroundTimerNotification() is properly awaitable now instead of fire-and-forget, closing the race where a fast next rest timer's schedule() call (same reused id) could be wiped out by a late-arriving cancel meant for the previous timer.
  • The four spots in workout.ts that can independently notice a timer expired (the poll, the visibilitychange reconciler, the resumed-timer recovery interval, and the already-expired-on-mount check) now share one handleTimerExpiry helper with a single guard, instead of a duplicated sequence that could drift out of sync.
  • That guard is scoped per renderWorkout() call, not module-level — a module-level guard let a stale render's detector (left running if the workout screen is left via a raw route change instead of the in-app Back button) win the race and permanently block the live render's own detector from updating the visible page.
  • The resumed-timer recovery interval now clears any pre-existing timerInterval before overwriting it, so a stale render's interval can't later clobber the live render's interval through the shared module variable.

Process

This went through five rounds of adversarial code review (via the code-review skill at max effort), each round's confirmed findings fixed and regression-tested before the next pass — including a couple of bugs the review surfaced in my own earlier fixes for this same issue (an in-memory tracking flag that didn't survive reload, a stale-notification-masking bug, and the module-level-guard regression above).

Testing

  • bun run typecheck and eslint — clean
  • Full Playwright suite (275 tests) on chromium — all passing
  • Every new/changed regression test verified to fail without its corresponding fix and pass with it (including 3x/12x repeats on the two timing-sensitive ones to rule out flakiness in either direction)

🤖 Generated with Claude Code


Generated by Claude Code

…ication)
fireTimerNotification() was unconditionally playing a client-side haptic
+ Web Audio beep on native, on top of the OS local notification that
scheduleBackgroundTimerNotification() already scheduled for the same
expiry (with its own sound file and system alert vibration).
notifyTimerExpired() only cancels that scheduled notification *after*
detecting expiry client-side (via the 250ms poll or the visibilitychange
reconciler) — i.e. at or after the exact instant the OS notification is
due to fire — so the cancel essentially never won the race. Every rest
timer completion on the native app produced two audible/haptic alerts.
Fix: before playing the client-side cue, check whether the OS
notification will actually alert (LocalNotifications.checkPermissions()).
If it will, skip the redundant haptic/beep and let the OS notification be
the sole alert. If notification permission isn't granted, keep the
client-side fallback since nothing else will cue the user.
fireTimerNotification() is now async to support the permission check;
notifyTimerExpired() and its four call sites await it accordingly.
Adds a regression test in native-platform.spec.ts asserting no
vibrate/haptic and no oscillator (beep) calls when the OS will alert;
verified it fails without the fix and passes with it.
… fix
Follow-up to the previous commit's native double-alert fix. Five rounds
of adversarial code review surfaced further issues in that fix and in
the surrounding pre-existing expiry-detection code; this addresses all
confirmed findings, each backed by a regression test that fails without
its fix and passes with it.
notifications.ts:
- fireTimerNotification()'s "will the OS alert" check now queries the
OS's own state (getPending/getDeliveredNotifications) instead of an
in-memory "did schedule() succeed" flag, which reset on every page
reload even though the OS-scheduled notification itself survives one.
- scheduleBackgroundTimerNotification() clears stale delivered
notifications before scheduling a new one — the fixed notification id
meant a previous timer's delivered entry never expired, permanently
masking a later schedule() failure.
- fireTimerNotification() now returns whether cancellation is still
warranted, and skips cancelling when the OS is trusted to alert
(pending or already delivered) — unconditionally cancelling a still-
pending notification we'd just decided to trust would silently
prevent it from ever firing.
- cancelBackgroundTimerNotification() is now properly awaitable instead
of fire-and-forget, closing (not just narrowing) the race where a
fast next rest timer's schedule() call, reusing the same fixed
notification id, could be wiped out by a late-arriving cancel meant
for the previous timer.
- Promise.all -> Promise.allSettled when checking pending/delivered
state, so one call rejecting doesn't discard a result the other call
already provided.
workout.ts:
- The four expiry-detection call sites now share one handleTimerExpiry
helper instead of a duplicated guard+cleanup sequence, with a
try/catch that resets the guard on failure so a transient IndexedDB
write error can't permanently block every future expiry.
- timerExpiryHandled (the guard closing the race between the poll,
visibilitychange, and recovery-interval detectors) is scoped per
renderWorkout() call, not module-level — a module-level guard let a
stale render's detector (left running when the workout screen is
left via a raw route change instead of the in-app Back button) win
the race and permanently block the *live* render's own detector from
updating the visible page.
- The resumed-timer recovery interval now clears any pre-existing
timerInterval before overwriting it — otherwise a stale render's
interval keeps running unowned, and when it later notices the same
expiry and clears "timerInterval" itself, it cancels the live
render's interval instead (same shared module variable).
Verified via bun run typecheck, eslint, and the full Playwright suite
(275 tests) on chromium, plus repeated runs of every new/changed
regression test to rule out flakiness in either direction.
…leanup
CI (iphone-webkit shard) caught a real regression from the previous
commit's stale-delivered-notification fix: awaiting
removeAllDeliveredNotifications() before schedule() delayed the moment
a new timer's notification actually became pending/cancellable. A
cancelBackgroundTimerNotification() call fired immediately after (e.g.
skip-timer-btn, or the "cancelling before the fire time prevents the
notification" test) could now complete before schedule() had even run,
cancelling nothing — leaving the notification to fire anyway.
The cleanup doesn't need to land before schedule() call, only sometime
before this timer's own eventual fireTimerNotification() check (90+
seconds later) — so make it non-blocking (fire-and-forget, run in
parallel) instead of awaited ahead of schedule(). Restores the original
timing between schedule() and an immediately-following cancel() call.
Verified via bun run typecheck, eslint, and the full Playwright suite
on chromium (this environment has no WebKit available to reproduce the
iphone-webkit-only failure directly, but the fix directly addresses the
root cause CI's log identified: schedule() no longer waits on the
cleanup call).
@willchan
willchan merged commit c20b150 into mainAug 10, 2026
8 checks passed
@willchan
willchan deleted the claude/double-beep-pullups-xvv86h branch August 10, 2026 15:08
Sign up for freeto 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

@willchan@claude