From 8d882ad763fe4bc06822218914eecb2b3b97f71d Mon Sep 17 00:00:00 2001 From: Malaber Date: Fri, 24 Apr 2026 07:50:32 +0200 Subject: [PATCH] docs: add timer frontend integration guide --- README.md | 1 + doc/timer_api.md | 325 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 doc/timer_api.md diff --git a/README.md b/README.md index 90bf18f..670b9c6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ bundle exec rspec Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) +- [doc/timer_api.md](doc/timer_api.md) ## Docker [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) diff --git a/doc/timer_api.md b/doc/timer_api.md new file mode 100644 index 0000000..8e63d04 --- /dev/null +++ b/doc/timer_api.md @@ -0,0 +1,325 @@ +# 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 small state machine: + +- no timer: `timer_end = 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: + +```json +{ + "timer_end": "2026-04-24T10:15:00Z", + "timer_mode": "countdown" +} +``` + +Possible values: + +- `timer_mode = "countdown"` +- `timer_mode = "countup"` +- `timer_mode = null` + +## Meaning Of `timer_end` + +Name old. Semantics now depend on mode. + +### Countdown mode + +`timer_end` means exact wall-clock timestamp when countdown reaches zero. + +Frontend math: + +```text +remaining_ms = max(0, timer_end - now) +``` + +### Countup mode + +`timer_end` 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 - timer_end) +``` + +Practical example: + +- backend returns: + - `timer_mode = "countup"` + - `timer_end = "2026-04-24T10:00:00Z"` +- current time: + - `2026-04-24T10:03:15Z"` + +Frontend should display about `03:15` elapsed. + +### Unset timer + +- `timer_mode = null` +- `timer_end = 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` + +### Start countdown by duration + +Request: + +```json +{ + "timer_end_seconds": 120 +} +``` + +Behavior: + +- backend computes `Time.zone.now + 120` +- backend stores: + - `timer_mode = "countdown"` + - `timer_end = computed future timestamp` + +### Start countdown by explicit timestamp + +Request: + +```json +{ + "timer_end": "2026-04-24T10:15:00Z" +} +``` + +Optional explicit mode: + +```json +{ + "timer_end": "2026-04-24T10:15:00Z", + "timer_mode": "countdown" +} +``` + +### Start countup + +Request: + +```json +{ + "timer_mode": "countup" +} +``` + +Behavior: + +- backend stores current server time into `timer_end` +- backend stores `timer_mode = "countup"` + +Frontend should interpret returned `timer_end` as start time anchor. + +### Restore countup from explicit anchor + +Backend also accepts explicit timestamp with countup mode: + +```json +{ + "timer_end": "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: + - `timer_end = null` + - `timer_mode = null` + +## Validation Rules + +Backend rejects: + +- both `timer_end` and `timer_end_seconds` +- invalid `timer_mode` +- `timer_end_seconds <= 0` +- `timer_end_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, + "timer_end": "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 `timer_end` +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 server timestamp + +Recommended client state: + +```ts +type TimerState = + | { mode: null; anchorIso: null } + | { mode: "countdown"; anchorIso: string } + | { mode: "countup"; anchorIso: string }; +``` + +Suggested derivation logic: + +```ts +function getTimerDisplay(state: TimerState, nowMs: number) { + if (state.mode === null || state.anchorIso === null) { + return { active: false, ms: 0 }; + } + + const anchorMs = new Date(state.anchorIso).getTime(); + + if (state.mode === "countdown") { + return { active: true, ms: Math.max(0, anchorMs - nowMs) }; + } + + return { active: true, ms: Math.max(0, nowMs - anchorMs) }; +} +``` + +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 250ms or 1s local interval +- do not poll backend every second + +Recommended write UX: + +- owner actions send explicit target request +- after successful response, 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 `timer_end` / `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 environment/network issue in existing Docker follow setup, so production-style follow stack verification is not green yet