package api import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "smartrun/backend/internal/classify" "smartrun/backend/internal/store" ) // workoutKindResponse combines a workout kind's rule/metadata with its pace // range and expected HR zone (stored separately in workout_type_paces), // since the frontend always edits and displays them together. type workoutKindResponse struct { store.WorkoutKind PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` ExpectedHRZone *int `json:"expected_hr_zone"` } func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) { pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID) if err != nil { return workoutKindResponse{}, err } return workoutKindResponse{ WorkoutKind: k, PaceMinSecPerKm: pace.PaceMinSecPerKm, PaceMaxSecPerKm: pace.PaceMaxSecPerKm, ExpectedHRZone: pace.ExpectedHRZone, }, nil } type workoutKindRequest struct { Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` Rule json.RawMessage `json:"rule"` Priority int `json:"priority"` IsActive *bool `json:"is_active"` PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` ExpectedHRZone *int `json:"expected_hr_zone"` } func (req workoutKindRequest) validate() (classify.Node, error) { var node classify.Node if req.Name == "" { return node, errors.New("name is required") } if err := json.Unmarshal(req.Rule, &node); err != nil { return node, errors.New("rule is not valid JSON: " + err.Error()) } if err := node.Validate(); err != nil { return node, errors.New("invalid rule: " + err.Error()) } if req.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) { return node, errors.New("expected_hr_zone must be between 1 and 5") } return node, nil } func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) { activeOnly := r.URL.Query().Get("include_inactive") != "true" kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp := make([]workoutKindResponse, 0, len(kinds)) for _, k := range kinds { wr, err := s.toWorkoutKindResponse(r, k) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp = append(resp, wr) } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { writeError(w, http.StatusNotFound, "workout kind not found") return } resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { writeError(w, http.StatusNotFound, "workout kind not found") return } var req workoutKindRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if _, err := req.validate(); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } isActive := existing.IsActive if req.IsActive != nil { isActive = *req.IsActive } if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{ ID: id, Name: req.Name, Description: req.Description, Color: req.Color, RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{ WorkoutKindID: id, PaceMinSecPerKm: req.PaceMinSecPerKm, PaceMaxSecPerKm: req.PaceMaxSecPerKm, ExpectedHRZone: req.ExpectedHRZone, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } // handleReclassifyKind re-runs the rule engine for every activity currently // assigned to (or under review for) this kind. It's a synchronous, bounded // operation (unlike sync), so it runs inline rather than in the background. func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } assignments, err := s.DB.AssignmentsForKind(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } reviewQueue, err := s.DB.ReviewQueue(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } seen := make(map[int64]bool) var activityIDs []int64 for _, a := range assignments { if !seen[a.ActivityID] { seen[a.ActivityID] = true activityIDs = append(activityIDs, a.ActivityID) } } for _, a := range reviewQueue { if !seen[a.ActivityID] { seen[a.ActivityID] = true activityIDs = append(activityIDs, a.ActivityID) } } for _, activityID := range activityIDs { if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)}) }