Initial release: Disc Agenda frisbee tournament platform
Some checks failed
Build and Push / build (push) Failing after 8s

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>
This commit is contained in:
2026-03-15 14:48:15 +01:00
commit a7244406fd
38 changed files with 5749 additions and 0 deletions

943
docs/DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,943 @@
# 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

220
docs/PROJECT.md Normal file
View File

@@ -0,0 +1,220 @@
# Disc Agenda -- Project Description
## 1. Overview
Disc Agenda is a self-hosted web application for managing ultimate frisbee tournaments. It provides a single URL where players and spectators can view the tournament schedule, follow live scores in real-time, fill out post-tournament questionnaires, and browse final standings -- all from their phones, with no app install required.
Built for the Czech ultimate frisbee community, the platform is currently deployed for the **Fujarna** tournament series in Prague.
---
## 2. Problem Statement
Small and casual frisbee tournaments typically lack proper digital infrastructure. Organizers rely on a patchwork of tools:
- **Google Sheets** for schedules (not real-time, clunky on mobile)
- **WhatsApp/Telegram groups** for score announcements (noisy, easy to miss)
- **Paper scorecards** at the field (single point of truth, hard to read from a distance)
- **Google Forms** for post-tournament feedback (disconnected from the event)
This creates friction for everyone: players don't know when their next game starts or what the current score is in a parallel game, spectators can't follow along remotely, and organizers spend time relaying information manually.
**Disc Agenda solves this** by providing a single, mobile-friendly web app where:
- The schedule is always visible and up-to-date
- Any participant can update scores in real-time
- Scores appear live on everyone's screen via WebSocket
- Post-tournament feedback is collected in context
---
## 3. Key Features
### 3.1 Tournament Hub
The landing page for each tournament displays all essential information at a glance: event name, date, location, venue, participating teams, and rules. Clear navigation links guide users to the schedule, questionnaire, and results.
### 3.2 Live Scoreboard
The core feature. Each game has its own scoring page with large, touch-friendly controls:
- **+/- buttons** for quick score increments
- **SET button** for direct value entry (correcting mistakes)
- **Real-time sync** -- multiple people can score simultaneously, and all changes appear instantly on every connected device via WebSocket
- **Audit trail** -- every score change is logged with timestamp and user identity, viewable on the game page
- **QR code** -- each game page includes a QR code linking to the post-tournament questionnaire, making it easy to share
Game states transition automatically: `Scheduled` -> `Live` (when the first point is scored) -> `Final` (set manually). Once a game is marked as final, the score is locked.
### 3.3 Live Schedule
The schedule page groups all games by round (Pool A, Pool B, placement bracket) with start times and field assignments. Scores update in real-time across all games -- viewers don't need to open individual game pages to see current scores.
### 3.4 Post-Tournament Questionnaire
A mobile-friendly survey form with:
- **Spirit of the Game voting** -- select which team showed the best sportsmanship
- **Team selector** -- identify your own team
- **Custom questions** -- organizer-configurable (food rating, field conditions, free-text feedback)
- **Attend next tournament** -- gauge interest for future events
Responses are stored per tournament and can be reviewed by organizers.
### 3.5 Results & Standings
A final standings table showing each team's position, win/loss/draw record, points scored and conceded, point differential, and spirit score. The team with the highest spirit score receives a highlighted award.
### 3.6 Past Tournaments Archive
An archive page listing completed tournaments, allowing the community to look back at previous events and their results.
---
## 4. How It Works
### For the Organizer
1. **Prepare data** -- create JSON files defining the tournament (teams, schedule, questionnaire)
2. **Deploy** -- `docker compose up` on any server (a Raspberry Pi works fine)
3. **Share the URL** -- send it to teams before or on tournament day
### On Tournament Day
1. Players open the URL on their phones
2. Designated scorers (or anyone) navigate to a game page and tap +/- to update scores
3. All connected viewers see score changes instantly
4. The schedule page reflects live scores across all games in real-time
5. Between games, players can check upcoming matches and current standings
### After the Tournament
1. Organizer creates `results.json` with final standings
2. Players fill out the questionnaire (linked via QR codes on game pages)
3. Organizer reviews feedback via the questionnaire results API
4. Tournament status is set to `completed` and appears in the archive
---
## 5. Technology Stack & Rationale
### Go Backend
**Why Go:** A single statically-compiled binary with excellent concurrency primitives. Perfect for WebSocket-heavy applications where multiple clients need real-time updates. Minimal runtime dependencies -- the compiled binary runs on bare Alpine Linux.
**Libraries:** Only three external dependencies:
- `gorilla/mux` -- HTTP router
- `gorilla/websocket` -- WebSocket protocol
- `rs/cors` -- Cross-origin request handling
### React + Vite Frontend
**Why React:** The component model maps naturally to the interactive scoreboard UI -- each game card, score counter, and status badge is a composable piece. React's state management handles the bidirectional WebSocket data flow cleanly.
**Why Vite:** Modern build tooling with fast hot-reload during development. Produces optimized static assets for production.
**Minimal dependencies:** No state management library (React's built-in state is sufficient), no UI component framework (custom CSS provides an athletic visual identity), just `react-router-dom` for routing and `qrcode.react` for QR generation.
### File-Based Storage
**Why no database:** The application serves small tournaments (10-20 teams, 20-30 games). JSON files provide:
- Zero operational overhead (no database to install, configure, or maintain)
- Human-readable data (edit tournament data with any text editor)
- Easy backup (copy a directory)
- Portability (works anywhere a filesystem exists)
Audit logs use JSONL (JSON Lines) format for append-only writes that survive crashes without corruption.
**Trade-off:** Not suitable for high-concurrency write scenarios or multi-server deployments. Single-server, single-process is the intended deployment model.
### Docker
**Why Docker:** Reproducible deployment in a single command. The multi-stage build produces a minimal image (~15 MB) containing just the Go binary, frontend assets, and Alpine Linux. A named volume persists tournament data across container updates.
---
## 6. Architecture Overview
```
┌─────────────────────────────────────────────────┐
│ Go Backend (single binary) │
│ ├── REST API → JSON file storage │
│ ├── WebSocket hubs → real-time broadcasting │
│ └── SPA handler → serves React frontend │
├─────────────────────────────────────────────────┤
│ React Frontend (compiled static assets) │
│ ├── Schedule view ← tournament WebSocket │
│ ├── Scoreboard ← game WebSocket │
│ └── Forms → REST API │
├─────────────────────────────────────────────────┤
│ Data (flat JSON files) │
│ ├── Tournament config, schedule, results │
│ ├── Per-game score state │
│ └── JSONL audit logs │
└─────────────────────────────────────────────────┘
```
The entire application runs as a single process. The Go server handles HTTP requests, WebSocket connections, and file I/O. The React frontend is compiled to static files and served by the same Go process. No separate web server, no reverse proxy (though one is recommended for TLS), no database.
---
## 7. Current State
### First Tournament: Fujarna - 14.3.2026
- **Location:** Prague, Czech Republic
- **Venue:** Kotlarka multi-purpose sports facility
- **Teams:** 10 teams in 2 pools of 5
- Pool A: FUJ 1, Kocicaci, Spitalska, Sunset, Hoko-Coko Diskyto
- Pool B: FUJ 2, Bjorn, GyBot, Poletime, Kachny
- **Format:** Full round-robin within pools (10 games per pool), followed by crossover placement bracket (5th place through Grand Final)
- **Schedule:** 25 games total, 08:30 - 17:10, 20-minute games
- **Rules:** 20 min per game, no breaks, no timeouts, no draws allowed
### Application Status
- All core features are implemented and functional
- UI is in Czech language throughout
- Ready for production deployment via Docker
- CI/CD pipeline configured for Gitea container registry
---
## 8. Design Philosophy
**Simple over complex.** No admin UI, no user accounts, no database. Tournament data is managed by editing JSON files. This keeps the codebase small, the deployment trivial, and the maintenance burden near zero.
**Mobile-first.** Every page is designed for phone screens -- the primary use case is field-side scoring and schedule checking. Large touch targets, readable typography, responsive layout.
**Real-time everywhere.** WebSocket connections power both the scoreboard and the schedule. If a score changes, everyone sees it within milliseconds. No polling, no manual refresh.
**Self-hosted.** Full control over data and infrastructure. No external service dependencies, no API keys, no subscriptions. Runs on any machine that can run Docker -- a home server, a VPS, or a Raspberry Pi.
**Trust-based.** In a community of ~50 frisbee players, authentication is overhead without benefit. Anyone can update scores, and the audit log provides accountability.
---
## 9. Future Plans
Potential directions for the platform:
- **Admin panel** -- web UI for managing tournaments, teams, and schedules (replacing manual JSON editing)
- **Authentication** -- optional login for score-keeping to prevent accidental edits
- **Automatic standings** -- calculate standings from game results instead of manual `results.json`
- **Photo gallery** -- tournament photos integrated into the event page
- **Push notifications** -- alerts for game start, game end, or close scores
- **Player registration** -- team sign-up and roster management
- **Statistics** -- game history, scoring trends, spirit score analytics
- **Multi-language support** -- currently Czech-only
- **Multiple fields** -- field assignment management for larger tournaments
---
## 10. Contributing
The project is organized for easy contribution:
- **Backend** (`backend/`): Standard Go project structure with `cmd/` and `internal/` packages
- **Frontend** (`frontend/`): React SPA with page-based component organization
- **Data** (`data/`): JSON seed data that can be modified for testing
For detailed technical reference, API documentation, and development setup instructions, see the [Technical Documentation](DOCUMENTATION.md). For quick start and project structure, see the [README](../README.md).