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

@@ -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()