From 078c87c4133c2526aba8735c9cacc8301dd290aa Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 17 Jul 2026 19:09:55 +0200 Subject: [PATCH] feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API --- backend/internal/api/api_test.go | 108 ++++++++++++++++++++----------- backend/internal/api/kinds.go | 105 +++++++++++++++++------------- backend/internal/api/server.go | 2 - 3 files changed, 133 insertions(+), 82 deletions(-) diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 7482715..335f89d 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -58,51 +58,69 @@ func TestHealth(t *testing.T) { } } -func TestWorkoutKindCRUD(t *testing.T) { +func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) { s, _ := newTestServer(t) - router := s.Router() - - createBody := map[string]any{ - "name": "Tempo", - "rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`), - } - rec := doJSON(t, router, http.MethodPost, "/api/workout-kinds/", createBody) - if rec.Code != http.StatusCreated { - t.Fatalf("create status = %d, body = %s", rec.Code, rec.Body.String()) - } - var created store.WorkoutKind - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("unmarshal created kind: %v", err) - } - if created.ID == 0 { - t.Fatal("expected non-zero id") - } - - rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil) if rec.Code != http.StatusOK { - t.Fatalf("list status = %d", rec.Code) + t.Fatalf("status = %d", rec.Code) } - var kinds []store.WorkoutKind - json.Unmarshal(rec.Body.Bytes(), &kinds) - if len(kinds) != 1 { - t.Fatalf("expected 1 kind, got %d", len(kinds)) + var kinds []workoutKindResponse + if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil { + t.Fatalf("unmarshal: %v", err) } - - rec = doJSON(t, router, http.MethodDelete, "/api/workout-kinds/"+itoa(created.ID), nil) - if rec.Code != http.StatusNoContent { - t.Fatalf("delete status = %d", rec.Code) + if len(kinds) != 7 { + t.Fatalf("expected 7 seeded kinds, got %d", len(kinds)) } - rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) - json.Unmarshal(rec.Body.Bytes(), &kinds) - if len(kinds) != 0 { - t.Fatalf("expected 0 active kinds after soft delete, got %d", len(kinds)) + for _, k := range kinds { + if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil { + t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k) + } } } -func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) { +func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { s, _ := newTestServer(t) - rec := doJSON(t, s.Router(), http.MethodPost, "/api/workout-kinds/", map[string]any{ - "name": "Bad", + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + var kinds []workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &kinds) + target := kinds[0] + + minPace, maxPace, zone := 330.0, 420.0, 2 + body := map[string]any{ + "name": target.Name, + "rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`), + "pace_min_sec_per_km": minPace, + "pace_max_sec_per_km": maxPace, + "expected_hr_zone": zone, + } + rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body) + if rec.Code != http.StatusOK { + t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil) + var updated workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &updated) + if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 { + t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm) + } + if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 { + t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone) + } +} + +func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + var kinds []workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &kinds) + + rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ + "name": kinds[0].Name, "rule": json.RawMessage(`{"match":"xor","conditions":[]}`), }) if rec.Code != http.StatusBadRequest { @@ -110,6 +128,24 @@ func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) { } } +func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + var kinds []workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &kinds) + + rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ + "name": kinds[0].Name, + "rule": json.RawMessage(`{"match":"all","conditions":[]}`), + "expected_hr_zone": 9, + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } +} + func TestReviewQueueResolve(t *testing.T) { s, db := newTestServer(t) ctx := newCtx() diff --git a/backend/internal/api/kinds.go b/backend/internal/api/kinds.go index 1416d26..5bd47a8 100644 --- a/backend/internal/api/kinds.go +++ b/backend/internal/api/kinds.go @@ -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 diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 1b0a49d..276b175 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -65,10 +65,8 @@ func (s *Server) Router() http.Handler { r.Route("/workout-kinds", func(r chi.Router) { r.Get("/", s.handleListWorkoutKinds) - r.Post("/", s.handleCreateWorkoutKind) r.Get("/{id}", s.handleGetWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind) - r.Delete("/{id}", s.handleDeleteWorkoutKind) r.Post("/{id}/reclassify", s.handleReclassifyKind) })