6.3 KiB
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_end
Response shape:
{
"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:
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:
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 = nulltimestamp = 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/set_timer_end
Current route name still says set_timer_end.
Payload field name is now timestamp.
Start countdown by duration
Request:
{
"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:
{
"timestamp": "2026-04-24T10:15:00Z"
}
Optional explicit mode:
{
"timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown"
}
Start countup
Request:
{
"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:
{
"timestamp": "2026-04-24T10:00:00Z",
"timer_mode": "countup"
}
Useful for:
- restoring imported state
- rehydrating synced follower state
- advanced admin tooling
Clear timer
Request:
{
"clear_timer": true
}
Behavior:
- backend clears both fields
- response returns:
timestamp = nulltimer_mode = null
Validation Rules
Backend rejects:
- both
timestampandtimestamp_seconds - invalid
timer_mode timestamp_seconds <= 0timestamp_secondstogether withtimer_mode = "countup"timer_modeby itself unless it iscountupclear_timermixed 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:
{
"channel": "TournamentTimersChannel",
"tournament_id": 123
}
Payload:
{
"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:
- Load tournament via
GET /tournaments/:id - Read
timer_modeandtimestamp - Open ActionCable subscription for same tournament id
- Replace local timer state whenever
timer.updatedarrives - Derive displayed time from
Date.now()plus stored backend timestamp
Recommended client state:
type TimerState =
| { mode: null; timestampIso: null }
| { mode: "countdown"; timestampIso: string }
| { mode: "countup"; timestampIso: string };
Suggested derivation logic:
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 timecountup: 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
- direct websocket timer updates:
- follower websocket timer propagation:
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