package web import ( "context" "embed" "encoding/json" "fmt" "io/fs" "log" "log/slog" "net/http" "net/url" "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 } // NewServer creates a new web server func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, dbPath string, port int, 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), } // 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.Logger) s.router.Use(middleware.Recoverer) // 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("/api/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()) // Main page s.router.Get("/", s.handleIndex()) // 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()) } // Start starts the web server func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) s.server = &http.Server{ Addr: addr, Handler: s.router, } // Start server in a goroutine go func() { s.logger.Info("Web server starting", "port", s.port) if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.logger.Error("HTTP server error", "error", err) } }() return nil } // Stop gracefully shuts down the server func (s *Server) Stop() error { // Close database connection if s.db != nil { if err := s.db.Close(); err != nil { s.logger.Error("Error closing database", "error", err) } } // Create a context with timeout for shutdown ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.logger.Info("Shutting down web server") // Shutdown the HTTP server if s.server != nil { return s.server.Shutdown(ctx) } return nil } // forwardEvents forwards derby events to SSE clients func (s *Server) forwardEvents() { for { select { case event, ok := <-s.events: if !ok { return } // Process the event and send to clients s.broadcastRaceEvent(event) case <-s.shutdown: return } } } // broadcastEvent sends an 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())) } } } func (s *Server) sendRaceEventToAllClients(message string) { if message == "" { return } // Send to all clients s.clientsMux.Lock() clientCount := len(s.clients) sentCount := 0 for clientChan := range s.clients { clientChan <- message sentCount++ } s.logger.Debug("Event broadcast complete", "sentCount", sentCount, "totalClients", clientCount, "message", message) s.clientsMux.Unlock() } func (s *Server) sendAdminEventToAllClients(message string) { if message == "" { return } // Send to all clients s.adminclientsMux.Lock() clientCount := len(s.adminclients) sentCount := 0 for clientChan := range s.adminclients { clientChan <- message sentCount++ } s.logger.Debug("Event broadcast complete", "sentCount", sentCount, "totalClients", clientCount, "message", message) s.adminclientsMux.Unlock() } // 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))) } } // handleEvents handles SSE events func (s *Server) handleEvents() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // 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 } // 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", "clientIP", r.RemoteAddr, "totalClients", clientCount) // 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", "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 } } } } // 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"}) } }