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

@@ -58,51 +58,69 @@ func TestHealth(t *testing.T) {
} }
} }
func TestWorkoutKindCRUD(t *testing.T) { func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) {
s, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var kinds []workoutKindResponse
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(kinds) != 7 {
t.Fatalf("expected 7 seeded kinds, 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 TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
s, _ := newTestServer(t) s, _ := newTestServer(t)
router := s.Router() router := s.Router()
createBody := map[string]any{ rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
"name": "Tempo", 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]}]}`), "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.MethodPost, "/api/workout-kinds/", createBody) rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
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)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code) t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String())
}
var kinds []store.WorkoutKind
json.Unmarshal(rec.Body.Bytes(), &kinds)
if len(kinds) != 1 {
t.Fatalf("expected 1 kind, got %d", len(kinds))
} }
rec = doJSON(t, router, http.MethodDelete, "/api/workout-kinds/"+itoa(created.ID), nil) rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil)
if rec.Code != http.StatusNoContent { var updated workoutKindResponse
t.Fatalf("delete status = %d", rec.Code) json.Unmarshal(rec.Body.Bytes(), &updated)
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
} }
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 {
json.Unmarshal(rec.Body.Bytes(), &kinds) t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone)
if len(kinds) != 0 {
t.Fatalf("expected 0 active kinds after soft delete, got %d", len(kinds))
} }
} }
func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) { func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
s, _ := newTestServer(t) s, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/workout-kinds/", map[string]any{ router := s.Router()
"name": "Bad",
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":[]}`), "rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
}) })
if rec.Code != http.StatusBadRequest { 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) { func TestReviewQueueResolve(t *testing.T) {
s, db := newTestServer(t) s, db := newTestServer(t)
ctx := newCtx() ctx := newCtx()

View File

@@ -12,6 +12,29 @@ import (
"smartrun/backend/internal/store" "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 { type workoutKindRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
@@ -19,6 +42,9 @@ type workoutKindRequest struct {
Rule json.RawMessage `json:"rule"` Rule json.RawMessage `json:"rule"`
Priority int `json:"priority"` Priority int `json:"priority"`
IsActive *bool `json:"is_active"` 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) { func (req workoutKindRequest) validate() (classify.Node, error) {
@@ -32,6 +58,9 @@ func (req workoutKindRequest) validate() (classify.Node, error) {
if err := node.Validate(); err != nil { if err := node.Validate(); err != nil {
return node, errors.New("invalid rule: " + err.Error()) 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 return node, nil
} }
@@ -42,7 +71,16 @@ func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { 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") writeError(w, http.StatusNotFound, "workout kind not found")
return return
} }
writeJSON(w, http.StatusOK, kind) resp, err := s.toWorkoutKindResponse(r, 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,
})
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) writeJSON(w, http.StatusOK, resp)
writeJSON(w, http.StatusCreated, kind)
} }
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) { 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()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{
writeJSON(w, http.StatusOK, kind) WorkoutKindID: id,
} PaceMinSecPerKm: req.PaceMinSecPerKm,
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
func (s *Server) handleDeleteWorkoutKind(w http.ResponseWriter, r *http.Request) { ExpectedHRZone: req.ExpectedHRZone,
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) }); err != nil {
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
if err := s.DB.SoftDeleteWorkoutKind(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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 // handleReclassifyKind re-runs the rule engine for every activity currently

View File

@@ -65,10 +65,8 @@ func (s *Server) Router() http.Handler {
r.Route("/workout-kinds", func(r chi.Router) { r.Route("/workout-kinds", func(r chi.Router) {
r.Get("/", s.handleListWorkoutKinds) r.Get("/", s.handleListWorkoutKinds)
r.Post("/", s.handleCreateWorkoutKind)
r.Get("/{id}", s.handleGetWorkoutKind) r.Get("/{id}", s.handleGetWorkoutKind)
r.Put("/{id}", s.handleUpdateWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind)
r.Delete("/{id}", s.handleDeleteWorkoutKind)
r.Post("/{id}/reclassify", s.handleReclassifyKind) r.Post("/{id}/reclassify", s.handleReclassifyKind)
}) })