353 lines
7.5 KiB
Markdown
353 lines
7.5 KiB
Markdown
# 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"`
|
|
- optional reason: `timer_reason` may be `tournament_start`, `playoff_start`, `match_end`, or `custom`
|
|
- custom reason: `timer_reason = "custom"` and `timer_reason_text` contains the display text
|
|
|
|
## 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",
|
|
"timer_reason": "tournament_start",
|
|
"timer_reason_text": null
|
|
}
|
|
```
|
|
|
|
Possible values:
|
|
|
|
- `timer_mode = "countdown"`
|
|
- `timer_mode = "countup"`
|
|
- `timer_mode = null`
|
|
- `timer_reason = "tournament_start"`
|
|
- `timer_reason = "playoff_start"`
|
|
- `timer_reason = "match_end"`
|
|
- `timer_reason = "custom"`
|
|
- `timer_reason = null`
|
|
|
|
`timer_reason_text` is only used when `timer_reason = "custom"`.
|
|
|
|
## 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,
|
|
"timer_reason": "tournament_start"
|
|
}
|
|
```
|
|
|
|
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",
|
|
"timer_reason": "playoff_start"
|
|
}
|
|
```
|
|
|
|
Optional explicit mode:
|
|
|
|
```json
|
|
{
|
|
"timestamp": "2026-04-24T10:15:00Z",
|
|
"timer_mode": "countdown",
|
|
"timer_reason": "custom",
|
|
"timer_reason_text": "Opening ceremony ran long"
|
|
}
|
|
```
|
|
|
|
### Start countup
|
|
|
|
Request:
|
|
|
|
```json
|
|
{
|
|
"timer_mode": "countup",
|
|
"timer_reason": "match_end"
|
|
}
|
|
```
|
|
|
|
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",
|
|
"timer_reason": "custom",
|
|
"timer_reason_text": "Manual overtime"
|
|
}
|
|
```
|
|
|
|
Useful for:
|
|
|
|
- restoring imported state
|
|
- rehydrating synced follower state
|
|
- advanced admin tooling
|
|
|
|
### Clear timer
|
|
|
|
Request:
|
|
|
|
```json
|
|
{
|
|
"clear_timer": true
|
|
}
|
|
```
|
|
|
|
Behavior:
|
|
|
|
- backend clears all timer fields
|
|
- response returns:
|
|
- `timestamp = null`
|
|
- `timer_mode = null`
|
|
- `timer_reason = null`
|
|
- `timer_reason_text = 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
|
|
- invalid `timer_reason`
|
|
- `timer_reason = "custom"` without `timer_reason_text`
|
|
- `timer_reason_text` with any non-custom `timer_reason`
|
|
|
|
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",
|
|
"timer_reason": "tournament_start",
|
|
"timer_reason_text": null
|
|
}
|
|
```
|
|
|
|
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`, `timestamp`, `timer_reason`, and `timer_reason_text`
|
|
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; reason: null; reasonText: null }
|
|
| { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }
|
|
| { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null };
|
|
|
|
type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom";
|
|
```
|
|
|
|
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 + reason
|
|
- 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` / `timer_reason` / `timer_reason_text`
|
|
- 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 and local dual-server 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
|