# Tournament Timer API ## Purpose Backend timer supports: - countdown mode - countup mode - clear / unset - live websocket updates - follower sync propagation Frontend should treat timer state as: - no timer: `timestamp = null`, `timer_mode = null` - countdown: `timer_mode = "countdown"` - countup: `timer_mode = "countup"` ## Read Flow Timer state is available in normal tournament payload: - `GET /tournaments/:id` Dedicated timer endpoint also exists: - `GET /tournaments/:id/timer` Response shape: ```json { "timestamp": "2026-04-24T10:15:00Z", "timer_mode": "countdown" } ``` Possible values: - `timer_mode = "countdown"` - `timer_mode = "countup"` - `timer_mode = null` ## Meaning Of `timestamp` Same field. Mode decides meaning. ### Countdown mode `timestamp` means exact wall-clock timestamp when countdown reaches zero. Frontend math: ```text remaining_ms = max(0, timestamp - now) ``` ### Countup mode `timestamp` means anchor timestamp when countup started. It is **not** finish time in this mode. Think of it as: - timer start time - elapsed-time anchor Frontend math: ```text elapsed_ms = max(0, now - timestamp) ``` Practical example: - backend returns: - `timer_mode = "countup"` - `timestamp = "2026-04-24T10:00:00Z"` - current time: - `2026-04-24T10:03:15Z` Frontend should display about `03:15` elapsed. ### Unset timer - `timer_mode = null` - `timestamp = null` Frontend should render no active timer. ## Write Flow Only tournament owner may change timer. Follower tournaments in `read_only_mode` reject writes with `423`. Endpoint: - `PATCH /tournaments/:id/timer` ### Start countdown by duration Request: ```json { "timestamp_seconds": 120 } ``` Behavior: - backend computes `Time.zone.now + 120` - backend stores: - `timer_mode = "countdown"` - `timestamp = computed future timestamp` ### Start countdown by explicit timestamp Request: ```json { "timestamp": "2026-04-24T10:15:00Z" } ``` Optional explicit mode: ```json { "timestamp": "2026-04-24T10:15:00Z", "timer_mode": "countdown" } ``` ### Start countup Request: ```json { "timer_mode": "countup" } ``` Behavior: - backend stores current server time into `timestamp` - backend stores `timer_mode = "countup"` Frontend should interpret returned `timestamp` as start time anchor. ### Restore countup from explicit anchor Backend also accepts explicit timestamp with countup mode: ```json { "timestamp": "2026-04-24T10:00:00Z", "timer_mode": "countup" } ``` Useful for: - restoring imported state - rehydrating synced follower state - advanced admin tooling ### Clear timer Request: ```json { "clear_timer": true } ``` Behavior: - backend clears both fields - response returns: - `timestamp = null` - `timer_mode = null` ## Validation Rules Backend rejects: - both `timestamp` and `timestamp_seconds` - invalid `timer_mode` - `timestamp_seconds <= 0` - `timestamp_seconds` together with `timer_mode = "countup"` - `timer_mode` by itself unless it is `countup` - `clear_timer` mixed with timer values Countdown timestamps must be future timestamps. Countup timestamps may be current or past timestamps. ## Websocket / Live Updates Timer uses ActionCable websocket broadcast. This is websocket push, not webhook callback. Channel: - `TournamentTimersChannel` Subscription identifier: ```json { "channel": "TournamentTimersChannel", "tournament_id": 123 } ``` Payload: ```json { "type": "timer.updated", "tournament_id": 123, "timestamp": "2026-04-24T10:15:00Z", "timer_mode": "countdown" } ``` Behavior: - subscription immediately receives current timer snapshot - later timer changes broadcast full replacement snapshot - direct owner edits and follower sync imports both use same broadcast path Frontend rule: - replace local timer state from websocket payload - do not merge by guessing transitions ## Frontend Implementation Guide Recommended flow: 1. Load tournament via `GET /tournaments/:id` 2. Read `timer_mode` and `timestamp` 3. Open ActionCable subscription for same tournament id 4. Replace local timer state whenever `timer.updated` arrives 5. Derive displayed time from `Date.now()` plus stored backend timestamp Recommended client state: ```ts type TimerState = | { mode: null; timestampIso: null } | { mode: "countdown"; timestampIso: string } | { mode: "countup"; timestampIso: string }; ``` Suggested derivation logic: ```ts function getTimerDisplay(state: TimerState, nowMs: number) { if (state.mode === null || state.timestampIso === null) { return { active: false, ms: 0 }; } const timestampMs = new Date(state.timestampIso).getTime(); if (state.mode === "countdown") { return { active: true, ms: Math.max(0, timestampMs - nowMs) }; } return { active: true, ms: Math.max(0, nowMs - timestampMs) }; } ``` Recommended rendering: - `countdown`: show remaining time - `countup`: show elapsed time - unset: hide timer or show inactive state Recommended local ticking: - keep backend state as timestamp + mode - update rendered value with local interval - do not poll backend every second Recommended write UX: - owner actions send explicit target request - after success, update local state from response body - websocket should converge all viewers to same state ## Follower Behavior Timer state is included in tournament sync snapshots. That means: - leader timer changes propagate to follower - follower tournament exposes same `timestamp` / `timer_mode` - follower websocket subscribers receive `timer.updated` - follower still rejects timer writes while read only ## E2E Coverage Implemented coverage: - API surface timer lifecycle: - countdown - countup - clear - validation - [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb) - direct websocket timer updates: - [spec/e2e/http/tournament_timer_websocket_spec.rb](../spec/e2e/http/tournament_timer_websocket_spec.rb) - follower websocket timer propagation: - [spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb](../spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb) Current note: - single-backend HTTP E2E timer specs pass - follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet