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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
frontend/node_modules
frontend/dist
backend/vendor
*.exe
*.dll
*.so
*.dylib
.git
.DS_Store

View File

@@ -0,0 +1,35 @@
name: Build and Push
on:
workflow_dispatch:
inputs:
tag:
description: 'Image tag'
required: true
default: 'latest'
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
- name: Build and push
run: |
TAG=${{ github.ref_name }}
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG=${{ inputs.tag }}
fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -t $IMAGE .
docker push $IMAGE

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Dependencies
frontend/node_modules/
frontend/dist/
# Go
backend/server
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Leftover / duplicate
frisbee-tournament/
# Data (keep structure but ignore runtime data)
data/tournaments/*/games/*_audit.jsonl
data/tournaments/*/games/*_score.json
data/tournaments/*/questionnaire_responses/*.json

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# ---- Stage 1: Build frontend ----
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npx vite build
# ---- Stage 2: Build Go backend ----
FROM golang:1.26-alpine AS backend-build
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
# ---- Stage 3: Runtime ----
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend-build /server /app/server
COPY --from=frontend-build /app/frontend/dist /app/static
COPY data/ /app/data-seed/
EXPOSE 8080
ENV PORT=8080
ENV DATA_DIR=/app/data
# Copy seed data on first run if /app/data is empty (volume mount)
COPY <<'EOF' /app/entrypoint.sh
#!/bin/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
EOF
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

42
Makefile Normal file
View File

@@ -0,0 +1,42 @@
.PHONY: help dev dev-backend dev-frontend build-frontend docker image run stop logs clean tidy
.DEFAULT_GOAL := help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk -F ':.*## ' '{printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
dev: ## Print instructions for local development
@echo "Start backend and frontend in separate terminals:"
@echo " make dev-backend"
@echo " make dev-frontend"
tidy: ## Run go mod tidy (requires Go 1.26+)
cd backend && go mod tidy
dev-backend: tidy ## Run backend dev server (requires Go 1.26+)
cd backend && go run ./cmd/server -data ../data -static ../frontend/dist -port 8080
dev-frontend: ## Run frontend dev server (requires Node 22+)
cd frontend && npm run dev
build-frontend: ## Build frontend for production
cd frontend && npm ci && npx vite build
docker: ## Build Docker images
docker compose build
image: ## Build production Docker image
docker build -t fujarna-claude:latest .
run: ## Start Docker containers
docker compose up -d
stop: ## Stop Docker containers
docker compose down
logs: ## Follow Docker container logs
docker compose logs -f
clean: ## Remove build artifacts and node_modules
rm -rf frontend/dist frontend/node_modules
cd backend && go clean

164
README.md Normal file
View File

@@ -0,0 +1,164 @@
# 🥏 Disc Agenda — Frisbee Tournament Platform
A self-hosted tournament management web app for ultimate frisbee, featuring live multiplayer scoring via WebSockets, a mobile-friendly questionnaire with QR codes, and a clean athletic visual design.
## Architecture
```
┌──────────────────────────────────────────┐
│ Go 1.26 Backend (single binary) │
│ ├─ REST API (gorilla/mux) │
│ ├─ WebSocket hub (gorilla/websocket) │
│ ├─ SPA file server │
│ └─ File-based JSON storage │
├──────────────────────────────────────────┤
│ React 19 + Vite 8 Frontend │
│ ├─ react-router-dom v7 (SPA) │
│ ├─ qrcode.react (QR generation) │
│ └─ Custom CSS (Bebas Neue + Barlow) │
├──────────────────────────────────────────┤
│ Data: flat JSON files + JSONL audit logs │
│ (no database required) │
└──────────────────────────────────────────┘
```
## Features
- **Tournament hub** — home page with location, dates, teams, rules
- **Schedule** — round-grouped game schedule with status badges
- **Live scoring** — WebSocket-powered multiplayer scoreboard with +/- and SET controls
- **Audit log** — every score change persisted as JSONL per game
- **Questionnaire** — mobile-friendly survey with QR code, team selectors, spirit voting, custom questions
- **Results** — final standings table with spirit award highlight
- **Past tournaments** — archive of completed events
## Quick Start
### Docker (recommended)
```bash
docker compose up --build
# → http://localhost:8080
```
Data persists in a Docker volume. Seed data auto-copies on first run.
### Local Development
**Prerequisites:** Go 1.26+, Node 22+
Terminal 1 — backend:
```bash
cd backend
go mod tidy
go run ./cmd/server -data ../data -static ../frontend/dist -port 8080
```
Terminal 2 — frontend (with hot reload + API proxy):
```bash
cd frontend
npm install
npm run dev
# → http://localhost:5173 (proxies /api and /ws to :8080)
```
## Project Structure
```
├── backend/
│ ├── cmd/server/main.go # Entry point, router, SPA handler
│ └── internal/
│ ├── handlers/handlers.go # REST + WS HTTP handlers
│ ├── models/models.go # Domain types
│ ├── storage/storage.go # File-based persistence
│ └── websocket/hub.go # Per-game WS hub + broadcast
├── frontend/
│ ├── src/
│ │ ├── api.js # API client + WS factory
│ │ ├── App.jsx # Router
│ │ ├── main.jsx # Entry
│ │ ├── components/ # Header, Footer, Icons
│ │ ├── pages/ # All page components
│ │ └── styles/global.css # Full stylesheet
│ ├── vite.config.js # Dev proxy config
│ └── index.html
├── data/ # Seed data (JSON files)
│ ├── tournaments.json
│ └── tournaments/{id}/
│ ├── schedule.json
│ ├── questionnaire_config.json
│ ├── results.json
│ └── games/ # Score state + audit logs
├── Dockerfile # Multi-stage build
├── docker-compose.yml
└── Makefile
```
## API Reference
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tournaments` | List all tournaments |
| GET | `/api/tournaments/{id}` | Tournament details |
| GET | `/api/tournaments/{id}/schedule` | Game schedule |
| GET | `/api/tournaments/{id}/games/{gid}/score` | Current score |
| POST | `/api/tournaments/{id}/games/{gid}/score` | Update score (REST) |
| GET | `/api/tournaments/{id}/games/{gid}/audit` | Audit log |
| WS | `/ws/game/{id}/{gid}?user_id=x` | Live score WebSocket |
| GET | `/api/tournaments/{id}/questionnaire` | Questionnaire config + teams |
| POST | `/api/tournaments/{id}/questionnaire` | Submit response |
| GET | `/api/tournaments/{id}/questionnaire/results` | All responses (admin) |
| GET | `/api/tournaments/{id}/results` | Final standings |
### WebSocket Protocol
Connect: `ws://host/ws/game/{tourneyId}/{gameId}?user_id=alice`
**Send** (client → server):
```json
{"action": "increment", "team": "home"}
{"action": "decrement", "team": "away"}
{"action": "set", "team": "home", "value": 12}
```
**Receive** (server → all clients):
```json
{
"type": "score_update",
"state": {"game_id": "g01", "home_score": 8, "away_score": 6, ...},
"audit": {"action": "increment", "team": "home", "old_home": 7, ...}
}
```
## Configuration
All via flags or env vars:
| Flag | Env | Default | Description |
|------|-----|---------|-------------|
| `-port` | `PORT` | `8080` | Listen port |
| `-data` | `DATA_DIR` | `./data` | Data directory |
| `-static` | — | `./static` | Frontend files |
## Adding Tournament Data
Edit JSON files directly in `data/`:
- `tournaments.json` — add/edit tournament objects
- `tournaments/{id}/schedule.json` — game schedule
- `tournaments/{id}/questionnaire_config.json` — custom survey questions
- `tournaments/{id}/results.json` — final standings
No admin UI yet — data is managed via files. Future: add admin panel.
## Tech Stack
- **Backend:** Go 1.26, gorilla/mux 1.8.1, gorilla/websocket 1.5.3, rs/cors
- **Frontend:** React 19, Vite 8, react-router-dom 7, qrcode.react
- **Storage:** Flat JSON files + JSONL audit logs
- **Container:** Alpine 3.21, multi-stage Docker build
## Documentation
- **[Technical Documentation](docs/DOCUMENTATION.md)** — detailed API reference, data models, WebSocket protocol, deployment guides, troubleshooting
- **[Project Description](docs/PROJECT.md)** — project overview, motivation, features, design philosophy, roadmap

View File

@@ -0,0 +1,97 @@
package main
import (
"flag"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"github.com/rs/cors"
"github.com/frisbee-tournament/backend/internal/handlers"
"github.com/frisbee-tournament/backend/internal/storage"
ws "github.com/frisbee-tournament/backend/internal/websocket"
)
func main() {
port := flag.String("port", "8080", "server port")
dataDir := flag.String("data", "./data", "data directory")
staticDir := flag.String("static", "./static", "frontend static files")
flag.Parse()
if envPort := os.Getenv("PORT"); envPort != "" {
*port = envPort
}
if envData := os.Getenv("DATA_DIR"); envData != "" {
*dataDir = envData
}
store := storage.New(*dataDir)
hubMgr := ws.NewHubManager(store)
h := handlers.New(store, hubMgr)
r := mux.NewRouter()
// API routes
api := r.PathPrefix("/api").Subrouter()
api.HandleFunc("/tournaments", h.ListTournaments).Methods("GET")
api.HandleFunc("/tournaments/{id}", h.GetTournament).Methods("GET")
api.HandleFunc("/tournaments/{id}/schedule", h.GetSchedule).Methods("GET")
api.HandleFunc("/tournaments/{id}/games/{gid}/score", h.GetScore).Methods("GET")
api.HandleFunc("/tournaments/{id}/games/{gid}/score", h.UpdateScore).Methods("POST")
api.HandleFunc("/tournaments/{id}/games/{gid}/audit", h.GetAuditLog).Methods("GET")
api.HandleFunc("/tournaments/{id}/questionnaire", h.GetQuestionnaire).Methods("GET")
api.HandleFunc("/tournaments/{id}/questionnaire", h.SubmitQuestionnaire).Methods("POST")
api.HandleFunc("/tournaments/{id}/questionnaire/results", h.GetQuestionnaireResults).Methods("GET")
api.HandleFunc("/tournaments/{id}/results", h.GetResults).Methods("GET")
// WebSocket
r.HandleFunc("/ws/game/{id}/{gid}", h.WebSocketHandler)
r.HandleFunc("/ws/tournament/{id}", h.TournamentWebSocketHandler)
// Serve frontend (SPA fallback)
spa := spaHandler{staticPath: *staticDir, indexPath: "index.html"}
r.PathPrefix("/").Handler(spa)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
srv := &http.Server{
Addr: ":" + *port,
Handler: c.Handler(r),
}
log.Printf("🥏 Frisbee Tournament server starting on :%s", *port)
log.Printf(" Data dir: %s", *dataDir)
log.Printf(" Static dir: %s", *staticDir)
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
// spaHandler serves an SPA with history-mode fallback
type spaHandler struct {
staticPath string
indexPath string
}
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := h.staticPath + r.URL.Path
_, err := os.Stat(path)
if os.IsNotExist(err) {
// SPA fallback: serve index.html
http.ServeFile(w, r, h.staticPath+"/"+h.indexPath)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
}

9
backend/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module github.com/frisbee-tournament/backend
go 1.26
require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/rs/cors v1.11.0
)

6
backend/go.sum Normal file
View File

@@ -0,0 +1,6 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=

View File

@@ -0,0 +1,216 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/mux"
gorillaWs "github.com/gorilla/websocket"
"github.com/frisbee-tournament/backend/internal/models"
"github.com/frisbee-tournament/backend/internal/storage"
ws "github.com/frisbee-tournament/backend/internal/websocket"
)
type Handler struct {
store *storage.Store
hubMgr *ws.HubManager
}
func New(store *storage.Store, hubMgr *ws.HubManager) *Handler {
return &Handler{store: store, hubMgr: hubMgr}
}
var upgrader = gorillaWs.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // trust-based, no auth
},
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// --- Tournament endpoints ---
func (h *Handler) ListTournaments(w http.ResponseWriter, r *http.Request) {
ts, err := h.store.GetTournaments()
if err != nil {
writeError(w, 500, err.Error())
return
}
writeJSON(w, 200, ts)
}
func (h *Handler) GetTournament(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
t, err := h.store.GetTournament(id)
if err != nil {
writeError(w, 404, err.Error())
return
}
writeJSON(w, 200, t)
}
// --- Schedule ---
func (h *Handler) GetSchedule(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
sched, err := h.store.GetSchedule(id)
if err != nil {
writeError(w, 500, err.Error())
return
}
// Merge persisted scores into schedule games
for i := range sched.Games {
g := &sched.Games[i]
state, err := h.store.GetScore(id, g.ID)
if err == nil && state.Status != "" {
g.HomeScore = state.HomeScore
g.AwayScore = state.AwayScore
g.Status = state.Status
}
}
writeJSON(w, 200, sched)
}
// --- Scoring ---
func (h *Handler) GetScore(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
state, err := h.store.GetScore(vars["id"], vars["gid"])
if err != nil {
writeError(w, 500, err.Error())
return
}
writeJSON(w, 200, state)
}
func (h *Handler) UpdateScore(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var update models.ScoreUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
writeError(w, 400, "invalid request body")
return
}
hub := h.hubMgr.GetOrCreateHub(vars["id"], vars["gid"])
state, err := hub.HandleScoreUpdate(update)
if err != nil {
writeError(w, 500, err.Error())
return
}
writeJSON(w, 200, state)
}
func (h *Handler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
entries, err := h.store.GetAuditLog(vars["id"], vars["gid"])
if err != nil {
writeError(w, 500, err.Error())
return
}
if entries == nil {
entries = []models.AuditEntry{}
}
writeJSON(w, 200, entries)
}
// --- WebSocket ---
func (h *Handler) WebSocketHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade error: %v", err)
return
}
userID := r.URL.Query().Get("user_id")
if userID == "" {
userID = "anonymous"
}
hub := h.hubMgr.GetOrCreateHub(vars["id"], vars["gid"])
client := hub.RegisterClient(conn, userID)
go client.WritePump()
go client.ReadPump()
}
func (h *Handler) TournamentWebSocketHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade error: %v", err)
return
}
th := h.hubMgr.GetOrCreateTournamentHub(vars["id"])
client := th.RegisterClient(conn)
go client.WritePump()
go client.ReadPumpTournament(th)
}
// --- Questionnaire ---
func (h *Handler) GetQuestionnaire(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
cfg, err := h.store.GetQuestionnaireConfig(id)
if err != nil {
writeError(w, 500, err.Error())
return
}
t, _ := h.store.GetTournament(id)
resp := map[string]any{
"config": cfg,
}
if t != nil {
resp["teams"] = t.Teams
}
writeJSON(w, 200, resp)
}
func (h *Handler) SubmitQuestionnaire(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
var resp models.QuestionnaireResponse
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
writeError(w, 400, "invalid request body")
return
}
resp.TourneyID = id
if err := h.store.SaveQuestionnaireResponse(id, &resp); err != nil {
writeError(w, 500, err.Error())
return
}
writeJSON(w, 201, map[string]string{"status": "ok", "id": resp.ID})
}
func (h *Handler) GetQuestionnaireResults(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
resps, err := h.store.GetQuestionnaireResponses(id)
if err != nil {
writeError(w, 500, err.Error())
return
}
if resps == nil {
resps = []models.QuestionnaireResponse{}
}
writeJSON(w, 200, resps)
}
// --- Results ---
func (h *Handler) GetResults(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
res, err := h.store.GetResults(id)
if err != nil {
writeError(w, 500, err.Error())
return
}
writeJSON(w, 200, res)
}

View File

@@ -0,0 +1,118 @@
package models
import "time"
type TournamentStatus string
const (
StatusUpcoming TournamentStatus = "upcoming"
StatusInProgress TournamentStatus = "in_progress"
StatusCompleted TournamentStatus = "completed"
)
type Tournament struct {
ID string `json:"id"`
Name string `json:"name"`
Status TournamentStatus `json:"status"`
Location string `json:"location"`
Venue string `json:"venue"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Description string `json:"description"`
Teams []Team `json:"teams"`
ImageURL string `json:"image_url,omitempty"`
Rules string `json:"rules,omitempty"`
}
type Team struct {
ID string `json:"id"`
Name string `json:"name"`
Logo string `json:"logo,omitempty"`
}
type Game struct {
ID string `json:"id"`
TourneyID string `json:"tourney_id"`
HomeTeam string `json:"home_team"`
AwayTeam string `json:"away_team"`
HomeScore int `json:"home_score"`
AwayScore int `json:"away_score"`
StartTime string `json:"start_time"`
Field string `json:"field"`
Round string `json:"round"`
Status string `json:"status"` // scheduled, live, final
}
type Schedule struct {
TourneyID string `json:"tourney_id"`
Games []Game `json:"games"`
}
type ScoreUpdate struct {
Action string `json:"action"` // increment, decrement, set
Team string `json:"team"` // home, away
Value int `json:"value"` // used for "set"
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id,omitempty"`
}
type ScoreState struct {
GameID string `json:"game_id"`
HomeScore int `json:"home_score"`
AwayScore int `json:"away_score"`
HomeTeam string `json:"home_team"`
AwayTeam string `json:"away_team"`
Status string `json:"status"`
}
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
Team string `json:"team"`
Value int `json:"value"`
OldHome int `json:"old_home"`
OldAway int `json:"old_away"`
NewHome int `json:"new_home"`
NewAway int `json:"new_away"`
UserID string `json:"user_id,omitempty"`
}
type QuestionnaireConfig struct {
TourneyID string `json:"tourney_id"`
CustomQuestions []Question `json:"custom_questions"`
}
type Question struct {
ID string `json:"id"`
Text string `json:"text"`
Type string `json:"type"` // text, select, radio
Options []string `json:"options,omitempty"`
Required bool `json:"required"`
}
type QuestionnaireResponse struct {
ID string `json:"id"`
TourneyID string `json:"tourney_id"`
MyTeam string `json:"my_team"`
SpiritWinner string `json:"spirit_winner"`
AttendNext bool `json:"attend_next"`
CustomAnswers map[string]string `json:"custom_answers"`
SubmittedAt time.Time `json:"submitted_at"`
}
type FinalResults struct {
TourneyID string `json:"tourney_id"`
Standings []Standing `json:"standings"`
}
type Standing struct {
Position int `json:"position"`
TeamID string `json:"team_id"`
TeamName string `json:"team_name"`
Wins int `json:"wins"`
Losses int `json:"losses"`
Draws int `json:"draws"`
PointsFor int `json:"points_for"`
PointsAgainst int `json:"points_against"`
SpiritScore float64 `json:"spirit_score,omitempty"`
}

View File

@@ -0,0 +1,265 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/frisbee-tournament/backend/internal/models"
)
type Store struct {
dataDir string
mu sync.RWMutex
}
func New(dataDir string) *Store {
return &Store{dataDir: dataDir}
}
func (s *Store) ensureDir(path string) error {
return os.MkdirAll(path, 0755)
}
func (s *Store) readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
func (s *Store) writeJSON(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
if err := s.ensureDir(filepath.Dir(path)); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// --- Tournaments ---
func (s *Store) tournamentsFile() string {
return filepath.Join(s.dataDir, "tournaments.json")
}
func (s *Store) GetTournaments() ([]models.Tournament, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var ts []models.Tournament
if err := s.readJSON(s.tournamentsFile(), &ts); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return ts, nil
}
func (s *Store) GetTournament(id string) (*models.Tournament, error) {
ts, err := s.GetTournaments()
if err != nil {
return nil, err
}
for _, t := range ts {
if t.ID == id {
return &t, nil
}
}
return nil, fmt.Errorf("tournament %s not found", id)
}
func (s *Store) SaveTournaments(ts []models.Tournament) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeJSON(s.tournamentsFile(), ts)
}
// --- Schedule ---
func (s *Store) tourneyDir(tourneyID string) string {
return filepath.Join(s.dataDir, "tournaments", tourneyID)
}
func (s *Store) GetSchedule(tourneyID string) (*models.Schedule, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var sched models.Schedule
path := filepath.Join(s.tourneyDir(tourneyID), "schedule.json")
if err := s.readJSON(path, &sched); err != nil {
if os.IsNotExist(err) {
return &models.Schedule{TourneyID: tourneyID}, nil
}
return nil, err
}
return &sched, nil
}
func (s *Store) SaveSchedule(sched *models.Schedule) error {
s.mu.Lock()
defer s.mu.Unlock()
path := filepath.Join(s.tourneyDir(sched.TourneyID), "schedule.json")
return s.writeJSON(path, sched)
}
// --- Score ---
func (s *Store) gameDir(tourneyID string) string {
return filepath.Join(s.tourneyDir(tourneyID), "games")
}
func (s *Store) GetScore(tourneyID, gameID string) (*models.ScoreState, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var state models.ScoreState
path := filepath.Join(s.gameDir(tourneyID), gameID+"_score.json")
if err := s.readJSON(path, &state); err != nil {
if os.IsNotExist(err) {
return &models.ScoreState{GameID: gameID}, nil
}
return nil, err
}
return &state, nil
}
func (s *Store) SaveScore(tourneyID string, state *models.ScoreState) error {
s.mu.Lock()
defer s.mu.Unlock()
path := filepath.Join(s.gameDir(tourneyID), state.GameID+"_score.json")
return s.writeJSON(path, state)
}
func (s *Store) AppendAuditLog(tourneyID, gameID string, entry models.AuditEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
dir := s.gameDir(tourneyID)
if err := s.ensureDir(dir); err != nil {
return err
}
path := filepath.Join(dir, gameID+"_audit.jsonl")
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
data, err := json.Marshal(entry)
if err != nil {
return err
}
_, err = f.Write(append(data, '\n'))
return err
}
func (s *Store) GetAuditLog(tourneyID, gameID string) ([]models.AuditEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
path := filepath.Join(s.gameDir(tourneyID), gameID+"_audit.jsonl")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var entries []models.AuditEntry
for _, line := range splitLines(data) {
if len(line) == 0 {
continue
}
var e models.AuditEntry
if err := json.Unmarshal(line, &e); err != nil {
continue
}
entries = append(entries, e)
}
return entries, nil
}
// --- Questionnaire ---
func (s *Store) GetQuestionnaireConfig(tourneyID string) (*models.QuestionnaireConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var cfg models.QuestionnaireConfig
path := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_config.json")
if err := s.readJSON(path, &cfg); err != nil {
if os.IsNotExist(err) {
return &models.QuestionnaireConfig{TourneyID: tourneyID}, nil
}
return nil, err
}
return &cfg, nil
}
func (s *Store) SaveQuestionnaireResponse(tourneyID string, resp *models.QuestionnaireResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
resp.SubmittedAt = time.Now()
if resp.ID == "" {
resp.ID = fmt.Sprintf("resp_%d", time.Now().UnixNano())
}
dir := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_responses")
path := filepath.Join(dir, resp.ID+".json")
return s.writeJSON(path, resp)
}
func (s *Store) GetQuestionnaireResponses(tourneyID string) ([]models.QuestionnaireResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dir := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_responses")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var resps []models.QuestionnaireResponse
for _, e := range entries {
if filepath.Ext(e.Name()) != ".json" {
continue
}
var r models.QuestionnaireResponse
if err := s.readJSON(filepath.Join(dir, e.Name()), &r); err == nil {
resps = append(resps, r)
}
}
return resps, nil
}
// --- Results ---
func (s *Store) GetResults(tourneyID string) (*models.FinalResults, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var res models.FinalResults
path := filepath.Join(s.tourneyDir(tourneyID), "results.json")
if err := s.readJSON(path, &res); err != nil {
if os.IsNotExist(err) {
return &models.FinalResults{TourneyID: tourneyID}, nil
}
return nil, err
}
return &res, nil
}
func splitLines(data []byte) [][]byte {
var lines [][]byte
start := 0
for i, b := range data {
if b == '\n' {
lines = append(lines, data[start:i])
start = i + 1
}
}
if start < len(data) {
lines = append(lines, data[start:])
}
return lines
}

View File

@@ -0,0 +1,355 @@
package websocket
import (
"encoding/json"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/frisbee-tournament/backend/internal/models"
"github.com/frisbee-tournament/backend/internal/storage"
)
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
userID string
}
type Hub struct {
gameID string
tourneyID string
store *storage.Store
hubMgr *HubManager
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
mu sync.RWMutex
}
// TournamentHub forwards game score updates to all schedule viewers for a tournament.
type TournamentHub struct {
tourneyID string
store *storage.Store
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
mu sync.RWMutex
}
func (th *TournamentHub) Run() {
for {
select {
case client := <-th.register:
th.mu.Lock()
th.clients[client] = true
th.mu.Unlock()
case client := <-th.unregister:
th.mu.Lock()
if _, ok := th.clients[client]; ok {
delete(th.clients, client)
close(client.send)
}
th.mu.Unlock()
case message := <-th.broadcast:
th.mu.RLock()
for client := range th.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(th.clients, client)
}
}
th.mu.RUnlock()
}
}
}
func (th *TournamentHub) RegisterClient(conn *websocket.Conn) *Client {
c := &Client{
conn: conn,
send: make(chan []byte, 256),
}
th.register <- c
return c
}
// ReadPumpTournament keeps the connection alive (reads pongs) but ignores incoming messages.
func (c *Client) ReadPumpTournament(th *TournamentHub) {
defer func() {
th.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(512)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
if _, _, err := c.conn.ReadMessage(); err != nil {
break
}
}
}
type HubManager struct {
hubs map[string]*Hub
tournamentHubs map[string]*TournamentHub
mu sync.RWMutex
store *storage.Store
}
func NewHubManager(store *storage.Store) *HubManager {
return &HubManager{
hubs: make(map[string]*Hub),
tournamentHubs: make(map[string]*TournamentHub),
store: store,
}
}
func (m *HubManager) GetOrCreateHub(tourneyID, gameID string) *Hub {
key := tourneyID + "/" + gameID
m.mu.Lock()
defer m.mu.Unlock()
if h, ok := m.hubs[key]; ok {
return h
}
h := &Hub{
gameID: gameID,
tourneyID: tourneyID,
store: m.store,
hubMgr: m,
clients: make(map[*Client]bool),
broadcast: make(chan []byte, 256),
register: make(chan *Client),
unregister: make(chan *Client),
}
m.hubs[key] = h
go h.Run()
return h
}
func (m *HubManager) GetOrCreateTournamentHub(tourneyID string) *TournamentHub {
m.mu.Lock()
defer m.mu.Unlock()
if th, ok := m.tournamentHubs[tourneyID]; ok {
return th
}
th := &TournamentHub{
tourneyID: tourneyID,
store: m.store,
clients: make(map[*Client]bool),
broadcast: make(chan []byte, 256),
register: make(chan *Client),
unregister: make(chan *Client),
}
m.tournamentHubs[tourneyID] = th
go th.Run()
return th
}
// BroadcastToTournament forwards a game update to the tournament hub if it exists.
func (m *HubManager) BroadcastToTournament(tourneyID string, msg []byte) {
m.mu.RLock()
th, ok := m.tournamentHubs[tourneyID]
m.mu.RUnlock()
if ok {
select {
case th.broadcast <- msg:
default:
}
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
// Send current score to new client
state, err := h.store.GetScore(h.tourneyID, h.gameID)
if err == nil {
data, _ := json.Marshal(map[string]any{
"type": "score_state",
"state": state,
})
client.send <- data
}
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
h.mu.Unlock()
case message := <-h.broadcast:
h.mu.RLock()
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
h.mu.RUnlock()
}
}
}
func (h *Hub) HandleScoreUpdate(update models.ScoreUpdate) (*models.ScoreState, error) {
state, err := h.store.GetScore(h.tourneyID, h.gameID)
if err != nil {
return nil, err
}
oldHome := state.HomeScore
oldAway := state.AwayScore
switch update.Action {
case "increment":
if state.Status == "final" {
return state, nil
}
if update.Team == "home" {
state.HomeScore++
} else {
state.AwayScore++
}
case "decrement":
if state.Status == "final" {
return state, nil
}
if update.Team == "home" && state.HomeScore > 0 {
state.HomeScore--
} else if update.Team == "away" && state.AwayScore > 0 {
state.AwayScore--
}
case "set":
if state.Status == "final" {
return state, nil
}
if update.Team == "home" {
state.HomeScore = update.Value
} else {
state.AwayScore = update.Value
}
case "set_status":
// update.Team carries the target status: scheduled, live, final
if update.Team == "scheduled" || update.Team == "live" || update.Team == "final" {
state.Status = update.Team
}
}
// Auto-transition: scheduled → live when score becomes non-zero
if update.Action != "set_status" && state.Status == "scheduled" && (state.HomeScore > 0 || state.AwayScore > 0) {
state.Status = "live"
}
if err := h.store.SaveScore(h.tourneyID, state); err != nil {
return nil, err
}
// Write audit log
entry := models.AuditEntry{
Timestamp: time.Now(),
Action: update.Action,
Team: update.Team,
Value: update.Value,
OldHome: oldHome,
OldAway: oldAway,
NewHome: state.HomeScore,
NewAway: state.AwayScore,
UserID: update.UserID,
}
if err := h.store.AppendAuditLog(h.tourneyID, h.gameID, entry); err != nil {
log.Printf("audit log error: %v", err)
}
// Broadcast to all clients on this game hub
msg, _ := json.Marshal(map[string]any{
"type": "score_update",
"state": state,
"audit": entry,
})
h.broadcast <- msg
// Also forward to tournament hub for schedule viewers
h.hubMgr.BroadcastToTournament(h.tourneyID, msg)
return state, nil
}
func (h *Hub) RegisterClient(conn *websocket.Conn, userID string) *Client {
c := &Client{
hub: h,
conn: conn,
send: make(chan []byte, 256),
userID: userID,
}
h.register <- c
return c
}
func (c *Client) ReadPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(4096)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
break
}
var update models.ScoreUpdate
if err := json.Unmarshal(message, &update); err != nil {
continue
}
update.Timestamp = time.Now()
update.UserID = c.userID
if _, err := c.hub.HandleScoreUpdate(update); err != nil {
log.Printf("score update error: %v", err)
}
}
}
func (c *Client) WritePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

25
data/tournaments.json Normal file
View File

@@ -0,0 +1,25 @@
[
{
"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"},
{"id": "spitalska", "name": "Špitálská"},
{"id": "sunset", "name": "Sunset"},
{"id": "hoko-coko-diskyto", "name": "Hoko-Čoko Diskýto"},
{"id": "fuj-2", "name": "FUJ 2"},
{"id": "bjorn", "name": "Björn"},
{"id": "gybot", "name": "GyBot"},
{"id": "poletime", "name": "Poletíme"},
{"id": "kachny", "name": "Kachny"}
],
"rules": "Hra 20 min. Bez pauzy. Bez TO. Remíza není možná."
}
]

View File

@@ -0,0 +1,25 @@
{
"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_fields",
"text": "How were the playing fields?",
"type": "select",
"options": ["Great condition", "Acceptable", "Needs improvement"],
"required": false
},
{
"id": "q_feedback",
"text": "Any other feedback or suggestions?",
"type": "text",
"required": false
}
]
}

View File

@@ -0,0 +1,30 @@
{
"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": 0, "away_score": 0, "start_time": "2026-03-14T08:30:00", "field": "Field 1", "round": "Pool A - Round 1", "status": "scheduled"},
{"id": "g02", "tourney_id": "fujarna-14-3-2026", "home_team": "Špitálská", "away_team": "Sunset", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T08:50:00", "field": "Field 1", "round": "Pool A - Round 1", "status": "scheduled"},
{"id": "g03", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T09:10:00", "field": "Field 1", "round": "Pool A - Round 2", "status": "scheduled"},
{"id": "g04", "tourney_id": "fujarna-14-3-2026", "home_team": "Špitálská", "away_team": "Kočičáci", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T09:30:00", "field": "Field 1", "round": "Pool A - Round 2", "status": "scheduled"},
{"id": "g05", "tourney_id": "fujarna-14-3-2026", "home_team": "Sunset", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T09:50:00", "field": "Field 1", "round": "Pool A - Round 3", "status": "scheduled"},
{"id": "g06", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Špitálská", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T10:10:00", "field": "Field 1", "round": "Pool A - Round 3", "status": "scheduled"},
{"id": "g07", "tourney_id": "fujarna-14-3-2026", "home_team": "Kočičáci", "away_team": "Sunset", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T10:30:00", "field": "Field 1", "round": "Pool A - Round 4", "status": "scheduled"},
{"id": "g08", "tourney_id": "fujarna-14-3-2026", "home_team": "Špitálská", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T10:50:00", "field": "Field 1", "round": "Pool A - Round 4", "status": "scheduled"},
{"id": "g09", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Sunset", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T11:10:00", "field": "Field 1", "round": "Pool A - Round 5", "status": "scheduled"},
{"id": "g10", "tourney_id": "fujarna-14-3-2026", "home_team": "Kočičáci", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T11:30:00", "field": "Field 1", "round": "Pool A - Round 5", "status": "scheduled"},
{"id": "g11", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "Björn", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T11:50:00", "field": "Field 1", "round": "Pool B - Round 1", "status": "scheduled"},
{"id": "g12", "tourney_id": "fujarna-14-3-2026", "home_team": "GyBot", "away_team": "Poletíme", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T12:10:00", "field": "Field 1", "round": "Pool B - Round 1", "status": "scheduled"},
{"id": "g13", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T12:30:00", "field": "Field 1", "round": "Pool B - Round 2", "status": "scheduled"},
{"id": "g14", "tourney_id": "fujarna-14-3-2026", "home_team": "Björn", "away_team": "GyBot", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T12:50:00", "field": "Field 1", "round": "Pool B - Round 2", "status": "scheduled"},
{"id": "g15", "tourney_id": "fujarna-14-3-2026", "home_team": "Poletíme", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T13:10:00", "field": "Field 1", "round": "Pool B - Round 3", "status": "scheduled"},
{"id": "g16", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "GyBot", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T13:30:00", "field": "Field 1", "round": "Pool B - Round 3", "status": "scheduled"},
{"id": "g17", "tourney_id": "fujarna-14-3-2026", "home_team": "Björn", "away_team": "Poletíme", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T13:50:00", "field": "Field 1", "round": "Pool B - Round 4", "status": "scheduled"},
{"id": "g18", "tourney_id": "fujarna-14-3-2026", "home_team": "GyBot", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T14:10:00", "field": "Field 1", "round": "Pool B - Round 4", "status": "scheduled"},
{"id": "g19", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "Poletíme", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T14:30:00", "field": "Field 1", "round": "Pool B - Round 5", "status": "scheduled"},
{"id": "g20", "tourney_id": "fujarna-14-3-2026", "home_team": "Björn", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T14:50:00", "field": "Field 1", "round": "Pool B - Round 5", "status": "scheduled"},
{"id": "p5", "tourney_id": "fujarna-14-3-2026", "home_team": "5A", "away_team": "5B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T15:10:00", "field": "Field 1", "round": "5th Place", "status": "scheduled"},
{"id": "p4", "tourney_id": "fujarna-14-3-2026", "home_team": "4A", "away_team": "4B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T15:40:00", "field": "Field 1", "round": "4th Place", "status": "scheduled"},
{"id": "p3", "tourney_id": "fujarna-14-3-2026", "home_team": "3A", "away_team": "3B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T16:10:00", "field": "Field 1", "round": "3rd Place", "status": "scheduled"},
{"id": "p2", "tourney_id": "fujarna-14-3-2026", "home_team": "2A", "away_team": "2B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T16:40:00", "field": "Field 1", "round": "2nd Place", "status": "scheduled"},
{"id": "final", "tourney_id": "fujarna-14-3-2026", "home_team": "1A", "away_team": "1B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T17:10:00", "field": "Field 1", "round": "Grand Final", "status": "scheduled"}
]
}

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
frisbee:
build: .
ports:
- "8080:8080"
volumes:
- tournament-data:/app/data
environment:
- PORT=8080
- TZ=Europe/Prague
restart: unless-stopped
volumes:
tournament-data:

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).

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frisbee Tournament</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:wght@400;500;600;700&family=Barlow+Condensed:wght@600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>

1043
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0"
}
}

View File

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" fill="none">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0d2818"/>
<stop offset="100%" stop-color="#166534"/>
</linearGradient>
<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#22c55e"/>
<stop offset="100%" stop-color="#16a34a"/>
</linearGradient>
<linearGradient id="disc-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#fb923c"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="800" height="400" fill="url(#sky)"/>
<!-- Grass field -->
<ellipse cx="400" cy="420" rx="500" ry="120" fill="url(#grass)" opacity="0.3"/>
<!-- Field lines -->
<line x1="100" y1="350" x2="700" y2="350" stroke="#22c55e" stroke-width="1" opacity="0.2"/>
<line x1="150" y1="330" x2="650" y2="330" stroke="#22c55e" stroke-width="1" opacity="0.15"/>
<!-- Player silhouette - layout dive -->
<g transform="translate(320, 180) rotate(-15)">
<!-- Body -->
<ellipse cx="0" cy="0" rx="18" ry="35" fill="white" opacity="0.9"/>
<!-- Head -->
<circle cx="-5" cy="-42" r="14" fill="white" opacity="0.9"/>
<!-- Extended arm (throwing) -->
<line x1="15" y1="-15" x2="65" y2="-35" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
<!-- Other arm -->
<line x1="-15" y1="-10" x2="-55" y2="10" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
<!-- Legs (diving) -->
<line x1="-5" y1="30" x2="-40" y2="70" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
<line x1="5" y1="30" x2="30" y2="75" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
</g>
<!-- Flying disc -->
<g transform="translate(480, 100) rotate(-20)">
<ellipse cx="0" cy="4" rx="32" ry="10" fill="url(#disc-grad)" opacity="0.3"/>
<ellipse cx="0" cy="0" rx="28" ry="28" fill="url(#disc-grad)"/>
<ellipse cx="0" cy="0" rx="18" ry="18" fill="none" stroke="white" stroke-width="1.5" opacity="0.4"/>
<ellipse cx="0" cy="0" rx="8" ry="8" fill="white" opacity="0.2"/>
<!-- Motion trails -->
<line x1="-40" y1="5" x2="-55" y2="8" stroke="#fb923c" stroke-width="2" opacity="0.5"/>
<line x1="-38" y1="12" x2="-52" y2="14" stroke="#fb923c" stroke-width="2" opacity="0.4"/>
<line x1="-42" y1="-2" x2="-54" y2="0" stroke="#fb923c" stroke-width="2" opacity="0.3"/>
</g>
<!-- Decorative dots -->
<circle cx="120" cy="80" r="2" fill="#4ade80" opacity="0.3"/>
<circle cx="680" cy="120" r="3" fill="#4ade80" opacity="0.2"/>
<circle cx="200" cy="300" r="2" fill="#38bdf8" opacity="0.2"/>
<circle cx="600" cy="280" r="2" fill="#38bdf8" opacity="0.15"/>
<circle cx="150" cy="200" r="1.5" fill="white" opacity="0.1"/>
<circle cx="650" cy="60" r="1.5" fill="white" opacity="0.15"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

31
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Header from './components/Header';
import Footer from './components/Footer';
import HomePage from './pages/HomePage';
import TournamentPage from './pages/TournamentPage';
import SchedulePage from './pages/SchedulePage';
import GamePage from './pages/GamePage';
import QuestionnairePage from './pages/QuestionnairePage';
import ResultsPage from './pages/ResultsPage';
import PastPage from './pages/PastPage';
export default function App() {
return (
<BrowserRouter>
<div className="app">
<Header />
<Routes>
<Route path="/" element={<Navigate to="/tournament/fujarna-14-3-2026" replace />} />
<Route path="/tournament/:id" element={<TournamentPage />} />
<Route path="/tournament/:id/schedule" element={<SchedulePage />} />
<Route path="/tournament/:id/game/:gid" element={<GamePage />} />
<Route path="/tournament/:id/questionnaire" element={<QuestionnairePage />} />
<Route path="/tournament/:id/results" element={<ResultsPage />} />
<Route path="/past" element={<PastPage />} />
</Routes>
<Footer />
</div>
</BrowserRouter>
);
}

38
frontend/src/api.js Normal file
View File

@@ -0,0 +1,38 @@
const API_BASE = '/api';
async function fetchJSON(path) {
const res = await fetch(`${API_BASE}${path}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
async function postJSON(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export const getTournaments = () => fetchJSON('/tournaments');
export const getTournament = (id) => fetchJSON(`/tournaments/${id}`);
export const getSchedule = (id) => fetchJSON(`/tournaments/${id}/schedule`);
export const getScore = (tid, gid) => fetchJSON(`/tournaments/${tid}/games/${gid}/score`);
export const updateScore = (tid, gid, u) => postJSON(`/tournaments/${tid}/games/${gid}/score`, u);
export const getAuditLog = (tid, gid) => fetchJSON(`/tournaments/${tid}/games/${gid}/audit`);
export const getQuestionnaire = (id) => fetchJSON(`/tournaments/${id}/questionnaire`);
export const submitQuestionnaire = (id, d) => postJSON(`/tournaments/${id}/questionnaire`, d);
export const getQuestionnaireResults = (id) => fetchJSON(`/tournaments/${id}/questionnaire/results`);
export const getResults = (id) => fetchJSON(`/tournaments/${id}/results`);
export function createGameWebSocket(tourneyId, gameId, userId = 'anon') {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(`${proto}//${location.host}/ws/game/${tourneyId}/${gameId}?user_id=${userId}`);
}
export function createTournamentWebSocket(tourneyId) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(`${proto}//${location.host}/ws/tournament/${tourneyId}`);
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
export default function Footer() {
return (
<footer className="site-footer">
<p>Disc Agenda &mdash; Turnajová platforma pro ultimate frisbee &copy; {new Date().getFullYear()}</p>
</footer>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { FlyingDiscIcon } from './Icons';
export default function Header() {
const { pathname } = useLocation();
const navLinks = [
{ to: '/tournament/fujarna-14-3-2026', label: 'Domů' },
{ to: '/past', label: 'Archiv' },
];
return (
<header className="site-header">
<div className="header-inner">
<Link to="/tournament/fujarna-14-3-2026" className="header-logo">
<FlyingDiscIcon size={36} />
</Link>
<nav className="header-nav">
{navLinks.map(({ to, label }) => (
<Link
key={to}
to={to}
className={pathname === to ? 'active' : ''}
>
{label}
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
export function DiscIcon({ size = 36, className = '' }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="20" cy="22" rx="18" ry="8" fill="currentColor" opacity="0.15"/>
<ellipse cx="20" cy="18" rx="16" ry="16" fill="none" stroke="currentColor" strokeWidth="2"/>
<ellipse cx="20" cy="18" rx="10" ry="10" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.5"/>
<ellipse cx="20" cy="18" rx="4" ry="4" fill="currentColor" opacity="0.3"/>
<path d="M8 14 C12 8, 28 8, 32 14" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.4"/>
</svg>
);
}
export function FlyingDiscIcon({ size = 48, className = '' }) {
return (
<svg width={size} height={size} viewBox="0 0 60 60" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="rotate(-25, 30, 30)">
<ellipse cx="30" cy="30" rx="22" ry="8" fill="currentColor" opacity="0.2"/>
<ellipse cx="30" cy="26" rx="20" ry="20" fill="none" stroke="currentColor" strokeWidth="2.5"/>
<ellipse cx="30" cy="26" rx="12" ry="12" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
<ellipse cx="30" cy="26" rx="5" ry="5" fill="currentColor" opacity="0.25"/>
</g>
{/* Motion lines */}
<line x1="8" y1="20" x2="2" y2="22" stroke="currentColor" strokeWidth="1.5" opacity="0.3"/>
<line x1="10" y1="26" x2="3" y2="27" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
<line x1="9" y1="32" x2="4" y2="32" stroke="currentColor" strokeWidth="1.5" opacity="0.2"/>
</svg>
);
}
export function MapPinIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
}
export function CalendarIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
);
}
export function UsersIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
);
}
export function ClockIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
);
}
export function TrophyIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
</svg>
);
}
export function FieldIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="1"/><line x1="12" y1="4" x2="12" y2="20"/><circle cx="12" cy="12" r="3"/>
</svg>
);
}
export function ChevronRightIcon({ size = 18 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
);
}

