fix: backfill binaryDecision, fix boolean criterion lookup, add assign buttons
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m9s

- Backfilled 166 evaluations' binaryDecision from criterionScoresJson on production DB
- Fixed roundEvaluationScores and ai-ranking to look in EvaluationForm.criteriaJson
  instead of round.configJson for the boolean "Move to the Next Stage?" criterion
- Added advanceMode (count/threshold) toggle to round config Advancement Targets
- Added "Assign to Jurors" button on Unassigned Projects section and Projects tab bulk bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 12:48:08 +01:00
parent 19b58e4434
commit 2df9c54de2
6 changed files with 142 additions and 70 deletions

View File

@@ -1434,7 +1434,10 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4"> <TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} /> <ProjectStatesTable competitionId={competitionId} roundId={roundId} onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}} />
</TabsContent> </TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */} {/* ═══════════ FILTERING TAB ═══════════ */}
@@ -1797,7 +1800,7 @@ export default function RoundDetailPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<COIReviewSection roundId={roundId} /> <COIReviewSection roundId={roundId} />
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} /> <RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} onAssignUnassigned={() => setPreviewSheetOpen(true)} />
</CardContent> </CardContent>
</Card> </Card>
@@ -2157,50 +2160,103 @@ export default function RoundDetailPage() {
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6"> <div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label> <Label className="text-sm font-medium">Advancement Targets</Label>
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
<div className="mt-2 mb-1 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">Advancement targets not configured all passed projects will be eligible to advance.</p>
</div>
)}
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round How to determine which projects advance from this round
</p> </p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> {/* Mode toggle */}
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground"> <div className="flex gap-2 mb-4">
Startup Projects <Button
</Label> type="button"
<Input size="sm"
id="startup-advance-count" variant={(config.advanceMode as string) === 'threshold' ? 'outline' : 'default'}
type="number" className="h-8 text-xs"
min={0} onClick={() => handleConfigChange({ ...config, advanceMode: 'count' })}
className="h-9" >
placeholder="No limit" Fixed Count
value={(config.startupAdvanceCount as number) ?? ''} </Button>
onChange={(e) => { <Button
const val = e.target.value ? parseInt(e.target.value, 10) : undefined type="button"
handleConfigChange({ ...config, startupAdvanceCount: val }) size="sm"
}} variant={(config.advanceMode as string) === 'threshold' ? 'default' : 'outline'}
/> className="h-8 text-xs"
</div> onClick={() => handleConfigChange({ ...config, advanceMode: 'threshold' })}
<div className="space-y-1.5"> >
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground"> Score Threshold
Concept Projects </Button>
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div> </div>
{(config.advanceMode as string) === 'threshold' ? (
<div className="space-y-2">
<Label htmlFor="advance-threshold" className="text-xs text-muted-foreground">
Minimum Average Score to Advance
</Label>
<p className="text-xs text-muted-foreground">
All projects scoring at or above this threshold will advance (both categories)
</p>
<Input
id="advance-threshold"
type="number"
min={0}
max={10}
step={0.1}
className="h-9 w-32"
placeholder="e.g. 6.5"
value={(config.advanceScoreThreshold as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseFloat(e.target.value) : undefined
handleConfigChange({ ...config, advanceScoreThreshold: val })
}}
/>
</div>
) : (
<>
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
<div className="mb-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">Advancement targets not configured all passed projects will be eligible to advance.</p>
</div>
)}
<p className="text-xs text-muted-foreground mb-2">
Target number of projects per category to advance
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
Startup Projects
</Label>
<Input
id="startup-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.startupAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, startupAdvanceCount: val })
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
Concept Projects
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,16 +1,19 @@
'use client' 'use client'
import { Users } from 'lucide-react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
export type RoundUnassignedQueueProps = { export type RoundUnassignedQueueProps = {
roundId: string roundId: string
requiredReviews?: number requiredReviews?: number
onAssignUnassigned?: () => void
} }
export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) { export function RoundUnassignedQueue({ roundId, requiredReviews = 3, onAssignUnassigned }: RoundUnassignedQueueProps) {
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery( const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId, requiredReviews }, { roundId, requiredReviews },
{ refetchInterval: 15_000 }, { refetchInterval: 15_000 },
@@ -18,9 +21,17 @@ export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnas
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div className="flex items-center justify-between">
<p className="text-sm font-medium">Unassigned Projects</p> <div>
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p> <p className="text-sm font-medium">Unassigned Projects</p>
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
</div>
{unassigned && unassigned.length > 0 && onAssignUnassigned && (
<Button size="sm" variant="outline" onClick={onAssignUnassigned}>
<Users className="h-3.5 w-3.5 mr-1.5" />
Assign to Jurors
</Button>
)}
</div> </div>
<div> <div>
{isLoading ? ( {isLoading ? (

View File

@@ -59,6 +59,7 @@ import {
Search, Search,
ExternalLink, ExternalLink,
Sparkles, Sparkles,
Users,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@@ -78,9 +79,10 @@ const stateConfig: Record<ProjectState, { label: string; color: string; icon: Re
type ProjectStatesTableProps = { type ProjectStatesTableProps = {
competitionId: string competitionId: string
roundId: string roundId: string
onAssignProjects?: (projectIds: string[]) => void
} }
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) { export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }: ProjectStatesTableProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [stateFilter, setStateFilter] = useState<string>('ALL') const [stateFilter, setStateFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -321,6 +323,16 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
<ArrowRight className="h-3.5 w-3.5 mr-1.5" /> <ArrowRight className="h-3.5 w-3.5 mr-1.5" />
Change State Change State
</Button> </Button>
{onAssignProjects && (
<Button
variant="outline"
size="sm"
onClick={() => onAssignProjects(Array.from(selectedIds))}
>
<Users className="h-3.5 w-3.5 mr-1.5" />
Assign to Jurors
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -408,18 +408,15 @@ export const rankingRouter = router({
roundEvaluationScores: adminProcedure roundEvaluationScores: adminProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Fetch the round config to find the boolean criterion ID (legacy fallback) // Find the boolean criterion ID from the EvaluationForm (not round configJson)
const round = await ctx.prisma.round.findUniqueOrThrow({ const evalForm = await ctx.prisma.evaluationForm.findFirst({
where: { id: input.roundId }, where: { roundId: input.roundId, isActive: true },
select: { configJson: true }, select: { criteriaJson: true },
}) })
const roundConfig = round.configJson as Record<string, unknown> | null const formCriteria = (evalForm?.criteriaJson as Array<{
const criteria = (roundConfig?.criteria ?? roundConfig?.evaluationCriteria ?? []) as Array<{ id: string; label: string; type?: string
id: string }> | null) ?? []
label: string const boolCriterionId = formCriteria.find(
type?: string
}>
const boolCriterionId = criteria.find(
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'), (c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
)?.id ?? null )?.id ?? null

View File

@@ -247,17 +247,11 @@ function anonymizeProjectsForRanking(
} }
/** /**
* Find the boolean criterion ID for "Move to the Next Stage?" from round config. * Find the boolean criterion ID for "Move to the Next Stage?" from criteria definitions.
* Returns null if no such criterion exists. * Accepts criteria from EvaluationForm.criteriaJson (preferred) or round configJson (legacy).
*/ */
function findBooleanCriterionId(roundConfig: Record<string, unknown> | null): string | null { function findBooleanCriterionId(criterionDefs: Array<{ id: string; label: string; type?: string }>): string | null {
if (!roundConfig) return null const boolCriterion = criterionDefs.find(
const criteria = (roundConfig.criteria ?? roundConfig.evaluationCriteria ?? []) as Array<{
id: string
label: string
type?: string
}>
const boolCriterion = criteria.find(
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'), (c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
) )
return boolCriterion?.id ?? null return boolCriterion?.id ?? null
@@ -593,7 +587,6 @@ export async function fetchAndRankCategory(
]) ])
const roundConfig = round.configJson as Record<string, unknown> | null const roundConfig = round.configJson as Record<string, unknown> | null
const boolCriterionId = findBooleanCriterionId(roundConfig)
// Parse evaluation config for criteria weights // Parse evaluation config for criteria weights
const evalConfig = roundConfig as EvaluationConfig | null const evalConfig = roundConfig as EvaluationConfig | null
@@ -603,6 +596,7 @@ export async function fetchAndRankCategory(
const criterionDefs: CriterionDef[] = evalForm?.criteriaJson const criterionDefs: CriterionDef[] = evalForm?.criteriaJson
? (evalForm.criteriaJson as unknown as CriterionDef[]) ? (evalForm.criteriaJson as unknown as CriterionDef[])
: [] : []
const boolCriterionId = findBooleanCriterionId(criterionDefs)
const numericCriterionIds = new Set( const numericCriterionIds = new Set(
criterionDefs.filter((d) => !d.type || d.type === 'numeric').map((d) => d.id), criterionDefs.filter((d) => !d.type || d.type === 'numeric').map((d) => d.id),
) )

View File

@@ -12,6 +12,8 @@ import type { RoundType, AwardEligibilityMode, AwardScoringMode, AwardStatus } f
const generalSettingsFields = { const generalSettingsFields = {
startupAdvanceCount: z.number().int().nonnegative().optional(), startupAdvanceCount: z.number().int().nonnegative().optional(),
conceptAdvanceCount: z.number().int().nonnegative().optional(), conceptAdvanceCount: z.number().int().nonnegative().optional(),
advanceMode: z.enum(['count', 'threshold']).default('count'),
advanceScoreThreshold: z.number().min(0).max(10).optional(),
notifyOnEntry: z.boolean().default(false), notifyOnEntry: z.boolean().default(false),
notifyOnAdvance: z.boolean().default(false), notifyOnAdvance: z.boolean().default(false),
} }