From 97d7f9b625f2d1aa28e05c21ee09f93bddf6751a Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 7 Apr 2026 19:36:20 -0400 Subject: [PATCH] docs: add implementation plan for award UX fixes and bulk invite Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-07-award-ux-fixes.md | 747 ++++++++++++++++++ 1 file changed, 747 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-award-ux-fixes.md diff --git a/docs/superpowers/plans/2026-04-07-award-ux-fixes.md b/docs/superpowers/plans/2026-04-07-award-ux-fixes.md new file mode 100644 index 0000000..b358f2c --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-award-ux-fixes.md @@ -0,0 +1,747 @@ +# Award UX Fixes & Bulk Invite — 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:** Fix 5 issues: upload auto-retry for flaky connections, bulk invite for award jurors, jury group selector UX, decision mode setting on award edit page, and award juror role filter. + +**Architecture:** These are 5 independent fixes touching different files. No schema changes needed — all are UI/UX and router-level changes. The bulk invite is the largest task, requiring a new reusable component, a new tRPC procedure, and integration into the award juror tab. + +**Tech Stack:** Next.js 15 App Router, tRPC 11, Prisma 6, shadcn/ui, Tailwind CSS 4 + +--- + +## File Map + +### New Files +| File | Purpose | +|------|---------| +| `src/components/shared/bulk-invite-form.tsx` | Reusable multi-row name+email invite form component | + +### Modified Files +| File | Change | +|------|--------| +| `src/components/shared/requirement-upload-slot.tsx` | Add XHR retry logic (3 attempts, exponential backoff) | +| `src/app/(admin)/admin/awards/[id]/page.tsx` | Integrate bulk invite form, widen role filter, add chair toggle | +| `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Move jury group selector above empty-state message | +| `src/app/(admin)/admin/awards/[id]/edit/page.tsx` | Add Decision Mode dropdown | +| `src/server/routers/specialAward.ts` | Add `bulkInviteJurors` procedure | + +--- + +## Task 1: Upload Auto-Retry + +**Files:** +- Modify: `src/components/shared/requirement-upload-slot.tsx:169-188` + +- [ ] **Step 1: Replace the single-attempt XHR upload with a retry wrapper** + +In `src/components/shared/requirement-upload-slot.tsx`, find the XHR upload block (lines 169-188). Replace it with a retry loop. The key change is wrapping the XHR Promise in a function and calling it up to 3 times with exponential backoff. + +Find this code: + +```tsx + // Upload file with progress tracking + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + setProgress(Math.round((event.loaded / event.total) * 100)) + } + }) + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)) + } + }) + xhr.addEventListener('error', () => reject(new Error('Upload failed'))) + xhr.open('PUT', url) + xhr.setRequestHeader('Content-Type', file.type) + xhr.send(file) + }) +``` + +Replace with: + +```tsx + // Upload file with progress tracking and auto-retry + const maxRetries = 3 + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + setProgress(Math.round((event.loaded / event.total) * 100)) + } + }) + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)) + } + }) + xhr.addEventListener('error', () => + reject(new Error('Network error during upload')) + ) + xhr.addEventListener('abort', () => + reject(new Error('Upload was aborted')) + ) + xhr.open('PUT', url) + xhr.setRequestHeader('Content-Type', file.type) + xhr.send(file) + }) + break // Success — exit retry loop + } catch (uploadErr) { + if (attempt < maxRetries) { + const delay = attempt * 2000 // 2s, 4s + toast.info(`Upload interrupted, retrying... (${attempt}/${maxRetries})`) + setProgress(0) + await new Promise((r) => setTimeout(r, delay)) + } else { + throw uploadErr // Final attempt failed — propagate to outer catch + } + } + } +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'requirement-upload'" +``` + +Expected: No errors for this file. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/shared/requirement-upload-slot.tsx +git commit -m "feat: add auto-retry (3 attempts) for file uploads on flaky connections + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 2: Bulk Invite Form Component + +**Files:** +- Create: `src/components/shared/bulk-invite-form.tsx` + +- [ ] **Step 1: Create the reusable bulk invite component** + +Create `src/components/shared/bulk-invite-form.tsx`. This is a multi-row form where each row has Name + Email fields, with an "Add Row" button and a "Send Invites" button. + +```tsx +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { Plus, Trash2, Send, Loader2 } from 'lucide-react' + +interface InviteRow { + name: string + email: string +} + +interface BulkInviteFormProps { + onSubmit: (rows: InviteRow[]) => Promise + isPending?: boolean + /** Label for the submit button */ + submitLabel?: string +} + +const emptyRow = (): InviteRow => ({ name: '', email: '' }) + +export function BulkInviteForm({ + onSubmit, + isPending = false, + submitLabel = 'Send Invites', +}: BulkInviteFormProps) { + const [rows, setRows] = useState([emptyRow()]) + + const updateRow = (index: number, field: keyof InviteRow, value: string) => { + setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))) + } + + const addRow = () => setRows((prev) => [...prev, emptyRow()]) + + const removeRow = (index: number) => { + if (rows.length <= 1) return + setRows((prev) => prev.filter((_, i) => i !== index)) + } + + const validRows = rows.filter( + (r) => r.email.trim() && r.email.includes('@') + ) + + const handleSubmit = async () => { + if (validRows.length === 0) { + toast.error('Please enter at least one valid email address') + return + } + + // Check for duplicate emails + const emails = validRows.map((r) => r.email.trim().toLowerCase()) + const dupes = emails.filter((e, i) => emails.indexOf(e) !== i) + if (dupes.length > 0) { + toast.error(`Duplicate email: ${dupes[0]}`) + return + } + + await onSubmit(validRows.map((r) => ({ name: r.name.trim(), email: r.email.trim() }))) + setRows([emptyRow()]) + } + + return ( +
+
+ {rows.map((row, index) => ( +
+
+ {index === 0 && ( + + )} + updateRow(index, 'name', e.target.value)} + disabled={isPending} + /> +
+
+ {index === 0 && ( + + )} + updateRow(index, 'email', e.target.value)} + disabled={isPending} + /> +
+ +
+ ))} +
+ +
+ + + +
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/shared/bulk-invite-form.tsx +git commit -m "feat: add reusable BulkInviteForm component for multi-row name+email invites + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 3: Backend — Bulk Invite Jurors Procedure + +**Files:** +- Modify: `src/server/routers/specialAward.ts` — add `bulkInviteJurors` procedure + +This procedure accepts an array of `{ name, email }`, creates user accounts (or finds existing ones), assigns them the specified role, adds them as `AwardJuror`, and sends invite emails. + +- [ ] **Step 1: Add the `bulkInviteJurors` procedure** + +In `src/server/routers/specialAward.ts`, add the following imports near the top (after existing imports): + +```typescript +import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' +import { sendJuryInvitationEmail } from '@/lib/email' +``` + +Check if these are already imported — `generateInviteToken` and `getInviteExpiryMs` are already imported (line 9). `sendJuryInvitationEmail` may not be — add it if missing. + +Then add the procedure after the existing `bulkAddJurors` procedure (after ~line 576): + +```typescript + /** + * Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails + */ + bulkInviteJurors: adminProcedure + .input( + z.object({ + awardId: z.string(), + role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'), + invitees: z.array( + z.object({ + name: z.string().optional(), + email: z.string().email(), + }) + ).min(1).max(50), + }) + ) + .mutation(async ({ ctx, input }) => { + const award = await ctx.prisma.specialAward.findUniqueOrThrow({ + where: { id: input.awardId }, + select: { id: true, name: true }, + }) + + const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = [] + + for (const invitee of input.invitees) { + try { + // Check if user already exists + let user = await ctx.prisma.user.findUnique({ + where: { email: invitee.email }, + select: { id: true, status: true, role: true }, + }) + + if (!user) { + // Create new user with invite token + const inviteToken = generateInviteToken() + const expiryMs = await getInviteExpiryMs(ctx.prisma) + + user = await ctx.prisma.user.create({ + data: { + email: invitee.email, + name: invitee.name || null, + role: input.role, + status: 'INVITED', + inviteToken, + inviteTokenExpiresAt: new Date(Date.now() + expiryMs), + }, + select: { id: true, status: true, role: true }, + }) + + // Send invite email + const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}` + try { + await sendJuryInvitationEmail( + invitee.email, + invitee.name || null, + inviteUrl, + award.name + ) + } catch { + // Email failure shouldn't block the invite + } + + results.push({ email: invitee.email, status: 'created' }) + } else { + results.push({ email: invitee.email, status: 'existing' }) + } + + // Add as award juror (skip if already added) + await ctx.prisma.awardJuror.upsert({ + where: { + awardId_userId: { awardId: input.awardId, userId: user.id }, + }, + update: {}, + create: { awardId: input.awardId, userId: user.id }, + }) + } catch (err) { + results.push({ + email: invitee.email, + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error', + }) + } + } + + await logAudit({ + userId: ctx.user.id, + action: 'CREATE', + entityType: 'AwardJuror', + entityId: input.awardId, + detailsJson: { + action: 'BULK_INVITE', + awardName: award.name, + role: input.role, + count: input.invitees.length, + results, + }, + }) + + return { + created: results.filter((r) => r.status === 'created').length, + existing: results.filter((r) => r.status === 'existing').length, + errors: results.filter((r) => r.status === 'error').length, + results, + } + }), +``` + +- [ ] **Step 2: Check that `sendJuryInvitationEmail` is importable** + +Verify the import exists in `src/lib/email.ts`: + +```bash +grep -n "export.*sendJuryInvitationEmail" src/lib/email.ts +``` + +Expected: Shows the exported function. If it doesn't exist, check for the actual name and adjust the import. + +- [ ] **Step 3: Verify it compiles** + +```bash +powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'specialAward'" +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/server/routers/specialAward.ts +git commit -m "feat: add bulkInviteJurors procedure — creates accounts, assigns role, sends invites + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 4: Integrate Bulk Invite into Award Juror Tab + Widen Role Filter + +**Files:** +- Modify: `src/app/(admin)/admin/awards/[id]/page.tsx` + +Three changes in this file: +1. Widen the `allUsers` query role filter to include `AWARD_MASTER` +2. Add a collapsible "Invite New Jurors" section with the `BulkInviteForm` +3. Wire up the `bulkInviteJurors` mutation + +- [ ] **Step 1: Widen the role filter** + +At line 405-408, change: + +```tsx +const { data: allUsers } = trpc.user.list.useQuery( + { role: 'JURY_MEMBER', page: 1, perPage: 100 }, + { enabled: activeTab === 'jurors' } +) +``` + +to: + +```tsx +const { data: allUsers } = trpc.user.list.useQuery( + { page: 1, perPage: 200 }, + { enabled: activeTab === 'jurors' } +) +``` + +This removes the role filter entirely — `AwardJuror` is role-agnostic, so any user should be selectable. Increase `perPage` to 200 to cover more users. + +- [ ] **Step 2: Add the bulk invite mutation** + +Near the other mutations (after `removeJuror` at ~line 479), add: + +```tsx +const bulkInvite = trpc.specialAward.bulkInviteJurors.useMutation({ + onSuccess: (data) => { + utils.specialAward.listJurors.invalidate({ awardId }) + toast.success(`${data.created} invited, ${data.existing} already existed${data.errors > 0 ? `, ${data.errors} failed` : ''}`) + }, + onError: (err) => toast.error(err.message), +}) +``` + +- [ ] **Step 3: Add the BulkInviteForm to the juror tab** + +Add the import at the top of the file: + +```tsx +import { BulkInviteForm } from '@/components/shared/bulk-invite-form' +``` + +In the jurors tab (after the existing "Add Juror" button section, around line 1329), add a separator and the bulk invite form: + +```tsx + +
+

