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"}) }