Fetching every needs_review activity's laps and per-second samples up front
gets expensive once there are many -- that work is now deferred to the
current page only. GET /api/review-queue/ takes limit/before and returns
{items, next_cursor, total}; sorting/cursor filtering still needs each
activity's cheap summary row, but only the requested page's items get their
laps/samples fetched.
Frontend loads 10 at a time and fetches the next page when the list's
bottom sentinel scrolls into view. Selecting a specific-kind filter (not
"All") loads the rest of the backlog up front, since filtering only the
items scrolled into view so far would hide matches further down.
160 lines
4.7 KiB
Go
160 lines
4.7 KiB
Go
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"})
|
|
}
|