feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API

This commit is contained in:
2026-07-17 19:09:55 +02:00
parent 9c42931c9e
commit 078c87c413
3 changed files with 133 additions and 82 deletions

View File

@@ -12,13 +12,39 @@ import (
"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"`
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) {
@@ -32,6 +58,9 @@ func (req workoutKindRequest) validate() (classify.Node, 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
}
@@ -42,7 +71,16 @@ func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, kinds)
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) {
@@ -60,35 +98,12 @@ func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "workout kind not found")
return
}
writeJSON(w, http.StatusOK, kind)
}
func (s *Server) handleCreateWorkoutKind(w http.ResponseWriter, r *http.Request) {
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 := true
if req.IsActive != nil {
isActive = *req.IsActive
}
id, err := s.DB.CreateWorkoutKind(r.Context(), store.WorkoutKind{
Name: req.Name, Description: req.Description, Color: req.Color,
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
})
resp, err := s.toWorkoutKindResponse(r, kind)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
writeJSON(w, http.StatusCreated, kind)
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
@@ -129,21 +144,23 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
writeJSON(w, http.StatusOK, kind)
}
func (s *Server) handleDeleteWorkoutKind(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
}
if err := s.DB.SoftDeleteWorkoutKind(r.Context(), id); err != nil {
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
}
w.WriteHeader(http.StatusNoContent)
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