31 KiB
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/<file>.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:
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:
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):
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:
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
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 tofinalistUploadsEnabled~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):
import { describe, it, expect, afterAll, vi } from 'vitest'
vi.mock('@/lib/minio', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/minio')>()
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<string, unknown>
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):
/**
* 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:
- After the
if (!round) return ...guard, read the selection:const visibleIds = reviewVisibleRequirementIds(round.configJson) - Add
requirementId: trueto theallFilesselect. - At the top of the
for (const f of allFiles)loop, before buildingrf:
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
- Use the
isFinaleUploadconst in therfobject (replacing the inlinef.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
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, afterlistFinalistDocumentsForReview) -
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):
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:
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<ReviewDocSlot[]> {
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<string, ReviewDocSlot>()
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
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 togetRevisedUploadSetting/setRevisedUploadSetting, ~line 1695; extend the existing@/server/services/final-documentsimport withlistReviewVisibilityOptionsandreviewVisibleRequirementIds) -
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 * 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<string, unknown>).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:
/** 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<string, unknown>
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
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:
- Guard (line 11):
if (!status || status.requirements.length === 0) return null - Replace the
donecomputation (line 18) and add the mode flag:
const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
- Replace the title span (lines 26-28):
<span className="font-semibold">
{done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
Everything else (styling, checklist, deadline, button gated on !done) stays as is.
- Step 2: Update the panel
In final-documents-panel.tsx:
- Guard (line 21):
if (!status || status.requirements.length === 0) return null - Add after the guard:
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded - Replace both
status.allRequiredUploadedusages (badge at line 29, team upload-button gate at line 51) withdone. - Badge label:
{status.hasRequired ? 'Submitted' : 'Uploaded'} - Description (line 38) — append the optional hint:
<CardDescription>
{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.'}
</CardDescription>
- Step 3: Typecheck
Run: npm run typecheck
Expected: clean. (The tRPC client types pick up hasRequired/allUploaded from Task 1 automatically.)
- Step 4: Commit
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):
'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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} — {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}
- 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):
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
Render inside the existing isGrandFinale && programId block, directly after the flex row containing <FinalDocsUploadsToggle …> (the </div> around line 1545):
<ReviewDocsPicker programId={programId} roundId={roundId} />
- Step 3: Typecheck
Run: npm run typecheck
Expected: clean.
- Step 4: Commit
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)
- 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.
- Open
/admin/finals-documents→ the unticked document type disappears from every team; any Grand Final uploads remain. - Flip the curation switch off → all documents reappear.
- With
allowFinalistRevisedUploadsON 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
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/allUploadedused consistently across tasks.