Invite New Jurors by Email

+

+ Invite new users who don't have accounts yet. They'll receive an invitation email and be added as jurors for this award. +

+ { + await bulkInvite.mutateAsync({ + awardId, + role: 'AWARD_MASTER', + invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })), + }) + }} + isPending={bulkInvite.isPending} + submitLabel="Invite & Add as Jurors" + /> +
+``` + +Make sure `Separator` is imported from `@/components/ui/separator` (check existing imports). + +- [ ] **Step 4: Verify it compiles** + +```bash +powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'awards/\[id\]'" +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(admin)/admin/awards/[id]/page.tsx +git commit -m "feat: add bulk invite form to award juror tab, widen role filter to all users + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 5: Move Jury Group Selector to Top of Assignments Tab + +**Files:** +- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx:1787-1793, 1987-2060` + +- [ ] **Step 1: Replace the empty-state placeholder with the jury group selector** + +In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`, find the "Select a jury group below" placeholder (lines 1787-1793): + +```tsx + {!round?.juryGroupId ? ( + + +

Select a jury group below to get started.

+
+
+ ) : ( +``` + +Replace the Card block inside the `!round?.juryGroupId` branch with the actual jury group selector (same UI that's at the bottom). Change it to: + +```tsx + {!round?.juryGroupId ? ( + + +
+
+ Jury Group + + Select or create a jury group to get started + +
+ +
+
+ + {juryGroups && juryGroups.length > 0 ? ( + + ) : ( +

+ No jury groups exist yet. Create one to get started. +

+ )} +
+
+ ) : ( +``` + +This replaces the unhelpful "Select a jury group below" message with the actual selector, right at the top where the user expects it. + +- [ ] **Step 2: Verify it compiles** + +```bash +powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'rounds/\[roundId\]'" +``` + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx" +git commit -m "fix: show jury group selector at top of assignments tab when none assigned + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 6: Add Decision Mode Dropdown to Award Edit Page + +**Files:** +- Modify: `src/app/(admin)/admin/awards/[id]/edit/page.tsx` +- Modify: `src/server/routers/specialAward.ts` (update mutation input to accept `decisionMode`) + +- [ ] **Step 1: Verify the `update` procedure accepts `decisionMode`** + +Check if the `specialAward.update` procedure already accepts `decisionMode` in its input schema. Search for `decisionMode` in the update input: + +```bash +grep -A5 "decisionMode" src/server/routers/specialAward.ts | head -20 +``` + +If it's NOT in the update input, add it. In the `update` procedure's `.input()` schema (around line 200-220), add: + +```typescript +decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(), +``` + +And in the mutation data (the `ctx.prisma.specialAward.update` call), include: + +```typescript +decisionMode: input.decisionMode, +``` + +- [ ] **Step 2: Add state and initialization for decisionMode on the edit page** + +In `src/app/(admin)/admin/awards/[id]/edit/page.tsx`, near the other state declarations (around line 54), add: + +```tsx +const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE') +``` + +In the `useEffect` or initialization block where award data populates the form (around line 76), add: + +```tsx +setDecisionMode((award.decisionMode as typeof decisionMode) || 'JURY_VOTE') +``` + +In the submit handler (around line 95), include `decisionMode` in the mutation call. + +- [ ] **Step 3: Add the Decision Mode dropdown to the form** + +In the grid that contains the Scoring Mode dropdown (around line 199, the `sm:grid-cols-2` div), add a new cell after the Scoring Mode ``: + +```tsx +
+ + +
+``` + +- [ ] **Step 4: Verify it compiles** + +```bash +powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'edit/page'" +``` + +- [ ] **Step 5: Commit** + +```bash +git add "src/app/(admin)/admin/awards/[id]/edit/page.tsx" src/server/routers/specialAward.ts +git commit -m "feat: add Decision Mode dropdown to award edit page + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 7: Verification + +- [ ] **Step 1: Run typecheck** + +```bash +powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit" +``` + +- [ ] **Step 2: Run tests** + +```bash +npx vitest run +``` + +- [ ] **Step 3: Run build** + +```bash +npm run build +``` + +- [ ] **Step 4: Manual smoke test checklist** + +1. **Upload retry:** Find a large file upload, verify progress shows. Simulate by disconnecting network briefly during upload — should see "retrying" toast, then succeed. +2. **Bulk invite:** Go to Awards → [award] → Jurors tab. See the "Invite New Jurors by Email" section. Add 2 rows with name+email, click "Invite & Add as Jurors". Verify users created and added as jurors. +3. **Jury group selector:** Go to Rounds → any evaluation round with no jury assigned. The jury group dropdown should appear at the top, not buried at the bottom. +4. **Decision Mode:** Go to Awards → [award] → Edit. See the Decision Mode dropdown. Select "Award Master" and save. Verify it persists on reload. +5. **Role filter:** On the award juror tab, the "Select a juror" dropdown should show all users, not just JURY_MEMBER. + +--- + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Retry with exponential backoff (2s, 4s) | Gives time for transient network issues to resolve without overwhelming the server | +| `BulkInviteForm` as shared component | Reusable for jury group invites later, not award-specific | +| `bulkInviteJurors` creates accounts + adds jurors in one call | Avoids requiring admin to do two separate steps (create user, then add juror) | +| `upsert` for AwardJuror in bulk invite | Safe for re-inviting — won't error if user is already a juror | +| Remove role filter entirely from juror dropdown | `AwardJuror` is role-agnostic; any user should be selectable. The old JURY_MEMBER filter was too restrictive. | +| Inline jury group selector when empty | Users couldn't find it at the bottom; showing it where the empty state message is matches user expectation |