Files
fujarna/docs/DOCUMENTATION.md
Jan Novak a7244406fd
Some checks failed
Build and Push / build (push) Failing after 8s
Initial release: Disc Agenda frisbee tournament platform
Full-stack tournament management app with real-time scoring:
- Go 1.26 backend with REST API and WebSocket live scoring
- React 19 + Vite 8 frontend with mobile-first design
- File-based JSON storage with JSONL audit logs
- Multi-stage Docker build with Gitea CI/CD pipeline
- Post-tournament questionnaire with spirit voting
- Technical documentation and project description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:48:15 +01:00

944 lines
29 KiB
Markdown

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