Skip to content

fix(guest): paginate collect leaderboard + join list (drop 200/50 hard caps)#411

Merged
thewrz merged 3 commits into
mainfrom
feat/paginate-guest-lists
Jun 8, 2026
Merged

fix(guest): paginate collect leaderboard + join list (drop 200/50 hard caps)#411
thewrz merged 3 commits into
mainfrom
feat/paginate-guest-lists

Conversation

@thewrz

@thewrz thewrz commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

Production event ELZ2G2 only showed 200 songs on the collect page, with more clearly submitted. Investigation found it was a backend hard cap, not a display bug — and there were two:

Surface Endpoint Cap
Collect leaderboard GET /api/public/collect/{code}/leaderboard q.limit(200).all()
Join request list GET /api/public/events/{code}/requests .limit(50)

Both returned total=len(rows), so beyond the cap the list was silently truncated and the displayed song count froze at the cap (the count badge literally couldn't exceed 200). Neither endpoint accepted any paging params, so the client had no way to reach later rows.

Fix — proper offset/limit pagination

Backend

  • limit (default 100, max 500) + offset query params on both endpoints
  • True total via COUNT(*) computed before pagination (not len(rows))
  • total added to GuestRequestListResponse (the collect response already had it)
  • New server/app/core/pagination.py documents the bounds as a payload guard, not a display ceiling
  • OpenAPI schema + TS types regenerated

Frontend — growing-window model

  • displayLimit state grows on "Load more"; the fetch always reads from offset=0
  • This is intentional: both pages already re-fetch and replace their whole list on a timer (collect 5s, join 10s + SSE). A growing window keeps live vote/status updates correct with no client-side dedup and no offset-drift when vote counts reorder rows. The backend still exposes true offset paging for any future non-polling consumer.
  • Count badges now show the real total instead of the capped page length

Trade-off

MAX_PAGE_SIZE=500 bounds a single request's payload. The product ceiling is gone (count is honest; offset can reach any row), but the frontend's growing window tops out at 500 displayed rows — far above any realistic single event. If an event is ever expected to exceed 500 distinct requests, the frontend would need multi-page fetching (backend already supports it).

Tests

  • Backend (6): offset/limit slicing across pages, total honest beyond the returned page (regression refs the old .limit(200)/total=len(rows)), oversized-limit → 422 — for both endpoints
  • Frontend (4): getPublicRequests / getCollectLeaderboard append limit/offset correctly and omit the query string when unset

Verification (local CI, all green)

  • Backend: 2065 passed, coverage 88.07% (≥85% gate), ruff + format + bandit clean
  • Frontend: 952 passed, tsc clean, ESLint clean

Manual test plan

  • On an event with >100 collection submissions, open /collect/{code} — count badge shows the true total; list shows 100 with a Load more · N more button that loads the rest
  • On a live event with >100 NEW/ACCEPTED requests, open /join/{code} — QUEUE pill shows true total; Load more works on LEADERBOARD/RECENT
  • Confirm ELZ2G2 now shows all songs, not 200
  • Verify live vote updates still reorder correctly while a window is expanded

Summary by CodeRabbit

  • New Features

    • Leaderboards and request queues support incremental pagination with a visible "LOAD MORE" button and capped page size
    • Headers, counters and queue pills show accurate total counts for all items
    • Changing tabs resets the view to the initial paginated window
  • Tests

    • Added coverage for pagination slicing, total-count accuracy, ordering stability and oversized-limit validation

…d caps)

The collect leaderboard hard-capped at `q.limit(200).all()` and the join
request list at `.limit(50)`, each returning `total=len(rows)`. Any event
with more songs was silently truncated AND the displayed song count froze
at the cap — why ELZ2G2 showed exactly 200.

Replace both with proper offset/limit pagination:
- Add `limit` (default 100, max 500) + `offset` query params to
  GET /api/public/collect/{code}/leaderboard and
  GET /api/public/events/{code}/requests
- Compute the true total via COUNT(*) before pagination; add `total` to
  GuestRequestListResponse (the collect response already had it)
- New app/core/pagination.py documents the bounds as a payload guard, not
  a display ceiling
- Regenerate OpenAPI schema + TS types

Frontend uses a growing-window model: `displayLimit` grows on "Load more"
and the fetch always reads from offset 0, so the existing poll-and-replace
loop (collect 5s, join 10s + SSE) keeps live vote updates correct without
client-side dedup or offset-drift bugs. Count badges on both pages now show
the real total instead of the capped page length.

Tests: 6 backend (offset/limit slicing, honest total beyond the page,
oversized-limit rejection) + 4 frontend (query-param URL building).
@thewrz

