turniere-backend/doc/timer_api.md

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 = 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/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 = 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:

{
  "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:

  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:

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 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:

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