feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user