thewrz commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c6f5cc93-6204-40ea-be8f-28af106eec6d

📥 Commits

Reviewing files that changed from the base of the PR and between a0d5084 and 47f908c.

📒 Files selected for processing (9)
  • dashboard/app/collect/[code]/page.test.tsx
  • dashboard/app/collect/[code]/page.tsx
  • dashboard/app/join/[code]/__tests__/page.test.tsx
  • dashboard/app/join/[code]/page.tsx
  • dashboard/lib/api.ts
  • server/app/api/collect.py
  • server/app/api/public.py
  • server/tests/test_collect_public.py
  • server/tests/test_public.py
✅ Files skipped from review due to trivial changes (1)
  • dashboard/app/join/[code]/tests/page.test.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • server/tests/test_collect_public.py
  • dashboard/app/join/[code]/page.tsx
  • dashboard/app/collect/[code]/page.tsx
  • server/tests/test_public.py

📝 Walkthrough

Walkthrough

Adds client-controlled pagination: server endpoints accept optional limit/offset and return full total counts; shared pagination bounds added; frontend API and collect/join pages request growing windows and show a capped "LOAD MORE"; tests cover URL construction, backend pagination, totals, validation, and ordering stability.

Changes

Client-Controlled Pagination for Leaderboards and Request Queues

Layer / File(s) Summary
Pagination constants and API contracts
server/app/core/pagination.py, dashboard/lib/api-types.generated.ts, server/openapi.json
Adds DEFAULT_PAGE_SIZE (100) and MAX_PAGE_SIZE (500). Updates OpenAPI/TS types to add total to guest-list responses and declare optional limit/offset query params for leaderboard and requests endpoints.
Backend leaderboard pagination
server/app/api/collect.py, server/tests/test_collect_public.py
Leaderboard endpoint now accepts limit/offset, computes an unpaginated total, applies deterministic ordering (id desc tiebreaker), returns a paged slice, and tests verify pagination slices, total correctness, validation bounds, and tie-break stability.
Backend request queue pagination
server/app/api/public.py, server/tests/test_public.py
Public requests endpoint now accepts limit/offset, computes full NEW/ACCEPTED total before pagination, orders stably (vote_count desc, created_at desc, id desc), and tests verify slicing, total reporting, validation, and tie stability.
Frontend API client pagination support
dashboard/lib/api.ts, dashboard/lib/__tests__/api.test.ts
Exports PUBLIC_PAGE_MAX (500). getPublicRequests() and getCollectLeaderboard() accept optional limit/offset and build query strings when provided. Tests validate generated URLs with/without paging params.
Frontend collect page leaderboard pagination
dashboard/app/collect/[code]/page.tsx, dashboard/app/collect/[code]/page.test.tsx
Introduces PAGE_SIZE and displayLimit state, includes displayLimit in leaderboard fetches and polling deps, resets window on tab changes, shows capped "LOAD MORE", and uses leaderboard.total for header and CollectDetailSheet.
Frontend join page request queue pagination
dashboard/app/join/[code]/page.tsx, dashboard/app/join/[code]/__tests__/page.test.tsx
Adds PAGE_SIZE, displayLimit, and total state; polls using displayLimit, stores server total, displays total in the queue pill, and shows a capped "LOAD MORE" when more items remain.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • wrzonance/WrzDJ#284: Touches the same public leaderboard and requests paths and components (adds requester_verified and badge rendering).
  • wrzonance/WrzDJ#287: Modifies CollectDetailSheet usage at the same collect page call site where this PR changes totalCount plumbing.

Poem

🐰 I hopped through pages, limit by limit,
A little window that grows — oh so lit!
Counters now true, no counts misled,
Load more, scroll more, the list gently spread.
🎶 A rabbit cheers for pagination well-bred.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing pagination for collect leaderboard and join list while removing hard caps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/paginate-guest-lists

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/app/api/collect.py (1)

142-149: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a deterministic tie-breaker to paginated ordering.

With offset pagination, ties in current sort keys can reshuffle rows between requests. At Line 142-149, append a stable secondary key (e.g., SongRequest.id) in both tabs to prevent duplicate/missing rows across pages.

Suggested patch
     if tab == "trending":
         q = q.filter(SongRequest.vote_count >= 1).order_by(
-            SongRequest.vote_count.desc(), SongRequest.created_at.desc()
+            SongRequest.vote_count.desc(),
+            SongRequest.created_at.desc(),
+            SongRequest.id.desc(),
         )
     else:
         # "All" is the discovery view — alphabetical makes it easy to scan
         # and upvote existing submissions rather than recency bias.
