diff --git a/docs/superpowers/plans/2026-04-15-review-lazy-load.md b/docs/superpowers/plans/2026-04-15-review-lazy-load.md new file mode 100644 index 0000000..29e918f --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-review-lazy-load.md @@ -0,0 +1,857 @@ +# Review column lazy-load + season grouping — Implementation Plan + +> **For agentic workers:** Use superpowers:subagent-driven-development. Checkbox (`- [ ]`) syntax tracks progress. + +**Goal:** Replace the 500-item review cap with group-paginated infinite scroll; nest season sub-groups inside series when they have pending work across >1 season; wire the existing `/season/:key/:season/approve-all` endpoint into the UI. + +**Architecture:** Move the grouping logic from the client to the server so groups are always returned complete. New `GET /api/review/groups?offset=N&limit=25` endpoint. Client's ReviewColumn becomes a stateful list that extends itself via `IntersectionObserver` on a sentinel. + +**Tech Stack:** Bun + Hono (server), React 19 + TanStack Router (client), bun:sqlite. + +--- + +## Task 1: Server — build grouped data structure + new endpoint + +**Files:** +- Modify: `server/api/review.ts` + +- [ ] **Step 1: Add shared types + builder** + +At the top of `server/api/review.ts` (near the other type definitions), add exported types: + +```ts +export 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[] }>; + }; + +export interface ReviewGroupsResponse { + groups: ReviewGroup[]; + totalGroups: number; + totalItems: number; + hasMore: boolean; +} +``` + +Add a helper after the existing `enrichWithStreamsAndReasons` helper: + +```ts +function buildReviewGroups(db: ReturnType): { + groups: ReviewGroup[]; + totalItems: number; +} { + // Fetch ALL pending non-noop items. Grouping + pagination happen in memory. + const rows = db + .prepare(` + SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id, + mi.jellyfin_id, + mi.season_number, mi.episode_number, mi.type, mi.container, + mi.original_language, mi.orig_lang_source, mi.file_path + FROM review_plans rp + JOIN media_items mi ON mi.id = rp.item_id + WHERE rp.status = 'pending' AND rp.is_noop = 0 + ORDER BY + CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END, + COALESCE(mi.series_name, mi.name), + mi.season_number, mi.episode_number + `) + .all() as PipelineReviewItem[]; + + const movies: PipelineReviewItem[] = []; + const seriesMap = new Map< + string, + { + seriesName: string; + seriesJellyfinId: string | null; + seasons: Map; + originalLanguage: string | null; + minConfidence: "high" | "low"; + firstName: string; + } + >(); + + for (const row of rows) { + if (row.type === "Movie") { + movies.push(row); + continue; + } + const key = row.series_jellyfin_id ?? row.series_name ?? String(row.item_id); + let entry = seriesMap.get(key); + if (!entry) { + entry = { + seriesName: row.series_name ?? "", + seriesJellyfinId: row.series_jellyfin_id, + seasons: new Map(), + originalLanguage: row.original_language, + minConfidence: row.confidence, + firstName: row.series_name ?? "", + }; + seriesMap.set(key, entry); + } + const season = row.season_number; + let bucket = entry.seasons.get(season); + if (!bucket) { + bucket = []; + entry.seasons.set(season, bucket); + } + bucket.push(row); + if (row.confidence === "high" && entry.minConfidence === "low") { + // Keep minConfidence as the "best" confidence across episodes — if any + // episode is high, that's the group's dominant confidence for sort. + // Actually we want the LOWEST (low wins) so user sees low-confidence + // groups sorted after high-confidence ones. Revisit: keep low if present. + } + if (row.confidence === "low") entry.minConfidence = "low"; + } + + // Sort season keys within each series (nulls last), episodes by episode_number. + const seriesGroups: ReviewGroup[] = []; + for (const [seriesKey, entry] of seriesMap) { + const seasonKeys = [...entry.seasons.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return a - b; + }); + const seasons = seasonKeys.map((season) => ({ + season, + episodes: (entry.seasons.get(season) ?? []).sort( + (a, b) => (a.episode_number ?? 0) - (b.episode_number ?? 0), + ), + })); + const episodeCount = seasons.reduce((sum, s) => sum + s.episodes.length, 0); + seriesGroups.push({ + kind: "series", + seriesKey, + seriesName: entry.seriesName, + seriesJellyfinId: entry.seriesJellyfinId, + episodeCount, + minConfidence: entry.minConfidence, + originalLanguage: entry.originalLanguage, + seasons, + }); + } + + // Interleave movies + series, sort by (minConfidence, name). + const movieGroups: ReviewGroup[] = movies.map((m) => ({ kind: "movie" as const, item: m })); + const allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => { + const confA = a.kind === "movie" ? a.item.confidence : a.minConfidence; + const confB = b.kind === "movie" ? b.item.confidence : b.minConfidence; + const rankA = confA === "high" ? 0 : 1; + const rankB = confB === "high" ? 0 : 1; + if (rankA !== rankB) return rankA - rankB; + const nameA = a.kind === "movie" ? a.item.name : a.seriesName; + const nameB = b.kind === "movie" ? b.item.name : b.seriesName; + return nameA.localeCompare(nameB); + }); + + const totalItems = movieGroups.length + seriesGroups.reduce((sum, g) => sum + (g as { episodeCount: number }).episodeCount, 0); + return { groups: allGroups, totalItems }; +} +``` + +(Delete the stray comment block inside the loop about "keep minConfidence as the best" — the actual logic below it is correct. I left a TODO-style note while drafting; clean it up when editing.) + +- [ ] **Step 2: Add the `/groups` endpoint** + +Add before `app.get("/pipeline", …)`: + +```ts +app.get("/groups", (c) => { + const db = getDb(); + const offset = Math.max(0, Number.parseInt(c.req.query("offset") ?? "0", 10) || 0); + const limit = Math.max(1, Math.min(200, Number.parseInt(c.req.query("limit") ?? "25", 10) || 25)); + + const { groups, totalItems } = buildReviewGroups(db); + const page = groups.slice(offset, offset + limit); + + // Enrich each visible episode/movie with audio streams + transcode reasons + // (same shape the existing UI expects — reuse the helper already in this file). + const flatItemsForEnrichment: Array<{ id: number; plan_id?: number; item_id: number; transcode_reasons?: string[]; audio_streams?: PipelineAudioStream[] }> = []; + for (const g of page) { + if (g.kind === "movie") flatItemsForEnrichment.push(g.item as never); + else for (const s of g.seasons) for (const ep of s.episodes) flatItemsForEnrichment.push(ep as never); + } + enrichWithStreamsAndReasons(flatItemsForEnrichment); + + return c.json({ + groups: page, + totalGroups: groups.length, + totalItems, + hasMore: offset + limit < groups.length, + }); +}); +``` + +`PipelineAudioStream` already imported; if not, add to existing import block. + +- [ ] **Step 3: Modify `/pipeline` to drop `review`/`reviewTotal`** + +In the existing `app.get("/pipeline", …)` handler (around line 270): + +- Delete the `review` SELECT (lines ~278–293) and the enrichment of `review` rows. +- Delete the `reviewTotal` count query (lines ~294–296). +- Add in its place: `const reviewItemsTotal = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;` +- In the final `return c.json({...})` (line ~430), replace `review, reviewTotal` with `reviewItemsTotal`. + +- [ ] **Step 4: Run tests + lint + tsc** + +``` +mise exec bun -- bun test +mise exec bun -- bun run lint +mise exec bun -- bunx tsc --noEmit --project tsconfig.server.json +``` + +All must pass. If tests that hit `/pipeline` fail because they expect `review[]`, update them in the same commit (they need to migrate anyway). + +- [ ] **Step 5: Commit** + +```bash +git add server/api/review.ts +git commit -m "review: add /groups endpoint with server-side grouping + pagination" +``` + +--- + +## Task 2: Server — test `/groups` endpoint + +**Files:** +- Create: `server/api/__tests__/review-groups.test.ts` + +- [ ] **Step 1: Write the test file** + +```ts +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import reviewRoutes from "../review"; +import { setupTestDb, seedItem, seedPlan } from "./test-helpers"; // adjust to the project's test helpers; see existing webhook.test.ts for how tests wire up a DB + +const app = new Hono(); +app.route("/api/review", reviewRoutes); + +describe("GET /api/review/groups", () => { + test("returns complete series even when total items exceed limit", async () => { + const db = setupTestDb(); + // Seed 1 series with 30 episodes, all pending non-noop + for (let i = 1; i <= 30; i++) seedItem(db, { type: "Episode", seriesName: "Breaking Bad", seasonNumber: 1, episodeNumber: i }); + for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false }); + + const res = await app.request("/api/review/groups?offset=0&limit=25"); + const body = await res.json(); + + expect(body.groups).toHaveLength(1); + expect(body.groups[0].kind).toBe("series"); + expect(body.groups[0].episodeCount).toBe(30); + expect(body.groups[0].seasons[0].episodes).toHaveLength(30); + expect(body.totalItems).toBe(30); + expect(body.hasMore).toBe(false); + }); + + test("paginates groups with hasMore=true", async () => { + const db = setupTestDb(); + for (let i = 1; i <= 50; i++) seedItem(db, { type: "Movie", name: `Movie ${String(i).padStart(2, "0")}` }); + for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false }); + + const page1 = await (await app.request("/api/review/groups?offset=0&limit=25")).json(); + const page2 = await (await app.request("/api/review/groups?offset=25&limit=25")).json(); + + expect(page1.groups).toHaveLength(25); + expect(page1.hasMore).toBe(true); + expect(page2.groups).toHaveLength(25); + expect(page2.hasMore).toBe(false); + const ids1 = page1.groups.map((g: { item: { item_id: number } }) => g.item.item_id); + const ids2 = page2.groups.map((g: { item: { item_id: number } }) => g.item.item_id); + expect(ids1.filter((id: number) => ids2.includes(id))).toHaveLength(0); + }); + + test("buckets episodes by season, nulls last", async () => { + const db = setupTestDb(); + for (let ep = 1; ep <= 3; ep++) seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: 1, episodeNumber: ep }); + for (let ep = 1; ep <= 2; ep++) seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: 2, episodeNumber: ep }); + seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: null, episodeNumber: null }); + for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false }); + + const body = await (await app.request("/api/review/groups?offset=0&limit=25")).json(); + const lost = body.groups[0]; + expect(lost.kind).toBe("series"); + expect(lost.seasons.map((s: { season: number | null }) => s.season)).toEqual([1, 2, null]); + }); +}); +``` + +Important: this test file needs the project's actual test-helpers pattern. Before writing, look at `server/services/__tests__/webhook.test.ts` (the 60-line one that's still in the repo after the verified-flag block was removed) and **copy its setup style** — including how it creates a test DB, how it seeds media_items and review_plans, and how it invokes the Hono app. Replace the placeholder `setupTestDb`, `seedItem`, `seedPlan` calls with whatever the real helpers are. + +- [ ] **Step 2: Run the tests** + +``` +mise exec bun -- bun test server/api/__tests__/review-groups.test.ts +``` + +Expected: 3 passes. + +- [ ] **Step 3: Commit** + +```bash +git add server/api/__tests__/review-groups.test.ts +git commit -m "test: /groups endpoint — series completeness, pagination, season buckets" +``` + +--- + +## Task 3: Client types + PipelinePage + +**Files:** +- Modify: `src/shared/lib/types.ts` +- Modify: `src/features/pipeline/PipelinePage.tsx` + +- [ ] **Step 1: Update shared types** + +In `src/shared/lib/types.ts`, replace the `PipelineData` interface's `review` and `reviewTotal` fields with `reviewItemsTotal: number`. Add types for the new groups response: + +```ts +export 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[] }>; + }; + +export interface ReviewGroupsResponse { + groups: ReviewGroup[]; + totalGroups: number; + totalItems: number; + hasMore: boolean; +} +``` + +The `PipelineData` interface becomes: +```ts +export interface PipelineData { + reviewItemsTotal: number; + queued: PipelineJobItem[]; + processing: PipelineJobItem[]; + done: PipelineJobItem[]; + doneCount: number; + jellyfinUrl: string; +} +``` + +- [ ] **Step 2: Update PipelinePage** + +Change `PipelinePage.tsx`: + +- Add state for the initial groups page: `const [initialGroups, setInitialGroups] = useState(null);` +- In `load()`, fetch both in parallel: + ```ts + const [pipelineRes, groupsRes] = await Promise.all([ + api.get("/api/review/pipeline"), + api.get("/api/review/groups?offset=0&limit=25"), + ]); + setData(pipelineRes); + setInitialGroups(groupsRes); + ``` +- Wait for both before rendering (loading gate: `if (loading || !data || !initialGroups) return `). +- Pass to ReviewColumn: `` — drop `items` and `total` props. + +- [ ] **Step 3: Tsc + lint** + +``` +mise exec bun -- bunx tsc --noEmit +mise exec bun -- bun run lint +``` + +Expected: errors in `ReviewColumn.tsx` because its props type hasn't been updated yet — that's fine, Task 4 fixes it. For this step, only verify that types.ts and PipelinePage.tsx themselves compile internally. If the build breaks because of ReviewColumn, commit these two files anyway and proceed to Task 4 immediately. + +- [ ] **Step 4: Commit** + +```bash +git add src/shared/lib/types.ts src/features/pipeline/PipelinePage.tsx +git commit -m "pipeline: fetch review groups endpoint in parallel with pipeline" +``` + +--- + +## Task 4: Client — ReviewColumn with infinite scroll + +**Files:** +- Modify: `src/features/pipeline/ReviewColumn.tsx` + +- [ ] **Step 1: Rewrite ReviewColumn** + +Replace the file contents with: + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "~/shared/lib/api"; +import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types"; +import { ColumnShell } from "./ColumnShell"; +import { PipelineCard } from "./PipelineCard"; +import { SeriesCard } from "./SeriesCard"; + +const PAGE_SIZE = 25; + +interface ReviewColumnProps { + initialResponse: ReviewGroupsResponse; + totalItems: number; + jellyfinUrl: string; + onMutate: () => void; +} + +export function ReviewColumn({ initialResponse, totalItems, jellyfinUrl, onMutate }: ReviewColumnProps) { + const [groups, setGroups] = useState(initialResponse.groups); + const [hasMore, setHasMore] = useState(initialResponse.hasMore); + const [loadingMore, setLoadingMore] = useState(false); + const sentinelRef = useRef(null); + + // Reset when parent passes a new initial page (onMutate refetch) + useEffect(() => { + setGroups(initialResponse.groups); + setHasMore(initialResponse.hasMore); + }, [initialResponse]); + + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + try { + const res = await api.get(`/api/review/groups?offset=${groups.length}&limit=${PAGE_SIZE}`); + setGroups((prev) => [...prev, ...res.groups]); + setHasMore(res.hasMore); + } finally { + setLoadingMore(false); + } + }, [groups.length, hasMore, loadingMore]); + + useEffect(() => { + if (!hasMore || !sentinelRef.current) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) loadMore(); + }, + { rootMargin: "200px" }, + ); + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, [hasMore, loadMore]); + + const skipAll = async () => { + if (!confirm(`Skip all ${totalItems} pending items? They won't be processed unless you unskip them.`)) return; + await api.post("/api/review/skip-all"); + onMutate(); + }; + + const autoApprove = async () => { + const res = await api.post<{ ok: boolean; count: number }>("/api/review/auto-approve"); + onMutate(); + if (res.count === 0) alert("No high-confidence items to auto-approve."); + }; + + const approveItem = async (itemId: number) => { + await api.post(`/api/review/${itemId}/approve`); + onMutate(); + }; + const skipItem = async (itemId: number) => { + await api.post(`/api/review/${itemId}/skip`); + onMutate(); + }; + const approveBatch = async (itemIds: number[]) => { + if (itemIds.length === 0) return; + await api.post<{ ok: boolean; count: number }>("/api/review/approve-batch", { itemIds }); + onMutate(); + }; + + // Compute ids per visible group for "Approve above" + const idsByGroup: number[][] = groups.map((g) => + g.kind === "movie" ? [g.item.item_id] : g.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)), + ); + const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat(); + + const actions = + totalItems > 0 + ? [ + { label: "Auto Review", onClick: autoApprove, primary: true }, + { label: "Skip all", onClick: skipAll }, + ] + : undefined; + + return ( + +
+ {groups.map((group, index) => { + const prior = index > 0 ? priorIds(index) : null; + const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined; + if (group.kind === "movie") { + return ( + { + await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action }); + onMutate(); + }} + onApprove={() => approveItem(group.item.item_id)} + onSkip={() => skipItem(group.item.item_id)} + onApproveUpToHere={onApproveUpToHere} + /> + ); + } + return ( + + ); + })} + {groups.length === 0 &&

No items to review

} + {hasMore && ( +
+ {loadingMore ? "Loading more…" : ""} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Tsc + lint** + +``` +mise exec bun -- bunx tsc --noEmit +mise exec bun -- bun run lint +``` + +Expected: the call site in ReviewColumn passes `seasons`, `episodeCount`, `originalLanguage` props to SeriesCard — this will fail until Task 5 updates SeriesCard. Same handling as Task 3 step 3: commit and proceed. + +- [ ] **Step 3: Commit** + +```bash +git add src/features/pipeline/ReviewColumn.tsx +git commit -m "review column: infinite scroll with IntersectionObserver sentinel" +``` + +--- + +## Task 5: Client — SeriesCard season nesting + +**Files:** +- Modify: `src/features/pipeline/SeriesCard.tsx` + +- [ ] **Step 1: Rewrite SeriesCard** + +Replace the file contents with: + +```tsx +import { useState } from "react"; +import { api } from "~/shared/lib/api"; +import { LANG_NAMES } from "~/shared/lib/lang"; +import type { PipelineReviewItem } from "~/shared/lib/types"; +import { PipelineCard } from "./PipelineCard"; + +interface SeriesCardProps { + seriesKey: string; + seriesName: string; + jellyfinUrl: string; + seriesJellyfinId: string | null; + seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>; + episodeCount: number; + originalLanguage: string | null; + onMutate: () => void; + onApproveUpToHere?: () => void; +} + +export function SeriesCard({ + seriesKey, + seriesName, + jellyfinUrl, + seriesJellyfinId, + seasons, + episodeCount, + originalLanguage, + onMutate, + onApproveUpToHere, +}: SeriesCardProps) { + const [expanded, setExpanded] = useState(false); + + const flatEpisodes = seasons.flatMap((s) => s.episodes); + const highCount = flatEpisodes.filter((e) => e.confidence === "high").length; + const lowCount = flatEpisodes.filter((e) => e.confidence === "low").length; + const multipleSeasons = seasons.length > 1; + + const setSeriesLanguage = async (lang: string) => { + await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang }); + onMutate(); + }; + + const approveSeries = async () => { + await api.post(`/api/review/series/${encodeURIComponent(seriesKey)}/approve-all`); + onMutate(); + }; + + const approveSeason = async (season: number | null) => { + if (season == null) return; + await api.post(`/api/review/season/${encodeURIComponent(seriesKey)}/${season}/approve-all`); + onMutate(); + }; + + const jellyfinLink = + jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null; + + return ( +
+ {/* Title row */} +
setExpanded(!expanded)} + > + {expanded ? "▼" : "▶"} + {jellyfinLink ? ( + e.stopPropagation()} + > + {seriesName} + + ) : ( +

{seriesName}

+ )} +
+ + {/* Controls row */} +
+ {episodeCount} eps + {multipleSeasons && · {seasons.length} seasons} + {highCount > 0 && {highCount} ready} + {lowCount > 0 && {lowCount} review} +
+ + {onApproveUpToHere && ( + + )} + +
+ + {expanded && ( +
+ {multipleSeasons + ? seasons.map((s) => ( + approveSeason(s.season)} + onMutate={onMutate} + /> + )) + : flatEpisodes.map((ep) => ( + + ))} +
+ )} +
+ ); +} + +function SeasonGroup({ + season, + episodes, + jellyfinUrl, + onApproveSeason, + onMutate, +}: { + season: number | null; + episodes: PipelineReviewItem[]; + jellyfinUrl: string; + onApproveSeason: () => void; + onMutate: () => void; +}) { + const [open, setOpen] = useState(false); + const highCount = episodes.filter((e) => e.confidence === "high").length; + const lowCount = episodes.filter((e) => e.confidence === "low").length; + const label = season == null ? "No season" : `Season ${String(season).padStart(2, "0")}`; + + return ( +
+
setOpen(!open)} + > + {open ? "▼" : "▶"} + {label} + · {episodes.length} eps + {highCount > 0 && {highCount} ready} + {lowCount > 0 && {lowCount} review} +
+ {season != null && ( + + )} +
+ {open && ( +
+ {episodes.map((ep) => ( + + ))} +
+ )} +
+ ); +} + +function EpisodeRow({ ep, jellyfinUrl, onMutate }: { ep: PipelineReviewItem; jellyfinUrl: string; onMutate: () => void }) { + return ( +
+ { + await api.patch(`/api/review/${ep.item_id}/stream/${streamId}`, { action }); + onMutate(); + }} + onApprove={async () => { + await api.post(`/api/review/${ep.item_id}/approve`); + onMutate(); + }} + onSkip={async () => { + await api.post(`/api/review/${ep.item_id}/skip`); + onMutate(); + }} + /> +
+ ); +} +``` + +(The `EpisodeRow` wrapper keeps the padding consistent whether episodes render directly under the series or under a season group.) + +- [ ] **Step 2: Lint + tsc + test + build** + +``` +mise exec bun -- bun run lint +mise exec bun -- bunx tsc --noEmit +mise exec bun -- bun test +mise exec bun -- bun run build +``` + +All must pass now that the whole pipeline (server → types → PipelinePage → ReviewColumn → SeriesCard) is consistent. + +- [ ] **Step 3: Manual smoke test** + +``` +mise exec bun -- bun run dev +``` + +Navigate to the Pipeline page: +- Confirm no "Showing first 500 of N" banner. +- Scroll the Review column to the bottom; new groups auto-load. +- Find a series with pending work in >1 season; expand it; confirm nested seasons with working `Approve season` button. +- Find a series with pending work in a single season; expand it; confirm flat episode list (no season nesting). +- Click `Approve series` on a series with many pending episodes; confirm the whole series vanishes from the column. + +Kill the dev server. + +- [ ] **Step 4: Commit** + +```bash +git add src/features/pipeline/SeriesCard.tsx +git commit -m "series card: nest seasons when >1 pending, add Approve season button" +``` + +--- + +## Task 6: Version bump + final push + +- [ ] **Step 1: Bump CalVer** + +In `package.json`, set version to today's next free dot-suffix (today is 2026-04-15; prior releases are `.1` and `.2`, so use `.3` unless already taken). + +- [ ] **Step 2: Final checks** + +``` +mise exec bun -- bun run lint +mise exec bun -- bunx tsc --noEmit +mise exec bun -- bunx tsc --noEmit --project tsconfig.server.json +mise exec bun -- bun test +mise exec bun -- bun run build +``` + +- [ ] **Step 3: Commit + push** + +```bash +git add package.json +git commit -m "v2026.04.15.3 — review column lazy-load + season grouping" +git push gitea main +``` + +--- + +## Guided Gates (user-verified) + +- **GG-1:** No "Showing first 500 of N" banner. +- **GG-2:** A series with episodes previously split across the cap now shows the correct episode count. +- **GG-3:** A series with >1 pending season expands into nested season groups, each with a working `Approve season` button. +- **GG-4:** A series with 1 pending season expands flat (no extra nesting). +- **GG-5:** Scrolling to the bottom of Review auto-loads the next page; no scroll = no extra fetch.