Summary
If a player has more than one session open (e.g. two browser tabs, or a phone + a laptop), starting, labelling, or submitting an activity in one session has no effect on the others. Each session's timer state (status, currentActivity, elapsed, limitSeconds, etc., managed by useActivityTimer in frontend/src/hooks/useActivityTimer.ts) is purely local React state, seeded once from the server via loadFromServer and never invalidated by another session's writes.
Concretely: start a timer in tab A, then open tab B — tab B shows no active timer (or a stale one) until it happens to reload/refetch. Submit the activity from tab A, and tab B keeps ticking a timer that the server has already completed.
Where this lives
Backend — none of the activity-timer mutation endpoints broadcast anything:
gameplay/views.py:37 (start), :56/:75 (complete), :97 (set_activity), :114 (label_activity) — all mutate timer and return a Response, with no WebSocket notification.
There's already per-player broadcast infrastructure to reuse:
gameplay/consumers.py:95 — TimerConsumer joins each connection to a player_{id} group (self.player_group) on connect, so all of a player's open sessions/tabs are already reachable as a group.
gameplay/signals.py shows the existing pattern: send_group_message(group, {"type": "..."}) via async_to_sync(channel_layer.group_send) off a model signal.
Frontend — the WebSocket message dispatcher has a stubbed-out, unused hook for exactly this:
frontend/src/websockets/handleGlobalWebSocketEvent.ts:66-68 — the load-game action case is a no-op: console.log("[WS] Django consumer 'load-game' message not currently in use.").
useActivityTimer.ts already exposes loadFromServer(serverData, opts) (frontend/src/hooks/useActivityTimer.ts:349), which does exactly what's needed to reconcile local state from a server-pushed snapshot — it's currently only called after an explicit fetch, not in response to a push.
Proposed approach
- Backend: after
start/complete/set_activity/label_activity/pause/reset mutate the timer, broadcast the serialized timer (ActivityTimerSerializer) to player_{id} over the channel layer, similar to the existing send_group_message pattern in gameplay/signals.py.
TimerConsumer: handle the new group message type and forward it to the socket as an action message (reusing/renaming the existing load-game stub, or adding a new activity_timer_update type).
- Frontend: wire
handleGlobalWebSocketEvent.ts to call loadFromServer (via whatever plumbs useActivityTimer today) when this message arrives, so every open session reconciles to the authoritative server state.
- Guard against a session echoing its own optimistic update — either by comparing timestamps/ids or simply treating
loadFromServer as idempotent (it already just overwrites local state, so accepting our own echo should be harmless, but worth confirming there's no jank/flicker in the tab that just made the change).
Why
- Prevents two tabs from double-submitting or diverging on elapsed time.
- Reuses existing per-player group infrastructure instead of introducing new plumbing.
- The
load-game stub suggests this was already anticipated but never finished.
Summary
If a player has more than one session open (e.g. two browser tabs, or a phone + a laptop), starting, labelling, or submitting an activity in one session has no effect on the others. Each session's timer state (
status,currentActivity,elapsed,limitSeconds, etc., managed byuseActivityTimerinfrontend/src/hooks/useActivityTimer.ts) is purely local React state, seeded once from the server vialoadFromServerand never invalidated by another session's writes.Concretely: start a timer in tab A, then open tab B — tab B shows no active timer (or a stale one) until it happens to reload/refetch. Submit the activity from tab A, and tab B keeps ticking a timer that the server has already completed.
Where this lives
Backend — none of the activity-timer mutation endpoints broadcast anything:
gameplay/views.py:37(start),:56/:75(complete),:97(set_activity),:114(label_activity) — all mutatetimerand return aResponse, with no WebSocket notification.There's already per-player broadcast infrastructure to reuse:
gameplay/consumers.py:95—TimerConsumerjoins each connection to aplayer_{id}group (self.player_group) on connect, so all of a player's open sessions/tabs are already reachable as a group.gameplay/signals.pyshows the existing pattern:send_group_message(group, {"type": "..."})viaasync_to_sync(channel_layer.group_send)off a model signal.Frontend — the WebSocket message dispatcher has a stubbed-out, unused hook for exactly this:
frontend/src/websockets/handleGlobalWebSocketEvent.ts:66-68— theload-gameaction case is a no-op:console.log("[WS] Django consumer 'load-game' message not currently in use.").useActivityTimer.tsalready exposesloadFromServer(serverData, opts)(frontend/src/hooks/useActivityTimer.ts:349), which does exactly what's needed to reconcile local state from a server-pushed snapshot — it's currently only called after an explicit fetch, not in response to a push.Proposed approach
start/complete/set_activity/label_activity/pause/resetmutate the timer, broadcast the serialized timer (ActivityTimerSerializer) toplayer_{id}over the channel layer, similar to the existingsend_group_messagepattern ingameplay/signals.py.TimerConsumer: handle the new group message type and forward it to the socket as anactionmessage (reusing/renaming the existingload-gamestub, or adding a newactivity_timer_updatetype).handleGlobalWebSocketEvent.tsto callloadFromServer(via whatever plumbsuseActivityTimertoday) when this message arrives, so every open session reconciles to the authoritative server state.loadFromServeras idempotent (it already just overwrites local state, so accepting our own echo should be harmless, but worth confirming there's no jank/flicker in the tab that just made the change).Why
load-gamestub suggests this was already anticipated but never finished.