package web import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "embed" "encoding/json" "encoding/pem" "fmt" "io" "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.Debug("Request started", "method", r.Method, "path", r.URL.Path, "requestID", requestID, "remoteAddr", r.RemoteAddr) start := time.Now() next.ServeHTTP(w, r) s.logger.Debug("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()) }) r.Post("/results/reveal", s.handleRevealNextResult()) }) 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()) // Add final results route s.router.Get("/results", s.handleFinalResults()) // Add public final results route s.router.Get("/results/public", s.handleFinalResultsPublic()) // 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, } // Start server in a goroutine so we can return from this function go func() { s.logger.Info("Starting web server", "port", s.port, "http2", s.useHTTP2) var err error if s.useHTTP2 { // Check if certificate files exist certFile := "server.crt" keyFile := "server.key" certExists := fileExists(certFile) keyExists := fileExists(keyFile) if certExists && keyExists { // Start HTTPS server with HTTP/2 support s.logger.Info("Using TLS with HTTP/2", "certFile", certFile, "keyFile", keyFile) err = s.server.ListenAndServeTLS(certFile, keyFile) } else { // Generate self-signed certificate for development s.logger.Info("Generating self-signed certificate for HTTP/2") cert, key, genErr := generateSelfSignedCert() if genErr != nil { s.logger.Error("Failed to generate self-signed certificate", "error", genErr) // Fall back to HTTP/1.1 s.logger.Info("Falling back to HTTP/1.1 (no TLS)") s.server.TLSConfig = nil err = s.server.ListenAndServe() } else { // Write certificate files if writeErr := os.WriteFile(certFile, cert, 0600); writeErr != nil { s.logger.Error("Failed to write certificate file", "error", writeErr) err = writeErr } else if writeErr := os.WriteFile(keyFile, key, 0600); writeErr != nil { s.logger.Error("Failed to write key file", "error", writeErr) err = writeErr } else { s.logger.Info("Self-signed certificate generated", "certFile", certFile, "keyFile", keyFile) err = s.server.ListenAndServeTLS(certFile, keyFile) } } } } else { // Start HTTP/1.1 server s.logger.Info("Using HTTP/1.1 (no TLS)") s.server.TLSConfig = nil err = s.server.ListenAndServe() } if err != nil && err != http.ErrServerClosed { s.logger.Error("HTTP server error", "error", err) } }() return nil } // 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 with a timeout shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() // 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 err := s.server.Shutdown(shutdownCtx) if err != nil { // If shutdown times out, force close s.logger.Warn("Server shutdown timed out, forcing close", "error", err) return s.server.Close() } s.logger.Info("Web server shutdown complete") return nil } // 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) { switch event.Type { case derby.EventRaceStart: s.logger.Info("Broadcasting race start event") s.sendRaceEventToAllClients("event: race-status\ndata:
Race Running
\n\n") case derby.EventLaneFinish: s.logger.Info("Broadcasting lane finish event", "lane", event.Result.Lane, "time", event.Result.Time, "place", event.Result.FinishPlace) heatGroup, _ := s.db.GetCurrentRacingGroup() heatNum, _ := s.db.GetCurrentHeatNumber(heatGroup.ID) s.db.SaveLaneResult(heatGroup.ID, heatNum, event.Result.Lane, event.Result.Time, event.Result.FinishPlace) s.sendRaceEventToAllClients(fmt.Sprintf("event: lane-%d-time\ndata: %.3f\n\n", event.Result.Lane, event.Result.Time)) s.sendRaceEventToAllClients(fmt.Sprintf("event: lane-%d-position\ndata: %d\n\n", event.Result.Lane, event.Result.FinishPlace)) case derby.EventRaceComplete: s.logger.Info("Broadcasting race complete event") s.sendRaceEventToAllClients("event: race-status\ndata:
Race Complete
\n\n") heatGroup, _ := s.db.GetCurrentRacingGroup() heatResults, _ := s.db.GetHeatResults(heatGroup.ID) 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\n\n", sb.String())) } } // 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\n\n", sb.String())) component = templates.RaceCurrentHeatLanes(&heatData) var sb2 strings.Builder err = component.Render(context.Background(), &sb2) if err != nil { s.logger.Error("Failed to render current heat lanes", "error", err) } s.sendAdminEventToAllClients(fmt.Sprintf("event: lane-data\ndata: %s\n\n", sb2.String())) nextHeatData, _ := s.db.GetHeatData(heatData.Group.ID, heatData.HeatNumber+1) component = templates.NextHeatDisplay(nextHeatData) var sb3 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\n\n", sb3.String())) component = templates.RaceNextHeatPreview(nextHeatData) var sb4 strings.Builder err = component.Render(context.Background(), &sb4) if err != nil { s.logger.Error("Failed to render next heat preview", "error", err) } s.sendAdminEventToAllClients(fmt.Sprintf("event: next-public-heat\ndata: %s\n\n", sb4.String())) upcomingHeatData, _ := s.db.GetHeatData(heatData.Group.ID, heatData.HeatNumber+2) component = templates.RaceNextHeatPreview(upcomingHeatData) var sb5 strings.Builder err = component.Render(context.Background(), &sb5) if err != nil { s.logger.Error("Failed to render upcoming heat preview", "error", err) } s.sendAdminEventToAllClients(fmt.Sprintf("event: upcoming-public-heat\ndata: %s\n\n", sb5.String())) s.sendAdminEventToAllClients(fmt.Sprintf("event: heat-number\ndata: %s - Heat: %d of %d\n\n", heatData.Group.Name, heatData.HeatNumber, heatData.TotalHeats)) s.sendAdminEventToAllClients(fmt.Sprintf("event: group-name\ndata: %s\n\n", heatData.Group.Name)) } 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\n\n", 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.Debug("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.Debug("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.Debug("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.Debug("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 } s.sendRaceEventToAllClients("event: race-status\ndata:
Ready
\n\n") 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.Debug("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.Debug("Admin events SSE headers flushed") // Create a channel for this client clientChan := make(chan string, 10) s.logger.Debug("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.Debug("Admin events SSE client added to map") s.logger.Debug("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.Debug("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, }) s.sendRaceEventToAllClients("event: race-status\ndata:
Idle
\n\n") 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, }) s.sendRaceEventToAllClients("event: race-status\ndata:
Idle
\n\n") 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, }) s.sendRaceEventToAllClients("event: race-status\ndata:
Idle
\n\n") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } } func parseRequestBody(r *http.Request) (string, error) { body, err := io.ReadAll(r.Body) if err != nil { return "", err } return string(body), nil } // handleSetRacingGroup sets the currently racing group func (s *Server) handleSetRacingGroup() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { text, err := parseRequestBody(r) if err != nil { s.logger.Error("Failed to parse request body", "error", err) http.Error(w, "Failed to parse request body", http.StatusBadRequest) return } s.logger.Info("Set racing group request", "text", text) // Parse request body var request struct { GroupID int64 `json:"group_id"` } request.GroupID, err = strconv.ParseInt(strings.Split(text, "=")[1], 10, 64) if err != nil { s.logger.Error("Failed to parse group ID", "error", err) http.Error(w, "Failed to parse group ID", 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, err := s.db.GetHeatData(request.GroupID, 1) if err != nil { s.logger.Error("Failed to get heat data", "error", err) http.Error(w, "Failed to get heat data", http.StatusInternalServerError) return } // 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, }) s.sendRaceEventToAllClients("event: race-status\ndata:
Idle
\n\n") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } } // handleFinalResults renders the final results page func (s *Server) handleFinalResults() 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) selectedGroupName := "" groupIDStr := r.URL.Query().Get("group_id") if groupIDStr != "" { groupID, err := strconv.ParseInt(groupIDStr, 10, 64) if err == nil { selectedGroupID = groupID // Find the group name for _, group := range groups { if group.ID == selectedGroupID { selectedGroupName = group.Name break } } } } var results []models.FinalResult if selectedGroupID > 0 { // Get final results for the selected group results, err = s.db.GetFinalResults(selectedGroupID) if err != nil { s.logger.Error("Failed to get final results", "error", err) http.Error(w, "Failed to get final results", http.StatusInternalServerError) return } } // Render template component := templates.FinalResultsPage(groups, selectedGroupID, results, selectedGroupName) if err := component.Render(r.Context(), w); err != nil { s.logger.Error("Failed to render final results template", "error", err) http.Error(w, "Failed to render page", http.StatusInternalServerError) } } } // handleFinalResultsPublic renders the public final results page func (s *Server) handleFinalResultsPublic() 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 group ID from query parameter if provided groupIDStr := r.URL.Query().Get("group_id") if groupIDStr != "" { groupID, err := strconv.ParseInt(groupIDStr, 10, 64) if err == nil { // Override current group if specified in URL currentGroup.ID = groupID // Get the group name group, err := s.db.GetGroup(groupID) if err == nil { currentGroup.Name = group.Name } } } // Get reveal count from query parameter revealCount := 0 revealCountStr := r.URL.Query().Get("reveal") if revealCountStr != "" { count, err := strconv.Atoi(revealCountStr) if err == nil { revealCount = count } } // Get final results for the group results, err := s.db.GetFinalResults(currentGroup.ID) if err != nil { s.logger.Error("Failed to get final results", "error", err) http.Error(w, "Failed to get final results", http.StatusInternalServerError) return } // Render template component := templates.FinalResultsPublic(results, currentGroup.Name, revealCount) if err := component.Render(r.Context(), w); err != nil { s.logger.Error("Failed to render final results template", "error", err) http.Error(w, "Failed to render page", http.StatusInternalServerError) } } } // handleRevealNextResult reveals the next result func (s *Server) handleRevealNextResult() 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 final results for the group results, err := s.db.GetFinalResults(currentGroup.ID) if err != nil { s.logger.Error("Failed to get final results", "error", err) http.Error(w, "Failed to get final results", http.StatusInternalServerError) return } // Broadcast the reveal event var sb strings.Builder component := templates.FinalResultsTable(results) component.Render(context.Background(), &sb) s.sendAdminEventToAllClients(fmt.Sprintf("event: results-reveal\ndata: %s\n\n", sb.String())) // Return success w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } }