# Disc Agenda -- Technical Documentation Detailed developer reference for the Disc Agenda frisbee tournament platform. For quick start and project overview, see the [README](../README.md). For project motivation and feature descriptions, see the [Project Description](PROJECT.md). --- ## Table of Contents 1. [Architecture Overview](#1-architecture-overview) 2. [Data Model Reference](#2-data-model-reference) 3. [API Reference](#3-api-reference) 4. [WebSocket Protocol](#4-websocket-protocol) 5. [Storage Format](#5-storage-format) 6. [How-To Guides](#6-how-to-guides) 7. [Development Setup](#7-development-setup) 8. [Docker Deployment](#8-docker-deployment) 9. [CI/CD Pipeline](#9-cicd-pipeline) 10. [Frontend Architecture](#10-frontend-architecture) 11. [Troubleshooting](#11-troubleshooting) --- ## 1. Architecture Overview Disc Agenda is a single-binary Go server that serves both a REST API and a compiled React SPA. There is no separate frontend server in production. ``` Browser │ ├── GET /tournament/... ──────► SPA Handler ──► index.html (React Router takes over) ├── GET/POST /api/... ──────► REST Handlers ──► Storage (JSON files) └── WS /ws/... ──────► WebSocket Hubs ──► Broadcast to all clients │ ▼ File System (data/) ├── tournaments.json └── tournaments/{id}/ ├── schedule.json ├── games/{gid}_score.json ├── games/{gid}_audit.jsonl ├── questionnaire_config.json └── questionnaire_responses/ ``` **Key design decisions:** - **No database** -- flat JSON files are sufficient for small tournament scale (10-20 teams). Human-readable and easily backed up. - **No authentication** -- trust-based model for community use. Anyone with the URL can update scores. - **SPA fallback** -- any unmatched request serves `index.html`, letting React Router handle client-side routing. - **WebSocket hubs** -- per-game hubs manage scoring clients; a separate tournament hub broadcasts all game updates to schedule viewers. ### Source files | File | Purpose | |------|---------| | `backend/cmd/server/main.go` | Entry point, router setup, SPA handler | | `backend/internal/handlers/handlers.go` | REST + WebSocket HTTP handlers | | `backend/internal/models/models.go` | Domain types | | `backend/internal/storage/storage.go` | File-based persistence layer | | `backend/internal/websocket/hub.go` | WebSocket hub management and scoring logic | --- ## 2. Data Model Reference All types are defined in `backend/internal/models/models.go`. ### 2.1 Tournament | Field | JSON | Type | Description | |-------|------|------|-------------| | ID | `id` | string | URL-safe unique identifier (e.g., `fujarna-14-3-2026`) | | Name | `name` | string | Display name | | Status | `status` | string | `upcoming`, `in_progress`, or `completed` | | Location | `location` | string | City/country | | Venue | `venue` | string | Specific venue name | | StartDate | `start_date` | string | `YYYY-MM-DD` format | | EndDate | `end_date` | string | `YYYY-MM-DD` format | | Description | `description` | string | Short description | | Teams | `teams` | []Team | Array of participating teams | | ImageURL | `image_url` | string | Optional banner image URL | | Rules | `rules` | string | Optional rules text | Example: ```json { "id": "fujarna-14-3-2026", "name": "Fujarna - 14.3.2026", "status": "upcoming", "location": "Praha, Czech Republic", "venue": "Víceúčelové sportoviště \"Kotlářka\"", "start_date": "2026-03-14", "end_date": "2026-03-14", "description": "Fujarna turnaj v ultimate frisbee. 10 týmů ve 2 skupinách, crossover pavouk.", "teams": [ {"id": "fuj-1", "name": "FUJ 1"}, {"id": "kocicaci", "name": "Kočičáci"} ], "rules": "Hra 20 min. Bez pauzy. Bez TO. Remíza není možná." } ``` ### 2.2 Team | Field | JSON | Type | Description | |-------|------|------|-------------| | ID | `id` | string | URL-safe identifier | | Name | `name` | string | Display name | | Logo | `logo` | string | Optional logo URL | ### 2.3 Game | Field | JSON | Type | Description | |-------|------|------|-------------| | ID | `id` | string | Game identifier (e.g., `g01`, `p5`, `final`) | | TourneyID | `tourney_id` | string | Parent tournament ID | | HomeTeam | `home_team` | string | Home team display name (not ID) | | AwayTeam | `away_team` | string | Away team display name (not ID) | | HomeScore | `home_score` | int | Current home score | | AwayScore | `away_score` | int | Current away score | | StartTime | `start_time` | string | ISO 8601 datetime | | Field | `field` | string | Field name/number | | Round | `round` | string | Round name (e.g., `Pool A - Round 1`, `Grand Final`) | | Status | `status` | string | `scheduled`, `live`, or `final` | Note: `home_team` and `away_team` store display names rather than team IDs. For bracket/placement games, placeholders like `1A`, `3B` are used until teams are determined. ### 2.4 Schedule | Field | JSON | Type | Description | |-------|------|------|-------------| | TourneyID | `tourney_id` | string | Tournament ID | | Games | `games` | []Game | Array of all games | ### 2.5 ScoreState Represents the current state of a game's score. Returned by score endpoints and WebSocket messages. | Field | JSON | Type | Description | |-------|------|------|-------------| | GameID | `game_id` | string | Game identifier | | HomeScore | `home_score` | int | Current home score | | AwayScore | `away_score` | int | Current away score | | HomeTeam | `home_team` | string | Home team name | | AwayTeam | `away_team` | string | Away team name | | Status | `status` | string | `scheduled`, `live`, or `final` | ### 2.6 ScoreUpdate Sent by clients to modify a game's score. | Field | JSON | Type | Description | |-------|------|------|-------------| | Action | `action` | string | `increment`, `decrement`, `set`, or `set_status` | | Team | `team` | string | `home` or `away` (for score actions); `scheduled`, `live`, or `final` (for `set_status`) | | Value | `value` | int | Target value (used only with `set` action) | | Timestamp | `timestamp` | time | Set server-side | | UserID | `user_id` | string | Set server-side from WebSocket query param | ### 2.7 AuditEntry One entry per score change, appended to the audit log. | Field | JSON | Type | Description | |-------|------|------|-------------| | Timestamp | `timestamp` | time | When the change occurred | | Action | `action` | string | The action performed | | Team | `team` | string | Which team/status was affected | | Value | `value` | int | Value for `set` actions | | OldHome | `old_home` | int | Home score before change | | OldAway | `old_away` | int | Away score before change | | NewHome | `new_home` | int | Home score after change | | NewAway | `new_away` | int | Away score after change | | UserID | `user_id` | string | Who made the change | ### 2.8 QuestionnaireConfig | Field | JSON | Type | Description | |-------|------|------|-------------| | TourneyID | `tourney_id` | string | Tournament ID | | CustomQuestions | `custom_questions` | []Question | Custom survey questions | ### 2.9 Question | Field | JSON | Type | Description | |-------|------|------|-------------| | ID | `id` | string | Unique question identifier | | Text | `text` | string | Question text displayed to user | | Type | `type` | string | `text`, `select`, or `radio` | | Options | `options` | []string | Choices (for `select` and `radio` types) | | Required | `required` | bool | Whether answer is mandatory | ### 2.10 QuestionnaireResponse | Field | JSON | Type | Description | |-------|------|------|-------------| | ID | `id` | string | Auto-generated: `resp_{unix_nano}` | | TourneyID | `tourney_id` | string | Set server-side from URL | | MyTeam | `my_team` | string | Respondent's team name | | SpiritWinner | `spirit_winner` | string | Team voted for best spirit | | AttendNext | `attend_next` | bool | Will attend next tournament | | CustomAnswers | `custom_answers` | map[string]string | Key: question ID, value: answer | | SubmittedAt | `submitted_at` | time | Set server-side | ### 2.11 FinalResults / Standing **FinalResults:** | Field | JSON | Type | Description | |-------|------|------|-------------| | TourneyID | `tourney_id` | string | Tournament ID | | Standings | `standings` | []Standing | Ordered standings | **Standing:** | Field | JSON | Type | Description | |-------|------|------|-------------| | Position | `position` | int | Final placement (1 = winner) | | TeamID | `team_id` | string | Team identifier | | TeamName | `team_name` | string | Team display name | | Wins | `wins` | int | Number of wins | | Losses | `losses` | int | Number of losses | | Draws | `draws` | int | Number of draws | | PointsFor | `points_for` | int | Total points scored | | PointsAgainst | `points_against` | int | Total points conceded | | SpiritScore | `spirit_score` | float64 | Optional spirit rating | --- ## 3. API Reference All endpoints return JSON. Error responses use the format `{"error": "message"}`. ### 3.1 GET /api/tournaments Returns all tournaments. **Response** `200`: ```json [ { "id": "fujarna-14-3-2026", "name": "Fujarna - 14.3.2026", "status": "upcoming", "location": "Praha, Czech Republic", "venue": "Víceúčelové sportoviště \"Kotlářka\"", "start_date": "2026-03-14", "end_date": "2026-03-14", "description": "Fujarna turnaj v ultimate frisbee...", "teams": [{"id": "fuj-1", "name": "FUJ 1"}, ...], "rules": "Hra 20 min. Bez pauzy. Bez TO. Remíza není možná." } ] ``` ### 3.2 GET /api/tournaments/{id} Returns a single tournament. **Response** `200`: Tournament object **Response** `404`: `{"error": "tournament fujarna-xyz not found"}` ### 3.3 GET /api/tournaments/{id}/schedule Returns the game schedule with current scores merged in. The handler reads each game's persisted score state and overwrites the schedule's default `home_score`, `away_score`, and `status` fields. **Response** `200`: ```json { "tourney_id": "fujarna-14-3-2026", "games": [ { "id": "g01", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Kočičáci", "home_score": 8, "away_score": 6, "start_time": "2026-03-14T08:30:00", "field": "Field 1", "round": "Pool A - Round 1", "status": "live" } ] } ``` ### 3.4 GET /api/tournaments/{id}/games/{gid}/score Returns current score state for a specific game. **Response** `200`: ```json { "game_id": "g01", "home_score": 8, "away_score": 6, "home_team": "FUJ 1", "away_team": "Kočičáci", "status": "live" } ``` If no score file exists yet, returns zeroed state with empty status. ### 3.5 POST /api/tournaments/{id}/games/{gid}/score Updates a game's score via REST (alternative to WebSocket). The update is also broadcast to all WebSocket clients on the game and tournament hubs. **Request body examples:** ```json {"action": "increment", "team": "home"} ``` ```json {"action": "decrement", "team": "away"} ``` ```json {"action": "set", "team": "home", "value": 12} ``` ```json {"action": "set_status", "team": "final"} ``` **Response** `200`: Updated ScoreState object **Response** `400`: `{"error": "invalid request body"}` **Behavior notes:** - `increment`, `decrement`, `set` are no-ops when game status is `final` - `decrement` will not go below 0 - Auto-transitions from `scheduled` to `live` when score becomes non-zero ### 3.6 GET /api/tournaments/{id}/games/{gid}/audit Returns the complete audit trail for a game. **Response** `200`: ```json [ { "timestamp": "2026-03-14T09:15:32.123Z", "action": "increment", "team": "home", "value": 0, "old_home": 7, "old_away": 6, "new_home": 8, "new_away": 6, "user_id": "user_abc123" } ] ``` Returns `[]` (empty array) if no audit entries exist. ### 3.7 GET /api/tournaments/{id}/questionnaire Returns the questionnaire config and team list for the tournament. **Response** `200`: ```json { "config": { "tourney_id": "fujarna-14-3-2026", "custom_questions": [ { "id": "q_food", "text": "How would you rate the food at the tournament?", "type": "select", "options": ["Excellent", "Good", "Average", "Poor"], "required": false }, { "id": "q_feedback", "text": "Any other feedback or suggestions?", "type": "text", "required": false } ] }, "teams": [ {"id": "fuj-1", "name": "FUJ 1"}, {"id": "kocicaci", "name": "Kočičáci"} ] } ``` ### 3.8 POST /api/tournaments/{id}/questionnaire Submits a questionnaire response. The server auto-generates `id` (as `resp_{unix_nano}`) and `submitted_at`. **Request body:** ```json { "my_team": "FUJ 1", "spirit_winner": "Kočičáci", "attend_next": true, "custom_answers": { "q_food": "Good", "q_fields": "Acceptable", "q_feedback": "Great tournament!" } } ``` **Response** `201`: ```json {"status": "ok", "id": "resp_1710423600000000000"} ``` ### 3.9 GET /api/tournaments/{id}/questionnaire/results Returns all submitted questionnaire responses. Intended for organizer/admin use. **Response** `200`: Array of QuestionnaireResponse objects. Returns `[]` if none exist. ### 3.10 GET /api/tournaments/{id}/results Returns final standings. **Response** `200`: ```json { "tourney_id": "fujarna-14-3-2026", "standings": [ { "position": 1, "team_id": "fuj-1", "team_name": "FUJ 1", "wins": 6, "losses": 1, "draws": 0, "points_for": 85, "points_against": 52, "spirit_score": 4.2 } ] } ``` --- ## 4. WebSocket Protocol ### 4.1 Game WebSocket **Endpoint:** `ws://host/ws/game/{tourneyId}/{gameId}?user_id=alice` The `user_id` query parameter identifies the scorer. If omitted, defaults to `"anonymous"`. **Connection lifecycle:** 1. Client connects 2. Server immediately sends current score state: ```json {"type": "score_state", "state": {"game_id": "g01", "home_score": 8, ...}} ``` 3. Client sends score updates: ```json {"action": "increment", "team": "home"} {"action": "decrement", "team": "away"} {"action": "set", "team": "home", "value": 12} {"action": "set_status", "team": "final"} ``` 4. Server broadcasts to ALL connected clients (including sender): ```json { "type": "score_update", "state": {"game_id": "g01", "home_score": 9, "away_score": 6, ...}, "audit": {"action": "increment", "team": "home", "old_home": 8, "new_home": 9, ...} } ``` **Score update rules:** - `increment` / `decrement` / `set` are ignored when `status == "final"` - `decrement` will not go below 0 - Auto-transition: `scheduled` -> `live` when any score becomes non-zero - `set_status` accepts `scheduled`, `live`, or `final` as the `team` field value **Keepalive:** - Server sends ping every 30 seconds - Read deadline: 60 seconds (client must respond with pong) - Write deadline: 10 seconds per message - Read limit: 4096 bytes ### 4.2 Tournament WebSocket **Endpoint:** `ws://host/ws/tournament/{tourneyId}` Read-only connection for schedule viewers. Receives all `score_update` broadcasts from every game hub in the tournament. - Client sends nothing (the read pump only processes pong frames) - Receives the same `score_update` messages as game WebSocket clients - Read limit: 512 bytes - Used by the SchedulePage for live score updates across all games ### 4.3 Hub Architecture ``` HubManager ├── hubs (map: "{tourneyId}/{gameId}" -> Hub) │ ├── Hub "fujarna.../g01" │ │ ├── clients[] (connected scorers) │ │ ├── register/unregister channels │ │ └── broadcast channel │ └── Hub "fujarna.../g02" ... └── tournamentHubs (map: "{tourneyId}" -> TournamentHub) └── TournamentHub "fujarna..." ├── clients[] (schedule viewers) └── broadcast channel ``` - **Lazy creation:** hubs are created on first connection via `GetOrCreateHub` / `GetOrCreateTournamentHub` - **Game -> Tournament forwarding:** after every score update, the game hub calls `BroadcastToTournament()` to push the same message to all tournament hub clients - **On client registration:** the game hub immediately sends the current `score_state` to the new client - **Thread safety:** `sync.RWMutex` protects hub maps and client maps --- ## 5. Storage Format ### 5.1 Directory Layout ``` data/ ├── tournaments.json # Array of all tournaments └── tournaments/ └── {tournament-id}/ ├── schedule.json # Game schedule ├── questionnaire_config.json # Survey configuration ├── results.json # Final standings ├── games/ │ ├── {game-id}_score.json # Current score state │ └── {game-id}_audit.jsonl # Append-only audit log └── questionnaire_responses/ └── resp_{unix_nano}.json # One file per response ``` ### 5.2 File Formats **Score state** (`{gid}_score.json`): Pretty-printed JSON of ScoreState: ```json { "game_id": "g01", "home_score": 8, "away_score": 6, "home_team": "FUJ 1", "away_team": "Kočičáci", "status": "live" } ``` **Audit log** (`{gid}_audit.jsonl`): One JSON object per line, appended atomically: ``` {"timestamp":"2026-03-14T09:00:15Z","action":"increment","team":"home","value":0,"old_home":0,"old_away":0,"new_home":1,"new_away":0,"user_id":"user_abc"} {"timestamp":"2026-03-14T09:01:22Z","action":"increment","team":"away","value":0,"old_home":1,"old_away":0,"new_home":1,"new_away":1,"user_id":"user_def"} ``` ### 5.3 Concurrency Model - A single `sync.RWMutex` on the `Store` protects all file reads and writes - Audit log uses `os.O_APPEND` for atomic line appends - Directories are auto-created on write via `os.MkdirAll` - All JSON files use `json.MarshalIndent` with 2-space indentation --- ## 6. How-To Guides ### 6.1 Adding a New Tournament 1. **Edit `data/tournaments.json`** -- add a new tournament object to the array: ```json { "id": "my-tournament-2026", "name": "My Tournament 2026", "status": "upcoming", "location": "City, Country", "venue": "Venue Name", "start_date": "2026-06-15", "end_date": "2026-06-15", "description": "Description of the tournament.", "teams": [ {"id": "team-a", "name": "Team A"}, {"id": "team-b", "name": "Team B"}, {"id": "team-c", "name": "Team C"}, {"id": "team-d", "name": "Team D"} ], "rules": "Game rules here." } ``` 2. **Create the tournament directory:** ```bash mkdir -p data/tournaments/my-tournament-2026/games ``` 3. **Create `data/tournaments/my-tournament-2026/schedule.json`:** ```json { "tourney_id": "my-tournament-2026", "games": [ { "id": "g01", "tourney_id": "my-tournament-2026", "home_team": "Team A", "away_team": "Team B", "home_score": 0, "away_score": 0, "start_time": "2026-06-15T09:00:00", "field": "Field 1", "round": "Pool - Round 1", "status": "scheduled" } ] } ``` 4. **Restart the server** (the server reads files on each request, but a restart ensures clean state for WebSocket hubs). ### 6.2 Configuring the Questionnaire Create `data/tournaments/{id}/questionnaire_config.json`: ```json { "tourney_id": "my-tournament-2026", "custom_questions": [ { "id": "q_food", "text": "How would you rate the food?", "type": "select", "options": ["Excellent", "Good", "Average", "Poor"], "required": false }, { "id": "q_feedback", "text": "Any suggestions?", "type": "text", "required": false } ] } ``` **Built-in fields** (always present in the UI, not configured here): - **My Team** -- select from tournament team list - **Spirit Winner** -- select from tournament team list - **Attend Next Tournament** -- checkbox **Custom question types:** - `select` -- dropdown with predefined options - `radio` -- radio button group - `text` -- free-text input ### 6.3 Adding Results After Tournament Create `data/tournaments/{id}/results.json`: ```json { "tourney_id": "my-tournament-2026", "standings": [ { "position": 1, "team_id": "team-a", "team_name": "Team A", "wins": 5, "losses": 0, "draws": 0, "points_for": 75, "points_against": 30, "spirit_score": 4.5 }, { "position": 2, "team_id": "team-b", "team_name": "Team B", "wins": 3, "losses": 2, "draws": 0, "points_for": 55, "points_against": 42 } ] } ``` Note: `spirit_score` is optional. The results page highlights the team with the highest spirit score. --- ## 7. Development Setup ### 7.1 Prerequisites - **Go 1.26+** -- backend - **Node 22+** -- frontend build and dev server - **npm** -- comes with Node ### 7.2 Backend ```bash cd backend go mod tidy go run ./cmd/server -data ../data -static ../frontend/dist -port 8080 ``` Server flags: | Flag | Env Var | Default | Description | |------|---------|---------|-------------| | `-port` | `PORT` | `8080` | HTTP listen port | | `-data` | `DATA_DIR` | `./data` | Data directory path | | `-static` | -- | `./static` | Frontend static files directory | The SPA handler serves `index.html` for any request that doesn't match a static file, `/api/`, or `/ws/` path. This enables React Router's client-side routing. CORS is configured to allow all origins (`*`), all common methods, and all headers. ### 7.3 Frontend ```bash cd frontend npm install npm run dev ``` Vite dev server runs on `http://localhost:5173` and proxies: - `/api/*` -> `http://localhost:8080` - `/ws/*` -> `ws://localhost:8080` This allows hot-reload development while the Go backend handles API and WebSocket connections. ### 7.4 Makefile Targets | Target | Description | |--------|-------------| | `make help` | Show all available targets | | `make dev` | Print local development instructions | | `make tidy` | Run `go mod tidy` | | `make dev-backend` | Run Go backend dev server | | `make dev-frontend` | Run Vite frontend dev server | | `make build-frontend` | Production frontend build (`npm ci && npx vite build`) | | `make docker` | Build Docker images via docker compose | | `make image` | Build standalone Docker image | | `make run` | Start Docker containers (detached) | | `make stop` | Stop Docker containers | | `make logs` | Follow Docker container logs | | `make clean` | Remove `frontend/dist`, `node_modules`, and Go build cache | --- ## 8. Docker Deployment ### 8.1 Multi-Stage Build The Dockerfile uses three stages for a minimal production image: All base images are pulled from `mirror.gcr.io` (Google's public Docker Hub mirror) to avoid Docker Hub rate limits. **Stage 1 -- Frontend build** (Node 22 Alpine): - Installs npm dependencies (`npm ci`) - Builds React app with Vite (`npx vite build`) - Output: `frontend/dist/` **Stage 2 -- Backend build** (Go 1.26 Alpine): - Downloads Go modules - Builds static binary with `CGO_ENABLED=0` and stripped debug symbols (`-ldflags="-s -w"`) - Output: `/server` binary **Stage 3 -- Runtime** (Alpine 3.21): - Copies server binary, built frontend, and seed data - Installs only `ca-certificates` and `tzdata` - Exposes port 8080 ### 8.2 Entrypoint & Data Seeding The entrypoint script seeds initial data on first run: ```sh if [ ! -f /app/data/tournaments.json ]; then echo "Seeding initial data..." cp -r /app/data-seed/* /app/data/ fi exec /app/server -static /app/static -data /app/data ``` Seed data is baked into the image at `/app/data-seed/`. On first run (when the volume is empty), it's copied to `/app/data/`. On subsequent runs, existing data in the volume is preserved. ### 8.3 Docker Compose ```yaml services: frisbee: build: . ports: - "8080:8080" volumes: - tournament-data:/app/data environment: - PORT=8080 - TZ=Europe/Prague restart: unless-stopped volumes: tournament-data: ``` - **Volume `tournament-data`** persists all game scores, audit logs, and questionnaire responses across container restarts - **Timezone** is set to `Europe/Prague` for correct timestamps in audit logs - **Restart policy** ensures the server comes back after crashes or host reboots ### 8.4 Production Considerations - **No TLS termination** -- use a reverse proxy (nginx, Caddy, Traefik) for HTTPS - **No authentication** -- anyone with the URL can update scores; consider network-level access control - **WebSocket origin check** is disabled (`CheckOrigin` returns `true` for all requests) - **Data backup** -- the Docker volume can be backed up by copying its contents or using `docker cp` --- ## 9. CI/CD Pipeline Defined in `.gitea/workflows/build.yaml`. **Triggers:** - Manual dispatch (`workflow_dispatch`) with optional `tag` input - Tag push (`push: tags`) **Steps:** 1. Checkout repository 2. Login to Gitea container registry at `gitea.home.hrajfrisbee.cz` 3. Build Docker image using the multi-stage Dockerfile 4. Push to registry as `gitea.home.hrajfrisbee.cz/{owner}/{repo}:{tag}` **Tag resolution:** uses the manual input tag, or falls back to `github.ref_name` (the pushed tag or branch name). **Secrets required:** - `REGISTRY_TOKEN` -- authentication token for the Gitea container registry --- ## 10. Frontend Architecture ### 10.1 Routes | Path | Component | Description | |------|-----------|-------------| | `/` | -- | Redirects to `/tournament/fujarna-14-3-2026` | | `/tournament/:id` | TournamentPage | Tournament detail with info cards | | `/tournament/:id/schedule` | SchedulePage | Live game schedule | | `/tournament/:id/game/:gid` | GamePage | Live scoreboard | | `/tournament/:id/questionnaire` | QuestionnairePage | Post-tournament survey | | `/tournament/:id/results` | ResultsPage | Final standings | | `/past` | PastPage | Archive of completed tournaments | ### 10.2 API Client (`frontend/src/api.js`) - `fetchJSON(path)` / `postJSON(path, body)` -- wrappers around `fetch` with error handling - All API functions are thin wrappers: `getTournaments()`, `getTournament(id)`, `getSchedule(id)`, etc. - `createGameWebSocket(tourneyId, gameId, userId)` -- creates WebSocket with auto protocol detection (`ws:` or `wss:` based on page protocol) - `createTournamentWebSocket(tourneyId)` -- read-only WebSocket for schedule updates ### 10.3 Key Components **GamePage** -- live scoreboard: - Connects via game WebSocket with a session-scoped `user_id` (random, stored in `sessionStorage`) - +/- buttons send `increment`/`decrement` actions - SET button opens a modal for direct value entry - Displays audit log (expandable) - Generates QR code linking to the questionnaire page - Auto-reconnects on WebSocket disconnect (3-second delay) **SchedulePage** -- tournament schedule: - Connects via tournament WebSocket for live updates across all games - Groups games by round (Pool A, Pool B, placement bracket) - Shows status badges and live scores - Auto-reconnects on disconnect **QuestionnairePage** -- post-tournament survey: - Loads questionnaire config and team list from API - Renders built-in fields (my team, spirit winner, attend next) and custom questions - Czech-language UI labels - Shows success message after submission **ResultsPage** -- final standings: - Renders standings table with W/L/D, points for/against, point differential - Medal emojis for top 3 positions - Highlights team with highest `spirit_score` --- ## 11. Troubleshooting **WebSocket not connecting:** - Ensure the backend is running and accessible - Check that your reverse proxy forwards WebSocket connections (upgrade headers) - Verify the `/ws/` path prefix is not blocked **Scores not persisting:** - Check `data/` directory permissions (needs read/write) - Verify the Docker volume is mounted correctly - Check server logs for write errors **Frontend shows 404 on page refresh:** - The SPA handler should serve `index.html` for unknown paths - Ensure `-static` flag points to the directory containing `index.html` **Schedule shows stale scores:** - The schedule endpoint merges from individual score files on every request - Check that WebSocket connection is active (green indicator in browser dev tools) - Try refreshing the page to re-establish the tournament WebSocket **Audit log growing large:** - Audit files are JSONL, one line per score change - Safe to truncate or archive old `.jsonl` files when not needed **Docker volume data reset:** - Data only seeds when `tournaments.json` is missing from the volume - To force a reseed: `docker compose down -v` (removes volumes), then `docker compose up` **CORS errors in development:** - The backend allows all origins (`*`) - In dev mode, use the Vite dev server (port 5173) which proxies API calls to the backend