package api import ( "encoding/json" "net/http" "sort" "strconv" "github.com/go-chi/chi/v5" "smartrun/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 store.Activity `json:"activity"` Laps []store.Lap `json:"laps"` Samples []store.Sample `json:"samples"` } // handleReviewQueue is cursor-paginated: fetching every needs_review // 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. func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { 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 queue, err := s.DB.ReviewQueue(r.Context()) 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 { activity, ok, err := s.DB.GetActivity(r.Context(), 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(), 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(), wa.assignment.ActivityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } items = append(items, reviewQueueItem{KindAssignment: wa.assignment, Activity: wa.activity, Laps: 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) { 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(), 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{ 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"}) }