Skip to content

Activity timer state doesn't sync across multiple open sessions/tabs #759

Description

@gaidheal1

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:95TimerConsumer 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

  1. 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.
  2. 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).
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: playersPlayer profiles, settings, and user-facing account features

    Projects

    Status
    Staging review

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions