turniere-backend/doc/timer_api.md

326 lines
6.2 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"`
## 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