All checks were successful
Build and Push / build (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
946 lines
29 KiB
Markdown
946 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:
|
|
|
|
All base images are pulled from `mirror.gcr.io` (Google's public Docker Hub mirror) to avoid Docker Hub rate limits.
|
|
|
|
**Stage 1 -- Frontend build** (Node 22 Alpine):
|
|
- Installs npm dependencies (`npm ci`)
|
|
- Builds React app with Vite (`npx vite build`)
|
|
- Output: `frontend/dist/`
|
|
|
|
**Stage 2 -- Backend build** (Go 1.26 Alpine):
|
|
- Downloads Go modules
|
|
- Builds static binary with `CGO_ENABLED=0` and stripped debug symbols (`-ldflags="-s -w"`)
|
|
- Output: `/server` binary
|
|
|
|
**Stage 3 -- Runtime** (Alpine 3.21):
|
|
- Copies server binary, built frontend, and seed data
|
|
- Installs only `ca-certificates` and `tzdata`
|
|
- Exposes port 8080
|
|
|
|
### 8.2 Entrypoint & Data Seeding
|
|
|
|
The entrypoint script seeds initial data on first run:
|
|
|
|
```sh
|
|
if [ ! -f /app/data/tournaments.json ]; then
|
|
echo "Seeding initial data..."
|
|
cp -r /app/data-seed/* /app/data/
|
|
fi
|
|
exec /app/server -static /app/static -data /app/data
|
|
```
|
|
|
|
Seed data is baked into the image at `/app/data-seed/`. On first run (when the volume is empty), it's copied to `/app/data/`. On subsequent runs, existing data in the volume is preserved.
|
|
|
|
### 8.3 Docker Compose
|
|
|
|
```yaml
|
|
services:
|
|
frisbee:
|
|
build: .
|
|
ports:
|
|
- "8080:8080"
|
|
volumes:
|
|
- tournament-data:/app/data
|
|
environment:
|
|
- PORT=8080
|
|
- TZ=Europe/Prague
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
tournament-data:
|
|
```
|
|
|
|
- **Volume `tournament-data`** persists all game scores, audit logs, and questionnaire responses across container restarts
|
|
- **Timezone** is set to `Europe/Prague` for correct timestamps in audit logs
|
|
- **Restart policy** ensures the server comes back after crashes or host reboots
|
|
|
|
### 8.4 Production Considerations
|
|
|
|
- **No TLS termination** -- use a reverse proxy (nginx, Caddy, Traefik) for HTTPS
|
|
- **No authentication** -- anyone with the URL can update scores; consider network-level access control
|
|
- **WebSocket origin check** is disabled (`CheckOrigin` returns `true` for all requests)
|
|
- **Data backup** -- the Docker volume can be backed up by copying its contents or using `docker cp`
|
|
|
|
---
|
|
|
|
## 9. CI/CD Pipeline
|
|
|
|
Defined in `.gitea/workflows/build.yaml`.
|
|
|
|
**Triggers:**
|
|
- Manual dispatch (`workflow_dispatch`) with optional `tag` input
|
|
- Tag push (`push: tags`)
|
|
|
|
**Steps:**
|
|
|
|
1. Checkout repository
|
|
2. Login to Gitea container registry at `gitea.home.hrajfrisbee.cz`
|
|
3. Build Docker image using the multi-stage Dockerfile
|
|
4. Push to registry as `gitea.home.hrajfrisbee.cz/{owner}/{repo}:{tag}`
|
|
|
|
**Tag resolution:** uses the manual input tag, or falls back to `github.ref_name` (the pushed tag or branch name).
|
|
|
|
**Secrets required:**
|
|
- `REGISTRY_TOKEN` -- authentication token for the Gitea container registry
|
|
|
|
---
|
|
|
|
## 10. Frontend Architecture
|
|
|
|
### 10.1 Routes
|
|
|
|
| Path | Component | Description |
|
|
|------|-----------|-------------|
|
|
| `/` | -- | Redirects to `/tournament/fujarna-14-3-2026` |
|
|
| `/tournament/:id` | TournamentPage | Tournament detail with info cards |
|
|
| `/tournament/:id/schedule` | SchedulePage | Live game schedule |
|
|
| `/tournament/:id/game/:gid` | GamePage | Live scoreboard |
|
|
| `/tournament/:id/questionnaire` | QuestionnairePage | Post-tournament survey |
|
|
| `/tournament/:id/results` | ResultsPage | Final standings |
|
|
| `/past` | PastPage | Archive of completed tournaments |
|
|
|
|
### 10.2 API Client (`frontend/src/api.js`)
|
|
|
|
- `fetchJSON(path)` / `postJSON(path, body)` -- wrappers around `fetch` with error handling
|
|
- All API functions are thin wrappers: `getTournaments()`, `getTournament(id)`, `getSchedule(id)`, etc.
|
|
- `createGameWebSocket(tourneyId, gameId, userId)` -- creates WebSocket with auto protocol detection (`ws:` or `wss:` based on page protocol)
|
|
- `createTournamentWebSocket(tourneyId)` -- read-only WebSocket for schedule updates
|
|
|
|
### 10.3 Key Components
|
|
|
|
**GamePage** -- live scoreboard:
|
|
- Connects via game WebSocket with a session-scoped `user_id` (random, stored in `sessionStorage`)
|
|
- +/- buttons send `increment`/`decrement` actions
|
|
- SET button opens a modal for direct value entry
|
|
- Displays audit log (expandable)
|
|
- Generates QR code linking to the questionnaire page
|
|
- Auto-reconnects on WebSocket disconnect (3-second delay)
|
|
|
|
**SchedulePage** -- tournament schedule:
|
|
- Connects via tournament WebSocket for live updates across all games
|
|
- Groups games by round (Pool A, Pool B, placement bracket)
|
|
- Shows status badges and live scores
|
|
- Auto-reconnects on disconnect
|
|
|
|
**QuestionnairePage** -- post-tournament survey:
|
|
- Loads questionnaire config and team list from API
|
|
- Renders built-in fields (my team, spirit winner, attend next) and custom questions
|
|
- Czech-language UI labels
|
|
- Shows success message after submission
|
|
|
|
**ResultsPage** -- final standings:
|
|
- Renders standings table with W/L/D, points for/against, point differential
|
|
- Medal emojis for top 3 positions
|
|
- Highlights team with highest `spirit_score`
|
|
|
|
---
|
|
|
|
## 11. Troubleshooting
|
|
|
|
**WebSocket not connecting:**
|
|
- Ensure the backend is running and accessible
|
|
- Check that your reverse proxy forwards WebSocket connections (upgrade headers)
|
|
- Verify the `/ws/` path prefix is not blocked
|
|
|
|
**Scores not persisting:**
|
|
- Check `data/` directory permissions (needs read/write)
|
|
- Verify the Docker volume is mounted correctly
|
|
- Check server logs for write errors
|
|
|
|
**Frontend shows 404 on page refresh:**
|
|
- The SPA handler should serve `index.html` for unknown paths
|
|
- Ensure `-static` flag points to the directory containing `index.html`
|
|
|
|
**Schedule shows stale scores:**
|
|
- The schedule endpoint merges from individual score files on every request
|
|
- Check that WebSocket connection is active (green indicator in browser dev tools)
|
|
- Try refreshing the page to re-establish the tournament WebSocket
|
|
|
|
**Audit log growing large:**
|
|
- Audit files are JSONL, one line per score change
|
|
- Safe to truncate or archive old `.jsonl` files when not needed
|
|
|
|
**Docker volume data reset:**
|
|
- Data only seeds when `tournaments.json` is missing from the volume
|
|
- To force a reseed: `docker compose down -v` (removes volumes), then `docker compose up`
|
|
|
|
**CORS errors in development:**
|
|
- The backend allows all origins (`*`)
|
|
- In dev mode, use the Vite dev server (port 5173) which proxies API calls to the backend
|