Replace per-kind reclassify with a single global reclassify that respects manual and Race locks

Manual assignments are the user's definitive word and Race assignments come
from a hard Garmin fact (eventType.typeKey), not a retunable rule -- neither
is ever touched by reclassify again, and Race can no longer be set by hand
via the review queue. Adds an Activities page so this locked/unlocked status
is visible per workout, since Review Queue only ever showed unresolved items.
This commit is contained in:
2026-07-19 11:11:50 +02:00
parent 8ff3d62b2f
commit 0610897541
15 changed files with 398 additions and 60 deletions

View File

@@ -9,6 +9,21 @@ import (
"smartrun/backend/internal/store"
)
// activityListItem is one row of the activities list, enriched with its
// current classification so the frontend can show what kind it's assigned
// to and whether that assignment is locked (see Locked's doc comment).
type activityListItem struct {
store.Activity
WorkoutKindID *int64 `json:"workout_kind_id"`
WorkoutKindName *string `json:"workout_kind_name"`
AssignmentSource *string `json:"assignment_source"`
AssignmentStatus *string `json:"assignment_status"`
// Locked is true when a global reclassify pass will never touch this
// activity again: a manual assignment is the user's definitive word, and
// a Race assignment comes from a hard Garmin fact, not a retunable rule.
Locked bool `json:"locked"`
}
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
filter := store.ActivityFilter{
@@ -27,7 +42,40 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, activities)
kinds, err := s.DB.ListWorkoutKinds(r.Context(), false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kindNames := make(map[int64]string, len(kinds))
for _, k := range kinds {
kindNames[k.ID] = k.Name
}
resp := make([]activityListItem, 0, len(activities))
for _, a := range activities {
item := activityListItem{Activity: a}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if ok {
source, status := assignment.AssignmentSource, assignment.Status
item.AssignmentSource, item.AssignmentStatus = &source, &status
if assignment.WorkoutKindID != nil {
item.WorkoutKindID = assignment.WorkoutKindID
if name, found := kindNames[*assignment.WorkoutKindID]; found {
item.WorkoutKindName = &name
}
}
item.Locked = source == store.AssignmentSourceManual ||
(item.WorkoutKindName != nil && *item.WorkoutKindName == "Race")
}
resp = append(resp, item)
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {

View File

@@ -188,6 +188,143 @@ func TestReviewQueueResolve(t *testing.T) {
}
}
func TestResolveReview_RejectsRaceKind(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID})
if rec.Code != http.StatusBadRequest {
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (manual): %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (race): %v", err)
}
rec := doJSON(t, s.Router(), http.MethodPost, "/api/reclassify", nil)
if rec.Code != http.StatusOK {
t.Fatalf("reclassify status = %d, body = %s", rec.Code, rec.Body.String())
}
var body struct {
Reclassified int `json:"reclassified"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.Reclassified != 1 {
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
}
manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity)
if err != nil || !ok {
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
}
if manualAssignment.AssignmentSource != store.AssignmentSourceManual {
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
}
raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity)
if err != nil || !ok {
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
}
if raceAssignment.WorkoutKindID == nil || *raceAssignment.WorkoutKindID != raceKind.ID {
t.Errorf("race assignment changed: %+v", raceAssignment)
}
}
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var items []activityListItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("unmarshal: %v", err)
}
locked := map[int64]bool{}
kindName := map[int64]string{}
for _, it := range items {
locked[it.ID] = it.Locked
if it.WorkoutKindName != nil {
kindName[it.ID] = *it.WorkoutKindName
}
}
if locked[ruleEngineActivity] {
t.Errorf("rule-engine activity should not be locked")
}
if !locked[manualActivity] {
t.Errorf("manual activity should be locked")
}
if !locked[raceActivity] {
t.Errorf("race activity should be locked")
}
if kindName[raceActivity] != "Race" {
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
}
}
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()

View File

@@ -162,48 +162,3 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
}
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)})
}

View File

@@ -0,0 +1,51 @@
package api
import (
"net/http"
"smartrun/backend/internal/store"
)
// handleReclassifyAll re-runs the rule engine for every activity except
// those that are locked:
// - a manual assignment is the user's definitive word and is never
// overwritten by the rule engine again;
// - an activity already assigned to Race is locked too, since that
// classification comes from a hard Garmin fact (eventType.typeKey ==
// "race"), not a rule the user might retune -- there's nothing to
// re-derive.
//
// It's a synchronous, bounded operation (unlike sync), so it runs inline
// rather than in the background.
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), "Race")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
assignments, err := s.DB.AllCurrentAssignments(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var activityIDs []int64
for _, a := range assignments {
if a.AssignmentSource == store.AssignmentSourceManual {
continue
}
if hasRaceKind && a.WorkoutKindID != nil && *a.WorkoutKindID == raceKind.ID {
continue
}
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)})
}

View File

@@ -63,13 +63,18 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
return
}
if _, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID); err != nil {
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else if !ok {
writeError(w, http.StatusBadRequest, "workout kind not found")
return
}
if kind.Name == "Race" {
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
return
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{

View File

@@ -67,9 +67,10 @@ func (s *Server) Router() http.Handler {
r.Get("/", s.handleListWorkoutKinds)
r.Get("/{id}", s.handleGetWorkoutKind)
r.Put("/{id}", s.handleUpdateWorkoutKind)
r.Post("/{id}/reclassify", s.handleReclassifyKind)
})
r.Post("/reclassify", s.handleReclassifyAll)
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)

View File

@@ -83,6 +83,27 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
return assignments, rows.Err()
}
// AllCurrentAssignments returns the latest assignment for every activity
// that has one, regardless of status or source -- the basis for deciding
// which activities a global reclassify pass is allowed to touch.
func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) {
rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`)
if err != nil {
return nil, fmt.Errorf("all current assignments: %w", err)
}
defer rows.Close()
assignments := []KindAssignment{}
for rows.Next() {
a, err := scanKindAssignment(rows)
if err != nil {
return nil, fmt.Errorf("scan kind assignment row: %w", err)
}
assignments = append(assignments, a)
}
return assignments, rows.Err()
}
// AssignmentsForKind returns every historical assignment where the given
// workout kind was the resolved kind (regardless of source), oldest first --
// the basis for progression-over-time charts.

View File

@@ -65,6 +65,19 @@ func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool,
return k, true, nil
}
// GetWorkoutKindByName fetches one workout kind by its unique name.
func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, name)
k, err := scanWorkoutKind(row)
if err == sql.ErrNoRows {
return WorkoutKind{}, false, nil
}
if err != nil {
return WorkoutKind{}, false, fmt.Errorf("get workout kind %q: %w", name, err)
}
return k, true, nil
}
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
// (is_active=0) kinds are excluded.
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {