package web
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"embed"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"log"
"log/slog"
"math/big"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"track-gopher/db"
"track-gopher/derby"
"track-gopher/models"
"track-gopher/web/templates"
)
//go:embed static
var content embed.FS
// Server represents the web server for the derby clock
type Server struct {
router *chi.Mux
clock *derby.DerbyClock
events <-chan derby.Event
clients map[chan string]bool
clientsMux sync.Mutex
adminclients map[chan string]bool
adminclientsMux sync.Mutex
port int
server *http.Server
shutdown chan struct{}
logger *slog.Logger
db *db.DB
adminEvents chan string
useHTTP2 bool
}
// NewServer creates a new web server
func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, useHTTP2 bool, logger *slog.Logger) (*Server, error) {
// Initialize database
database, err := db.New(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Create server
s := &Server{
router: chi.NewRouter(),
clock: clock,
events: events,
clients: make(map[chan string]bool),
clientsMux: sync.Mutex{},
adminclients: make(map[chan string]bool),
adminclientsMux: sync.Mutex{},
port: port,
shutdown: make(chan struct{}),
logger: logger,
db: database,
adminEvents: make(chan string, 10),
useHTTP2: useHTTP2,
}
// Set up routes
s.routes()
// Start event forwarder
go s.forwardEvents()
return s, nil
}
// routes sets up the routes for the server
func (s *Server) routes() {
// Middleware
s.router.Use(middleware.RequestID)
s.router.Use(middleware.RealIP)
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
// Custom middleware to log all requests
s.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetReqID(r.Context())
s.logger.Info("Request started",
"method", r.Method,
"path", r.URL.Path,
"requestID", requestID,
"remoteAddr", r.RemoteAddr)
start := time.Now()
next.ServeHTTP(w, r)
s.logger.Info("Request completed",
"method", r.Method,
"path", r.URL.Path,
"requestID", requestID,
"duration", time.Since(start))
})
})
// Use a very long timeout for SSE endpoints, shorter for others
s.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/api/events") || strings.Contains(r.URL.Path, "/api/admin/events") {
// For SSE endpoints, use a context with a very long timeout
ctx, cancel := context.WithTimeout(r.Context(), 24*time.Hour)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
} else {
// For regular endpoints, use a shorter timeout
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
}
})
})
// Add middleware to set appropriate headers for SSE
s.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/api/events") || strings.Contains(r.URL.Path, "/api/admin/events") {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
s.logger.Debug("SSE headers set", "path", r.URL.Path)
}
next.ServeHTTP(w, r)
})
})
// Create a file server for static files
staticFS, err := fs.Sub(content, "static")
if err != nil {
log.Fatal(err)
}
// Set up static file server with proper MIME types
fileServer := http.FileServer(http.FS(staticFS))
s.router.Get("/static/*", func(w http.ResponseWriter, r *http.Request) {
// Set correct MIME types based on file extension
path := r.URL.Path
if strings.HasSuffix(path, ".js") {
w.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(path, ".css") {
w.Header().Set("Content-Type", "text/css")
}
// The key fix: properly handle the path
// Remove the /static prefix for the filesystem lookup
pathWithoutPrefix := strings.TrimPrefix(r.URL.Path, "/static")
// Create a new request with the modified path
r2 := new(http.Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
r2.URL.Path = pathWithoutPrefix
fileServer.ServeHTTP(w, r2)
})
// API routes
s.router.Route("/api", func(r chi.Router) {
r.Post("/reset", s.handleReset())
r.Post("/force-end", s.handleForceEnd())
r.Get("/events", s.handleEvents())
r.Get("/validate/car-number", s.handleValidateCarNumber())
r.Route("/groups", func(r chi.Router) {
r.Post("/", s.handleCreateGroup())
r.Put("/{id}", s.handleUpdateGroup())
r.Delete("/{id}", s.handleDeleteGroup())
})
r.Route("/racers", func(r chi.Router) {
r.Post("/", s.handleCreateRacer())
r.Put("/{id}", s.handleUpdateRacer())
r.Delete("/{id}", s.handleDeleteRacer())
})
r.Route("/heats", func(r chi.Router) {
r.Post("/generate", s.handleGenerateHeats())
})
r.Route("/race", func(r chi.Router) {
r.Get("/current-heat", s.handleCurrentHeat())
r.Post("/next-heat", s.handleNextHeat())
r.Post("/previous-heat", s.handlePreviousHeat())
r.Post("/rerun-heat", s.handleRerunHeat())
r.Post("/set-group", s.handleSetRacingGroup())
})
r.Route("/admin", func(r chi.Router) {
r.Get("/events", s.handleAdminEvents())
})
})
s.router.Get("/admin", s.handleAdmin())
s.router.Get("/register", s.handleRegister())
s.router.Get("/register/form", s.handleRegisterForm())
// Add heats page route
s.router.Get("/heats", s.handleHeats())
s.router.Get("/heats-content", s.handleHeatsContent())
// Add racers list route
s.router.Get("/admin/racers/list", s.handleRacersList())
// Add race manager routes
s.router.Get("/race", s.handleRacePublic())
s.router.Get("/race/manage", s.handleRaceManage())
// Main page
s.router.Get("/", s.handleIndex())
}
// Start starts the web server with HTTP/2 support
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.port)
// Configure TLS
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
NextProtos: []string{"h2", "http/1.1"},
}
s.server = &http.Server{
Addr: addr,
Handler: s.router,
TLSConfig: tlsConfig,
ReadTimeout: 120 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Check if certificate files exist
certFile := "server.crt"
keyFile := "server.key"
certExists := fileExists(certFile)
keyExists := fileExists(keyFile)
s.logger.Info("Starting web server", "port", s.port, "http2", true)
if certExists && keyExists {
// Start HTTPS server with HTTP/2 support
s.logger.Info("Using TLS with HTTP/2", "certFile", certFile, "keyFile", keyFile)
return s.server.ListenAndServeTLS(certFile, keyFile)
} else {
// Generate self-signed certificate for development
s.logger.Info("Generating self-signed certificate for HTTP/2")
cert, key, err := generateSelfSignedCert()
if err != nil {
s.logger.Error("Failed to generate self-signed certificate", "error", err)
// Fall back to HTTP/1.1
s.logger.Info("Falling back to HTTP/1.1 (no TLS)")
s.server.TLSConfig = nil
return s.server.ListenAndServe()
}
// Write certificate files
if err := os.WriteFile(certFile, cert, 0600); err != nil {
s.logger.Error("Failed to write certificate file", "error", err)
return err
}
if err := os.WriteFile(keyFile, key, 0600); err != nil {
s.logger.Error("Failed to write key file", "error", err)
return err
}
s.logger.Info("Self-signed certificate generated", "certFile", certFile, "keyFile", keyFile)
return s.server.ListenAndServeTLS(certFile, keyFile)
}
}
// Helper function to check if a file exists
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return err == nil
}
// generateSelfSignedCert creates a self-signed certificate for development
func generateSelfSignedCert() ([]byte, []byte, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Derby Race Manager"},
CommonName: "localhost",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
certPEM := &bytes.Buffer{}
pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := &bytes.Buffer{}
pem.Encode(keyPEM, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM.Bytes(), keyPEM.Bytes(), nil
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("Shutting down web server")
// Signal event forwarder to stop
close(s.shutdown)
// Close all client connections
s.clientsMux.Lock()
for clientChan := range s.clients {
close(clientChan)
}
s.clients = make(map[chan string]bool)
s.clientsMux.Unlock()
s.adminclientsMux.Lock()
for clientChan := range s.adminclients {
close(clientChan)
}
s.adminclients = make(map[chan string]bool)
s.adminclientsMux.Unlock()
// Shutdown the HTTP server
return s.server.Shutdown(ctx)
}
// forwardEvents forwards derby events to SSE clients
func (s *Server) forwardEvents() {
s.logger.Info("Starting event forwarder")
for {
select {
case event, ok := <-s.events:
if !ok {
s.logger.Warn("Events channel closed, stopping forwarder")
return
}
s.logger.Debug("Received event from derby clock",
"type", event.Type,
"clientCount", len(s.clients))
// Process the event and send to clients
s.broadcastRaceEvent(event)
case <-s.shutdown:
s.logger.Info("Shutdown signal received, stopping forwarder")
return
}
}
}
// broadcastRaceEvent sends a race event to all connected clients
func (s *Server) broadcastRaceEvent(event derby.Event) {
var heatResult models.HeatResult
switch event.Type {
case derby.EventRaceStart:
s.logger.Info("Broadcasting race start event")
s.sendRaceEventToAllClients("event: race-status\ndata:
Race Running
")
heatGroup, _ := s.db.GetCurrentRacingGroup()
heatResult.GroupID = heatGroup.ID
heatResult.HeatNumber, _ = s.db.GetCurrentHeatNumber(heatGroup.ID)
case derby.EventLaneFinish:
s.logger.Info("Broadcasting lane finish event",
"lane", event.Result.Lane,
"time", event.Result.Time,
"place", event.Result.FinishPlace)
switch event.Result.Lane {
case 1:
heatResult.Lane1Time = event.Result.Time
heatResult.Lane1Position = event.Result.FinishPlace
case 2:
heatResult.Lane2Time = event.Result.Time
heatResult.Lane2Position = event.Result.FinishPlace
case 3:
heatResult.Lane3Time = event.Result.Time
heatResult.Lane3Position = event.Result.FinishPlace
case 4:
heatResult.Lane4Time = event.Result.Time
heatResult.Lane4Position = event.Result.FinishPlace
}
s.sendRaceEventToAllClients(fmt.Sprintf("event: lane-%d-time\ndata: %.4f", event.Result.Lane, event.Result.Time))
s.sendRaceEventToAllClients(fmt.Sprintf("event: lane-%d-position\ndata: %d", event.Result.Lane, event.Result.FinishPlace))
case derby.EventRaceComplete:
s.logger.Info("Broadcasting race complete event")
s.sendRaceEventToAllClients("event: race-status\ndata: Race Complete
")
s.db.SaveHeatResult(heatResult)
}
}
// broadcastEvent sends an event to all connected clients
func (s *Server) broadcastAdminEvent(event models.AdminEvent) {
switch event.Type {
case models.EventHeatChanged:
s.logger.Info("Broadcasting heat changed event")
if heatData, ok := event.Event.(models.HeatData); ok {
component := templates.CurrentHeatDisplay(&heatData)
var sb strings.Builder
err := component.Render(context.Background(), &sb)
if err != nil {
s.logger.Error("Failed to render current heat display", "error", err)
}
s.sendAdminEventToAllClients(fmt.Sprintf("event: current-heat\ndata: %s", sb.String()))
nextHeatData, _ := s.db.GetHeatData(heatData.Group.ID, heatData.HeatNumber+1)
component = templates.NextHeatDisplay(nextHeatData)
var sb2 strings.Builder
err = component.Render(context.Background(), &sb2)
if err != nil {
s.logger.Error("Failed to render next heat display", "error", err)
}
s.sendAdminEventToAllClients(fmt.Sprintf("event: next-heat\ndata: %s", sb2.String()))
s.sendAdminEventToAllClients(fmt.Sprintf("event: heat-number\ndata: Current Heat: %d of %d", heatData.HeatNumber, heatData.TotalHeats))
} else {
s.logger.Error("Failed to convert event to HeatData")
}
case models.EventGroupChanged:
s.logger.Info("Broadcasting group changed event",
"group", event.Event)
if heatResults, ok := event.Event.([]models.HeatResult); ok {
component := templates.ResultsDisplay(heatResults)
var sb strings.Builder
err := component.Render(context.Background(), &sb)
if err != nil {
s.logger.Error("Failed to render current heat results", "error", err)
}
s.sendAdminEventToAllClients(fmt.Sprintf("event: results\ndata: %s", sb.String()))
}
}
}
// sendRaceEventToAllClients sends an event to all connected clients
func (s *Server) sendRaceEventToAllClients(message string) {
// Log the message being sent (truncate if too long)
msgToLog := message
if len(msgToLog) > 100 {
msgToLog = msgToLog[:100] + "..."
}
s.logger.Debug("Sending message to all clients",
"message", msgToLog,
"clientCount", len(s.clients))
// Make a copy of the clients map to avoid holding the lock while sending
s.clientsMux.Lock()
clientsToSend := make([]chan string, 0, len(s.clients))
for clientChan := range s.clients {
clientsToSend = append(clientsToSend, clientChan)
}
s.clientsMux.Unlock()
s.logger.Debug("Prepared to send to clients", "count", len(clientsToSend))
// Count successful and failed sends
successCount := 0
failCount := 0
// Send to all clients without holding the lock
for _, clientChan := range clientsToSend {
select {
case clientChan <- message:
// Message sent successfully
successCount++
default:
// Client is not receiving, remove it
s.clientsMux.Lock()
delete(s.clients, clientChan)
s.clientsMux.Unlock()
close(clientChan)
failCount++
}
}
s.logger.Debug("Finished sending message",
"successCount", successCount,
"failCount", failCount,
"remainingClients", len(s.clients))
}
// sendAdminEventToAllClients sends an event to all connected clients
func (s *Server) sendAdminEventToAllClients(message string) {
// Log the message being sent (truncate if too long)
msgToLog := message
if len(msgToLog) > 100 {
msgToLog = msgToLog[:100] + "..."
}
s.logger.Debug("Sending message to all clients",
"message", msgToLog,
"clientCount", len(s.adminclients))
// Make a copy of the clients map to avoid holding the lock while sending
s.adminclientsMux.Lock()
clientsToSend := make([]chan string, 0, len(s.adminclients))
for clientChan := range s.adminclients {
clientsToSend = append(clientsToSend, clientChan)
}
s.adminclientsMux.Unlock()
s.logger.Debug("Prepared to send to clients", "count", len(clientsToSend))
// Count successful and failed sends
successCount := 0
failCount := 0
// Send to all clients without holding the lock
for _, clientChan := range clientsToSend {
select {
case clientChan <- message:
// Message sent successfully
successCount++
default:
// Client is not receiving, remove it
s.adminclientsMux.Lock()
delete(s.adminclients, clientChan)
s.adminclientsMux.Unlock()
close(clientChan)
failCount++
}
}
s.logger.Debug("Finished sending message",
"successCount", successCount,
"failCount", failCount,
"remainingClients", len(s.adminclients))
}
// handleEvents handles SSE events
func (s *Server) handleEvents() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetReqID(r.Context())
if requestID == "" {
requestID = fmt.Sprintf("req-%d", time.Now().UnixNano())
}
s.logger.Info("SSE connection request received",
"requestID", requestID,
"remoteAddr", r.RemoteAddr,
"userAgent", r.UserAgent())
// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Flush headers to ensure they're sent to the client
if flusher, ok := w.(http.Flusher); ok {
s.logger.Debug("Flushing headers", "requestID", requestID)
flusher.Flush()
} else {
s.logger.Error("Streaming unsupported!", "requestID", requestID)
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
// Create a channel for this client
clientChan := make(chan string, 10)
// Add client to map with mutex protection
s.clientsMux.Lock()
s.clients[clientChan] = true
clientCount := len(s.clients)
s.clientsMux.Unlock()
s.logger.Info("New client connected",
"requestID", requestID,
"clientIP", r.RemoteAddr,
"totalClients", clientCount)
// Send a ping immediately to test the connection
pingMsg := "event: ping\ndata: connection established\n\n"
fmt.Fprint(w, pingMsg)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
s.logger.Debug("Initial ping sent", "requestID", requestID)
}
// Remove client when connection is closed
defer func() {
s.clientsMux.Lock()
delete(s.clients, clientChan)
remainingClients := len(s.clients)
s.clientsMux.Unlock()
close(clientChan)
s.logger.Info("Client disconnected",
"requestID", requestID,
"clientIP", r.RemoteAddr,
"remainingClients", remainingClients)
}()
// Set up a heartbeat to keep the connection alive
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
// Keep connection open and send events as they arrive
for {
select {
case msg, ok := <-clientChan:
if !ok {
s.logger.Warn("Client channel closed", "requestID", requestID)
return
}
s.logger.Debug("Sending message to client",
"requestID", requestID,
"messageLength", len(msg))
fmt.Fprint(w, msg)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
s.logger.Debug("Message flushed", "requestID", requestID)
} else {
s.logger.Warn("Could not flush - client may not receive updates", "requestID", requestID)
}
case <-heartbeat.C:
// Send a heartbeat to keep the connection alive
s.logger.Debug("Sending heartbeat", "requestID", requestID)
fmt.Fprint(w, "event: ping\ndata: heartbeat\n\n")
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
case <-r.Context().Done():
s.logger.Info("Client context done",
"requestID", requestID,
"error", r.Context().Err())
return
}
}
}
}
// handleIndex handles the index page
func (s *Server) handleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
templates.Index().Render(r.Context(), w)
}
}
// handleReset handles the reset API endpoint
func (s *Server) handleReset() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := s.clock.Reset(); err != nil {
http.Error(w, fmt.Sprintf("Failed to reset clock: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status": "reset"}`))
}
}
// handleForceEnd handles the force end API endpoint
func (s *Server) handleForceEnd() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := s.clock.ForceEnd(); err != nil {
http.Error(w, fmt.Sprintf("Failed to force end race: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status": "forced"}`))
}
}
// handleStatus handles the status API endpoint
func (s *Server) handleStatus() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status := s.clock.Status()
var statusStr string
switch status {
case derby.StatusIdle:
statusStr = "idle"
case derby.StatusRunning:
statusStr = "running"
case derby.StatusFinished:
statusStr = "finished"
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf(`{"status": "%s"}`, statusStr)))
}
}
// handleAdminEvents handles SSE events
func (s *Server) handleAdminEvents() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.logger.Info("Admin events SSE connection established")
// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Flush headers to ensure they're sent to the client
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
} else {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
s.logger.Info("Admin events SSE headers flushed")
// Create a channel for this client
clientChan := make(chan string, 10)
s.logger.Info("Admin events SSE client channel created")
// Add client to map with mutex protection
s.adminclientsMux.Lock()
s.adminclients[clientChan] = true
clientCount := len(s.adminclients)
s.adminclientsMux.Unlock()
s.logger.Info("Admin events SSE client added to map")
s.logger.Info("New client connected",
"clientIP", r.RemoteAddr,
"totalClients", clientCount)
// Remove client when connection is closed
defer func() {
s.adminclientsMux.Lock()
delete(s.adminclients, clientChan)
remainingClients := len(s.adminclients)
s.adminclientsMux.Unlock()
close(clientChan)
s.logger.Info("Client disconnected",
"clientIP", r.RemoteAddr,
"remainingClients", remainingClients)
}()
// Keep connection open and send events as they arrive
for {
select {
case msg, ok := <-clientChan:
if !ok {
return
}
fmt.Fprintf(w, "%s\n\n", msg)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
case <-r.Context().Done():
return
}
}
}
}
// handleAdmin renders the admin page
func (s *Server) handleAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get groups and racers from database
groups, err := s.db.GetGroups()
if err != nil {
s.logger.Error("Failed to get groups", "error", err)
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
return
}
racers, err := s.db.GetRacers()
if err != nil {
s.logger.Error("Failed to get racers", "error", err)
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
return
}
// Render template
component := templates.Admin(groups, racers)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render admin template", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
}
// handleRegister renders the racer registration page
func (s *Server) handleRegister() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get groups from database
groups, err := s.db.GetGroups()
if err != nil {
s.logger.Error("Failed to get groups", "error", err)
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
return
}
// Render template
component := templates.Register(groups)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render register template", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
}
// handleRegisterForm returns just the registration form component
func (s *Server) handleRegisterForm() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get groups
groups, err := s.db.GetGroups()
if err != nil {
s.logger.Error("Failed to get groups", "error", err)
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
return
}
// Render form with isAdmin=true
component := templates.RegisterForm(groups, true)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render form", "error", err)
http.Error(w, "Failed to render form", http.StatusInternalServerError)
}
}
}
// API handlers for groups
// handleCreateGroup creates a new group
func (s *Server) handleCreateGroup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse form
if err := r.ParseForm(); err != nil {
s.logger.Error("Failed to parse form", "error", err)
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
// Get form values
name := r.FormValue("name")
description := r.FormValue("description")
// Validate
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
// Create group
id, err := s.db.CreateGroup(name, description)
if err != nil {
s.logger.Error("Failed to create group", "error", err)
http.Error(w, "Failed to create group", http.StatusInternalServerError)
return
}
// Return success
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"id":%d}`, id)
}
}
// handleUpdateGroup updates a group
func (s *Server) handleUpdateGroup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get ID from URL
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
// Parse form
if err := r.ParseForm(); err != nil {
s.logger.Error("Failed to parse form", "error", err)
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
// Get form values
name := r.FormValue("name")
description := r.FormValue("description")
// Validate
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
// Update group
if err := s.db.UpdateGroup(id, name, description); err != nil {
s.logger.Error("Failed to update group", "error", err)
http.Error(w, "Failed to update group", http.StatusInternalServerError)
return
}
// Return success
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"id":%d}`, id)
}
}
// handleDeleteGroup deletes a group
func (s *Server) handleDeleteGroup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get ID from URL
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
// Delete group
if err := s.db.DeleteGroup(id); err != nil {
s.logger.Error("Failed to delete group", "error", err)
http.Error(w, "Failed to delete group", http.StatusInternalServerError)
return
}
// Return success
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"success":true}`)
}
}
// API handlers for racers
// handleCreateRacer creates a new racer
func (s *Server) handleCreateRacer() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse form
if err := r.ParseForm(); err != nil {
s.logger.Error("Failed to parse form", "error", err)
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
// Get form values
firstName := r.FormValue("first_name")
lastName := r.FormValue("last_name")
carNumber := r.FormValue("car_number")
carWeightStr := r.FormValue("car_weight")
groupIDStr := r.FormValue("group_id")
// Validate
if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
// Parse numeric values
carWeight, err := strconv.ParseFloat(carWeightStr, 64)
if err != nil {
http.Error(w, "Invalid car weight", http.StatusBadRequest)
return
}
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid group ID", http.StatusBadRequest)
return
}
// Check if car number is unique before creating
isUnique, err := s.db.IsCarNumberUnique(carNumber)
if err != nil {
s.logger.Error("Failed to check car number uniqueness", "error", err)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`Failed to validate car number
`))
return
}
if !isUnique {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`Car number is already in use
`))
return
}
id, err := s.db.CreateRacer(firstName, lastName, carNumber, carWeight, groupID)
if err != nil {
s.logger.Error("Failed to create racer", "error", err)
http.Error(w, "Failed to create racer", http.StatusInternalServerError)
return
}
s.logger.Info("Racer created", "id", id)
// Broadcast event to admin page
select {
case s.adminEvents <- "racer-added":
// Event sent
default:
// Channel full, non-blocking
}
// Return success message
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`Racer added successfully!
`))
}
}
// handleUpdateRacer updates a racer
func (s *Server) handleUpdateRacer() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get ID from URL
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
// Parse form
if err := r.ParseForm(); err != nil {
s.logger.Error("Failed to parse form", "error", err)
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
// Get form values
firstName := r.FormValue("first_name")
lastName := r.FormValue("last_name")
carNumber := r.FormValue("car_number")
carWeightStr := r.FormValue("car_weight")
groupIDStr := r.FormValue("group_id")
// Validate
if firstName == "" || lastName == "" || carNumber == "" || carWeightStr == "" || groupIDStr == "" {
http.Error(w, "All fields are required", http.StatusBadRequest)
return
}
// Parse numeric values
carWeight, err := strconv.ParseFloat(carWeightStr, 64)
if err != nil {
http.Error(w, "Invalid car weight", http.StatusBadRequest)
return
}
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid group ID", http.StatusBadRequest)
return
}
// Update racer
if err := s.db.UpdateRacer(id, firstName, lastName, carNumber, carWeight, groupID); err != nil {
s.logger.Error("Failed to update racer", "error", err)
http.Error(w, "Failed to update racer", http.StatusInternalServerError)
return
}
// Return success
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"id":%d}`, id)
}
}
// handleDeleteRacer deletes a racer
func (s *Server) handleDeleteRacer() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get ID from URL
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
// Delete racer
if err := s.db.DeleteRacer(id); err != nil {
s.logger.Error("Failed to delete racer", "error", err)
http.Error(w, "Failed to delete racer", http.StatusInternalServerError)
return
}
// Return success
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"success":true}`)
}
}
// handleHeats renders the heats page
func (s *Server) handleHeats() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get groups from database
groups, err := s.db.GetGroups()
if err != nil {
s.logger.Error("Failed to get groups", "error", err)
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
return
}
// Get selected group ID from query parameter
selectedGroupID := int64(0)
groupIDStr := r.URL.Query().Get("group_id")
if groupIDStr != "" {
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
if err == nil {
selectedGroupID = groupID
}
}
// Render template
component := templates.Heats(groups, selectedGroupID, s.db)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render heats template", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
}
// handleHeatsContent renders the heats content
func (s *Server) handleHeatsContent() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get selected group ID from query parameter
groupIDStr := r.URL.Query().Get("group_id")
if groupIDStr == "" {
http.Error(w, "Group ID is required", http.StatusBadRequest)
return
}
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid group ID", http.StatusBadRequest)
return
}
// Check for saved heats
hasSavedHeats, err := s.db.HasSavedHeats(groupID)
if err != nil {
s.logger.Error("Failed to check for saved heats", "error", err)
http.Error(w, "Failed to check for saved heats", http.StatusInternalServerError)
return
}
var heats []models.Heat
if hasSavedHeats {
// Get saved heats
heats, err = s.db.GetHeats(groupID)
if err != nil {
s.logger.Error("Failed to get saved heats", "error", err)
http.Error(w, "Failed to get saved heats", http.StatusInternalServerError)
return
}
}
// Get racers for the group
racers, err := s.db.GetRacersByGroup(groupID)
if err != nil {
s.logger.Error("Failed to get racers", "error", err)
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
return
}
// Convert models.Racer to derby.Racer
derbyRacers := make([]derby.Racer, len(racers))
for i, r := range racers {
derbyRacers[i] = derby.Racer{
ID: r.ID,
FirstName: r.FirstName,
LastName: r.LastName,
CarNumber: r.CarNumber,
}
}
// Generate and convert heats
if !hasSavedHeats && len(racers) > 0 {
derbyHeats := derby.GenerateHeats(derbyRacers)
heats = make([]models.Heat, len(derbyHeats))
for i, h := range derbyHeats {
heats[i] = models.Heat{
HeatNum: i + 1,
Lane1ID: h.Lane1ID,
Lane2ID: h.Lane2ID,
Lane3ID: h.Lane3ID,
Lane4ID: h.Lane4ID,
}
}
s.db.SaveHeats(groupID, heats)
}
// Render template
component := templates.HeatsContent(heats, racers, groupID)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render heats content", "error", err)
http.Error(w, "Failed to render content", http.StatusInternalServerError)
}
}
}
// handleGenerateHeats generates heats for a group
func (s *Server) handleGenerateHeats() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get selected group ID from query parameter
groupIDStr := r.URL.Query().Get("group_id")
if groupIDStr == "" {
http.Error(w, "Group ID is required", http.StatusBadRequest)
return
}
groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid group ID", http.StatusBadRequest)
return
}
// Get racers for the group
racers, err := s.db.GetRacersByGroup(groupID)
if err != nil {
s.logger.Error("Failed to get racers", "error", err)
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
return
}
// Convert models.Racer to derby.Racer
derbyRacers := make([]derby.Racer, len(racers))
for i, r := range racers {
derbyRacers[i] = derby.Racer{
ID: r.ID,
FirstName: r.FirstName,
LastName: r.LastName,
CarNumber: r.CarNumber,
}
}
// Generate and convert heats
derbyHeats := derby.GenerateHeats(derbyRacers)
heats := make([]models.Heat, len(derbyHeats))
for i, h := range derbyHeats {
heats[i] = models.Heat{
HeatNum: i + 1,
Lane1ID: h.Lane1ID,
Lane2ID: h.Lane2ID,
Lane3ID: h.Lane3ID,
Lane4ID: h.Lane4ID,
}
}
s.db.SaveHeats(groupID, heats)
// Render template
component := templates.HeatsContent(heats, racers, groupID)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render heats content", "error", err)
http.Error(w, "Failed to render content", http.StatusInternalServerError)
}
}
}
// handleSaveHeats saves heats for a group
func (s *Server) handleSaveHeats() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse request body
var request struct {
GroupID int64 `json:"group_id"`
Heats []models.Heat `json:"heats"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Save heats
if err := s.db.SaveHeats(request.GroupID, request.Heats); err != nil {
s.logger.Error("Failed to save heats", "error", err)
http.Error(w, "Failed to save heats", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
}
// handleRacersList renders the racers list component
func (s *Server) handleRacersList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get racers and groups
racers, err := s.db.GetRacers()
if err != nil {
s.logger.Error("Failed to get racers", "error", err)
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
return
}
groups, err := s.db.GetGroups()
if err != nil {
s.logger.Error("Failed to get groups", "error", err)
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
return
}
// Render just the racers list component
component := templates.RacersList(racers, groups)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render racers list", "error", err)
http.Error(w, "Failed to render racers list", http.StatusInternalServerError)
return
}
}
}
// handleValidateCarNumber handles the validate car number API endpoint
func (s *Server) handleValidateCarNumber() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
carNumber := r.URL.Query().Get("car_number")
if carNumber == "" {
http.Error(w, "Car number is required
", http.StatusBadRequest)
return
}
isUnique, err := s.db.IsCarNumberUnique(carNumber)
if err != nil {
s.logger.Error("Failed to check car number uniqueness", "error", err)
http.Error(w, "Failed to validate car number
", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
if !isUnique {
w.Write([]byte("Car number is already in use
"))
} else {
w.Write([]byte(""))
}
}
}
// handleRacePublic renders the public race view page
func (s *Server) handleRacePublic() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get the currently racing group
currentGroup, err := s.db.GetCurrentRacingGroup()
if err != nil {
s.logger.Error("Failed to get current racing group", "error", err)
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
return
}
// Get current heat number
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get current heat number", "error", err)
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
return
}
// Get heats for the group
heatData, err := s.db.GetHeatData(currentGroup.ID, currentHeatNum)
if err != nil {
s.logger.Error("Failed to get heats", "error", err)
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
return
}
// Get next heat data
nextHeatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+1)
// Get on-deck heat data
onDeckHeatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+2)
// Render template
component := templates.RacePublic(heatData, nextHeatData, onDeckHeatData)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render race public template", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
}
// handleRaceManage renders the race management page
func (s *Server) handleRaceManage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.logger.Info("Rendering race manage template")
// Get all groups
groups, err := s.db.GetGroups()
if err != nil {
s.logger.Error("Failed to get groups", "error", err)
http.Error(w, "Failed to get groups", http.StatusInternalServerError)
return
}
// Get the currently racing group
currentGroup, err := s.db.GetCurrentRacingGroup()
if err != nil {
s.logger.Error("Failed to get current racing group", "error", err)
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
return
}
// Get current heat number
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get current heat number", "error", err)
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
return
}
// Get heat results
results, err := s.db.GetHeatResults(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get heat results", "error", err)
http.Error(w, "Failed to get heat results", http.StatusInternalServerError)
return
}
// Get heats for the group
heatData, err := s.db.GetHeatData(currentGroup.ID, currentHeatNum)
if err != nil {
s.logger.Error("Failed to get heats", "error", err)
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
return
}
// Get next heat data
nextHeatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+1)
s.logger.Info("Rendering race manage template", "heatData", heatData, "nextHeatData", nextHeatData, "groups", groups, "results", results)
// Render template
component := templates.RaceManage(heatData, nextHeatData, groups, results)
if err := component.Render(r.Context(), w); err != nil {
s.logger.Error("Failed to render race manage template", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
}
// API handlers for race management
// handleCurrentHeat returns the current heat data
func (s *Server) handleCurrentHeat() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get the currently racing group
currentGroup, err := s.db.GetCurrentRacingGroup()
if err != nil {
s.logger.Error("Failed to get current racing group", "error", err)
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
return
}
// Get current heat number
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get current heat number", "error", err)
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
return
}
// Get heats for the group
heats, err := s.db.GetHeats(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get heats", "error", err)
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
return
}
// Get racers for the group
racers, err := s.db.GetRacersByGroup(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get racers", "error", err)
http.Error(w, "Failed to get racers", http.StatusInternalServerError)
return
}
// Find current heat
var currentHeat models.Heat
for _, heat := range heats {
if heat.HeatNum == currentHeatNum {
currentHeat = heat
break
}
}
// Create response with racer details
type LaneInfo struct {
Lane int `json:"lane"`
RacerID int64 `json:"racer_id"`
Name string `json:"name"`
CarNum string `json:"car_number"`
Time interface{} `json:"time"`
Position interface{} `json:"position"`
}
response := struct {
HeatNumber int `json:"heat_number"`
TotalHeats int `json:"total_heats"`
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
Lanes []LaneInfo `json:"lanes"`
}{
HeatNumber: currentHeatNum,
TotalHeats: len(heats),
GroupID: currentGroup.ID,
GroupName: currentGroup.Name,
Lanes: make([]LaneInfo, 0),
}
// Get heat result if available
result, err := s.db.GetHeatResult(currentGroup.ID, currentHeatNum)
if err != nil && err != db.ErrNotFound {
s.logger.Error("Failed to get heat result", "error", err)
}
// Add lane 1 info
if currentHeat.Lane1ID != nil {
lane := LaneInfo{Lane: 1, RacerID: *currentHeat.Lane1ID}
for _, racer := range racers {
if racer.ID == *currentHeat.Lane1ID {
lane.Name = racer.FirstName + " " + racer.LastName
lane.CarNum = racer.CarNumber
break
}
}
if result != nil {
lane.Time = result.Lane1Time
lane.Position = result.Lane1Position
}
response.Lanes = append(response.Lanes, lane)
}
// Add lane 2 info
if currentHeat.Lane2ID != nil {
lane := LaneInfo{Lane: 2, RacerID: *currentHeat.Lane2ID}
for _, racer := range racers {
if racer.ID == *currentHeat.Lane2ID {
lane.Name = racer.FirstName + " " + racer.LastName
lane.CarNum = racer.CarNumber
break
}
}
if result != nil {
lane.Time = result.Lane2Time
lane.Position = result.Lane2Position
}
response.Lanes = append(response.Lanes, lane)
}
// Add lane 3 info
if currentHeat.Lane3ID != nil {
lane := LaneInfo{Lane: 3, RacerID: *currentHeat.Lane3ID}
for _, racer := range racers {
if racer.ID == *currentHeat.Lane3ID {
lane.Name = racer.FirstName + " " + racer.LastName
lane.CarNum = racer.CarNumber
break
}
}
if result != nil {
lane.Time = result.Lane3Time
lane.Position = result.Lane3Position
}
response.Lanes = append(response.Lanes, lane)
}
// Add lane 4 info
if currentHeat.Lane4ID != nil {
lane := LaneInfo{Lane: 4, RacerID: *currentHeat.Lane4ID}
for _, racer := range racers {
if racer.ID == *currentHeat.Lane4ID {
lane.Name = racer.FirstName + " " + racer.LastName
lane.CarNum = racer.CarNumber
break
}
}
if result != nil {
lane.Time = result.Lane4Time
lane.Position = result.Lane4Position
}
response.Lanes = append(response.Lanes, lane)
}
// Return JSON response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// handleNextHeat advances to the next heat
func (s *Server) handleNextHeat() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get the currently racing group
currentGroup, err := s.db.GetCurrentRacingGroup()
if err != nil {
s.logger.Error("Failed to get current racing group", "error", err)
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
return
}
// Get current heat number
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get current heat number", "error", err)
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
return
}
// Get heats for the group
heats, err := s.db.GetHeats(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get heats", "error", err)
http.Error(w, "Failed to get heats", http.StatusInternalServerError)
return
}
// Check if we're already at the last heat
if currentHeatNum >= len(heats) {
http.Error(w, "Already at the last heat", http.StatusBadRequest)
return
}
// Advance to next heat
if err := s.db.SetCurrentHeatNumber(currentGroup.ID, currentHeatNum+1); err != nil {
s.logger.Error("Failed to set current heat number", "error", err)
http.Error(w, "Failed to advance to next heat", http.StatusInternalServerError)
return
}
heatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum+1)
// Broadcast event to admin page
s.broadcastAdminEvent(models.AdminEvent{
Type: models.EventHeatChanged,
Event: &heatData,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
}
// handlePreviousHeat goes back to the previous heat
func (s *Server) handlePreviousHeat() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get the currently racing group
currentGroup, err := s.db.GetCurrentRacingGroup()
if err != nil {
s.logger.Error("Failed to get current racing group", "error", err)
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
return
}
// Get current heat number
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get current heat number", "error", err)
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
return
}
// Check if we're already at the first heat
if currentHeatNum <= 1 {
http.Error(w, "Already at the first heat", http.StatusBadRequest)
return
}
// Go back to previous heat
if err := s.db.SetCurrentHeatNumber(currentGroup.ID, currentHeatNum-1); err != nil {
s.logger.Error("Failed to set current heat number", "error", err)
http.Error(w, "Failed to go back to previous heat", http.StatusInternalServerError)
return
}
heatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum-1)
// Broadcast event to admin page
s.broadcastAdminEvent(models.AdminEvent{
Type: models.EventHeatChanged,
Event: &heatData,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
}
// handleRerunHeat marks a heat for rerun
func (s *Server) handleRerunHeat() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get the currently racing group
currentGroup, err := s.db.GetCurrentRacingGroup()
if err != nil {
s.logger.Error("Failed to get current racing group", "error", err)
http.Error(w, "Failed to get current racing group", http.StatusInternalServerError)
return
}
// Get current heat number
currentHeatNum, err := s.db.GetCurrentHeatNumber(currentGroup.ID)
if err != nil {
s.logger.Error("Failed to get current heat number", "error", err)
http.Error(w, "Failed to get current heat number", http.StatusInternalServerError)
return
}
// Delete any existing result for this heat
if err := s.db.DeleteHeatResult(currentGroup.ID, currentHeatNum); err != nil {
s.logger.Error("Failed to delete heat result", "error", err)
http.Error(w, "Failed to mark heat for rerun", http.StatusInternalServerError)
return
}
heatData, _ := s.db.GetHeatData(currentGroup.ID, currentHeatNum)
// Broadcast event to admin page
s.broadcastAdminEvent(models.AdminEvent{
Type: models.EventHeatChanged,
Event: &heatData,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
}
// handleSetRacingGroup sets the currently racing group
func (s *Server) handleSetRacingGroup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse request body
var request struct {
GroupID int64 `json:"group_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Set current racing group
if err := s.db.SetCurrentRacingGroup(request.GroupID); err != nil {
s.logger.Error("Failed to set current racing group", "error", err)
http.Error(w, "Failed to set current racing group", http.StatusInternalServerError)
return
}
heatData, _ := s.db.GetHeatData(request.GroupID, 1)
// Broadcast event to admin page
s.broadcastAdminEvent(models.AdminEvent{
Type: models.EventHeatChanged,
Event: &heatData,
})
heatResults, _ := s.db.GetHeatResults(request.GroupID)
s.broadcastAdminEvent(models.AdminEvent{
Type: models.EventGroupChanged,
Event: heatResults,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
}