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:
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -182,16 +184,20 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review queue status = %d", rec.Code)
|
||||
}
|
||||
var queue []map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &queue)
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
|
||||
var page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
laps, _ := queue[0]["laps"].([]any)
|
||||
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())
|
||||
}
|
||||
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())
|
||||
}
|
||||
samples, _ := queue[0]["samples"].([]any)
|
||||
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())
|
||||
}
|
||||
@@ -202,9 +208,87 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &queue)
|
||||
if len(queue) != 0 {
|
||||
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if len(page.Items) != 0 || page.Total != 0 {
|
||||
t.Fatalf("expected empty review queue after resolve, got %d items, total=%d", len(page.Items), page.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
||||
for i := 1; i <= 5; i++ {
|
||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
||||
GarminActivityID: int64(i), ActivityType: "running",
|
||||
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
|
||||
type page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
getPage := func(query string) page {
|
||||
t.Helper()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var p page
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
startTime := func(item map[string]any) string {
|
||||
return item["activity"].(map[string]any)["StartTimeUTC"].(string)
|
||||
}
|
||||
|
||||
first := getPage("?limit=2")
|
||||
if len(first.Items) != 2 || first.Total != 5 {
|
||||
t.Fatalf("first page: expected 2 items (total=5), got %d items, total=%d", len(first.Items), first.Total)
|
||||
}
|
||||
if startTime(first.Items[0]) != "2026-07-05 06:00:00" || startTime(first.Items[1]) != "2026-07-04 06:00:00" {
|
||||
t.Fatalf("first page not newest-first: %v", first.Items)
|
||||
}
|
||||
if first.NextCursor == nil {
|
||||
t.Fatal("expected a next_cursor on the first page")
|
||||
}
|
||||
|
||||
second := getPage("?limit=2&before=" + url.QueryEscape(*first.NextCursor))
|
||||
if len(second.Items) != 2 {
|
||||
t.Fatalf("second page: expected 2 items, got %d", len(second.Items))
|
||||
}
|
||||
if startTime(second.Items[0]) != "2026-07-03 06:00:00" || startTime(second.Items[1]) != "2026-07-02 06:00:00" {
|
||||
t.Fatalf("second page not continuing newest-first: %v", second.Items)
|
||||
}
|
||||
if second.NextCursor == nil {
|
||||
t.Fatal("expected a next_cursor on the second page")
|
||||
}
|
||||
|
||||
third := getPage("?limit=2&before=" + url.QueryEscape(*second.NextCursor))
|
||||
if len(third.Items) != 1 {
|
||||
t.Fatalf("third page: expected 1 remaining item, got %d", len(third.Items))
|
||||
}
|
||||
if startTime(third.Items[0]) != "2026-07-01 06:00:00" {
|
||||
t.Fatalf("third page wrong item: %v", third.Items)
|
||||
}
|
||||
if third.NextCursor != nil {
|
||||
t.Fatalf("expected no next_cursor on the last page, got %v", *third.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user