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

Full-stack tournament management app with real-time scoring:
- Go 1.26 backend with REST API and WebSocket live scoring
- React 19 + Vite 8 frontend with mobile-first design
- File-based JSON storage with JSONL audit logs
- Multi-stage Docker build with Gitea CI/CD pipeline
- Post-tournament questionnaire with spirit voting
- Technical documentation and project description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:48:15 +01:00
commit a7244406fd
38 changed files with 5749 additions and 0 deletions

View File

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

9
backend/go.mod Normal file
View File

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

6
backend/go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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