From a394bbb77056ac702a0d0b658911cc60242a8ee7 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Tue, 4 Aug 2026 00:04:03 +0200 Subject: [PATCH] refactor(api): merge review-queue into /api/activities, rename resolve to assign Removes the unused GET /api/activities list/detail endpoints, moves the review-queue list and resolve/unlock/unassign actions under /api/activities, renames resolve to assign end-to-end, and drops the now-unused store.ReviewQueue helper. Co-Authored-By: Claude Fable 5 --- backend/internal/api/activities.go | 277 +++++++++++++++++++------ backend/internal/api/api_test.go | 105 +++------- backend/internal/api/isolation_test.go | 37 ++-- backend/internal/api/review.go | 258 ----------------------- backend/internal/api/server.go | 11 +- backend/internal/store/assignments.go | 25 --- backend/internal/store/store_test.go | 18 +- frontend/src/api/client.ts | 39 ++-- frontend/src/pages/Activities.tsx | 34 +-- frontend/src/types/api.ts | 14 +- 10 files changed, 297 insertions(+), 521 deletions(-) delete mode 100644 backend/internal/api/review.go diff --git a/backend/internal/api/activities.go b/backend/internal/api/activities.go index defc327..2e421ce 100644 --- a/backend/internal/api/activities.go +++ b/backend/internal/api/activities.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "net/http" + "sort" "strconv" "github.com/go-chi/chi/v5" @@ -9,109 +11,248 @@ import ( "geniusrun/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 { - activityResponse - 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"` +// defaultActivitiesPageSize matches the frontend's initial/incremental +// page size for its infinite-scroll list. +const defaultActivitiesPageSize = 10 + +type activityItem struct { + store.KindAssignment + Activity activityResponse `json:"activity"` + Laps []lapResponse `json:"laps"` + Samples []store.Sample `json:"samples"` } +// handleListActivities backs the Activities page: every activity that has been +// classified at least once, not just ones still needing review, so the page +// can show each activity's current kind (or "Unclassified") and let the user +// lock/unlock it. It's cursor-paginated: fetching every activity's laps and +// (especially) per-second samples is expensive once there are many of them, +// so that work only happens for the requested page, not the full backlog. +// Sorting/cursor filtering still needs each activity's summary row (a cheap +// indexed lookup, no laps/samples), which happens for the whole backlog -- +// only the heavy per-item fetches are deferred to the page actually being +// returned. The optional kind_id/unclassified filters are applied before +// that cursor slicing too, so a filtered view still only loads (and +// chart-renders) one page at a time instead of the whole matching backlog. func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) - q := r.URL.Query() - filter := store.ActivityFilter{ - FromDate: q.Get("from"), - ToDate: q.Get("to"), - } - if limit, err := strconv.Atoi(q.Get("limit")); err == nil { - filter.Limit = limit - } - if offset, err := strconv.Atoi(q.Get("offset")); err == nil { - filter.Offset = offset + limit := defaultActivitiesPageSize + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } } + cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page - activities, err := s.DB.ListActivities(r.Context(), userID, filter) + var kindIDFilter *int64 + if v := r.URL.Query().Get("kind_id"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + kindIDFilter = &n + } + } + unclassifiedOnly := r.URL.Query().Get("unclassified") == "true" + + queue, err := s.DB.AllCurrentAssignments(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return + type withActivity struct { + assignment store.KindAssignment + activity store.Activity } - 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{activityResponse: toActivityResponse(a)} - assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID) + all := make([]withActivity, 0, len(queue)) + for _, a := range queue { + if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) { + continue + } + if unclassifiedOnly && a.WorkoutKindID != nil { + continue + } + activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID) 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") + if !ok { + continue } - resp = append(resp, item) + all = append(all, withActivity{assignment: a, activity: activity}) } - writeJSON(w, http.StatusOK, resp) + + // Most recent run first, by when the activity actually happened (not + // when the rule engine flagged it), so the queue reads like a log. + sort.Slice(all, func(i, j int) bool { + return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC + }) + + total := len(all) + + if cursor != "" { + idx := 0 + for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor { + idx++ + } + all = all[idx:] + } + hasMore := len(all) > limit + if len(all) > limit { + all = all[:limit] + } + + items := make([]activityItem, 0, len(all)) + for _, wa := range all { + laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Per-second telemetry, not just per-lap averages, so the chart can + // show real within-lap variation instead of one flat segment per lap + // (most activities only have a handful of laps). + samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + items = append(items, activityItem{ + KindAssignment: wa.assignment, + Activity: toActivityResponse(wa.activity), + Laps: toLapResponses(laps), + Samples: samples, + }) + } + + var nextCursor *string + if hasMore && len(items) > 0 { + c := items[len(items)-1].Activity.StartTimeUTC + nextCursor = &c + } + + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "next_cursor": nextCursor, + "total": total, + }) } -func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid activity id") return } - activity, ok, err := s.DB.GetActivity(r.Context(), userID, id) + var body struct { + WorkoutKindID int64 `json:"workout_kind_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.WorkoutKindID == 0 { + writeError(w, http.StatusBadRequest, "workout_kind_id is required") + return + } + + kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, 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(), userID, store.KindAssignment{ + ActivityID: activityID, + WorkoutKindID: &kindID, + AssignmentSource: store.AssignmentSourceManual, + Status: store.AssignmentStatusAssigned, + CandidateKindsJSON: "[]", + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "assigned"}) +} + +// handleUnassignActivity manually clears an activity's kind back to +// Unclassified. Like handleAssignActivity, it's a deliberate manual choice -- +// recorded as source=manual (with no kind) so the rule engine leaves it alone +// on a later reclassify pass, exactly as it would for a manual kind +// assignment. The frontend only offers this while the activity is unlocked +// (locked activities must be unlocked first, same precondition as picking a +// different kind), but the backend doesn't re-enforce that here, matching +// handleAssignActivity's own lack of a lock precondition check. +func (s *Server) handleUnassignActivity(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid activity id") + return + } + + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ + ActivityID: activityID, + WorkoutKindID: nil, + AssignmentSource: store.AssignmentSourceManual, + Status: store.AssignmentStatusNeedsReview, + CandidateKindsJSON: "[]", + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"}) +} + +// handleUnlockActivity reverts a manual assignment back to rule_engine +// sourcing, keeping the same kind, so a later reclassify pass (which always +// skips manual assignments) is free to change it again. It's the inverse of +// handleAssignActivity, not a delete: the kind stays visible as-is until +// something actually reclassifies it. +func (s *Server) handleUnlockActivity(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid activity id") + return + } + + current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { - writeError(w, http.StatusNotFound, "activity not found") + writeError(w, http.StatusNotFound, "activity has no assignment yet") + return + } + if current.AssignmentSource != store.AssignmentSourceManual { + writeError(w, http.StatusBadRequest, "activity is not manually locked") return } - laps, err := s.DB.LapsForActivity(r.Context(), userID, id) - if err != nil { + status := store.AssignmentStatusAssigned + if current.WorkoutKindID == nil { + status = store.AssignmentStatusNeedsReview + } + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ + ActivityID: activityID, + WorkoutKindID: current.WorkoutKindID, + AssignmentSource: store.AssignmentSourceRuleEngine, + Status: status, + CandidateKindsJSON: "[]", + }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - - assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)} - if hasAssignment { - resp["assignment"] = assignment - } - writeJSON(w, http.StatusOK, resp) + writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"}) } diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index ae9cea1..2c4398d 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -197,7 +197,7 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) { } } -func TestReviewQueueResolve(t *testing.T) { +func TestActivitiesAssign(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() @@ -229,9 +229,9 @@ func TestReviewQueueResolve(t *testing.T) { } router := s.Router() - rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) + rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil) if rec.Code != http.StatusOK { - t.Fatalf("review queue status = %d", rec.Code) + t.Fatalf("activities list status = %d", rec.Code) } var page struct { Items []map[string]any `json:"items"` @@ -240,43 +240,43 @@ func TestReviewQueueResolve(t *testing.T) { } json.Unmarshal(rec.Body.Bytes(), &page) if len(page.Items) != 1 || page.Total != 1 { - t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String()) + t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String()) } laps, _ := page.Items[0]["laps"].([]any) if len(laps) != 1 { - t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String()) + t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String()) } samples, _ := page.Items[0]["samples"].([]any) if len(samples) != 1 { - t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String()) + t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String()) } - rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID}) + rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID}) if rec.Code != http.StatusOK { - t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String()) + t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String()) } // The activity stays listed after resolving -- the Activities page shows // every activity, locked or not, not just ones still needing review. - rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) + rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if len(page.Items) != 1 || page.Total != 1 { - t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total) + t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total) } if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { - t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"]) + t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"]) } if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) { - t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got) + t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got) } // Unlocking reverts the source to rule_engine but keeps the same kind, // so a later reclassify pass is free to change it again. - rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil) + rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil) if rec.Code != http.StatusOK { t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String()) } - rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) + rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) @@ -286,18 +286,18 @@ func TestReviewQueueResolve(t *testing.T) { } // Unlocking an already-unlocked activity is rejected. - rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil) + rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code) } // Manually unassigning clears the kind back to Unclassified and locks // that decision (source=manual), same as resolving to a specific kind. - rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil) + rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil) if rec.Code != http.StatusOK { t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String()) } - rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) + rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"]) @@ -307,7 +307,7 @@ func TestReviewQueueResolve(t *testing.T) { } // It also shows up under the Unclassified filter now. - rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil) + rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil) json.Unmarshal(rec.Body.Bytes(), &page) if len(page.Items) != 1 || page.Total != 1 { t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total) @@ -315,18 +315,18 @@ func TestReviewQueueResolve(t *testing.T) { // Unassigning locks it, so unlocking works again (reverting to // rule_engine sourcing with no kind, i.e. needs_review). - rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil) + rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil) if rec.Code != http.StatusOK { t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String()) } - rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) + rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) } } -func TestReviewQueue_PaginatesByCursor(t *testing.T) { +func TestActivities_PaginatesByCursor(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() @@ -356,7 +356,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) { } getPage := func(query string) page { t.Helper() - rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil) + rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } @@ -404,7 +404,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) { } } -func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { +func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() @@ -448,7 +448,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { } getPage := func(query string) page { t.Helper() - rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil) + rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } @@ -491,7 +491,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { } } -func TestResolveReview_RejectsRaceKind(t *testing.T) { +func TestAssignActivity_RejectsRaceKind(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() @@ -504,9 +504,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) { 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}) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", 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()) + t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } @@ -577,57 +577,6 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { } } -func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) { - s, db, userID := newTestServer(t) - ctx := newCtx() - - easyID, err := db.CreateWorkoutKind(ctx, userID, 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, userID, "Race") - if err != nil || !ok { - t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) - } - - ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) - manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) - raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) - - db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) - db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) - db.InsertKindAssignment(ctx, userID, 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 TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go index 28848ae..0578ce5 100644 --- a/backend/internal/api/isolation_test.go +++ b/backend/internal/api/isolation_test.go @@ -43,7 +43,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body return rec } -func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) { +func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) { s, db, _ := newTestServer(t) // provisions "test-user" (userA) userB, err := db.ProvisionUser(newCtx(), "user-b", "B") if err != nil { @@ -58,21 +58,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) { if err != nil { t.Fatalf("UpsertActivity: %v", err) } - _ = userB - router := s.Router() - - // userA (the default doJSON identity) can see it. - rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil) - if rec.Code != http.StatusOK { - t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String()) + kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false) + if err != nil || len(kindsB) == 0 { + t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err) + } + kindB := kindsB[0] + if kindB.Name == "Race" { // Race is rejected before the ownership check + kindB = kindsB[1] } - // userB, given the exact same activity id, gets 404 -- not another - // user's data, and not a 500 that would leak existence either way. - rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil) - if rec.Code != http.StatusNotFound { - t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String()) + // userB, given userA's real activity id (and a kind userB legitimately + // owns), must not be able to write an assignment onto it. + rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID}) + if rec.Code == http.StatusOK { + t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String()) + } + if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil { + t.Fatalf("CurrentAssignment: %v", err) + } else if ok { + t.Fatalf("userB's rejected assign still created an assignment on userA's activity") } } @@ -106,7 +111,7 @@ func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) { } } -func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) { +func TestIsolation_ActivitiesListOnlyShowsOwnActivities(t *testing.T) { s, db, _ := newTestServer(t) // provisions "test-user" (userA) userB, err := db.ProvisionUser(newCtx(), "user-b", "B") if err != nil { @@ -125,7 +130,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) { t.Fatalf("InsertKindAssignment(b): %v", err) } - rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA + rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // as userA if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } @@ -135,7 +140,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) { } json.Unmarshal(rec.Body.Bytes(), &page) if page.Total != 0 || len(page.Items) != 0 { - t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items)) + t.Fatalf("expected userA's activities list to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items)) } } diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go deleted file mode 100644 index 4928809..0000000 --- a/backend/internal/api/review.go +++ /dev/null @@ -1,258 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "sort" - "strconv" - - "github.com/go-chi/chi/v5" - - "geniusrun/backend/internal/store" -) - -// defaultReviewQueuePageSize matches the frontend's initial/incremental -// page size for its infinite-scroll list. -const defaultReviewQueuePageSize = 10 - -type reviewQueueItem struct { - store.KindAssignment - Activity activityResponse `json:"activity"` - Laps []lapResponse `json:"laps"` - Samples []store.Sample `json:"samples"` -} - -// handleReviewQueue backs the Activities page: every activity that has been -// classified at least once, not just ones still needing review, so the page -// can show each activity's current kind (or "Unclassified") and let the user -// lock/unlock it. It's cursor-paginated: fetching every activity's laps and -// (especially) per-second samples is expensive once there are many of them, -// so that work only happens for the requested page, not the full backlog. -// Sorting/cursor filtering still needs each activity's summary row (a cheap -// indexed lookup, no laps/samples), which happens for the whole backlog -- -// only the heavy per-item fetches are deferred to the page actually being -// returned. The optional kind_id/unclassified filters are applied before -// that cursor slicing too, so a filtered view still only loads (and -// chart-renders) one page at a time instead of the whole matching backlog. -func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { - userID := userIDFromContext(r.Context()) - limit := defaultReviewQueuePageSize - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - limit = n - } - } - cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page - - var kindIDFilter *int64 - if v := r.URL.Query().Get("kind_id"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - kindIDFilter = &n - } - } - unclassifiedOnly := r.URL.Query().Get("unclassified") == "true" - - queue, err := s.DB.AllCurrentAssignments(r.Context(), userID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - type withActivity struct { - assignment store.KindAssignment - activity store.Activity - } - all := make([]withActivity, 0, len(queue)) - for _, a := range queue { - if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) { - continue - } - if unclassifiedOnly && a.WorkoutKindID != nil { - continue - } - activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if !ok { - continue - } - all = append(all, withActivity{assignment: a, activity: activity}) - } - - // Most recent run first, by when the activity actually happened (not - // when the rule engine flagged it), so the queue reads like a log. - sort.Slice(all, func(i, j int) bool { - return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC - }) - - total := len(all) - - if cursor != "" { - idx := 0 - for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor { - idx++ - } - all = all[idx:] - } - hasMore := len(all) > limit - if len(all) > limit { - all = all[:limit] - } - - items := make([]reviewQueueItem, 0, len(all)) - for _, wa := range all { - laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - // Per-second telemetry, not just per-lap averages, so the chart can - // show real within-lap variation instead of one flat segment per lap - // (most activities only have a handful of laps). - samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - items = append(items, reviewQueueItem{ - KindAssignment: wa.assignment, - Activity: toActivityResponse(wa.activity), - Laps: toLapResponses(laps), - Samples: samples, - }) - } - - var nextCursor *string - if hasMore && len(items) > 0 { - c := items[len(items)-1].Activity.StartTimeUTC - nextCursor = &c - } - - writeJSON(w, http.StatusOK, map[string]any{ - "items": items, - "next_cursor": nextCursor, - "total": total, - }) -} - -func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { - userID := userIDFromContext(r.Context()) - activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid activity id") - return - } - - var body struct { - WorkoutKindID int64 `json:"workout_kind_id"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - if body.WorkoutKindID == 0 { - writeError(w, http.StatusBadRequest, "workout_kind_id is required") - return - } - - kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, 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(), userID, store.KindAssignment{ - ActivityID: activityID, - WorkoutKindID: &kindID, - AssignmentSource: store.AssignmentSourceManual, - Status: store.AssignmentStatusAssigned, - CandidateKindsJSON: "[]", - }); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"}) -} - -// handleUnassignReview manually clears an activity's kind back to -// Unclassified. Like handleResolveReview, it's a deliberate manual choice -- -// recorded as source=manual (with no kind) so the rule engine leaves it alone -// on a later reclassify pass, exactly as it would for a manual kind -// assignment. The frontend only offers this while the activity is unlocked -// (locked activities must be unlocked first, same precondition as picking a -// different kind), but the backend doesn't re-enforce that here, matching -// handleResolveReview's own lack of a lock precondition check. -func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) { - userID := userIDFromContext(r.Context()) - activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid activity id") - return - } - - if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ - ActivityID: activityID, - WorkoutKindID: nil, - AssignmentSource: store.AssignmentSourceManual, - Status: store.AssignmentStatusNeedsReview, - CandidateKindsJSON: "[]", - }); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"}) -} - -// handleUnlockReview reverts a manual assignment back to rule_engine -// sourcing, keeping the same kind, so a later reclassify pass (which always -// skips manual assignments) is free to change it again. It's the inverse of -// handleResolveReview, not a delete: the kind stays visible as-is until -// something actually reclassifies it. -func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) { - userID := userIDFromContext(r.Context()) - activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid activity id") - return - } - - current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if !ok { - writeError(w, http.StatusNotFound, "activity has no assignment yet") - return - } - if current.AssignmentSource != store.AssignmentSourceManual { - writeError(w, http.StatusBadRequest, "activity is not manually locked") - return - } - - status := store.AssignmentStatusAssigned - if current.WorkoutKindID == nil { - status = store.AssignmentStatusNeedsReview - } - if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ - ActivityID: activityID, - WorkoutKindID: current.WorkoutKindID, - AssignmentSource: store.AssignmentSourceRuleEngine, - Status: status, - CandidateKindsJSON: "[]", - }); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"}) -} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 24b73cd..fa5c133 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -350,7 +350,9 @@ func (s *Server) Router() http.Handler { r.Route("/activities", func(r chi.Router) { r.Get("/", s.handleListActivities) - r.Get("/{id}", s.handleGetActivity) + r.Post("/{activityID}/assign", s.handleAssignActivity) + r.Post("/{activityID}/unlock", s.handleUnlockActivity) + r.Post("/{activityID}/unassign", s.handleUnassignActivity) }) r.Route("/workout-kinds", func(r chi.Router) { @@ -361,13 +363,6 @@ func (s *Server) Router() http.Handler { r.Post("/reclassify", s.handleReclassifyAll) - r.Route("/review-queue", func(r chi.Router) { - r.Get("/", s.handleReviewQueue) - r.Post("/{activityID}/resolve", s.handleResolveReview) - r.Post("/{activityID}/unlock", s.handleUnlockReview) - r.Post("/{activityID}/unassign", s.handleUnassignReview) - }) - r.Get("/progression/{kindID}", s.handleProgression) }) }) diff --git a/backend/internal/store/assignments.go b/backend/internal/store/assignments.go index 0561fab..e65da2f 100644 --- a/backend/internal/store/assignments.go +++ b/backend/internal/store/assignments.go @@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) ( return a, true, nil } -// ReviewQueue returns userID's activities whose current assignment status -// is needs_review, newest first. -func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) { - rows, err := db.QueryContext(ctx, ` - SELECT `+kindAssignmentColumns+` - FROM current_kind_assignment a - JOIN activities ON activities.id = a.activity_id - WHERE activities.user_id = ? AND a.status = ? - ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview) - if err != nil { - return nil, fmt.Errorf("review queue for user %d: %w", userID, 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() -} - // AllCurrentAssignments returns the latest assignment for every one of // userID's activities that has one, regardless of status or source -- the // basis for deciding which activities a global reclassify pass may touch. diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index a132dbf..23a044e 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -149,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Fatalf("InsertKindAssignment (rule engine): %v", err) } - queue, err := db.ReviewQueue(ctx, userID) - if err != nil { - t.Fatalf("ReviewQueue: %v", err) + pending, ok, err := db.CurrentAssignment(ctx, userID, activityID) + if err != nil || !ok { + t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err) } - if len(queue) != 1 { - t.Fatalf("expected 1 item in review queue, got %d", len(queue)) + if pending.Status != AssignmentStatusNeedsReview { + t.Fatalf("current status = %q, want needs_review", pending.Status) } // Then: user manually resolves it. @@ -167,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Fatalf("InsertKindAssignment (manual): %v", err) } - queue, err = db.ReviewQueue(ctx, userID) - if err != nil { - t.Fatalf("ReviewQueue after resolve: %v", err) - } - if len(queue) != 0 { - t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue)) - } - current, ok, err := db.CurrentAssignment(ctx, userID, activityID) if err != nil || !ok { t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1442137..63d7dc2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,12 +1,9 @@ import type { - Activity, - ActivityListItem, + ActivitiesPage, AuthResponse, - KindAssignment, Profile, ProgressionMetric, ProgressionPoint, - ReviewQueuePage, SessionInfo, SyncRun, SyncStatus, @@ -37,7 +34,7 @@ async function request(path: string, init?: RequestInit): Promise { // is not something to show a user; every caller's catch-and-showError // already displays whatever this throws, so the friendly text only // needs to be written once, here. - throw new NetworkError("Can't reach the server — check your connection."); + throw new NetworkError("Can't reach the server."); } if (res.status === 401 && path !== "/api/session/me") { // An established session expired mid-use (not the initial "am I logged @@ -97,18 +94,6 @@ export const api = { syncRuns: () => request("/api/sync/runs"), syncStatus: () => request("/api/sync/status"), - // Activities - listActivities: (params?: { from?: string; to?: string; limit?: number }) => { - const q = new URLSearchParams(); - if (params?.from) q.set("from", params.from); - if (params?.to) q.set("to", params.to); - if (params?.limit) q.set("limit", String(params.limit)); - const qs = q.toString(); - return request(`/api/activities/${qs ? `?${qs}` : ""}`); - }, - getActivity: (id: number) => - request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`), - // Training types -- fixed taxonomy, no create/delete listWorkoutKinds: (includeInactive = false) => request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), @@ -141,34 +126,34 @@ export const api = { // navigation -- this does not touch the session cookie (see Profile.tsx). deleteProfile: () => request("/api/profile", { method: "DELETE" }), - // Review queue -- cursor-paginated: pass the previous page's next_cursor + // Activities -- cursor-paginated: pass the previous page's next_cursor // as `before` to fetch the next one. Fetching every activity's laps and // per-second samples up front gets expensive once there are many, so the // frontend loads it incrementally (infinite scroll) instead of all at once // -- kindId/unclassified filter server-side so a filtered view stays // paginated too, instead of having to load the whole matching backlog. - reviewQueue: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => { + listActivities: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => { const q = new URLSearchParams(); if (params?.limit) q.set("limit", String(params.limit)); if (params?.before) q.set("before", params.before); if (params?.kindId != null) q.set("kind_id", String(params.kindId)); if (params?.unclassified) q.set("unclassified", "true"); const qs = q.toString(); - return request(`/api/review-queue/${qs ? `?${qs}` : ""}`); + return request(`/api/activities/${qs ? `?${qs}` : ""}`); }, - resolveReview: (activityId: number, workoutKindId: number) => - request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, { + assignActivity: (activityId: number, workoutKindId: number) => + request<{ status: string }>(`/api/activities/${activityId}/assign`, { method: "POST", body: JSON.stringify({ workout_kind_id: workoutKindId }), }), // Reverts a manual assignment back to rule_engine sourcing (same kind), // so a later reclassify pass is free to change it again. - unlockReview: (activityId: number) => - request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }), + unlockActivity: (activityId: number) => + request<{ status: string }>(`/api/activities/${activityId}/unlock`, { method: "POST" }), // Manually clears an activity's kind back to Unclassified (locks that - // decision, same as resolveReview locks a specific kind). - unassignReview: (activityId: number) => - request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }), + // decision, same as assignActivity locks a specific kind). + unassignActivity: (activityId: number) => + request<{ status: string }>(`/api/activities/${activityId}/unassign`, { method: "POST" }), // Progression progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => { diff --git a/frontend/src/pages/Activities.tsx b/frontend/src/pages/Activities.tsx index 9824fd3..98cf790 100644 --- a/frontend/src/pages/Activities.tsx +++ b/frontend/src/pages/Activities.tsx @@ -4,12 +4,12 @@ import { showError } from "../banner"; import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; import { formatMinutesSeconds } from "../components/PaceField"; import { RawDataModal } from "../components/RawDataModal"; -import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api"; +import type { Profile, ActivityItem, ScoredKind, WorkoutKind } from "../types/api"; const UNCLASSIFIED = "__unclassified__"; const PAGE_SIZE = 10; -function candidates(item: ReviewQueueItem): ScoredKind[] { +function candidates(item: ActivityItem): ScoredKind[] { try { return JSON.parse(item.CandidateKindsJSON) as ScoredKind[]; } catch { @@ -47,8 +47,8 @@ function formatActivityDateTime(startTimeUTC: string): { date: string; time: str function kindColor(name: string | undefined): string { if (!name) return "#6b7280"; // grey -- unclassified if (name.startsWith("Easy") || name.startsWith("Long")) return "#3b82f6"; // blue - if (name.includes("Threshold")) return "#22c55e"; // green - if (name.startsWith("Tempo")) return "#eab308"; // yellow + if (name.startsWith("Tempo")) return "#22c55e"; // green + if (name.includes("Threshold")) return "#eab308"; // yellow if (name.startsWith("Interval")) return "#f97316"; // orange if (name.startsWith("MAS")) return "#ef4444"; // red if (name.startsWith("Race")) return "#a855f7"; // purple @@ -70,7 +70,7 @@ function ClassifyControl({ onUnassign, onUnlock, }: { - item: ReviewQueueItem; + item: ActivityItem; kinds: WorkoutKind[]; busy: boolean; onAssign: (activityId: number, kindId: number) => void; @@ -150,14 +150,14 @@ function filterParams(filter: string): { kindId?: number; unclassified?: boolean return {}; } -function matchesFilter(item: ReviewQueueItem, filter: string): boolean { +function matchesFilter(item: ActivityItem, filter: string): boolean { if (filter === "") return true; if (filter === UNCLASSIFIED) return item.WorkoutKindID == null; return item.WorkoutKindID === Number(filter); } export function Activities() { - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill const [nextCursor, setNextCursor] = useState(null); const [initialLoading, setInitialLoading] = useState(true); @@ -166,11 +166,11 @@ export function Activities() { const [profile, setProfile] = useState(null); const [filterKindId, setFilterKindId] = useState(""); const [resolvingId, setResolvingId] = useState(null); - const [rawDataItem, setRawDataItem] = useState(null); + const [rawDataItem, setRawDataItem] = useState(null); // Fetching every activity's laps and per-second samples is expensive once // there are many activities, so the list loads incrementally (infinite - // scroll) instead of all at once -- see the paginated GET /api/review-queue/. + // scroll) instead of all at once -- see the paginated GET /api/activities/. const allLoaded = !initialLoading && nextCursor === null; // A single in-flight request is shared by every concurrent caller (the // scroll observer, a filter switch, ...): each gets the same promise back @@ -201,7 +201,7 @@ export function Activities() { setLoadingMore(true); const promise = (async () => { try { - const page = await api.reviewQueue({ + const page = await api.listActivities({ limit: PAGE_SIZE, before: nextCursorRef.current ?? undefined, ...filterParams(filterRef.current), @@ -231,7 +231,7 @@ export function Activities() { setItems([]); setInitialLoading(true); api - .reviewQueue({ limit: PAGE_SIZE, ...filterParams(filter) }) + .listActivities({ limit: PAGE_SIZE, ...filterParams(filter) }) .then((page) => { if (gen !== generationRef.current) return; nextCursorRef.current = page.next_cursor; @@ -271,10 +271,10 @@ export function Activities() { return () => observer.disconnect(); }, [loadMore, items.length]); - async function resolve(activityId: number, kindId: number) { + async function assign(activityId: number, kindId: number) { setResolvingId(activityId); try { - await api.resolveReview(activityId, kindId); + await api.assignActivity(activityId, kindId); setItems((prev) => prev .map((i) => @@ -298,7 +298,7 @@ export function Activities() { async function unassign(activityId: number) { setResolvingId(activityId); try { - await api.unassignReview(activityId); + await api.unassignActivity(activityId); setItems((prev) => prev .map((i) => @@ -306,7 +306,7 @@ export function Activities() { ? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const } : i, ) - // Same as resolve() -- an active kind filter no longer matches an + // Same as assign() -- an active kind filter no longer matches an // activity just cleared back to Unclassified, so drop it. .filter((i) => matchesFilter(i, filterKindId)), ); @@ -320,7 +320,7 @@ export function Activities() { async function unlock(activityId: number) { setResolvingId(activityId); try { - await api.unlockReview(activityId); + await api.unlockActivity(activityId); setItems((prev) => prev .map((i) => (i.ActivityID === activityId ? { ...i, AssignmentSource: "rule_engine" as const } : i)) @@ -391,7 +391,7 @@ export function Activities() { item={item} kinds={kinds} busy={resolvingId === item.ActivityID} - onAssign={resolve} + onAssign={assign} onUnassign={unassign} onUnlock={unlock} /> diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 088c2d2..8feb68b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -67,14 +67,6 @@ export interface Sample { ElevationM: number | null; } -export interface ActivityListItem extends Activity { - workout_kind_id: number | null; - workout_kind_name: string | null; - assignment_source: "rule_engine" | "manual" | null; - assignment_status: "assigned" | "needs_review" | null; - locked: boolean; -} - export interface WorkoutKind { ID: number; Name: string; @@ -153,14 +145,14 @@ export interface KindAssignment { CreatedAt: string; } -export interface ReviewQueueItem extends KindAssignment { +export interface ActivityItem extends KindAssignment { activity: Activity; laps: Lap[]; samples: Sample[]; } -export interface ReviewQueuePage { - items: ReviewQueueItem[]; +export interface ActivitiesPage { + items: ActivityItem[]; next_cursor: string | null; total: number; }