From a7244406fd43209504820931399eb7ec35a334e1 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Sun, 15 Mar 2026 14:48:15 +0100 Subject: [PATCH] Initial release: Disc Agenda frisbee tournament platform Full-stack tournament management app with real-time scoring: - Go 1.26 backend with REST API and WebSocket live scoring - React 19 + Vite 8 frontend with mobile-first design - File-based JSON storage with JSONL audit logs - Multi-stage Docker build with Gitea CI/CD pipeline - Post-tournament questionnaire with spirit voting - Technical documentation and project description Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 9 + .gitea/workflows/build.yaml | 35 + .gitignore | 23 + Dockerfile | 42 + Makefile | 42 + README.md | 164 +++ backend/cmd/server/main.go | 97 ++ backend/go.mod | 9 + backend/go.sum | 6 + backend/internal/handlers/handlers.go | 216 ++++ backend/internal/models/models.go | 118 ++ backend/internal/storage/storage.go | 265 +++++ backend/internal/websocket/hub.go | 355 ++++++ data/tournaments.json | 25 + .../questionnaire_config.json | 25 + .../fujarna-14-3-2026/schedule.json | 30 + docker-compose.yml | 14 + docs/DOCUMENTATION.md | 943 +++++++++++++++ docs/PROJECT.md | 220 ++++ frontend/index.html | 15 + frontend/package-lock.json | 1043 +++++++++++++++++ frontend/package.json | 24 + frontend/public/hero-illustration.svg | 61 + frontend/src/App.jsx | 31 + frontend/src/api.js | 38 + frontend/src/components/Footer.jsx | 9 + frontend/src/components/Header.jsx | 33 + frontend/src/components/Icons.jsx | 87 ++ frontend/src/main.jsx | 6 + frontend/src/pages/GamePage.jsx | 197 ++++ frontend/src/pages/HomePage.jsx | 123 ++ frontend/src/pages/PastPage.jsx | 61 + frontend/src/pages/QuestionnairePage.jsx | 95 ++ frontend/src/pages/ResultsPage.jsx | 68 ++ frontend/src/pages/SchedulePage.jsx | 119 ++ frontend/src/pages/TournamentPage.jsx | 138 +++ frontend/src/styles/global.css | 944 +++++++++++++++ frontend/vite.config.js | 19 + 38 files changed, 5749 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/handlers/handlers.go create mode 100644 backend/internal/models/models.go create mode 100644 backend/internal/storage/storage.go create mode 100644 backend/internal/websocket/hub.go create mode 100644 data/tournaments.json create mode 100644 data/tournaments/fujarna-14-3-2026/questionnaire_config.json create mode 100644 data/tournaments/fujarna-14-3-2026/schedule.json create mode 100644 docker-compose.yml create mode 100644 docs/DOCUMENTATION.md create mode 100644 docs/PROJECT.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/hero-illustration.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/Footer.jsx create mode 100644 frontend/src/components/Header.jsx create mode 100644 frontend/src/components/Icons.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/GamePage.jsx create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/PastPage.jsx create mode 100644 frontend/src/pages/QuestionnairePage.jsx create mode 100644 frontend/src/pages/ResultsPage.jsx create mode 100644 frontend/src/pages/SchedulePage.jsx create mode 100644 frontend/src/pages/TournamentPage.jsx create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8f8d776 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +frontend/node_modules +frontend/dist +backend/vendor +*.exe +*.dll +*.so +*.dylib +.git +.DS_Store diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..9a16441 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5ed403 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22e8650 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1c0562b --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..279a160 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..35ba363 --- /dev/null +++ b/backend/cmd/server/main.go @@ -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) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..f92c805 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..36abfdd --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go new file mode 100644 index 0000000..98f0e3d --- /dev/null +++ b/backend/internal/handlers/handlers.go @@ -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) +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..c91d68f --- /dev/null +++ b/backend/internal/models/models.go @@ -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"` +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go new file mode 100644 index 0000000..8e90f98 --- /dev/null +++ b/backend/internal/storage/storage.go @@ -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 +} diff --git a/backend/internal/websocket/hub.go b/backend/internal/websocket/hub.go new file mode 100644 index 0000000..ed089d7 --- /dev/null +++ b/backend/internal/websocket/hub.go @@ -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 + } + } + } +} diff --git a/data/tournaments.json b/data/tournaments.json new file mode 100644 index 0000000..ccfc7e4 --- /dev/null +++ b/data/tournaments.json @@ -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á." + } +] diff --git a/data/tournaments/fujarna-14-3-2026/questionnaire_config.json b/data/tournaments/fujarna-14-3-2026/questionnaire_config.json new file mode 100644 index 0000000..ebb9bd6 --- /dev/null +++ b/data/tournaments/fujarna-14-3-2026/questionnaire_config.json @@ -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 + } + ] +} diff --git a/data/tournaments/fujarna-14-3-2026/schedule.json b/data/tournaments/fujarna-14-3-2026/schedule.json new file mode 100644 index 0000000..7fa1b87 --- /dev/null +++ b/data/tournaments/fujarna-14-3-2026/schedule.json @@ -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"} + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..34ed44d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md new file mode 100644 index 0000000..8cae2fb --- /dev/null +++ b/docs/DOCUMENTATION.md @@ -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 diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 0000000..1349595 --- /dev/null +++ b/docs/PROJECT.md @@ -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). diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..06a911a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Frisbee Tournament + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9db7f54 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1043 @@ +{ + "name": "frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f319a5f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/hero-illustration.svg b/frontend/public/hero-illustration.svg new file mode 100644 index 0000000..2bea921 --- /dev/null +++ b/frontend/public/hero-illustration.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..416a533 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..512a793 --- /dev/null +++ b/frontend/src/api.js @@ -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}`); +} diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 0000000..75d7603 --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Footer() { + return ( +
+

Disc Agenda — Turnajová platforma pro ultimate frisbee © {new Date().getFullYear()}

+
+ ); +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..f5d9e3b --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -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 ( +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/Icons.jsx b/frontend/src/components/Icons.jsx new file mode 100644 index 0000000..39c2830 --- /dev/null +++ b/frontend/src/components/Icons.jsx @@ -0,0 +1,87 @@ +import React from 'react'; + +export function DiscIcon({ size = 36, className = '' }) { + return ( + + + + + + + + ); +} + +export function FlyingDiscIcon({ size = 48, className = '' }) { + return ( + + + + + + + + {/* Motion lines */} + + + + + ); +} + +export function MapPinIcon({ size = 18 }) { + return ( + + + + + ); +} + +export function CalendarIcon({ size = 18 }) { + return ( + + + + ); +} + +export function UsersIcon({ size = 18 }) { + return ( + + + + ); +} + +export function ClockIcon({ size = 18 }) { + return ( + + + + ); +} + +export function TrophyIcon({ size = 18 }) { + return ( + + + + ); +} + +export function FieldIcon({ size = 18 }) { + return ( + + + + ); +} + +export function ChevronRightIcon({ size = 18 }) { + return ( + + + + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..9627af0 --- /dev/null +++ b/frontend/src/main.jsx @@ -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(); diff --git a/frontend/src/pages/GamePage.jsx b/frontend/src/pages/GamePage.jsx new file mode 100644 index 0000000..c837f3c --- /dev/null +++ b/frontend/src/pages/GamePage.jsx @@ -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
Načítání zápasu...
; + + return ( +
+
+ ← Rozpis +
+
+ {game.round} · {game.field} +
+ +
+
+ {gameStatus === 'final' ? 'Konečné skóre' : 'Živé skóre'} +
+
+
{game.home_team}
+
+ {score.home_score} + : + {score.away_score} +
+
{game.away_team}
+
+ {gameStatus !== 'final' && ( +
+ {['home', 'away'].map(team => ( +
+ {team === 'home' ? game.home_team : game.away_team} + + + +
+ ))} +
+ )} +
+ {gameStatus === 'scheduled' && ( + + )} + {gameStatus === 'live' && ( + + )} + {gameStatus === 'final' && ( + <> + + + + )} +
+
+ + {wsStatus === 'connected' ? 'Připojeno' : wsStatus === 'connecting' ? 'Připojování...' : 'Obnovování spojení...'} +
+
+ +
+ +
+ + {showSetModal && ( +
setShowSetModal(null)}> +
e.stopPropagation()}> +

+ Nastavit skóre — {showSetModal === 'home' ? game.home_team : game.away_team} +

+ setSetVal(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleSet(showSetModal)} /> +
+ + +
+
+
+ )} + +
+ + {showAudit && ( +
+
+ Historie skóre + {auditLog.length} záznamů +
+
+ {auditLog.length === 0 ? ( +
Zatím žádné změny.
+ ) : auditLog.map((e, i) => ( +
+ {e.action} {e.team} {e.action === 'set' ? `→ ${e.value}` : ''} ({e.old_home}:{e.old_away} → {e.new_home}:{e.new_away}) + {e.user_id} · {new Date(e.timestamp).toLocaleTimeString('cs-CZ')} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx new file mode 100644 index 0000000..39a8d62 --- /dev/null +++ b/frontend/src/pages/HomePage.jsx @@ -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
Načítání turnajů...
; + + // 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 && } +
+ {upcoming.length > 0 && !active && upcoming.length > 1 && ( +
+

Další nadcházející

+
+ {upcoming.slice(1).map(t => )} +
+
+ )} + {active && upcoming.length > 0 && ( +
+

Nadcházející turnaje

+
+ {upcoming.map(t => )} +
+
+ )} +
+ + Zobrazit minulé turnaje + +
+
+ + ); +} + +function FeaturedHero({ tournament: t }) { + const st = statusLabel(t.status); + return ( +
+
+
+ + {st.text} +
+

{t.name}

+

{t.description}

+
+
+ {formatDate(t.start_date)} — {formatDate(t.end_date)} +
+
+ {t.venue}, {t.location} +
+
+ {t.teams?.length || 0} týmů +
+
+
+ + Detail turnaje + + + Rozpis + +
+
+
+ ); +} + +function TournamentCard({ tournament: t }) { + const st = statusLabel(t.status); + return ( + +
+ + + {st.text} + +
+
+

{t.name}

+
{formatDate(t.start_date)} — {formatDate(t.end_date)}
+
+
+
+ {t.location} + {t.teams?.length || 0} týmů +
+
+ + ); +} diff --git a/frontend/src/pages/PastPage.jsx b/frontend/src/pages/PastPage.jsx new file mode 100644 index 0000000..4c10303 --- /dev/null +++ b/frontend/src/pages/PastPage.jsx @@ -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
Načítání...
; + + return ( +
+

Minulé turnaje

+ {tournaments.length === 0 ? ( +
+ Zatím žádné minulé turnaje. +
+ ) : ( +
+ {tournaments.map(t => ( +
+
+

{t.name}

+
{formatDate(t.start_date)} — {formatDate(t.end_date)}
+
+
+
+ {t.location} + {t.teams?.length || 0} týmů +
+
+ + Detail + + + Výsledky + +
+
+
+ ))} +
+ )} +
+ ← Zpět na hlavní stránku +
+
+ ); +} diff --git a/frontend/src/pages/QuestionnairePage.jsx b/frontend/src/pages/QuestionnairePage.jsx new file mode 100644 index 0000000..f0f1fcc --- /dev/null +++ b/frontend/src/pages/QuestionnairePage.jsx @@ -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
Načítání...
; + + if (submitted) return ( +
+
+
🥏
+

Díky!

+

Tvoje odpověď byla zaznamenána.

+
+ Zpět na turnaj +
+ ); + + const customQs = config?.custom_questions || []; + + return ( +
+
+ ← Zpět +
+

Dotazník po turnaji

+

Pomoz nám se zlepšit! Odpovědi jsou anonymní.

+
+
+
+ + +
+
+ + +
Který tým měl nejlepší spirit?
+
+
+ +
+
+ {customQs.length > 0 && ( +
+ {customQs.map(q => ( +
+ + {q.type === 'select' || q.type === 'radio' ? ( + + ) : ( +