From 85937ec9428a421cae5e90e7b530a488419c1036 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 9 Jun 2026 22:06:20 +0200 Subject: [PATCH] docs(final-docs): implementation plan for judge-doc curation + optional uploads Co-Authored-By: Claude Opus 4.8 (1M context) --- ...09-finale-doc-curation-optional-uploads.md | 745 ++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-finale-doc-curation-optional-uploads.md diff --git a/docs/superpowers/plans/2026-06-09-finale-doc-curation-optional-uploads.md b/docs/superpowers/plans/2026-06-09-finale-doc-curation-optional-uploads.md new file mode 100644 index 0000000..812b80d --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-finale-doc-curation-optional-uploads.md @@ -0,0 +1,745 @@ +# Grand Final Judge-Doc Curation + Optional Uploads Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let admins curate which previously-submitted documents finale judges see, and make the "optional revised uploads" mode render correctly for finalists. + +**Architecture:** Everything builds on the existing `final-documents.ts` service + `finalist` tRPC router + the LIVE_FINAL round's `configJson` (same pattern as the shipped `allowFinalistRevisedUploads` toggle). A new configJson key `reviewVisibleRequirementIds` filters the judge review payload; new status fields `hasRequired`/`allUploaded` drive optional-mode rendering in the finalist banner/panel. No schema migration. + +**Tech Stack:** Next.js 15 App Router, tRPC 11 + Zod, Prisma 6, Vitest 4 (sequential, real test DB), shadcn/ui (Card/Switch/Checkbox). + +**Spec:** `docs/superpowers/specs/2026-06-09-finale-doc-curation-optional-uploads-design.md` + +**Conventions for every task:** TypeScript strict, `type` over `interface`. Tests use the factories in `tests/helpers.ts` (`createTestProgram`, `createTestCompetition`, `createTestRound`, `createTestProject`, `createTestProjectRoundState`, `createTestUser`, `uid`) and clean up with `cleanupTestData(programId, userIds?)` in `afterAll`. Run a single file with `npx vitest run tests/unit/.test.ts`. + +--- + +### Task 1: `hasRequired` + `allUploaded` on `FinalDocumentStatus` + +**Files:** +- Modify: `src/server/services/final-documents.ts` (type at ~line 14, computation at ~line 95) +- Test: `tests/unit/final-documents.test.ts` (extend existing file) + +- [ ] **Step 1: Write the failing tests** + +In `tests/unit/final-documents.test.ts`, first extend the `makeFinaleProgram` factory (top of file) with an `optionalRequirements` option — change the two `fileRequirement.create` calls to use it: + +```ts +async function makeFinaleProgram( + opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {}, +) { + // ... existing body unchanged, except both requirement creates: + // isRequired: !opts.optionalRequirements +} +``` + +Then add inside the existing `describe('getFinalDocumentStatusForProject', ...)` block: + +```ts + it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => { + const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + + const before = await getFinalDocumentStatusForProject(prisma, project.id) + expect(before!.hasRequired).toBe(false) + expect(before!.allUploaded).toBe(false) + expect(before!.allRequiredUploaded).toBe(false) + + for (const req of [reqPlan!, reqVideo!]) { + await prisma.projectFile.create({ + data: { + id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id, + fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10, + bucket: 'b', objectKey: uid('key'), + }, + }) + } + const after = await getFinalDocumentStatusForProject(prisma, project.id) + expect(after!.hasRequired).toBe(false) + expect(after!.allUploaded).toBe(true) + }) + + it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => { + const { program, round, reqPlan } = await makeFinaleProgram() + await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const status = await getFinalDocumentStatusForProject(prisma, project.id) + expect(status!.hasRequired).toBe(true) // reqVideo still required + expect(status!.allUploaded).toBe(false) + }) + + it('zero slots: allUploaded false (no vacuous completeness)', async () => { + const { program, round } = await makeFinaleProgram({ skipRequirements: true }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const status = await getFinalDocumentStatusForProject(prisma, project.id) + expect(status!.hasRequired).toBe(false) + expect(status!.allUploaded).toBe(false) + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/unit/final-documents.test.ts` +Expected: the 3 new tests FAIL with TypeScript/undefined errors on `hasRequired` / `allUploaded` (fields don't exist yet); all pre-existing tests still pass. + +- [ ] **Step 3: Implement** + +In `src/server/services/final-documents.ts`, extend the type (~line 14): + +```ts +export type FinalDocumentStatus = { + roundId: string + roundName: string + deadline: Date | null + deadlinePassed: boolean + requirements: FinalDocRequirement[] + allRequiredUploaded: boolean + hasRequired: boolean // any slot is marked required + allUploaded: boolean // every listed slot has a file (false when no slots exist) +} +``` + +And in `getFinalDocumentStatusForProject` (~line 95), replace the return-value computation: + +```ts + const required = reqStatuses.filter((r) => r.isRequired) + const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded) + const hasRequired = required.length > 0 + const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded) + const deadline = round.windowCloseAt ?? null + return { + roundId: round.id, + roundName: round.name, + deadline, + deadlinePassed: deadline ? new Date() > deadline : false, + requirements: reqStatuses, + allRequiredUploaded, + hasRequired, + allUploaded, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/unit/final-documents.test.ts` +Expected: ALL tests pass (new + pre-existing). + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts +git commit -m "feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode" +``` + +--- + +### Task 2: Curation filter in `listFinalistDocumentsForReview` + +**Files:** +- Modify: `src/server/services/final-documents.ts` (`listFinalistDocumentsForReview`, ~line 257; new helper next to `finalistUploadsEnabled` ~line 45) +- Test: Create `tests/unit/final-documents-curation.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/final-documents-curation.test.ts`. The review service presigns every file via MinIO, so mock `getPresignedUrl` (partial module mock — keeps `BUCKET_NAME` etc. real): + +```ts +import { describe, it, expect, afterAll, vi } from 'vitest' + +vi.mock('@/lib/minio', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') } +}) + +import { prisma } from '../setup' +import { + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + createTestProjectRoundState, + cleanupTestData, + uid, +} from '../helpers' +import { listFinalistDocumentsForReview } from '@/server/services/final-documents' + +const programIds: string[] = [] +afterAll(async () => { + for (const id of programIds) await cleanupTestData(id) +}) + +/** + * One finalist team with 4 files: + * - Business Plan (prior SUBMISSION round, via requirement reqBP) + * - Pitch Deck (prior SUBMISSION round, via requirement reqDeck) + * - loose.pdf (prior SUBMISSION round, NO requirement) + * - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal) + */ +async function setupCuration() { + const program = await createTestProgram() + programIds.push(program.id) + const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 }) + const reqBP = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 }, + }) + const reqDeck = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 }, + }) + const finale = await createTestRound(comp.id, { + roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, + windowCloseAt: new Date(Date.now() + 86_400_000), + configJson: { allowFinalistRevisedUploads: true }, + }) + const reqFinal = await prisma.fileRequirement.create({ + data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 }, + }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, finale.id) + + const mkFile = (roundId: string, requirementId: string | null, fileName: string) => + prisma.projectFile.create({ + data: { + id: uid('file'), projectId: project.id, roundId, requirementId, + fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10, + bucket: 'b', objectKey: uid('key'), + }, + }) + await mkFile(priorRound.id, reqBP.id, 'bp.pdf') + await mkFile(priorRound.id, reqDeck.id, 'deck.pdf') + await mkFile(priorRound.id, null, 'loose.pdf') + await mkFile(finale.id, reqFinal.id, 'final.mp4') + + return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project } +} + +async function setSelection(roundId: string, ids: string[] | null) { + const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } }) + const cfg = (round.configJson ?? {}) as Record + if (ids === null) delete cfg.reviewVisibleRequirementIds + else cfg.reviewVisibleRequirementIds = ids + await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } }) +} + +describe('listFinalistDocumentsForReview curation', () => { + it('no selection key → all files visible (current behavior)', async () => { + const { program } = await setupCuration() + const result = await listFinalistDocumentsForReview(prisma, program.id) + expect(result.teams).toHaveLength(1) + expect(result.teams[0].files).toHaveLength(4) + }) + + it('selection → only matching prior files, finale uploads always visible', async () => { + const { program, finale, reqBP } = await setupCuration() + await setSelection(finale.id, [reqBP.id]) + const result = await listFinalistDocumentsForReview(prisma, program.id) + const names = result.teams[0].files.map((f) => f.fileName).sort() + expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden + }) + + it('empty selection → only finale uploads visible', async () => { + const { program, finale } = await setupCuration() + await setSelection(finale.id, []) + const result = await listFinalistDocumentsForReview(prisma, program.id) + expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4']) + }) + + it('prior file without a requirement is excluded under any selection', async () => { + const { program, finale, reqBP, reqDeck } = await setupCuration() + await setSelection(finale.id, [reqBP.id, reqDeck.id]) + const result = await listFinalistDocumentsForReview(prisma, program.id) + expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/unit/final-documents-curation.test.ts` +Expected: the first test PASSES (current behavior), the other three FAIL (filter not implemented — they see all 4 files). + +- [ ] **Step 3: Implement** + +In `src/server/services/final-documents.ts`, add a config reader next to `finalistUploadsEnabled` (~line 45): + +```ts +/** + * Which prior-round FileRequirement ids are visible to finale judges. + * null = no curation (show all prior files). Empty array = hide all prior + * files (Grand Final round uploads are always shown regardless). + */ +export function reviewVisibleRequirementIds(configJson: unknown): string[] | null { + const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null +} +``` + +In `listFinalistDocumentsForReview`: +1. After the `if (!round) return ...` guard, read the selection: `const visibleIds = reviewVisibleRequirementIds(round.configJson)` +2. Add `requirementId: true` to the `allFiles` select. +3. At the top of the `for (const f of allFiles)` loop, before building `rf`: + +```ts + const isFinaleUpload = f.roundId === round.id + // Curated mode: prior-round files must match a selected requirement; finale uploads always pass. + if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue +``` + +4. Use the `isFinaleUpload` const in the `rf` object (replacing the inline `f.roundId === round.id`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/unit/final-documents-curation.test.ts` +Expected: all 4 PASS. + +Also run the neighbors to catch regressions: `npx vitest run tests/unit/final-documents.test.ts` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts +git commit -m "feat(final-docs): filter judge review by reviewVisibleRequirementIds (finale uploads always shown)" +``` + +--- + +### Task 3: Picker options helper `listReviewVisibilityOptions` + +**Files:** +- Modify: `src/server/services/final-documents.ts` (new exported function + type, after `listFinalistDocumentsForReview`) +- Test: `tests/unit/final-documents-curation.test.ts` (extend) + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/final-documents-curation.test.ts` (import `listReviewVisibilityOptions` from the same service module): + +```ts +describe('listReviewVisibilityOptions', () => { + it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => { + const { program, reqBP, reqDeck } = await setupCuration() + const options = await listReviewVisibilityOptions(prisma, program.id) + expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort()) + const bp = options.find((o) => o.requirementId === reqBP.id)! + expect(bp.name).toBe('Business Plan') + expect(bp.fileCount).toBe(1) + expect(bp.roundName).toBeTruthy() + }) + + it('returns [] when there is no open finale round', async () => { + const program = await createTestProgram() + programIds.push(program.id) + expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([]) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/unit/final-documents-curation.test.ts` +Expected: FAIL — `listReviewVisibilityOptions` is not exported. + +- [ ] **Step 3: Implement** + +In `src/server/services/final-documents.ts`, after `listFinalistDocumentsForReview`: + +```ts +export type ReviewDocSlot = { + requirementId: string + name: string + roundName: string + roundSort: number + fileCount: number +} + +/** + * Distinct prior-round document slots (FileRequirements) that the finalist + * teams have files for — the options offered in the admin "documents shown to + * judges" picker. Excludes the finale round's own slots (those uploads are + * always visible to judges) and files without a requirement. + */ +export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise { + const round = await getOpenFinaleRound(prisma, programId) + if (!round) return [] + const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } }) + const files = await prisma.projectFile.findMany({ + where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } }, + select: { + requirementId: true, + requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } }, + }, + }) + const slots = new Map() + for (const f of files) { + if (!f.requirementId || !f.requirement) continue + const existing = slots.get(f.requirementId) + if (existing) existing.fileCount++ + else slots.set(f.requirementId, { + requirementId: f.requirementId, + name: f.requirement.name.trim(), + roundName: f.requirement.round.name, + roundSort: f.requirement.round.sortOrder, + fileCount: 1, + }) + } + return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name)) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/unit/final-documents-curation.test.ts` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts +git commit -m "feat(final-docs): listReviewVisibilityOptions — distinct prior-round doc slots for the curation picker" +``` + +--- + +### Task 4: tRPC procedures `getReviewDocSettings` / `setReviewVisibleRequirements` + +**Files:** +- Modify: `src/server/routers/finalist.ts` (add two procedures next to `getRevisedUploadSetting`/`setRevisedUploadSetting`, ~line 1695; extend the existing `@/server/services/final-documents` import with `listReviewVisibilityOptions` and `reviewVisibleRequirementIds`) +- Test: `tests/unit/final-documents-curation.test.ts` (extend) + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/final-documents-curation.test.ts`: + +```ts +import * as finalistRouter from '@/server/routers/finalist' +import { createCaller } from '../setup' +import { createTestUser } from '../helpers' // merge into the existing helpers import + +describe('finalist review-doc settings procedures', () => { + const userIds: string[] = [] + afterAll(async () => { + // cleanupTestData of programIds already runs in the file-level afterAll; + // pass userIds through an extra cleanup for the admin users: + for (const id of programIds) await cleanupTestData(id, userIds) + }) + + it('round-trips a selection and preserves sibling configJson keys', async () => { + const { program, finale, reqBP } = await setupCuration() + const admin = await createTestUser('PROGRAM_ADMIN') + userIds.push(admin.id) + const caller = createCaller(finalistRouter.finalistRouter, admin) + + const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id }) + expect(initial.selectedIds).toBeNull() + expect(initial.options.length).toBe(2) + + await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] }) + const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id }) + expect(curated.selectedIds).toEqual([reqBP.id]) + + // sibling key from setupCuration must survive + const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } }) + expect((round.configJson as Record).allowFinalistRevisedUploads).toBe(true) + + await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null }) + const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id }) + expect(cleared.selectedIds).toBeNull() + }) + + it('rejects a non-LIVE_FINAL round', async () => { + const { program, priorRound } = await setupCuration() + const admin = await createTestUser('PROGRAM_ADMIN') + userIds.push(admin.id) + const caller = createCaller(finalistRouter.finalistRouter, admin) + await expect( + caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }), + ).rejects.toThrow() + void program + }) +}) +``` + +(Adjust the top-of-file `../helpers` import to include `createTestUser` rather than re-importing.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/unit/final-documents-curation.test.ts` +Expected: FAIL — `getReviewDocSettings` does not exist on the router. + +- [ ] **Step 3: Implement** + +In `src/server/routers/finalist.ts`, extend the existing service import with `listReviewVisibilityOptions, reviewVisibleRequirementIds`, then add after `setRevisedUploadSetting`: + +```ts + /** Options + current selection for the "documents shown to judges" picker. */ + getReviewDocSettings: adminProcedure + .input(z.object({ programId: z.string(), roundId: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } }) + return { + options: await listReviewVisibilityOptions(ctx.prisma, input.programId), + selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null), + } + }), + + /** Set which prior-round documents finale judges see. null = show all (clears curation). */ + setReviewVisibleRequirements: adminProcedure + .input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() })) + .mutation(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } }) + if (!round || round.roundType !== 'LIVE_FINAL') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' }) + } + const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record + const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds } + await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next } }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_REVIEW_DOCS_CURATED', + entityType: 'Round', + entityId: input.roundId, + detailsJson: { requirementIds: input.requirementIds }, + }) + return { ok: true } + }), +``` + +Note: `data: { configJson: next }` may need `next as Prisma.InputJsonValue` depending on inference — match how the file already imports/uses Prisma types if the typechecker complains. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/unit/final-documents-curation.test.ts` +Expected: all PASS. Then `npm run typecheck` — clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/routers/finalist.ts tests/unit/final-documents-curation.test.ts +git commit -m "feat(final-docs): admin procedures to read/set judge-visible document curation" +``` + +--- + +### Task 5: Optional-mode rendering — banner + panel + +No component-test infrastructure exists in this repo (vitest covers server code only) — verify via typecheck + the manual smoke in Task 7. + +**Files:** +- Modify: `src/components/applicant/final-documents-banner.tsx` +- Modify: `src/components/applicant/final-documents-panel.tsx` + +- [ ] **Step 1: Update the banner** + +In `final-documents-banner.tsx`: + +1. Guard (line 11): `if (!status || status.requirements.length === 0) return null` +2. Replace the `done` computation (line 18) and add the mode flag: + +```ts + const optionalMode = !status.hasRequired + const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded +``` + +3. Replace the title span (lines 26-28): + +```tsx + + {done + ? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted' + : optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'} + +``` + +Everything else (styling, checklist, deadline, button gated on `!done`) stays as is. + +- [ ] **Step 2: Update the panel** + +In `final-documents-panel.tsx`: + +1. Guard (line 21): `if (!status || status.requirements.length === 0) return null` +2. Add after the guard: `const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded` +3. Replace both `status.allRequiredUploaded` usages (badge at line 29, team upload-button gate at line 51) with `done`. +4. Badge label: `{status.hasRequired ? 'Submitted' : 'Uploaded'}` +5. Description (line 38) — append the optional hint: + +```tsx + + {props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'} + {!status.hasRequired && ' These uploads are optional.'} + +``` + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: clean. (The tRPC client types pick up `hasRequired`/`allUploaded` from Task 1 automatically.) + +- [ ] **Step 4: Commit** + +```bash +git add src/components/applicant/final-documents-banner.tsx src/components/applicant/final-documents-panel.tsx +git commit -m "feat(final-docs): optional-mode rendering for finalist banner + panel" +``` + +--- + +### Task 6: Admin "Documents shown to judges" card + +**Files:** +- Create: `src/components/admin/grand-finale/review-docs-picker.tsx` +- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (import near line 100; render near line 1535) + +- [ ] **Step 1: Create the picker component** + +`src/components/admin/grand-finale/review-docs-picker.tsx` (full file): + +```tsx +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { Eye } from 'lucide-react' + +/** + * Admin picker: which previously-submitted documents finale judges see on the + * review page. Default (switch off) shows everything; switching to curated + * mode starts with all slots ticked, and the admin unticks what to hide. + * Grand Final round uploads are always visible regardless. + */ +export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) { + const utils = trpc.useUtils() + const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId }) + const set = trpc.finalist.setReviewVisibleRequirements.useMutation({ + onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }), + onError: (e) => toast.error(e.message), + }) + if (!data || data.options.length === 0) return null + + const curated = data.selectedIds !== null + const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId)) + const toggleSlot = (id: string, on: boolean) => { + const next = new Set(selected) + if (on) next.add(id) + else next.delete(id) + set.mutate({ roundId, requirementIds: [...next] }) + } + + return ( + + + + Documents shown to judges + + + Choose which previously submitted documents judges see on the finalist review page. + Documents uploaded directly to this Grand Final round are always visible. + + + +
+ + set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })} + /> + +
+ {curated && ( +
+ {data.options.map((o) => ( + + ))} +
+ )} +
+
+ ) +} +``` + +- [ ] **Step 2: Wire into the round admin page** + +In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`: + +Import (next to the other grand-finale imports, ~line 100): + +```tsx +import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker' +``` + +Render inside the existing `isGrandFinale && programId` block, directly after the flex row containing `` (the `` around line 1545): + +```tsx + +``` + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/admin/grand-finale/review-docs-picker.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx" +git commit -m "feat(final-docs): admin card to curate documents shown to finale judges" +``` + +--- + +### Task 7: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Full test suite** + +Run: `npx vitest run` +Expected: all tests pass (was 321 before this work; now more). + +- [ ] **Step 2: Lint + typecheck + build** + +Run: `npm run lint && npm run typecheck && npm run build` +Expected: all clean. (CLAUDE.md: always build before push.) + +- [ ] **Step 3: Manual smoke (dev server + Playwright or browser)** + +1. As admin, open the Grand Final round page → the "Documents shown to judges" card lists the prior-round slots with counts; flip to curated, untick one slot. +2. Open `/admin/finals-documents` → the unticked document type disappears from every team; any Grand Final uploads remain. +3. Flip the curation switch off → all documents reappear. +4. With `allowFinalistRevisedUploads` ON and all finale slots optional (set in dev data), check the applicant dashboard banner shows "Upload updated Grand Final documents (optional)" and turns green only when every slot is filled. + +- [ ] **Step 4: Commit anything outstanding** + +```bash +git status --short # should be clean except untracked screenshots/docs +``` + +--- + +## Self-Review (completed at planning time) + +- **Spec coverage:** configJson key + semantics → Tasks 2/4; always-visible finale uploads → Task 2; requirement-less files excluded under selection → Task 2; picker options with counts → Task 3; admin UI next to toggle → Task 6; `hasRequired`/`allUploaded` + zero-slot edge → Tasks 1/5; sibling-key preservation + audit → Task 4; reminders unchanged → verified in spec, no task needed. +- **Placeholder scan:** none. +- **Type consistency:** `ReviewDocSlot`, `reviewVisibleRequirementIds(configJson)`, `getReviewDocSettings`/`setReviewVisibleRequirements`, `hasRequired`/`allUploaded` used consistently across tasks.