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>
29 KiB
Disc Agenda -- Technical Documentation
Detailed developer reference for the Disc Agenda frisbee tournament platform. For quick start and project overview, see the README. For project motivation and feature descriptions, see the Project Description.
Table of Contents
- Architecture Overview
- Data Model Reference
- API Reference
- WebSocket Protocol
- Storage Format
- How-To Guides
- Development Setup
- Docker Deployment
- CI/CD Pipeline
- Frontend Architecture
- 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:
{
"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:
[
{
"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:
{
"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:
{
"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:
{"action": "increment", "team": "home"}
{"action": "decrement", "team": "away"}
{"action": "set", "team": "home", "value": 12}
{"action": "set_status", "team": "final"}
Response 200: Updated ScoreState object
Response 400: {"error": "invalid request body"}
Behavior notes:
increment,decrement,setare no-ops when game status isfinaldecrementwill not go below 0- Auto-transitions from
scheduledtolivewhen score becomes non-zero
3.6 GET /api/tournaments/{id}/games/{gid}/audit
Returns the complete audit trail for a game.
Response 200:
[
{
"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:
{
"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:
{
"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:
{"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:
{
"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:
- Client connects
- Server immediately sends current score state:
{"type": "score_state", "state": {"game_id": "g01", "home_score": 8, ...}} - Client sends score updates:
{"action": "increment", "team": "home"} {"action": "decrement", "team": "away"} {"action": "set", "team": "home", "value": 12} {"action": "set_status", "team": "final"} - Server broadcasts to ALL connected clients (including sender):
{ "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/setare ignored whenstatus == "final"decrementwill not go below 0- Auto-transition:
scheduled->livewhen any score becomes non-zero set_statusacceptsscheduled,live, orfinalas theteamfield 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_updatemessages 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_stateto the new client - Thread safety:
sync.RWMutexprotects 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:
{
"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.RWMutexon theStoreprotects all file reads and writes - Audit log uses
os.O_APPENDfor atomic line appends - Directories are auto-created on write via
os.MkdirAll - All JSON files use
json.MarshalIndentwith 2-space indentation
6. How-To Guides
6.1 Adding a New Tournament
- Edit
data/tournaments.json-- add a new tournament object to the array:
{
"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."
}
- Create the tournament directory:
mkdir -p data/tournaments/my-tournament-2026/games
- Create
data/tournaments/my-tournament-2026/schedule.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"
}
]
}
- 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:
{
"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 optionsradio-- radio button grouptext-- free-text input
6.3 Adding Results After Tournament
Create data/tournaments/{id}/results.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
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
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=0and stripped debug symbols (-ldflags="-s -w") - Output:
/serverbinary
Stage 3 -- Runtime (Alpine 3.21):
- Copies server binary, built frontend, and seed data
- Installs only
ca-certificatesandtzdata - Exposes port 8080
8.2 Entrypoint & Data Seeding
The entrypoint script seeds initial data on first run:
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
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-datapersists all game scores, audit logs, and questionnaire responses across container restarts - Timezone is set to
Europe/Praguefor 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 (
CheckOriginreturnstruefor 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 optionaltaginput - Tag push (
push: tags)
Steps:
- Checkout repository
- Login to Gitea container registry at
gitea.home.hrajfrisbee.cz - Build Docker image using the multi-stage Dockerfile
- 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 aroundfetchwith 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:orwss: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 insessionStorage) - +/- buttons send
increment/decrementactions - 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.htmlfor unknown paths - Ensure
-staticflag points to the directory containingindex.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
.jsonlfiles when not needed
Docker volume data reset:
- Data only seeds when
tournaments.jsonis missing from the volume - To force a reseed:
docker compose down -v(removes volumes), thendocker 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