Skip to content

web: send the workout "left" signal when the page goes away, and say why - #403

Merged
DuarteSantos8 merged 1 commit into
DuarteSantos8:mainfrom
kurktchiev:gh/presence-left-on-close
Oct 9, 2026
Merged

DuarteSantos8 merged 1 commit into
DuarteSantos8:mainfrom
kurktchiev:gh/presence-left-on-close

Conversation

@kurktchiev

Copy link
Copy Markdown
Contributor

The Workout screen sent its "left" signal (POST /api/activity { active: false }, which drops the athlete from the admin dashboard's "Training now") only
from its effect cleanup. That runs on in-app navigation, where the api()
call beside it sends the same thing anyway. A closed tab, or a home-screen
app swiped away or killed by the phone, unmounts nothing, so the athlete
stayed under "Training now" until the server's presence expiry, 70 s after
the last heartbeat.

The screen now sends it through beacon() (still navigator.sendBeacon,
unchanged) on pagehide, and on visibilitychange to hidden as well,
because iOS kills a hidden home-screen app without a pagehide. The signal
now carries a reason the server acts on: navigated from the effect
cleanup, closed from pagehide, hidden from visibilitychange. The
server keeps a hidden athlete listed for 45 s unless a heartbeat arrives.
A phone locked between sets keeps heartbeating and is never blinked off the
list, while an iOS app swiped away is gone in 45 s instead of the full 70 s
expiry. Each reason is sent once per hide, re-armed by a heartbeat; a close
whose pagehide follows a hidden visibilitychange still sends closed,
and nothing follows closed. A page shown again, or restored from the
back/forward cache, heartbeats at once.

api 7 new tests (server-presence.test.js; a new test/fake-clock.mjs
drives the spawned server's clock over IPC so each step in the test lands at
an exact server time), run with node --test. frontend 10 new tests in
Workout.test.jsx, run with npx vitest run --maxWorkers=2. 129 total in
that file, all passing.

Tested in real Chromium 153 and WebKit 26.6 through Playwright. With a bare page and a
server that logs what arrives, the beacon arrived on every tab close in both browsers (18 of
18, and 10 of 10 with 150 ms of added latency) and on every navigation away. In a separate
run of the full app with WebKit on macOS, a tab close lost the request in 4 of 4 tries, and
a keepalive fetch in place of the beacon lost it the same way, so that may be Playwright
tearing the page down rather than Safari. The hidden reason is what covers a home-screen
app iOS suspends or kills, which Playwright cannot exercise.

Conflicts with #312 (generic prescription engine v2) in Workout.test.jsx,
where both add new test blocks at the same places. Keeping both resolves it.

🤖 Generated with Claude Code

The Workout screen sent its "left" signal (POST /api/activity { active:
false }, which drops the athlete from the admin dashboard's "Training now")
only from its effect cleanup. That runs on in-app navigation, where the
api() call beside it sends the same thing anyway. A closed tab, or a
home-screen app swiped away or killed by the phone, unmounts nothing, so
the athlete stayed under "Training now" until the server's presence
expiry, 70 s after the last heartbeat.

The screen now sends it through beacon() (navigator.sendBeacon, unchanged)
on pagehide, and on visibilitychange to hidden as well, because iOS kills
a hidden home-screen app without a pagehide. The signal carries a reason
the server acts on: `navigated` from the effect cleanup, `closed` from
pagehide, `hidden` from visibilitychange, which the server keeps listed
for 45 s unless a heartbeat arrives -- a phone locked between sets keeps
heartbeating and is never blinked off the list, while an iOS app swiped
away is gone in 45 s instead of the full 70 s expiry. Each reason is sent
once per hide, re-armed by a heartbeat; a close whose pagehide follows a
hidden visibilitychange still sends `closed`, and nothing follows `closed`.
A page shown again, or restored from the back/forward cache, heartbeats
at once.

api 7 new tests (server-presence.test.js, api/test/fake-clock.mjs drives
the spawned server's clock over IPC so each step lands at an exact
server time). frontend 10 new tests in Workout.test.jsx. All pass.

Tested by hand against the real app (register, start a freestyle workout,
close the tab or navigate away) in Chromium 153.0.8010.12 and WebKit 26.6
via Playwright, and separately with an isolated navigator.sendBeacon
harness against a plain local server mirroring the same JS. On
navigating away, both browsers' isolated-harness delivery was 100%
reliable (18 of 18 standard page-close trials across two runs, plus 10
of 10 with 150ms of added server latency, and equally reliable navigating
to another URL). The real-app run's own network observer caught the
request from WebKit but not consistently from Chromium; the isolated
harness's server-side receipt log did catch it every time for both, which
points at a gap in how Playwright surfaces a beacon fired right at page
teardown rather than at an actual delivery failure. Unable to reproduce a
WebKit-specific loss on tab close that the browser itself, rather than
the observation method, would be responsible for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@DuarteSantos8
DuarteSantos8 merged commit 0ba3c44 into DuarteSantos8:main Oct 9, 2026
4 checks passed
@kurktchiev
kurktchiev deleted the gh/presence-left-on-close branch October 9, 2026 15:34
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