6
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/global.css';
createRoot(document.getElementById('root')).render(<App />);

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { getSchedule, getScore, getAuditLog, createGameWebSocket } from '../api';
function generateUserId() {
try {
const stored = sessionStorage.getItem('scorer_id');
if (stored) return stored;
const id = 'user_' + Math.random().toString(36).slice(2, 8);
sessionStorage.setItem('scorer_id', id);
return id;
} catch { return 'user_' + Math.random().toString(36).slice(2, 8); }
}
export default function GamePage() {
const { id, gid } = useParams();
const [game, setGame] = useState(null);
const [score, setScore] = useState({ home_score: 0, away_score: 0 });
const [gameStatus, setGameStatus] = useState('scheduled');
const [wsStatus, setWsStatus] = useState('connecting');
const [auditLog, setAuditLog] = useState([]);
const [showAudit, setShowAudit] = useState(false);
const [showSetModal, setShowSetModal] = useState(null);
const [setVal, setSetVal] = useState('');
const wsRef = useRef(null);
const userId = useRef(generateUserId());
useEffect(() => {
getSchedule(id).then(sched => {
const g = sched.games?.find(x => x.id === gid);
if (g) {
setGame(g);
setScore({ home_score: g.home_score, away_score: g.away_score });
if (g.status) setGameStatus(g.status);
}
}).catch(console.error);
// Fetch persisted score for latest state (schedule may lag behind)
getScore(id, gid).then(state => {
if (state && state.status) {
setScore({ home_score: state.home_score, away_score: state.away_score });
setGameStatus(state.status);
}
}).catch(() => {});
}, [id, gid]);
useEffect(() => {
let ws;
let reconnectTimer;
function connect() {
setWsStatus('connecting');
ws = createGameWebSocket(id, gid, userId.current);
wsRef.current = ws;
ws.onopen = () => setWsStatus('connected');
ws.onclose = () => { setWsStatus('disconnected'); reconnectTimer = setTimeout(connect, 3000); };
ws.onerror = () => ws.close();
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.state) {
setScore({ home_score: msg.state.home_score, away_score: msg.state.away_score });
if (msg.state.status) setGameStatus(msg.state.status);
}
if (msg.audit) setAuditLog(prev => [msg.audit, ...prev]);
} catch {}
};
}
connect();
return () => { clearTimeout(reconnectTimer); if (ws) ws.close(); };
}, [id, gid]);
useEffect(() => {
if (showAudit) {
getAuditLog(id, gid).then(entries => { if (entries) setAuditLog(entries.reverse()); }).catch(console.error);
}
}, [showAudit, id, gid]);
const sendAction = useCallback((action, team, value) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action, team, value: value || 0 }));
}
}, []);
const handleSet = (team) => {
const v = parseInt(setVal, 10);
if (!isNaN(v) && v >= 0) { sendAction('set', team, v); setShowSetModal(null); setSetVal(''); }
};
if (!game) return <div className="loading"><div className="spinner" /> Načítání zápasu...</div>;
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}/schedule`} className="btn btn-ghost btn-sm">&larr; Rozpis</Link>
</div>
<div style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)' }}>
{game.round} &middot; {game.field}
</div>
<div className="scoreboard">
<div className="scoreboard-header">
{gameStatus === 'final' ? 'Konečné skóre' : 'Živé skóre'}
</div>
<div className="scoreboard-teams">
<div className="scoreboard-team-name">{game.home_team}</div>
<div className="scoreboard-score">
<span>{score.home_score}</span>
<span className="divider">:</span>
<span>{score.away_score}</span>
</div>
<div className="scoreboard-team-name">{game.away_team}</div>
</div>
{gameStatus !== 'final' && (
<div className="score-controls">
{['home', 'away'].map(team => (
<div key={team} className="score-controls-row">
<span className="score-team-label" style={{ textAlign: 'right' }}>{team === 'home' ? game.home_team : game.away_team}</span>
<button className="score-btn minus" onClick={() => sendAction('decrement', team)}></button>
<button className="score-btn plus" onClick={() => sendAction('increment', team)}>+</button>
<button className="score-btn" onClick={() => { setShowSetModal(team); setSetVal(String(team === 'home' ? score.home_score : score.away_score)); }} style={{ fontSize: '0.7rem', fontFamily: 'var(--font-heading)' }}>SET</button>
</div>
))}
</div>
)}
<div style={{ marginTop: '1rem', textAlign: 'center', display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
{gameStatus === 'scheduled' && (
<button className="btn btn-sm btn-primary" onClick={() => sendAction('set_status', 'live')}>
Zahájit zápas
</button>
)}
{gameStatus === 'live' && (
<button className="btn btn-sm btn-primary" onClick={() => sendAction('set_status', 'final')}>
Ukončit zápas
</button>
)}
{gameStatus === 'final' && (
<>
<button className="btn btn-sm btn-outline" onClick={() => sendAction('set_status', 'live')}>
Znovu otevřít zápas
</button>
<button className="btn btn-sm btn-ghost" onClick={() => sendAction('set_status', 'scheduled')}>
Zpět na naplánovaný
</button>
</>
)}
</div>
<div className="ws-status">
<span className={`ws-dot ${wsStatus}`} />
{wsStatus === 'connected' ? 'Připojeno' : wsStatus === 'connecting' ? 'Připojování...' : 'Obnovování spojení...'}
</div>
</div>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<QRCodeSVG value={window.location.href} size={160} />
</div>
{showSetModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setShowSetModal(null)}>
<div className="card card-body" style={{ minWidth: 280 }} onClick={e => e.stopPropagation()}>
<h3 style={{ fontFamily: 'var(--font-display)', fontSize: '1.3rem', marginBottom: '1rem' }}>
Nastavit skóre {showSetModal === 'home' ? game.home_team : game.away_team}
</h3>
<input type="number" min="0" className="form-input" value={setVal} onChange={e => setSetVal(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleSet(showSetModal)} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button className="btn btn-primary" onClick={() => handleSet(showSetModal)}>Nastavit</button>
<button className="btn btn-ghost" onClick={() => setShowSetModal(null)}>Zrušit</button>
</div>
</div>
</div>
)}
<div style={{ marginTop: '2rem' }}>
<button className="btn btn-outline btn-sm" onClick={() => setShowAudit(!showAudit)}>
{showAudit ? 'Skrýt' : 'Zobrazit'} historii změn
</button>
{showAudit && (
<div className="card" style={{ marginTop: '1rem' }}>
<div className="card-header">
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase' }}>Historie skóre</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{auditLog.length} záznamů</span>
</div>
<div className="audit-log">
{auditLog.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Zatím žádné změny.</div>
) : auditLog.map((e, i) => (
<div key={i} className="audit-entry">
<span><strong>{e.action}</strong> {e.team} {e.action === 'set' ? `${e.value}` : ''} ({e.old_home}:{e.old_away} {e.new_home}:{e.new_away})</span>
<span style={{ color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{e.user_id} · {new Date(e.timestamp).toLocaleTimeString('cs-CZ')}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getTournaments } from '../api';
import { MapPinIcon, CalendarIcon, UsersIcon, ChevronRightIcon } from '../components/Icons';
function statusLabel(s) {
if (s === 'in_progress') return { text: 'Právě probíhá', cls: 'live', dot: '' };
if (s === 'upcoming') return { text: 'Nadcházející', cls: 'upcoming', dot: 'orange' };
return { text: 'Ukončený', cls: 'completed', dot: 'slate' };
}
function formatDate(d) {
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
export default function HomePage() {
const [tournaments, setTournaments] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getTournaments()
.then(setTournaments)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="loading"><div className="spinner" /> Načítání turnajů...</div>;
// Find active or nearest upcoming
const active = tournaments.find(t => t.status === 'in_progress');
const upcoming = tournaments.filter(t => t.status === 'upcoming').sort((a, b) => a.start_date.localeCompare(b.start_date));
const featured = active || upcoming[0];
return (
<>
{featured && <FeaturedHero tournament={featured} />}
<div className="page-content">
{upcoming.length > 0 && !active && upcoming.length > 1 && (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Další nadcházející</h2>
<div className="tournament-grid">
{upcoming.slice(1).map(t => <TournamentCard key={t.id} tournament={t} />)}
</div>
</section>
)}
{active && upcoming.length > 0 && (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Nadcházející turnaje</h2>
<div className="tournament-grid">
{upcoming.map(t => <TournamentCard key={t.id} tournament={t} />)}
</div>
</section>
)}
<section>
<Link to="/past" className="btn btn-outline" style={{ marginTop: '1rem' }}>
Zobrazit minulé turnaje <ChevronRightIcon size={16} />
</Link>
</section>
</div>
</>
);
}
function FeaturedHero({ tournament: t }) {
const st = statusLabel(t.status);
return (
<div className="hero">
<div className="hero-inner">
<div className="hero-badge">
<span className={`dot ${st.dot}`} />
{st.text}
</div>
<h1>{t.name}</h1>
<p>{t.description}</p>
<div className="hero-meta">
<div className="hero-meta-item">
<CalendarIcon /> {formatDate(t.start_date)} {formatDate(t.end_date)}
</div>
<div className="hero-meta-item">
<MapPinIcon /> {t.venue}, {t.location}
</div>
<div className="hero-meta-item">
<UsersIcon /> {t.teams?.length || 0} týmů
</div>
</div>
<div style={{ marginTop: '2rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/tournament/${t.id}`} className="btn btn-primary btn-lg">
Detail turnaje <ChevronRightIcon size={16} />
</Link>
<Link to={`/tournament/${t.id}/schedule`} className="btn btn-secondary btn-lg">
Rozpis
</Link>
</div>
</div>
</div>
);
}
function TournamentCard({ tournament: t }) {
const st = statusLabel(t.status);
return (
<Link to={`/tournament/${t.id}`} className="card game-link" style={{ position: 'relative' }}>
<div className="tournament-card-status">
<span className={`status-badge ${st.cls}`}>
<span className={`dot ${st.dot}`} style={{ width: 6, height: 6, borderRadius: '50%', display: 'inline-block' }} />
{st.text}
</span>
</div>
<div className="tournament-card-header">
<h3>{t.name}</h3>
<div className="tournament-card-date">{formatDate(t.start_date)} {formatDate(t.end_date)}</div>
</div>
<div className="tournament-card-body">
<div className="tournament-card-meta">
<span><MapPinIcon size={16} /> {t.location}</span>
<span><UsersIcon size={16} /> {t.teams?.length || 0} týmů</span>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getTournaments } from '../api';
import { MapPinIcon, CalendarIcon, UsersIcon, TrophyIcon, ChevronRightIcon } from '../components/Icons';
function formatDate(d) {
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', { day: 'numeric', month: 'short', year: 'numeric' });
}
export default function PastPage() {
const [tournaments, setTournaments] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getTournaments()
.then(ts => setTournaments(ts.filter(t => t.status === 'completed')))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
return (
<div className="page-content">
<h1 className="section-title">Minulé turnaje</h1>
{tournaments.length === 0 ? (
<div className="card card-body" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-secondary)' }}>
Zatím žádné minulé turnaje.
</div>
) : (
<div className="tournament-grid">
{tournaments.map(t => (
<div key={t.id} className="card" style={{ position: 'relative' }}>
<div className="tournament-card-header" style={{ background: 'linear-gradient(135deg, var(--slate-700), var(--slate-600))' }}>
<h3>{t.name}</h3>
<div className="tournament-card-date">{formatDate(t.start_date)} {formatDate(t.end_date)}</div>
</div>
<div className="tournament-card-body">
<div className="tournament-card-meta">
<span><MapPinIcon size={16} /> {t.location}</span>
<span><UsersIcon size={16} /> {t.teams?.length || 0} týmů</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<Link to={"/tournament/" + t.id} className="btn btn-outline btn-sm">
Detail <ChevronRightIcon size={14} />
</Link>
<Link to={"/tournament/" + t.id + "/results"} className="btn btn-secondary btn-sm">
<TrophyIcon size={14} /> Výsledky
</Link>
</div>
</div>
</div>
))}
</div>
)}
<div style={{ marginTop: '2rem' }}>
<Link to="/" className="btn btn-ghost">&larr; Zpět na hlavní stránku</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getQuestionnaire, submitQuestionnaire } from '../api';
export default function QuestionnairePage() {
const { id } = useParams();
const [config, setConfig] = useState(null);
const [teams, setTeams] = useState([]);
const [loading, setLoading] = useState(true);
const [submitted, setSubmitted] = useState(false);
const [myTeam, setMyTeam] = useState('');
const [spiritWinner, setSpiritWinner] = useState('');
const [attendNext, setAttendNext] = useState(false);
const [customAnswers, setCustomAnswers] = useState({});
useEffect(() => {
getQuestionnaire(id).then(data => { setConfig(data.config); setTeams(data.teams || []); }).catch(console.error).finally(() => setLoading(false));
}, [id]);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await submitQuestionnaire(id, { my_team: myTeam, spirit_winner: spiritWinner, attend_next: attendNext, custom_answers: customAnswers });
setSubmitted(true);
} catch (err) { alert('Chyba: ' + err.message); }
};
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
if (submitted) return (
<div className="page-content" style={{ textAlign: 'center', paddingTop: '4rem' }}>
<div className="success-msg" style={{ maxWidth: 500, margin: '0 auto' }}>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>🥏</div>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>Díky!</h2>
<p style={{ color: 'var(--text-secondary)' }}>Tvoje odpověď byla zaznamenána.</p>
</div>
<Link to={`/tournament/${id}`} className="btn btn-primary" style={{ marginTop: '2rem' }}>Zpět na turnaj</Link>
</div>
);
const customQs = config?.custom_questions || [];
return (
<div className="page-content" style={{ maxWidth: 640, margin: '0 auto' }}>
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">&larr; Zpět</Link>
</div>
<h1 className="section-title">Dotazník po turnaji</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>Pomoz nám se zlepšit! Odpovědi jsou anonymní.</p>
<form onSubmit={handleSubmit}>
<div className="card card-body" style={{ marginBottom: '1.5rem' }}>
<div className="form-group">
<label className="form-label">Můj tým *</label>
<select className="form-select" value={myTeam} onChange={e => setMyTeam(e.target.value)} required>
<option value="">Vyber svůj tým...</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Spirit winner *</label>
<select className="form-select" value={spiritWinner} onChange={e => setSpiritWinner(e.target.value)} required>
<option value="">Vyber tým se spirit...</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>Který tým měl nejlepší spirit?</div>
</div>
<div className="form-group">
<label className="form-check">
<input type="checkbox" checked={attendNext} onChange={e => setAttendNext(e.target.checked)} />
<span>Chci se zúčastnit příštího turnaje</span>
</label>
</div>
</div>
{customQs.length > 0 && (
<div className="card card-body" style={{ marginBottom: '1.5rem' }}>
{customQs.map(q => (
<div key={q.id} className="form-group">
<label className="form-label">{q.text} {q.required && '*'}</label>
{q.type === 'select' || q.type === 'radio' ? (
<select className="form-select" value={customAnswers[q.id] || ''} onChange={e => setCustomAnswers(p => ({ ...p, [q.id]: e.target.value }))} required={q.required}>
<option value="">Vyber...</option>
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<textarea className="form-textarea" value={customAnswers[q.id] || ''} onChange={e => setCustomAnswers(p => ({ ...p, [q.id]: e.target.value }))} required={q.required} placeholder="Tvoje odpověď..." />
)}
</div>
))}
</div>
)}
<button type="submit" className="btn btn-primary btn-lg" style={{ width: '100%' }}>Odeslat odpověď</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getResults } from '../api';
import { TrophyIcon } from '../components/Icons';
function medal(pos) {
if (pos === 1) return <span className="medal-1">🥇</span>;
if (pos === 2) return <span className="medal-2">🥈</span>;
if (pos === 3) return <span className="medal-3">🥉</span>;
return <span>{pos}</span>;
}
export default function ResultsPage() {
const { id } = useParams();
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getResults(id).then(setResults).catch(console.error).finally(() => setLoading(false));
}, [id]);
if (loading) return <div className="loading"><div className="spinner" /> Načítání výsledků...</div>;
const standings = results?.standings || [];
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">&larr; Zpět na turnaj</Link>
</div>
<h1 className="section-title"><TrophyIcon size={28} /> Konečné výsledky</h1>
{standings.length === 0 ? (
<div className="card card-body" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-secondary)' }}>Výsledky zatím nejsou k dispozici.</div>
) : (
<div className="card" style={{ overflow: 'auto' }}>
<table className="results-table">
<thead><tr><th>#</th><th>Tým</th><th>V</th><th>P</th><th>R</th><th>Body+</th><th>Body-</th><th>+/-</th><th>Spirit</th></tr></thead>
<tbody>
{standings.map(s => (
<tr key={s.team_id}>
<td className="position">{medal(s.position)}</td>
<td className="team-name">{s.team_name}</td>
<td>{s.wins}</td><td>{s.losses}</td><td>{s.draws}</td>
<td>{s.points_for}</td><td>{s.points_against}</td>
<td style={{ color: s.points_for - s.points_against > 0 ? 'var(--green-600)' : 'var(--red-500)', fontWeight: 600 }}>
{s.points_for - s.points_against > 0 ? '+' : ''}{s.points_for - s.points_against}
</td>
<td>{s.spirit_score?.toFixed(1) || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{standings.length > 0 && (() => {
const sw = [...standings].sort((a, b) => (b.spirit_score || 0) - (a.spirit_score || 0))[0];
return sw?.spirit_score ? (
<div className="card card-body" style={{ marginTop: '1.5rem', textAlign: 'center', background: 'var(--green-50)', borderColor: 'var(--green-400)' }}>
<div style={{ fontSize: '2rem', marginBottom: '0.25rem' }}>🕊</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, textTransform: 'uppercase', fontSize: '0.8rem', letterSpacing: '0.06em', color: 'var(--green-700)' }}>Spirit Award</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.8rem' }}>{sw.team_name}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Spirit skóre: {sw.spirit_score.toFixed(1)}</div>
</div>
) : null;
})()}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getSchedule, createTournamentWebSocket } from '../api';
import { ClockIcon } from '../components/Icons';
function formatTime(dt) {
return new Date(dt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' });
}
function groupBySection(games) {
const poolA = [];
const poolB = [];
const placement = [];
for (const g of games) {
if (g.round.startsWith('Pool A')) poolA.push(g);
else if (g.round.startsWith('Pool B')) poolB.push(g);
else placement.push(g);
}
const sections = [];
if (poolA.length) sections.push(['Skupina A', poolA]);
if (poolB.length) sections.push(['Skupina B', poolB]);
if (placement.length) sections.push(['Pavouk', placement]);
return sections;
}
function hasScore(g) {
return g.home_score > 0 || g.away_score > 0;
}
export default function SchedulePage() {
const { id } = useParams();
const [schedule, setSchedule] = useState(null);
const [loading, setLoading] = useState(true);
const fetchSchedule = useCallback(() => {
getSchedule(id).then(setSchedule).catch(console.error).finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchSchedule();
const onVisibility = () => { if (document.visibilityState === 'visible') fetchSchedule(); };
document.addEventListener('visibilitychange', onVisibility);
// Live updates via tournament WebSocket
let ws = null;
let reconnectTimer = null;
function connect() {
ws = createTournamentWebSocket(id);
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === 'score_update' && msg.state) {
setSchedule(prev => {
if (!prev) return prev;
return {
...prev,
games: prev.games.map(g =>
g.id === msg.state.game_id
? { ...g, home_score: msg.state.home_score, away_score: msg.state.away_score, status: msg.state.status }
: g
),
};
});
}
};
ws.onclose = () => {
reconnectTimer = setTimeout(connect, 3000);
};
}
connect();
return () => {
document.removeEventListener('visibilitychange', onVisibility);
clearTimeout(reconnectTimer);
if (ws) { ws.onclose = null; ws.close(); }
};
}, [fetchSchedule, id]);
if (loading) return <div className="loading"><div className="spinner" /> Načítání rozpisu...</div>;
const games = schedule?.games || [];
const sections = groupBySection(games);
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">&larr; Zpět na turnaj</Link>
</div>
<h1 className="section-title">Rozpis</h1>
{sections.length === 0 && <p style={{ color: 'var(--text-secondary)' }}>Zatím žádné naplánované zápasy.</p>}
<div className="schedule-grid">
{sections.map(([section, sectionGames]) => (
<div key={section} className="schedule-round">
<div className="schedule-round-title">{section}</div>
{sectionGames.map(g => (
<Link to={`/tournament/${id}/game/${g.id}`} key={g.id} className="game-row game-link" style={g.status === 'live' ? { borderColor: 'orange', borderWidth: '2px' } : undefined}>
<div className="game-time" style={{ fontWeight: 700, fontSize: '1.05rem', minWidth: '3.2rem' }}>
{formatTime(g.start_time)}
</div>
<div className="game-team home">{g.home_team}</div>
<div className="game-score" style={{
color: g.status === 'final' ? 'var(--text-primary)' : g.status === 'live' ? 'var(--green-700)' : undefined
}}>
{g.status === 'scheduled' && !hasScore(g) ? ' : ' : `${g.home_score} : ${g.away_score}`}
</div>
<div className="game-team away">{g.away_team}</div>
<span className={`status-badge ${g.status === 'live' ? 'live' : g.status === 'final' ? 'completed' : 'scheduled'}`}>
{g.status}
</span>
</Link>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { getTournament } from '../api';
import { MapPinIcon, CalendarIcon, UsersIcon, DiscIcon, ChevronRightIcon } from '../components/Icons';
function formatDate(d) {
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', {
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
});
}
export default function TournamentPage() {
const { id } = useParams();
const [t, setT] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getTournament(id).then(setT).catch(console.error).finally(() => setLoading(false));
}, [id]);
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
if (!t) return <div className="page-content"><p>Turnaj nenalezen.</p></div>;
const questionnaireUrl = `${window.location.origin}/tournament/${id}/questionnaire`;
return (
<div className="page-content">
<div style={{ marginBottom: '1rem' }}>
<Link to="/" className="btn btn-ghost btn-sm">&larr; Zpět</Link>
</div>
<h1 className="section-title" style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>{t.name}</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem', maxWidth: 700 }}>{t.description}</p>
{/* Info Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem', marginBottom: '2.5rem' }}>
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CalendarIcon size={24} />
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Datum</div>
<div style={{ fontWeight: 600 }}>{formatDate(t.start_date)} {formatDate(t.end_date)}</div>
</div>
</div>
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<MapPinIcon size={24} />
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Místo</div>
<div style={{ fontWeight: 600 }}>{t.venue}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{t.location}</div>
</div>
</div>
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<UsersIcon size={24} />
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Týmy</div>
<div style={{ fontWeight: 600 }}>{t.teams?.length || 0} registrovaných</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '2.5rem' }}>
<Link to={`/tournament/${id}/schedule`} className="btn btn-primary btn-lg">
Rozpis <ChevronRightIcon size={16} />
</Link>
<Link to={`/tournament/${id}/results`} className="btn btn-secondary btn-lg">
Výsledky
</Link>
<Link to={`/tournament/${id}/questionnaire`} className="btn btn-outline btn-lg">
Dotazník
</Link>
</div>
{/* Teams */}
{t.teams && t.teams.length > 0 && (() => {
const poolAIds = ['fuj-1', 'kocicaci', 'spitalska', 'sunset', 'hoko-coko-diskyto'];
const poolA = t.teams.filter(team => poolAIds.includes(team.id));
const poolB = t.teams.filter(team => !poolAIds.includes(team.id));
return (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Zúčastněné týmy</h2>
{poolA.length > 0 && (
<>
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Skupina A</h3>
<div className="teams-grid" style={{ marginBottom: '1.25rem' }}>
{poolA.map(team => (
<div key={team.id} className="team-chip">
<DiscIcon size={20} />
{team.name}
</div>
))}
</div>
</>
)}
{poolB.length > 0 && (
<>
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Skupina B</h3>
<div className="teams-grid">
{poolB.map(team => (
<div key={team.id} className="team-chip">
<DiscIcon size={20} />
{team.name}
</div>
))}
</div>
</>
)}
</section>
);
})()}
{/* Rules */}
{t.rules && (
<section style={{ marginBottom: '2.5rem' }}>
<h2 className="section-title">Pravidla</h2>
<div className="card card-body">
<p>{t.rules}</p>
</div>
</section>
)}
{/* QR Code for questionnaire */}
<section>
<h2 className="section-title">Dotazník</h2>
<div className="qr-section">
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
Naskenuj QR kód pro otevření dotazníku na telefonu:
</p>
<QRCodeSVG value={questionnaireUrl} size={180} level="M" bgColor="transparent" fgColor="#14532d" />
<p style={{ marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>
{questionnaireUrl}
</p>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,944 @@
:root {
/* Core palette — grass field greens + energetic accents */
--green-900: #0d2818;
--green-800: #14532d;
--green-700: #166534;
--green-600: #16a34a;
--green-500: #22c55e;
--green-400: #4ade80;
--green-100: #dcfce7;
--green-50: #f0fdf4;
--sky-500: #0ea5e9;
--sky-400: #38bdf8;
--sky-300: #7dd3fc;
--sky-100: #e0f2fe;
--orange-500: #f97316;
--orange-400: #fb923c;
--orange-300: #fdba74;
--slate-900: #0f172a;
--slate-800: #1e293b;
--slate-700: #334155;
--slate-600: #475569;
--slate-400: #94a3b8;
--slate-300: #cbd5e1;
--slate-200: #e2e8f0;
--slate-100: #f1f5f9;
--slate-50: #f8fafc;
--white: #ffffff;
--red-500: #ef4444;
--red-600: #dc2626;
/* Semantic */
--bg-primary: var(--green-900);
--bg-card: var(--white);
--bg-surface: var(--slate-50);
--text-primary: var(--slate-900);
--text-secondary: var(--slate-600);
--text-on-dark: var(--green-50);
--accent: var(--orange-500);
--accent-hover: var(--orange-400);
--border: var(--slate-200);
/* Typography */
--font-display: 'Bebas Neue', 'Impact', sans-serif;
--font-heading: 'Barlow Condensed', 'Arial Narrow', sans-serif;
--font-body: 'Barlow', 'Helvetica Neue', sans-serif;
/* Spacing */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
--shadow-xl: 0 20px 60px rgba(0,0,0,0.15);
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
color: var(--text-primary);
background: var(--bg-surface);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ---- LAYOUT ---- */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.page-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
width: 100%;
}
/* ---- HEADER / NAV ---- */
.site-header {
background: var(--green-900);
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 20px rgba(0,0,0,0.3);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
}
.header-logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-on-dark);
}
.header-logo svg {
width: 36px;
height: 36px;
}
.header-logo-text {
font-family: var(--font-display);
font-size: 1.75rem;
letter-spacing: 0.05em;
line-height: 1;
}
.header-nav {
display: flex;
gap: 0.25rem;
}
.header-nav a {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--green-400);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.header-nav a:hover,
.header-nav a.active {
background: rgba(255,255,255,0.1);
color: var(--white);
}
/* ---- HERO ---- */
.hero {
background: linear-gradient(135deg, var(--green-900) 0%, var(--green-800) 50%, var(--green-700) 100%);
color: var(--text-on-dark);
padding: 4rem 1.5rem;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 80% 20%, rgba(34, 197, 94, 0.15), transparent),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(14, 165, 233, 0.1), transparent);
pointer-events: none;
}
.hero-inner {
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(255,255,255,0.1);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.15);
padding: 0.35rem 1rem;
border-radius: 100px;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 1.25rem;
}
.hero-badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green-400);
animation: pulse 2s infinite;
}
.hero-badge .dot.orange { background: var(--orange-400); animation: none; }
.hero-badge .dot.slate { background: var(--slate-400); animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2.5rem, 6vw, 4.5rem);
letter-spacing: 0.03em;
line-height: 1;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.15rem;
color: rgba(255,255,255,0.75);
max-width: 600px;
line-height: 1.6;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-top: 2rem;
}
.hero-meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.95rem;
}
.hero-meta-item svg {
width: 18px;
height: 18px;
opacity: 0.7;
}
/* ---- CARDS ---- */
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border);
overflow: hidden;
transition: box-shadow 0.25s, transform 0.25s;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-body {
padding: 1.5rem;
}
.card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
/* ---- SECTION ---- */
.section-title {
font-family: var(--font-display);
font-size: 2rem;
letter-spacing: 0.04em;
margin-bottom: 1.5rem;
color: var(--green-800);
}
/* ---- BUTTONS ---- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.06em;
text-decoration: none;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: var(--white);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--green-700);
color: var(--white);
}
.btn-secondary:hover {
background: var(--green-600);
}
.btn-outline {
background: transparent;
border: 2px solid var(--green-600);
color: var(--green-700);
}
.btn-outline:hover {
background: var(--green-600);
color: var(--white);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--slate-100);
}
.btn-lg {
padding: 0.85rem 2rem;
font-size: 1rem;
}
.btn-sm {
padding: 0.4rem 0.85rem;
font-size: 0.8rem;
}
.btn-icon {
width: 44px;
height: 44px;
padding: 0;
border-radius: 50%;
}
/* ---- STATUS BADGES ---- */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.75rem;
border-radius: 100px;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-badge.live {
background: rgba(34, 197, 94, 0.12);
color: var(--green-700);
}
.status-badge.upcoming {
background: rgba(14, 165, 233, 0.12);
color: var(--sky-500);
}
.status-badge.completed {
background: var(--slate-100);
color: var(--slate-600);
}
.status-badge.scheduled {
background: var(--slate-100);
color: var(--slate-600);
}
/* ---- SCHEDULE TABLE ---- */
.schedule-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.schedule-round {
margin-bottom: 1rem;
}
.schedule-round-title {
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--green-700);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--green-100);
}
.game-row {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.25rem;
background: var(--white);
border-radius: var(--radius-md);
border: 1px solid var(--border);
transition: all 0.15s;
}
.game-row:hover {
border-color: var(--green-400);
box-shadow: var(--shadow-sm);
}
.game-team {
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.05rem;
}
.game-team.home { text-align: right; }
.game-team.away { text-align: left; }
.game-score {
font-family: var(--font-display);
font-size: 1.5rem;
min-width: 80px;
text-align: center;
letter-spacing: 0.05em;
}
.game-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 0.8rem;
color: var(--text-secondary);
gap: 0.15rem;
}
.game-link {
text-decoration: none;
color: inherit;
}
/* ---- SCOREBOARD ---- */
.scoreboard {
background: var(--green-900);
border-radius: var(--radius-xl);
padding: 2.5rem;
color: var(--text-on-dark);
text-align: center;
box-shadow: var(--shadow-xl);
position: relative;
overflow: hidden;
}
.scoreboard::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(34, 197, 94, 0.15), transparent 70%);
pointer-events: none;
}
.scoreboard-header {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.6;
margin-bottom: 0.5rem;
}
.scoreboard-teams {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 2rem;
position: relative;
z-index: 1;
}
.scoreboard-team-name {
font-family: var(--font-display);
font-size: clamp(1.2rem, 3vw, 2rem);
letter-spacing: 0.04em;
}
.scoreboard-score {
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 6rem);
letter-spacing: 0.05em;
line-height: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.scoreboard-score .divider {
opacity: 0.3;
font-size: 0.6em;
}
.score-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
margin-top: 1.5rem;
position: relative;
z-index: 1;
}
.score-controls-row {
display: flex;
gap: 0.75rem;
align-items: center;
}
.score-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.08);
color: var(--white);
font-family: var(--font-display);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.score-btn:hover {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.4);
transform: scale(1.08);
}
.score-btn:active {
transform: scale(0.95);
}
.score-btn.plus { border-color: var(--green-500); color: var(--green-400); }
.score-btn.plus:hover { background: rgba(34, 197, 94, 0.25); }
.score-btn.minus { border-color: var(--red-500); color: var(--red-500); }
.score-btn.minus:hover { background: rgba(239, 68, 68, 0.2); }
.score-team-label {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.5;
min-width: 120px;
}
.ws-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-family: var(--font-heading);
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
margin-top: 1.5rem;
}
.ws-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.ws-dot.connected { background: var(--green-400); }
.ws-dot.disconnected { background: var(--red-500); }
/* ---- QUESTIONNAIRE ---- */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.form-select,
.form-input,
.form-textarea {
width: 100%;
padding: 0.7rem 1rem;
font-family: var(--font-body);
font-size: 0.95rem;
border: 2px solid var(--border);
border-radius: var(--radius-md);
background: var(--white);
color: var(--text-primary);
transition: border-color 0.2s;
}
.form-select:focus,
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: var(--green-500);
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.form-check {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.form-check input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--green-600);
}
/* ---- RESULTS TABLE ---- */
.results-table {
width: 100%;
border-collapse: collapse;
}
.results-table th {
font-family: var(--font-heading);
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 2px solid var(--green-100);
}
.results-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.results-table tr:hover td {
background: var(--green-50);
}
.results-table .position {
font-family: var(--font-display);
font-size: 1.3rem;
color: var(--green-700);
width: 50px;
}
.results-table .team-name {
font-family: var(--font-heading);
font-weight: 600;
font-size: 1.05rem;
}
.medal-1 { color: #d4a017; }
.medal-2 { color: #9ca3af; }
.medal-3 { color: #b87333; }
/* ---- TOURNAMENT CARDS ---- */
.tournament-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
}
.tournament-card-status {
position: absolute;
top: 1rem;
right: 1rem;
}
.tournament-card-header {
padding: 2rem 1.5rem 1.5rem;
background: linear-gradient(135deg, var(--green-800), var(--green-700));
color: var(--white);
position: relative;
}
.tournament-card-header h3 {
font-family: var(--font-display);
font-size: 1.6rem;
letter-spacing: 0.03em;
margin-bottom: 0.25rem;
}
.tournament-card-date {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
opacity: 0.75;
}
.tournament-card-body {
padding: 1.25rem 1.5rem;
}
.tournament-card-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.tournament-card-meta span {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ---- TEAMS GRID ---- */
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.team-chip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--green-50);
border: 1px solid var(--green-100);
border-radius: var(--radius-md);
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.9rem;
color: var(--green-800);
}
.team-chip .disc-icon {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--green-600);
flex-shrink: 0;
}
/* ---- QR CODE ---- */
.qr-section {
text-align: center;
padding: 2rem;
background: var(--white);
border-radius: var(--radius-lg);
border: 2px dashed var(--border);
}
/* ---- FOOTER ---- */
.site-footer {
background: var(--green-900);
color: var(--green-400);
padding: 2rem 1.5rem;
text-align: center;
font-family: var(--font-heading);
font-size: 0.85rem;
margin-top: auto;
}
/* ---- AUDIT LOG ---- */
.audit-log {
max-height: 300px;
overflow-y: auto;
font-size: 0.8rem;
font-family: monospace;
}
.audit-entry {
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 1rem;
}
.audit-entry:nth-child(even) {
background: var(--slate-50);
}
/* ---- TABS ---- */
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.25rem;
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
background: none;
border-top: none;
border-left: none;
border-right: none;
}
.tab:hover {
color: var(--green-700);
}
.tab.active {
color: var(--green-700);
border-bottom-color: var(--green-600);
}
/* ---- LOADING ---- */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
font-family: var(--font-heading);
font-size: 1rem;
gap: 0.75rem;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border);
border-top-color: var(--green-600);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- SUCCESS MESSAGE ---- */
.success-msg {
background: var(--green-50);
border: 1px solid var(--green-400);
color: var(--green-800);
padding: 1.25rem;
border-radius: var(--radius-md);
text-align: center;
font-family: var(--font-heading);
font-weight: 600;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.header-inner { height: 56px; }
.header-nav a { padding: 0.4rem 0.6rem; font-size: 0.8rem; }
.hero { padding: 2.5rem 1rem; }
.page-content { padding: 1.25rem 1rem; }
.game-row {
grid-template-columns: 1fr auto 1fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.game-meta { display: none; }
.game-row .btn { display: none; }
.scoreboard { padding: 1.5rem 1rem; }
.scoreboard-teams { gap: 1rem; }
.score-btn { width: 48px; height: 48px; font-size: 1.25rem; }
.tournament-grid { grid-template-columns: 1fr; }
.teams-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}
@media (max-width: 480px) {
.header-logo-text { font-size: 1.3rem; }
.header-nav a { padding: 0.35rem 0.45rem; font-size: 0.7rem; }
.score-btn { width: 44px; height: 44px; }
}

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:8080',
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
build: {
outDir: 'dist',
},
})