diff --git a/db/schema.go b/db/schema.go index 5d58c62..473f333 100644 --- a/db/schema.go +++ b/db/schema.go @@ -16,7 +16,7 @@ type DB struct { logger *slog.Logger } -// New creates a new database connection +// NewDB creates a new database connection func New(dbPath string) (*DB, error) { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, @@ -112,5 +112,170 @@ func (db *DB) initSchema() error { return fmt.Errorf("failed to create race_results table: %w", err) } + // Create heats table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS heats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + heat_num INTEGER NOT NULL, + lane1_id INTEGER, + lane2_id INTEGER, + lane3_id INTEGER, + lane4_id INTEGER, + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY (lane1_id) REFERENCES racers(id) ON DELETE SET NULL, + FOREIGN KEY (lane2_id) REFERENCES racers(id) ON DELETE SET NULL, + FOREIGN KEY (lane3_id) REFERENCES racers(id) ON DELETE SET NULL, + FOREIGN KEY (lane4_id) REFERENCES racers(id) ON DELETE SET NULL + ) + `) + if err != nil { + return fmt.Errorf("failed to create heats table: %w", err) + } + return nil } + +// Close closes the database connection +func (db *DB) Close() error { + return db.DB.Close() +} + +// Group represents a group of racers +type Group struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// Racer represents a racer in the derby +type Racer struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + CarNumber string `json:"car_number"` + CarWeight float64 `json:"car_weight"` + GroupID int64 `json:"group_id"` +} + +// Heat represents a single race with 4 lanes +type Heat struct { + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + HeatNum int `json:"heat_num"` + Lane1ID *int64 `json:"lane1_id"` + Lane2ID *int64 `json:"lane2_id"` + Lane3ID *int64 `json:"lane3_id"` + Lane4ID *int64 `json:"lane4_id"` +} + +// SaveHeats saves a list of heats for a group +func (db *DB) SaveHeats(groupID int64, heats []Heat) error { + // Start a transaction + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete existing heats for this group + _, err = tx.Exec("DELETE FROM heats WHERE group_id = ?", groupID) + if err != nil { + return err + } + + // Insert new heats + stmt, err := tx.Prepare(` + INSERT INTO heats (group_id, heat_num, lane1_id, lane2_id, lane3_id, lane4_id) + VALUES (?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer stmt.Close() + + for _, heat := range heats { + _, err = stmt.Exec( + groupID, + heat.HeatNum, + nullableInt64(heat.Lane1ID), + nullableInt64(heat.Lane2ID), + nullableInt64(heat.Lane3ID), + nullableInt64(heat.Lane4ID), + ) + if err != nil { + return err + } + } + + // Commit the transaction + return tx.Commit() +} + +// Helper function to handle nullable int64 values +func nullableInt64(i *int64) interface{} { + if i == nil { + return nil + } + return *i +} + +// GetHeats retrieves all heats for a group +func (db *DB) GetHeats(groupID int64) ([]Heat, error) { + rows, err := db.Query(` + SELECT id, group_id, heat_num, lane1_id, lane2_id, lane3_id, lane4_id + FROM heats + WHERE group_id = ? + ORDER BY heat_num + `, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + var heats []Heat + for rows.Next() { + var heat Heat + var lane1, lane2, lane3, lane4 sql.NullInt64 + + err := rows.Scan( + &heat.ID, + &heat.GroupID, + &heat.HeatNum, + &lane1, + &lane2, + &lane3, + &lane4, + ) + if err != nil { + return nil, err + } + + if lane1.Valid { + val := lane1.Int64 + heat.Lane1ID = &val + } + if lane2.Valid { + val := lane2.Int64 + heat.Lane2ID = &val + } + if lane3.Valid { + val := lane3.Int64 + heat.Lane3ID = &val + } + if lane4.Valid { + val := lane4.Int64 + heat.Lane4ID = &val + } + + heats = append(heats, heat) + } + + return heats, nil +} + +// DeleteHeats deletes all heats for a group +func (db *DB) DeleteHeats(groupID int64) error { + _, err := db.Exec("DELETE FROM heats WHERE group_id = ?", groupID) + return err +} diff --git a/derby/heats.go b/derby/heats.go new file mode 100644 index 0000000..944c247 --- /dev/null +++ b/derby/heats.go @@ -0,0 +1,181 @@ +package derby + +import ( + "math/rand" + "time" +) + +// Racer represents a racer for heat generation +type Racer struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + CarNumber string `json:"car_number"` +} + +// Heat represents a single race with 4 lanes +type Heat struct { + HeatNum int `json:"heat_num"` + Lane1ID *int64 `json:"lane1_id"` + Lane2ID *int64 `json:"lane2_id"` + Lane3ID *int64 `json:"lane3_id"` + Lane4ID *int64 `json:"lane4_id"` +} + +func CreateRacer(id int64, firstName string, lastName string, carNumber string) Racer { + return Racer{ID: id, FirstName: firstName, LastName: lastName, CarNumber: carNumber} +} + +// GenerateHeats creates a set of heats for a group of racers +// Each racer should race in each lane once, with different opponents when possible +func GenerateHeats(racers []Racer) []Heat { + // If no racers, return empty heats + if len(racers) == 0 { + return []Heat{} + } + + // Seed the random number generator + rand.Seed(time.Now().UnixNano()) + + // Shuffle racers to randomize initial order + shuffledRacers := make([]Racer, len(racers)) + copy(shuffledRacers, racers) + rand.Shuffle(len(shuffledRacers), func(i, j int) { + shuffledRacers[i], shuffledRacers[j] = shuffledRacers[j], shuffledRacers[i] + }) + + // Track which lanes each racer has used + racerLanes := make(map[int64]map[int]bool) + for _, racer := range racers { + racerLanes[racer.ID] = make(map[int]bool) + } + + // Track which racers have raced against each other + racerOpponents := make(map[int64]map[int64]bool) + for _, racer := range racers { + racerOpponents[racer.ID] = make(map[int64]bool) + } + + // Calculate number of heats needed + // Each racer needs to race in 4 lanes, so total races = (racers * 4) / 4 = racers + // But we need to round up to ensure all racers get all lanes + numHeats := len(racers) + if len(racers) < 4 { + numHeats = 1 + } else if len(racers)%4 != 0 { + // Add extra heats to ensure all racers get all lanes + numHeats = (len(racers) + 3) / 4 * 4 + } + + // Generate heats + heats := make([]Heat, 0, numHeats) + heatNum := 1 + + // Create a queue of racers that need to race in each lane + laneQueues := make([][]int64, 4) + for i := 0; i < 4; i++ { + laneQueues[i] = make([]int64, 0, len(racers)) + for _, racer := range shuffledRacers { + laneQueues[i] = append(laneQueues[i], racer.ID) + } + // Shuffle each lane queue differently + rand.Shuffle(len(laneQueues[i]), func(j, k int) { + laneQueues[i][j], laneQueues[i][k] = laneQueues[i][k], laneQueues[i][j] + }) + } + + // Keep generating heats until all racers have raced in all lanes + for { + // Check if all racers have raced in all lanes + allDone := true + for _, racer := range racers { + if len(racerLanes[racer.ID]) < 4 { + allDone = false + break + } + } + if allDone { + break + } + + // Create a new heat + heat := Heat{ + HeatNum: heatNum, + } + heatNum++ + + // Fill lanes + filledLanes := 0 + usedRacers := make(map[int64]bool) + + // Try to fill each lane + for lane := 1; lane <= 4; lane++ { + // Find a racer for this lane + var selectedRacer *int64 = nil + + // First try racers who haven't used this lane yet + for i := 0; i < len(laneQueues[lane-1]); i++ { + racerID := laneQueues[lane-1][i] + + // Skip if racer already in this heat + if usedRacers[racerID] { + continue + } + + // Skip if racer already used this lane + if racerLanes[racerID][lane] { + continue + } + + // Use this racer + selectedRacer = &racerID + + // Remove from queue + laneQueues[lane-1] = append(laneQueues[lane-1][:i], laneQueues[lane-1][i+1:]...) + + break + } + + // If no racer found, leave lane empty + if selectedRacer != nil { + // Mark lane as used for this racer + racerLanes[*selectedRacer][lane] = true + + // Mark racer as used in this heat + usedRacers[*selectedRacer] = true + + // Update opponents + for otherRacerID := range usedRacers { + if otherRacerID != *selectedRacer { + racerOpponents[*selectedRacer][otherRacerID] = true + racerOpponents[otherRacerID][*selectedRacer] = true + } + } + + filledLanes++ + } + + // Set lane in heat + switch lane { + case 1: + heat.Lane1ID = selectedRacer + case 2: + heat.Lane2ID = selectedRacer + case 3: + heat.Lane3ID = selectedRacer + case 4: + heat.Lane4ID = selectedRacer + } + } + + // Only add heat if at least one lane is filled + if filledLanes > 0 { + heats = append(heats, heat) + } else { + // If we couldn't fill any lanes, we're done + break + } + } + + return heats +} diff --git a/web/server.go b/web/server.go index e923ecf..48a2eb9 100644 --- a/web/server.go +++ b/web/server.go @@ -129,6 +129,15 @@ func (s *Server) routes() { r.Delete("/{id}", s.handleDeleteRacer()) }) + // Add heats page route + s.router.Get("/heats", s.handleHeats()) + + // Add heats API routes + s.router.Route("/api/heats", func(r chi.Router) { + r.Post("/generate", s.handleGenerateHeats()) + r.Post("/save", s.handleSaveHeats()) + }) + // Main page s.router.Get("/", s.handleIndex()) } @@ -656,3 +665,155 @@ func (s *Server) handleDeleteRacer() http.HandlerFunc { 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 racers from database + 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 + } + + // 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, racers, selectedGroupID) + 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) + } + } +} + +// handleGenerateHeats generates heats for a group +func (s *Server) handleGenerateHeats() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get group ID from query parameter + groupIDStr := r.URL.Query().Get("group_id") + groupID, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + http.Error(w, "Invalid group ID", http.StatusBadRequest) + return + } + + // Get racers for this group + allRacers, 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 + } + + // Filter racers by group + var groupRacers []db.Racer + for _, racer := range allRacers { + if racer.GroupID == groupID { + groupRacers = append(groupRacers, racer) + } + } + + // Convert to derby racers + derbyRacers := make([]derby.Racer, len(groupRacers)) + for i, racer := range groupRacers { + derbyRacers[i] = derby.Racer{ + ID: racer.ID, + FirstName: racer.FirstName, + LastName: racer.LastName, + CarNumber: racer.CarNumber, + } + } + + // Render the generated heats + component := templates.GeneratedHeats(groupRacers) + if err := component.Render(r.Context(), w); err != nil { + s.logger.Error("Failed to render heats", "error", err) + http.Error(w, "Failed to render heats", http.StatusInternalServerError) + } + } +} + +// handleSaveHeats saves heats for a group +func (s *Server) handleSaveHeats() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get group ID from query parameter + groupIDStr := r.URL.Query().Get("group_id") + groupID, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + http.Error(w, "Invalid group ID", http.StatusBadRequest) + return + } + + // Get racers for this group + allRacers, 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 + } + + // Filter racers by group + var groupRacers []db.Racer + for _, racer := range allRacers { + if racer.GroupID == groupID { + groupRacers = append(groupRacers, racer) + } + } + + // Convert to derby racers + derbyRacers := make([]derby.Racer, len(groupRacers)) + for i, racer := range groupRacers { + derbyRacers[i] = derby.Racer{ + ID: racer.ID, + FirstName: racer.FirstName, + LastName: racer.LastName, + CarNumber: racer.CarNumber, + } + } + + // Generate heats + derbyHeats := derby.GenerateHeats(derbyRacers) + + // Convert to database heats + dbHeats := make([]db.Heat, len(derbyHeats)) + for i, heat := range derbyHeats { + dbHeats[i] = db.Heat{ + GroupID: groupID, + HeatNum: heat.HeatNum, + Lane1ID: heat.Lane1ID, + Lane2ID: heat.Lane2ID, + Lane3ID: heat.Lane3ID, + Lane4ID: heat.Lane4ID, + } + } + + // Save heats to database + if err := s.db.SaveHeats(groupID, dbHeats); err != nil { + s.logger.Error("Failed to save heats", "error", err) + http.Error(w, "Failed to save heats", http.StatusInternalServerError) + return + } + + // Return success message + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`
Heats saved successfully!
Please select a group to generate heats.
+No racers in this group. Add racers to generate heats.
+No heats could be generated. Please add more racers.
+| Lane | +Racer | +Car # | +
|---|---|---|
| 1 | +{ getRacerName(racers, heat.Lane1ID) } | +{ getRacerCarNumber(racers, heat.Lane1ID) } | +
| 2 | +{ getRacerName(racers, heat.Lane2ID) } | +{ getRacerCarNumber(racers, heat.Lane2ID) } | +
| 3 | +{ getRacerName(racers, heat.Lane3ID) } | +{ getRacerCarNumber(racers, heat.Lane3ID) } | +
| 4 | +{ getRacerName(racers, heat.Lane4ID) } | +{ getRacerCarNumber(racers, heat.Lane4ID) } | +
Please select a group to generate heats.
No racers in this group. Add racers to generate heats.
No heats could be generated. Please add more racers.
| Lane | Racer | Car # |
|---|---|---|
| 1 | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerName(racers, heat.Lane1ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 123, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerCarNumber(racers, heat.Lane1ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 124, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " |
| 2 | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerName(racers, heat.Lane2ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 128, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerCarNumber(racers, heat.Lane2ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 129, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " |
| 3 | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerName(racers, heat.Lane3ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 133, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerCarNumber(racers, heat.Lane3ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 134, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " |
| 4 | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerName(racers, heat.Lane4ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 138, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(getRacerCarNumber(racers, heat.Lane4ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/heats.templ`, Line: 139, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " |