spec: review column lazy-load + season grouping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
111
docs/superpowers/specs/2026-04-15-review-lazy-load-design.md
Normal file
111
docs/superpowers/specs/2026-04-15-review-lazy-load-design.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Review column lazy-load + season grouping
|
||||
|
||||
Date: 2026-04-15
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the Review column's 500-item hard cap with server-side group-paginated lazy loading. Series are always returned complete (every pending non-noop episode, grouped by season), eliminating the "2 eps" mirage caused by groups getting split across the cap. When a series has pending work in more than one season, the UI nests seasons as collapsible sub-groups, each with its own "Approve season" button.
|
||||
|
||||
## Motivation
|
||||
|
||||
`server/api/review.ts:277` caps the pipeline's review list at 500 items. ReviewColumn groups client-side, so any series whose episodes spill beyond the cap shows a wrong episode count and partial episode list. The banner "Showing first 500 of N" is present but misleading — the *groups* don't survive the cut, not just the tail.
|
||||
|
||||
The existing "Approve all" button on a series card already calls `/series/:seriesKey/approve-all`, which operates on the DB directly and does approve every pending episode — so functionality works, only the display is wrong. Still, partial groups are confusing and the 500 cap forces users to approve in waves.
|
||||
|
||||
## Server changes
|
||||
|
||||
### New endpoint `GET /api/review/groups?offset=0&limit=25`
|
||||
|
||||
Response:
|
||||
```ts
|
||||
{
|
||||
groups: ReviewGroup[];
|
||||
totalGroups: number;
|
||||
totalItems: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
type ReviewGroup =
|
||||
| { kind: "movie"; item: PipelineReviewItem }
|
||||
| {
|
||||
kind: "series";
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
seriesJellyfinId: string | null;
|
||||
episodeCount: number;
|
||||
minConfidence: "high" | "low";
|
||||
originalLanguage: string | null;
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
};
|
||||
```
|
||||
|
||||
Ordering:
|
||||
- Groups ordered by (min confidence across group ASC — `high` < `low`), then (series_name or movie name ASC)
|
||||
- Within a series, seasons ordered by `season_number` ASC (`null` last)
|
||||
- Within a season, episodes ordered by `episode_number` ASC
|
||||
|
||||
Implementation outline:
|
||||
1. Query all pending non-noop plans joined to media_items (existing `review` query minus the LIMIT).
|
||||
2. Walk once in sort order, producing groups: a Movie becomes a one-shot `{ kind: "movie" }`; consecutive Episodes sharing `series_jellyfin_id` (or `series_name` fallback) accumulate into a `{ kind: "series" }` with `seasons` bucketed by `season_number`.
|
||||
3. Apply `.slice(offset, offset + limit)` over the full group list, enrich per-episode audio streams + transcode reasons for episodes that survive (reuse existing `enrichWithStreamsAndReasons`).
|
||||
4. `totalGroups` = full group count before slicing. `totalItems` = sum of episode counts + movie count (unchanged from today's `reviewTotal`). `hasMore` = `offset + limit < totalGroups`.
|
||||
|
||||
### `GET /api/review/pipeline` changes
|
||||
|
||||
Drop `review` and `reviewTotal` from the response. Add `reviewItemsTotal: number` so the column header shows a count before the groups endpoint resolves. Queue / Processing / Done / doneCount stay unchanged.
|
||||
|
||||
### Kept as-is
|
||||
|
||||
- `POST /api/review/series/:seriesKey/approve-all` (`review.ts:529`)
|
||||
- `POST /api/review/season/:seriesKey/:season/approve-all` (`review.ts:549`) — already implemented, just unused by the UI until now
|
||||
|
||||
## Client changes
|
||||
|
||||
### PipelinePage
|
||||
|
||||
Fetches `/api/review/pipeline` for queue columns (existing) and separately `/api/review/groups?offset=0&limit=25` for the Review column's initial page. `onMutate` refetches both. Pass `reviewGroups`, `reviewGroupsTotalItems`, `reviewHasMore` into `ReviewColumn`.
|
||||
|
||||
### ReviewColumn
|
||||
|
||||
Replace the hard-cap rendering with infinite scroll:
|
||||
- Render the current loaded groups.
|
||||
- Append a sentinel `<div>` at the bottom when `hasMore`. An `IntersectionObserver` attached to it triggers a fetch of the next page when it enters the scroll viewport.
|
||||
- Pagination state (`offset`, `groups`, `hasMore`, `loading`) lives locally in ReviewColumn — parent passes `initialGroups` on mount and whenever the filter changes (`onMutate` → parent refetches page 0).
|
||||
- Remove the "Showing first N of M" banner and the `truncated` logic.
|
||||
|
||||
### SeriesCard
|
||||
|
||||
When `seasons.length > 1`:
|
||||
- Render seasons as collapsible sub-groups inside the expanded series body.
|
||||
- Each season header: `S{NN} — {episodeCount} eps · {high} high / {low} low` + an `Approve season` button.
|
||||
|
||||
When `seasons.length === 1`:
|
||||
- Render the current flat episode list (no extra nesting).
|
||||
|
||||
Rename the existing header button `Approve all` → `Approve series`.
|
||||
|
||||
### "Approve above"
|
||||
|
||||
Keeps its current "approve every group currently visible above this card" semantic. With lazy loading, that means "everything the user has scrolled past". Compute item ids client-side across the loaded groups as today. No endpoint change.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. PipelinePage mounts → parallel fetch `/pipeline` + `/groups?offset=0&limit=25`.
|
||||
2. User scrolls; sentinel becomes visible → fetch `/groups?offset=25&limit=25`; appended to the list.
|
||||
3. User clicks `Approve series` on a card → `POST /series/:key/approve-all` → `onMutate` → parent refetches `/pipeline` + `/groups?offset=0&limit=25`. Series gone from list.
|
||||
4. User clicks `Approve season S02` on a nested season → `POST /season/:key/2/approve-all` → `onMutate` → same refetch.
|
||||
|
||||
## Testing
|
||||
|
||||
- Server unit test: `/groups` endpoint returns a series with all pending episodes even when the total item count exceeds `limit * offset_pages`.
|
||||
- Server unit test: offset/limit/hasMore correctness across the group boundary.
|
||||
- Server unit test: seasons array is populated, sorted, with `null` season_number ordered last.
|
||||
- Manual: scroll through the Review column on a library with >1000 pending items and confirm episode counts match `SELECT COUNT(*) ... WHERE pending AND is_noop=0` scoped per series.
|
||||
|
||||
## Guided Gates
|
||||
|
||||
- **GG-1:** No "Showing first 500 of N" banner ever appears.
|
||||
- **GG-2:** A series whose episodes previously split across the cap now shows the correct episode count immediately on first page load (if the series is in the first page) or after scroll (if not).
|
||||
- **GG-3:** A series with pending episodes in 2+ seasons expands into nested season sub-groups, each with an `Approve season` button that approves only that season.
|
||||
- **GG-4:** A series with pending episodes in exactly one season expands into the flat episode list as before.
|
||||
- **GG-5:** Scrolling to the bottom of the Review column auto-fetches the next page without a click; scrolling stops fetching when `hasMore` is false.
|
||||
Reference in New Issue
Block a user