Paginate the Review Queue with cursor-based infinite scroll
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.
This commit is contained in:
@@ -11,20 +11,44 @@ import (
|
||||
"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 item struct {
|
||||
store.KindAssignment
|
||||
Activity store.Activity `json:"activity"`
|
||||
Laps []store.Lap `json:"laps"`
|
||||
Samples []store.Sample `json:"samples"`
|
||||
type withActivity struct {
|
||||
assignment store.KindAssignment
|
||||
activity store.Activity
|
||||
}
|
||||
items := make([]item, 0, len(queue))
|
||||
all := make([]withActivity, 0, len(queue))
|
||||
for _, a := range queue {
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||
if err != nil {
|
||||
@@ -34,7 +58,32 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), a.ActivityID)
|
||||
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
|
||||
@@ -42,21 +91,25 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
// 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(), a.ActivityID)
|
||||
samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, item{KindAssignment: a, Activity: activity, Laps: laps, Samples: samples})
|
||||
items = append(items, reviewQueueItem{KindAssignment: wa.assignment, Activity: wa.activity, Laps: laps, Samples: samples})
|
||||
}
|
||||
|
||||
// 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(items, func(i, j int) bool {
|
||||
return items[i].Activity.StartTimeUTC > items[j].Activity.StartTimeUTC
|
||||
})
|
||||
var nextCursor *string
|
||||
if hasMore && len(items) > 0 {
|
||||
c := items[len(items)-1].Activity.StartTimeUTC
|
||||
nextCursor = &c
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user