-        q = q.order_by(func.lower(SongRequest.song_title).asc())
+        q = q.order_by(
+            func.lower(SongRequest.song_title).asc(),
+            SongRequest.id.asc(),
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/app/api/collect.py` around lines 142 - 149, The paginated ordering can
be non-deterministic because ties on the primary sort keys can reorder between
offset pages; update both query.order_by calls where q is built to append a
stable tie-breaker using SongRequest.id (e.g., after
SongRequest.vote_count.desc() and SongRequest.created_at.desc() append
SongRequest.id.asc(), and after func.lower(SongRequest.song_title).asc() append
SongRequest.id.asc()) so rows with identical primary keys remain consistently
ordered across paginated requests.
dashboard/app/join/[code]/page.tsx (1)

888-888: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use true queue total in SongDetailSheet denominator.

totalCount still uses loaded rows (guestRequests.length), which under-reports rank context after pagination.

Suggested fix
-          totalCount={guestRequests.length}
+          totalCount={total}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/app/join/`[code]/page.tsx at line 888, Replace the paginated row
count used for the SongDetailSheet denominator — currently
totalCount={guestRequests.length} — with the true queue total value returned by
the server/pagination metadata (e.g., use guestRequestsTotal or the pagination
response's totalCount/meta.totalCount) so the denominator reflects the full
queue size; update the prop passed into SongDetailSheet (totalCount) to use that
server-side total instead of guestRequests.length.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dashboard/app/collect/`[code]/page.tsx:
- Around line 433-446: The Load More button currently increments displayLimit
past the backend cap and causes 422 errors; fix by clamping both the increment
and the visibility to the backend max (e.g., BACKEND_MAX = 500): change the
onClick updater passed to setDisplayLimit to setDisplayLimit(d => Math.min(d +
PAGE_SIZE, BACKEND_MAX)) and change the conditional that renders the button and
the "MORE" count to compare against Math.min(leaderboard?.total ?? 0,
BACKEND_MAX) (use a single BACKEND_MAX constant near PAGE_SIZE for clarity).
Ensure references: displayLimit, PAGE_SIZE, setDisplayLimit, leaderboard.total,
leaderboard.requests to locate the changes.

In `@dashboard/app/join/`[code]/page.tsx:
- Around line 787-800: The "LOAD MORE" button can grow displayLimit past the
backend max (500) causing 422s; update the onClick for setDisplayLimit to cap
increments with a backend max constant (e.g., BACKEND_MAX_PAGE_SIZE = 500) by
using setDisplayLimit(d => Math.min(d + PAGE_SIZE, BACKEND_MAX_PAGE_SIZE)), and
change the render condition so the button is hidden when displayLimit >=
BACKEND_MAX_PAGE_SIZE (in addition to guestRequests.length >= total) so it won't
try to request beyond the server max; reference activeTab, guestRequests, total,
setDisplayLimit, PAGE_SIZE, displayLimit, and tabRows when making this change.

In `@server/app/api/public.py`:
- Around line 286-289: The current query built in base_q uses ordering by
SongRequest.vote_count.desc() and SongRequest.created_at.desc(), which can lead
to unstable offset pagination when ties occur; update the ordering chain on
base_q to add a deterministic unique tiebreaker such as SongRequest.id.desc()
(i.e., ensure final sort keys are vote_count.desc(), created_at.desc(),
id.desc()) so offset/limit pagination yields stable, non-overlapping pages and
duplicates/skips are avoided.

---

Outside diff comments:
In `@dashboard/app/join/`[code]/page.tsx:
- Line 888: Replace the paginated row count used for the SongDetailSheet
denominator — currently totalCount={guestRequests.length} — with the true queue
total value returned by the server/pagination metadata (e.g., use
guestRequestsTotal or the pagination response's totalCount/meta.totalCount) so
the denominator reflects the full queue size; update the prop passed into
SongDetailSheet (totalCount) to use that server-side total instead of
guestRequests.length.

In `@server/app/api/collect.py`:
- Around line 142-149: The paginated ordering can be non-deterministic because
ties on the primary sort keys can reorder between offset pages; update both
query.order_by calls where q is built to append a stable tie-breaker using
SongRequest.id (e.g., after SongRequest.vote_count.desc() and
SongRequest.created_at.desc() append SongRequest.id.asc(), and after
func.lower(SongRequest.song_title).asc() append SongRequest.id.asc()) so rows
with identical primary keys remain consistently ordered across paginated
requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2469ba05-a706-476f-b423-8982b4440a54

📥 Commits

Reviewing files that changed from the base of the PR and between c4d0d6b and a0d5084.

📒 Files selected for processing (11)
  • dashboard/app/collect/[code]/page.tsx
  • dashboard/app/join/[code]/page.tsx
  • dashboard/lib/__tests__/api.test.ts
  • dashboard/lib/api-types.generated.ts
  • dashboard/lib/api.ts
  • server/app/api/collect.py
  • server/app/api/public.py
  • server/app/core/pagination.py
  • server/openapi.json
  • server/tests/test_collect_public.py
  • server/tests/test_public.py

Comment thread dashboard/app/collect/[code]/page.tsx Outdated
Comment thread dashboard/app/join/[code]/page.tsx Outdated
Comment thread server/app/api/public.py Outdated
Addresses CodeRabbit review on #411:

- Clamp the growing-window displayLimit to PUBLIC_PAGE_MAX (500) on both the
  collect and join pages. Both the +PAGE_SIZE increment and the "Load more"
  visibility now cap at the backend max, so the UI can't request limit>500 and
  trip the 422 -> global error path. The cap is exported once from lib/api.ts
  (mirrors server/app/core/pagination.py MAX_PAGE_SIZE) to keep both pages in
  sync rather than duplicating the literal.
- Add SongRequest.id.desc() as a unique final ORDER BY on both the join request
  list and the collect leaderboard (trending + all tabs) so offset pagination
  yields stable pages with no dup/skip when rows tie on the primary sort key.

Tests: 2 backend regression tests asserting deterministic id-desc tie ordering
(test_tiebreaker_orders_by_id_desc, test_collect_leaderboard_tiebreaker_orders_by_id_desc);
updated the two fully-mocked api modules in the page tests to export PUBLIC_PAGE_MAX.
@thewrz thewrz merged commit bb487ed into main Jun 8, 2026
11 checks passed
@thewrz thewrz deleted the feat/paginate-guest-lists branch June 8, 2026 21:26
thewrz added a commit that referenced this pull request Jun 18, 2026
GET /api/events/{code}/pending-review returned total=len(rows) after an
undocumented 200-row cap — the #411 failure mode applied to the Pre-Event
Voting tab. Replace it with the paginated envelope (requests/total/limit/
offset/sort/direction): total is counted before pagination so the DJ sees the
real pending count, and limit/offset drive a #411 growing window.

Reuse PR2's sort machinery — extract apply_field_sort and make key_sorted
public in request_sort.py — so pending-review shares one ORDER BY / Camelot
implementation with the DJ list. The default (no sort param) preserves the
vote-ranked review order (votes desc, age asc, id desc); best_match falls back
to it since priority scoring is meaningless pre-event. The Pre-Event tab gains
a focused Sort selector (Review order + upvotes/date/title/artist), "showing
X of Y", and Load More. Bulk actions (Accept top N / Reject remaining) keep
operating server-side against the full filtered set, not the loaded page.

Part of #478 (PR3/4). Builds on PR2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thewrz added a commit that referenced this pull request Jun 19, 2026
GET /api/events/{code}/pending-review returned total=len(rows) after an
undocumented 200-row cap — the #411 failure mode applied to the Pre-Event
Voting tab. Replace it with the paginated envelope (requests/total/limit/
offset/sort/direction): total is counted before pagination so the DJ sees the
real pending count, and limit/offset drive a #411 growing window.

Reuse PR2's sort machinery — extract apply_field_sort and make key_sorted
public in request_sort.py — so pending-review shares one ORDER BY / Camelot
implementation with the DJ list. The default (no sort param) preserves the
vote-ranked review order (votes desc, age asc, id desc); best_match falls back
to it since priority scoring is meaningless pre-event. The Pre-Event tab gains
a focused Sort selector (Review order + upvotes/date/title/artist), "showing
X of Y", and Load More. Bulk actions (Accept top N / Reject remaining) keep
operating server-side against the full filtered set, not the loaded page.

Part of #478 (PR3/4). Builds on PR2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thewrz added a commit that referenced this pull request Jun 19, 2026
…ting

Replace the bare list at GET /api/events/{code}/requests with a paginated
envelope (requests/total/limit/offset/sort/direction) and add deterministic
DJ-facing sorting across eight fields: date_requested, date_accepted, upvotes,
bpm, key (harmonic Camelot order), title, artist, and best_match (priority).

The backend computes a true total before pagination so the dashboard never
infers the count from a page length (the #411 failure mode). SQL-expressible
sorts run as ORDER BY ... NULLS LAST, id DESC + LIMIT/OFFSET; key and best_match
sort in Python (Camelot ordinal / priority scoring) over the full filtered set.
Every sort ends with an id DESC tie-breaker so pages never duplicate or skip
rows between polls.

The frontend adopts the #411 growing-window model for the DJ list: a unified
Sort selector (Best Match plus the seven fields) with a direction toggle, a
displayLimit that grows on Load More up to PUBLIC_PAGE_MAX, a truthful
"showing X of Y" from the real total, and every poll/SSE refresh re-fetching
from offset=0 to avoid drift when rows reorder. Best Match replaces the old
priority checkbox as a sort option.

Part of #478 (PR2/4). Builds on PR1 (accepted_at) and #411.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thewrz added a commit that referenced this pull request Jun 19, 2026
GET /api/events/{code}/pending-review returned total=len(rows) after an
undocumented 200-row cap — the #411 failure mode applied to the Pre-Event
Voting tab. Replace it with the paginated envelope (requests/total/limit/
offset/sort/direction): total is counted before pagination so the DJ sees the
real pending count, and limit/offset drive a #411 growing window.

Reuse PR2's sort machinery — extract apply_field_sort and make key_sorted
public in request_sort.py — so pending-review shares one ORDER BY / Camelot
implementation with the DJ list. The default (no sort param) preserves the
vote-ranked review order (votes desc, age asc, id desc); best_match falls back
to it since priority scoring is meaningless pre-event. The Pre-Event tab gains
a focused Sort selector (Review order + upvotes/date/title/artist), "showing
X of Y", and Load More. Bulk actions (Accept top N / Reject remaining) keep
operating server-side against the full filtered set, not the loaded page.

Part of #478 (PR3/4). Builds on PR2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thewrz added a commit that referenced this pull request Jun 19, 2026
Introduce a nullable, indexed `accepted_at` column stamped the first time a
request enters ACCEPTED and preserved through later status changes. It is the
truthful backing field for the upcoming DJ "date accepted" sort (issue #478) —
unlike `updated_at`, which moves on every metadata refresh or play and would
reorder rows long after acceptance.

Stamping is centralized in one idempotent `mark_accepted` helper reused at all
five accept sites (the status-transition path, accept-all, and the three
pre-event bulk-review branches). Exposed via RequestOut and regenerated
OpenAPI/TS types. Migration 061 backfills existing accepted/playing/played rows
best-effort from updated_at; rows never accepted stay NULL and sort null-last.

Part of #478 (PR1/4: foundation). Builds on #411.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thewrz added a commit that referenced this pull request Jun 19, 2026
…ting

Replace the bare list at GET /api/events/{code}/requests with a paginated
envelope (requests/total/limit/offset/sort/direction) and add deterministic
DJ-facing sorting across eight fields: date_requested, date_accepted, upvotes,
bpm, key (harmonic Camelot order), title, artist, and best_match (priority).

The backend computes a true total before pagination so the dashboard never
infers the count from a page length (the #411 failure mode). SQL-expressible
sorts run as ORDER BY ... NULLS LAST, id DESC + LIMIT/OFFSET; key and best_match
sort in Python (Camelot ordinal / priority scoring) over the full filtered set.
Every sort ends with an id DESC tie-breaker so pages never duplicate or skip
rows between polls.

The frontend adopts the #411 growing-window model for the DJ list: a unified
Sort selector (Best Match plus the seven fields) with a direction toggle, a
displayLimit that grows on Load More up to PUBLIC_PAGE_MAX, a truthful
"showing X of Y" from the real total, and every poll/SSE refresh re-fetching
from offset=0 to avoid drift when rows reorder. Best Match replaces the old
priority checkbox as a sort option.

Part of #478 (PR2/4). Builds on PR1 (accepted_at) and #411.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thewrz added a commit that referenced this pull request Jun 19, 2026
GET /api/events/{code}/pending-review returned total=len(rows) after an
undocumented 200-row cap — the #411 failure mode applied to the Pre-Event
Voting tab. Replace it with the paginated envelope (requests/total/limit/
offset/sort/direction): total is counted before pagination so the DJ sees the
real pending count, and limit/offset drive a #411 growing window.

Reuse PR2's sort machinery — extract apply_field_sort and make key_sorted
public in request_sort.py — so pending-review shares one ORDER BY / Camelot
implementation with the DJ list. The default (no sort param) preserves the
vote-ranked review order (votes desc, age asc, id desc); best_match falls back
to it since priority scoring is meaningless pre-event. The Pre-Event tab gains
a focused Sort selector (Review order + upvotes/date/title/artist), "showing
X of Y", and Load More. Bulk actions (Accept top N / Reject remaining) keep
operating server-side against the full filtered set, not the loaded page.

Part of #478 (PR3/4). Builds on PR2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant