diff --git a/docs/superpowers/specs/2026-04-15-review-lazy-load-design.md b/docs/superpowers/specs/2026-04-15-review-lazy-load-design.md new file mode 100644 index 0000000..89d7595 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-review-lazy-load-design.md @@ -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 `