2026-02-27 09:41:59 +01:00
'use client'
2026-03-01 14:34:32 +01:00
import React , { useState , useEffect , useRef , useMemo } from 'react'
2026-02-27 09:48:06 +01:00
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
DndContext ,
closestCenter ,
KeyboardSensor ,
PointerSensor ,
useSensor ,
useSensors ,
type DragEndEvent ,
} from '@dnd-kit/core'
import {
arrayMove ,
SortableContext ,
sortableKeyboardCoordinates ,
useSortable ,
verticalListSortingStrategy ,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { AnimatePresence , motion } from 'motion/react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card , CardContent , CardHeader , CardTitle , CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Sheet ,
SheetContent ,
SheetHeader ,
SheetTitle ,
SheetDescription ,
} from '@/components/ui/sheet'
2026-02-27 09:53:49 +01:00
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import {
Collapsible ,
CollapsibleContent ,
CollapsibleTrigger ,
} from '@/components/ui/collapsible'
2026-02-27 09:48:06 +01:00
import {
GripVertical ,
BarChart3 ,
2026-03-02 20:24:17 +01:00
Calculator ,
2026-02-27 09:48:06 +01:00
Loader2 ,
RefreshCw ,
2026-03-02 20:24:17 +01:00
Sparkles ,
2026-03-01 14:34:32 +01:00
ExternalLink ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
ChevronDown ,
Settings2 ,
Download ,
2026-02-27 09:48:06 +01:00
} from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
feat: multi-role jury fix, country flags, applicant deadline banner, timeline
- Fix project list returning empty for users with both SUPER_ADMIN and
JURY_MEMBER roles (jury filter now skips admins) in project, assignment,
and evaluation routers
- Add CountryDisplay component showing flag emoji + name everywhere
country is displayed (admin, observer, jury, mentor views — 17 files)
- Add countdown deadline banner on applicant dashboard for INTAKE,
SUBMISSION, and MENTORING rounds with live timer
- Remove quick action buttons from applicant dashboard
- Fix competition timeline sidebar: green dots/connectors only up to
current round, yellow dot for current round, red connector into
rejected round, grey after
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:00:29 +01:00
import { CountryDisplay } from '@/components/shared/country-display'
2026-02-27 09:48:06 +01:00
// ─── Types ────────────────────────────────────────────────────────────────────
2026-02-27 09:41:59 +01:00
type RankingDashboardProps = {
competitionId : string
roundId : string
}
2026-02-27 11:08:30 +01:00
type ProjectInfo = {
title : string
teamName : string | null
country : string | null
}
2026-03-02 10:46:52 +01:00
type JurorScore = {
jurorName : string
globalScore : number | null
decision : boolean | null
}
2026-02-27 09:48:06 +01:00
type SortableProjectRowProps = {
projectId : string
currentRank : number
2026-03-02 13:29:40 +01:00
entry : ( RankedProjectEntry & { originalIndex? : number } ) | undefined
2026-02-27 11:08:30 +01:00
projectInfo : ProjectInfo | undefined
2026-03-02 10:46:52 +01:00
jurorScores : JurorScore [ ] | undefined
2026-04-24 16:19:00 +02:00
balancedScore : number | null
2026-02-27 09:48:06 +01:00
onSelect : ( ) = > void
isSelected : boolean
2026-03-02 14:10:48 +01:00
originalRank : number | undefined // from snapshotOrder — always in sync with localOrder
2026-02-27 09:48:06 +01:00
}
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
function SortableProjectRow ( {
projectId ,
currentRank ,
entry ,
2026-02-27 11:08:30 +01:00
projectInfo ,
2026-03-02 10:46:52 +01:00
jurorScores ,
2026-04-24 16:19:00 +02:00
balancedScore ,
2026-02-27 09:48:06 +01:00
onSelect ,
isSelected ,
2026-03-02 14:10:48 +01:00
originalRank ,
2026-02-27 09:48:06 +01:00
} : SortableProjectRowProps ) {
const {
attributes ,
listeners ,
setNodeRef ,
transform ,
transition ,
isDragging ,
} = useSortable ( { id : projectId } )
const style = {
transform : CSS.Transform.toString ( transform ) ,
transition ,
}
2026-03-02 14:10:48 +01:00
// isOverridden: admin drag-reordered this project from its original snapshot position.
// Uses snapshotOrder (set in same effect as localOrder) so they are always in sync.
const isOverridden = originalRank !== undefined && currentRank !== originalRank
2026-02-27 09:48:06 +01:00
2026-03-02 10:46:52 +01:00
// Compute yes count from juror scores
const yesCount = jurorScores ? . filter ( ( j ) = > j . decision === true ) . length ? ? 0
const totalJurors = jurorScores ? . length ? ? entry ? . evaluatorCount ? ? 0
2026-02-27 09:41:59 +01:00
return (
2026-02-27 09:48:06 +01:00
< div
ref = { setNodeRef }
style = { style }
onClick = { onSelect }
className = { cn (
'flex items-center gap-3 rounded-lg border bg-card p-3 cursor-pointer transition-all hover:shadow-sm' ,
isDragging && 'opacity-50 shadow-lg ring-2 ring-[#de0f1e]/30' ,
isSelected && 'ring-2 ring-[#de0f1e]' ,
) }
>
{ /* Drag handle */ }
< button
className = "cursor-grab touch-none text-muted-foreground hover:text-foreground flex-shrink-0"
onClick = { ( e ) = > e . stopPropagation ( ) }
{ . . . attributes }
{ . . . listeners }
>
< GripVertical className = "h-4 w-4" / >
< / button >
{ /* Rank badge */ }
{ isOverridden ? (
< Badge className = "flex-shrink-0 bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 text-xs font-semibold" >
# { currentRank } ( override )
< / Badge >
) : (
< Badge
className = "flex-shrink-0 text-xs font-semibold"
style = { { backgroundColor : '#053d57' , color : '#fefefe' } }
>
# { currentRank }
< / Badge >
) }
2026-02-27 11:08:30 +01:00
{ /* Project info */ }
2026-02-27 09:48:06 +01:00
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium truncate" >
2026-02-27 11:08:30 +01:00
{ projectInfo ? . title ? ? ` Project … ${ projectId . slice ( - 6 ) } ` }
2026-02-27 09:48:06 +01:00
< / p >
2026-02-27 11:08:30 +01:00
{ projectInfo ? . teamName && (
< p className = "text-xs text-muted-foreground truncate" >
{ projectInfo . teamName }
feat: multi-role jury fix, country flags, applicant deadline banner, timeline
- Fix project list returning empty for users with both SUPER_ADMIN and
JURY_MEMBER roles (jury filter now skips admins) in project, assignment,
and evaluation routers
- Add CountryDisplay component showing flag emoji + name everywhere
country is displayed (admin, observer, jury, mentor views — 17 files)
- Add countdown deadline banner on applicant dashboard for INTAKE,
SUBMISSION, and MENTORING rounds with live timer
- Remove quick action buttons from applicant dashboard
- Fix competition timeline sidebar: green dots/connectors only up to
current round, yellow dot for current round, red connector into
rejected round, grey after
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:00:29 +01:00
{ projectInfo . country ? < > · < CountryDisplay country = { projectInfo . country } / > < / > : '' }
2026-02-27 11:08:30 +01:00
< / p >
) }
2026-02-27 09:48:06 +01:00
< / div >
2026-03-02 10:46:52 +01:00
{ /* Juror scores + advance decision */ }
< div className = "flex items-center gap-3 flex-shrink-0" >
{ /* Individual juror score pills */ }
{ jurorScores && jurorScores . length > 0 ? (
< div className = "flex items-center gap-1" title = { jurorScores . map ( ( j ) = > ` ${ j . jurorName } : ${ j . globalScore ? ? '—' } /10 ` ) . join ( '\n' ) } >
{ jurorScores . map ( ( j , i ) = > (
< span
key = { i }
className = { cn (
'inline-flex items-center justify-center rounded-md px-1.5 py-0.5 text-xs font-medium border' ,
j . globalScore != null && j . globalScore >= 8
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: j . globalScore != null && j . globalScore >= 6
? 'bg-blue-50 text-blue-700 border-blue-200'
: j . globalScore != null && j . globalScore >= 4
? 'bg-amber-50 text-amber-700 border-amber-200'
: 'bg-red-50 text-red-700 border-red-200' ,
) }
title = { ` ${ j . jurorName } : ${ j . globalScore ? ? '—' } /10 ` }
>
{ j . globalScore ? ? '—' }
< / span >
) ) }
< / div >
) : entry ? . avgGlobalScore !== null && entry ? . avgGlobalScore !== undefined ? (
< span className = "text-xs text-muted-foreground" >
Avg { entry . avgGlobalScore . toFixed ( 1 ) }
2026-02-27 09:48:06 +01:00
< / span >
2026-03-02 10:46:52 +01:00
) : null }
2026-04-24 16:19:00 +02:00
{ /* Raw + balanced averages shown side by side */ }
2026-03-02 10:46:52 +01:00
{ entry ? . avgGlobalScore !== null && entry ? . avgGlobalScore !== undefined && jurorScores && jurorScores . length > 1 && (
2026-04-24 16:19:00 +02:00
< div className = "flex items-center gap-1.5 text-xs" title = "Raw juror average vs. juror-balanced average (z-score normalized per juror, rescaled to 1-10)" >
< span className = "font-medium text-muted-foreground" >
{ entry . avgGlobalScore . toFixed ( 1 ) }
< / span >
{ balancedScore != null && Math . abs ( balancedScore - entry . avgGlobalScore ) >= 0.05 && (
< span
className = { cn (
'font-semibold tabular-nums rounded px-1.5 py-0.5 border' ,
balancedScore > entry . avgGlobalScore
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-amber-50 text-amber-700 border-amber-200' ,
) }
>
⇢ { balancedScore . toFixed ( 1 ) }
< / span >
) }
< / div >
2026-03-02 10:46:52 +01:00
) }
{ /* Advance decision indicator */ }
< div className = { cn (
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium' ,
2026-03-02 23:31:13 +01:00
totalJurors === 0
? 'bg-gray-100 text-gray-500'
: yesCount === totalJurors
? 'bg-emerald-100 text-emerald-700'
: yesCount > 0
? 'bg-amber-100 text-amber-700'
: 'bg-red-100 text-red-600' ,
2026-03-02 10:46:52 +01:00
) } >
2026-03-02 23:31:13 +01:00
{ totalJurors > 0 ? (
2026-03-02 10:46:52 +01:00
< > { yesCount } / { totalJurors } Yes < / >
) : (
2026-03-02 23:31:13 +01:00
< > 0 jurors < / >
2026-03-02 10:46:52 +01:00
) }
2026-02-27 09:48:06 +01:00
< / div >
2026-03-02 10:46:52 +01:00
< / div >
2026-02-27 09:41:59 +01:00
< / div >
)
}
2026-02-27 09:48:06 +01:00
// ─── Main component ────────────────────────────────────────────────────────────
export function RankingDashboard ( { competitionId : _competitionId , roundId } : RankingDashboardProps ) {
// ─── State ────────────────────────────────────────────────────────────────
const [ selectedProjectId , setSelectedProjectId ] = useState < string | null > ( null )
const [ localOrder , setLocalOrder ] = useState < Record < 'STARTUP' | 'BUSINESS_CONCEPT' , string [ ] > > ( {
STARTUP : [ ] ,
BUSINESS_CONCEPT : [ ] ,
} )
2026-03-02 14:10:48 +01:00
// Track the original snapshot order (projectId → 1-based rank) for override detection.
// Updated in the same effect as localOrder so they are always in sync.
const [ snapshotOrder , setSnapshotOrder ] = useState < Record < string , number > > ( { } )
2026-02-27 09:48:06 +01:00
const initialized = useRef ( false )
2026-02-27 09:53:49 +01:00
const pendingReorderCount = useRef ( 0 )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
// ─── Export state ──────────────────────────────────────────────────────────
const [ exportLoading , setExportLoading ] = useState ( false )
2026-03-01 14:34:32 +01:00
// ─── Expandable review state ──────────────────────────────────────────────
const [ expandedReviews , setExpandedReviews ] = useState < Set < string > > ( new Set ( ) )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
// ─── Criteria weights state ────────────────────────────────────────────────
const [ weightsOpen , setWeightsOpen ] = useState ( false )
const [ localWeights , setLocalWeights ] = useState < Record < string , number > > ( { } )
const [ localCriteriaText , setLocalCriteriaText ] = useState < string > ( '' )
2026-03-02 20:24:17 +01:00
const [ localScoreWeight , setLocalScoreWeight ] = useState ( 5 )
const [ localPassRateWeight , setLocalPassRateWeight ] = useState ( 5 )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
const weightsInitialized = useRef ( false )
2026-02-27 09:48:06 +01:00
// ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors (
useSensor ( PointerSensor ) ,
useSensor ( KeyboardSensor , { coordinateGetter : sortableKeyboardCoordinates } ) ,
)
// ─── tRPC queries ─────────────────────────────────────────────────────────
const { data : snapshots , isLoading : snapshotsLoading } = trpc . ranking . listSnapshots . useQuery (
{ roundId } ,
2026-03-02 19:57:11 +01:00
// Poll every 3s so all admins see ranking progress/completion quickly
{ refetchInterval : 3_000 } ,
2026-02-27 09:48:06 +01:00
)
2026-03-02 19:57:11 +01:00
// Derive ranking-in-progress from server state (visible to ALL admins)
const rankingInProgress = snapshots ? . [ 0 ] ? . status === 'RUNNING'
// Find the latest COMPLETED snapshot (skip RUNNING/FAILED)
const latestCompleted = snapshots ? . find ( ( s ) = > s . status === 'COMPLETED' )
const latestSnapshotId = latestCompleted ? . id ? ? null
const latestSnapshot = latestCompleted ? ? null
2026-02-27 09:48:06 +01:00
const { data : snapshot , isLoading : snapshotLoading } = trpc . ranking . getSnapshot . useQuery (
{ snapshotId : latestSnapshotId ! } ,
{ enabled : ! ! latestSnapshotId } ,
)
2026-02-27 11:08:30 +01:00
const { data : projectStates } = trpc . roundEngine . getProjectStates . useQuery (
{ roundId } ,
)
2026-02-27 09:48:06 +01:00
const { data : projectDetail , isLoading : detailLoading } = trpc . project . getFullDetail . useQuery (
{ id : selectedProjectId ! } ,
{ enabled : ! ! selectedProjectId } ,
)
2026-03-01 14:34:32 +01:00
const { data : roundData } = trpc . round . getById . useQuery ( { id : roundId } )
2026-03-02 10:46:52 +01:00
const { data : evalScores } = trpc . ranking . roundEvaluationScores . useQuery (
{ roundId } ,
)
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
const { data : evalForm } = trpc . evaluation . getStageForm . useQuery (
{ roundId } ,
)
2026-02-27 09:48:06 +01:00
// ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc . useUtils ( )
const saveReorderMutation = trpc . ranking . saveReorder . useMutation ( {
2026-02-27 09:53:49 +01:00
onMutate : ( ) = > { pendingReorderCount . current ++ } ,
onSettled : ( ) = > { pendingReorderCount . current -- } ,
2026-02-27 09:48:06 +01:00
onError : ( err ) = > toast . error ( ` Failed to save order: ${ err . message } ` ) ,
// Do NOT invalidate getSnapshot — would reset localOrder
} )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
const updateRoundMutation = trpc . round . update . useMutation ( {
onSuccess : ( ) = > {
toast . success ( 'Ranking config saved' )
void utils . round . getById . invalidate ( { id : roundId } )
} ,
onError : ( err ) = > toast . error ( ` Failed to save: ${ err . message } ` ) ,
} )
2026-02-27 09:48:06 +01:00
const triggerRankMutation = trpc . ranking . triggerAutoRank . useMutation ( {
onSuccess : ( ) = > {
2026-03-02 10:46:52 +01:00
toast . success ( 'Ranking complete!' )
2026-02-27 09:48:06 +01:00
initialized . current = false // allow re-init on next snapshot load
void utils . ranking . listSnapshots . invalidate ( { roundId } )
2026-03-02 10:46:52 +01:00
void utils . ranking . getSnapshot . invalidate ( )
} ,
onError : ( err ) = > {
toast . error ( err . message )
2026-03-02 19:57:11 +01:00
void utils . ranking . listSnapshots . invalidate ( { roundId } )
2026-02-27 09:48:06 +01:00
} ,
} )
2026-03-01 14:34:32 +01:00
// ─── evalConfig (advancement counts from round config) ────────────────────
const evalConfig = useMemo ( ( ) = > {
if ( ! roundData ? . configJson ) return null
try {
const config = roundData . configJson as Record < string , unknown >
const advConfig = config . advancementConfig as Record < string , unknown > | undefined
return {
2026-03-02 13:25:49 +01:00
advanceMode : ( config . advanceMode as string ) ? ? 'count' ,
advanceScoreThreshold : ( config . advanceScoreThreshold as number ) ? ? undefined ,
2026-03-01 14:34:32 +01:00
startupAdvanceCount : ( advConfig ? . startupCount ? ? config . startupAdvanceCount ? ? 0 ) as number ,
conceptAdvanceCount : ( advConfig ? . conceptCount ? ? config . conceptAdvanceCount ? ? 0 ) as number ,
}
} catch { return null }
} , [ roundData ] )
2026-02-27 09:48:06 +01:00
// ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
const rankingMap = useMemo ( ( ) = > {
2026-03-02 13:29:40 +01:00
const map = new Map < string , RankedProjectEntry & { originalIndex : number } > ( )
2026-02-27 09:48:06 +01:00
if ( ! snapshot ) return map
const startup = ( snapshot . startupRankingJson ? ? [ ] ) as unknown as RankedProjectEntry [ ]
const concept = ( snapshot . conceptRankingJson ? ? [ ] ) as unknown as RankedProjectEntry [ ]
2026-03-02 13:29:40 +01:00
startup . forEach ( ( entry , i ) = > map . set ( entry . projectId , { . . . entry , originalIndex : i + 1 } ) )
concept . forEach ( ( entry , i ) = > map . set ( entry . projectId , { . . . entry , originalIndex : i + 1 } ) )
2026-02-27 09:48:06 +01:00
return map
} , [ snapshot ] )
2026-02-27 11:08:30 +01:00
// ─── projectInfoMap (O(1) lookup by projectId) ────────────────────────────
const projectInfoMap = useMemo ( ( ) = > {
const map = new Map < string , ProjectInfo > ( )
if ( ! projectStates ) return map
for ( const ps of projectStates ) {
map . set ( ps . project . id , {
title : ps.project.title ,
teamName : ps.project.teamName ,
country : ps.project.country ,
} )
}
return map
} , [ projectStates ] )
2026-02-27 09:48:06 +01:00
// ─── localOrder init (once, with useRef guard) ────────────────────────────
2026-04-26 15:33:56 +02:00
// Wait for evalScores too — the initial sort uses balanced (juror-corrected)
// averages, so we can't initialize until those are loaded.
2026-02-27 09:48:06 +01:00
useEffect ( ( ) = > {
2026-04-26 15:33:56 +02:00
if ( ! initialized . current && snapshot && evalScores ) {
2026-02-27 09:48:06 +01:00
const startup = ( snapshot . startupRankingJson ? ? [ ] ) as unknown as RankedProjectEntry [ ]
const concept = ( snapshot . conceptRankingJson ? ? [ ] ) as unknown as RankedProjectEntry [ ]
2026-03-02 15:04:14 +01:00
2026-03-02 19:34:31 +01:00
// Deduplicate ranking entries (AI may return duplicates) — keep first occurrence
const dedup = ( arr : RankedProjectEntry [ ] ) : RankedProjectEntry [ ] = > {
const seen = new Set < string > ( )
return arr . filter ( ( r ) = > {
if ( seen . has ( r . projectId ) ) return false
seen . add ( r . projectId )
return true
} )
}
const dedupedStartup = dedup ( startup )
const dedupedConcept = dedup ( concept )
2026-04-26 15:33:56 +02:00
// Sort by balanced (juror-corrected) score descending, falling back to raw
// avgGlobalScore when no balanced score is available, then compositeScore as
// a final tiebreaker. The threshold cutoff line uses the same metric so the
// cutoff lands in the correct spot regardless of which score type is used.
const scoreFor = ( projectId : string , raw : number | null | undefined ) = >
evalScores . balanced [ projectId ] ? . balancedAverage ? ? raw ? ? 0
2026-03-02 21:20:11 +01:00
dedupedStartup . sort ( ( a , b ) = >
2026-04-26 15:33:56 +02:00
scoreFor ( b . projectId , b . avgGlobalScore ) - scoreFor ( a . projectId , a . avgGlobalScore )
|| b . compositeScore - a . compositeScore )
2026-03-02 21:20:11 +01:00
dedupedConcept . sort ( ( a , b ) = >
2026-04-26 15:33:56 +02:00
scoreFor ( b . projectId , b . avgGlobalScore ) - scoreFor ( a . projectId , a . avgGlobalScore )
|| b . compositeScore - a . compositeScore )
2026-03-02 20:43:49 +01:00
// Track original order for override detection (same effect = always in sync)
2026-03-02 14:10:48 +01:00
const order : Record < string , number > = { }
2026-03-02 19:34:31 +01:00
dedupedStartup . forEach ( ( r , i ) = > { order [ r . projectId ] = i + 1 } )
dedupedConcept . forEach ( ( r , i ) = > { order [ r . projectId ] = i + 1 } )
2026-03-02 14:10:48 +01:00
setSnapshotOrder ( order )
2026-03-02 15:04:14 +01:00
// Apply saved reorders so the ranking persists across all admin sessions.
// reordersJson is append-only — the latest event per category is the current order.
const reorders = ( snapshot . reordersJson as Array < {
category : 'STARTUP' | 'BUSINESS_CONCEPT'
orderedProjectIds : string [ ]
} > | null ) ? ? [ ]
const latestStartupReorder = [ . . . reorders ] . reverse ( ) . find ( ( r ) = > r . category === 'STARTUP' )
const latestConceptReorder = [ . . . reorders ] . reverse ( ) . find ( ( r ) = > r . category === 'BUSINESS_CONCEPT' )
2026-03-02 19:34:31 +01:00
// Deduplicate reorder IDs too, and filter out IDs not in the current snapshot
const dedupIds = ( ids : string [ ] , validSet : Set < string > ) : string [ ] = > {
const seen = new Set < string > ( )
return ids . filter ( ( id ) = > {
if ( seen . has ( id ) || ! validSet . has ( id ) ) return false
seen . add ( id )
return true
} )
}
const startupIdSet = new Set ( dedupedStartup . map ( ( r ) = > r . projectId ) )
const conceptIdSet = new Set ( dedupedConcept . map ( ( r ) = > r . projectId ) )
2026-03-02 15:04:14 +01:00
setLocalOrder ( {
2026-03-02 19:34:31 +01:00
STARTUP : latestStartupReorder
? dedupIds ( latestStartupReorder . orderedProjectIds , startupIdSet )
: dedupedStartup . map ( ( r ) = > r . projectId ) ,
BUSINESS_CONCEPT : latestConceptReorder
? dedupIds ( latestConceptReorder . orderedProjectIds , conceptIdSet )
: dedupedConcept . map ( ( r ) = > r . projectId ) ,
2026-03-02 15:04:14 +01:00
} )
2026-02-27 09:48:06 +01:00
initialized . current = true
}
2026-04-26 15:33:56 +02:00
} , [ snapshot , evalScores ] )
2026-02-27 09:48:06 +01:00
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
// ─── numericCriteria from eval form ─────────────────────────────────────
const numericCriteria = useMemo ( ( ) = > {
if ( ! evalForm ? . criteriaJson ) return [ ]
return ( evalForm . criteriaJson as Array < { id : string ; label : string ; type ? : string ; scale? : number | string } > )
. filter ( ( c ) = > ! c . type || c . type === 'numeric' )
} , [ evalForm ] )
// ─── Init local weights + criteriaText from round config ──────────────────
useEffect ( ( ) = > {
if ( ! weightsInitialized . current && roundData ? . configJson ) {
const cfg = roundData . configJson as Record < string , unknown >
const saved = ( cfg . criteriaWeights ? ? { } ) as Record < string , number >
setLocalWeights ( saved )
setLocalCriteriaText ( ( cfg . rankingCriteria as string ) ? ? '' )
2026-03-02 20:24:17 +01:00
setLocalScoreWeight ( ( cfg . scoreWeight as number ) ? ? 5 )
setLocalPassRateWeight ( ( cfg . passRateWeight as number ) ? ? 5 )
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
weightsInitialized . current = true
}
} , [ roundData ] )
// ─── Save weights + criteria text to round config ─────────────────────────
const saveRankingConfig = ( ) = > {
if ( ! roundData ? . configJson ) return
const cfg = roundData . configJson as Record < string , unknown >
updateRoundMutation . mutate ( {
id : roundId ,
2026-03-02 20:24:17 +01:00
configJson : {
. . . cfg ,
criteriaWeights : localWeights ,
rankingCriteria : localCriteriaText ,
scoreWeight : localScoreWeight ,
passRateWeight : localPassRateWeight ,
} ,
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
} )
}
2026-03-02 20:24:17 +01:00
// Derive ranking mode from criteria text
const isFormulaMode = ! localCriteriaText . trim ( )
2026-02-27 09:48:06 +01:00
// ─── handleDragEnd ────────────────────────────────────────────────────────
function handleDragEnd ( category : 'STARTUP' | 'BUSINESS_CONCEPT' , event : DragEndEvent ) {
const { active , over } = event
if ( ! over || active . id === over . id ) return
setLocalOrder ( ( prev ) = > {
const ids = prev [ category ]
const newIds = arrayMove (
ids ,
ids . indexOf ( active . id as string ) ,
ids . indexOf ( over . id as string ) ,
)
saveReorderMutation . mutate ( {
snapshotId : latestSnapshotId ! ,
category ,
orderedProjectIds : newIds ,
} )
return { . . . prev , [ category ] : newIds }
} )
}
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
// ─── handleExport ──────────────────────────────────────────────────────────
async function handleExportScores() {
setExportLoading ( true )
try {
const result = await utils . export . projectScores . fetch ( { roundId } )
if ( ! result . data || result . data . length === 0 ) {
toast . error ( 'No data to export' )
return
}
const headers = result . columns
const csvRows = [
headers . join ( ',' ) ,
. . . result . data . map ( ( row : Record < string , unknown > ) = >
headers . map ( ( h : string ) = > {
const val = row [ h ]
if ( val == null ) return ''
const str = String ( val )
return str . includes ( ',' ) || str . includes ( '"' ) || str . includes ( '\n' )
? ` " ${ str . replace ( /"/g , '""' ) } " `
: str
} ) . join ( ',' ) ,
) ,
]
const blob = new Blob ( [ csvRows . join ( '\n' ) ] , { type : 'text/csv;charset=utf-8;' } )
const url = URL . createObjectURL ( blob )
const a = document . createElement ( 'a' )
a . href = url
a . download = ` round-scores- ${ roundId . slice ( - 8 ) } .csv `
a . click ( )
URL . revokeObjectURL ( url )
toast . success ( 'CSV exported' )
} catch ( err ) {
toast . error ( ` Export failed: ${ err instanceof Error ? err . message : 'Unknown error' } ` )
} finally {
setExportLoading ( false )
}
}
2026-02-27 09:48:06 +01:00
// ─── Loading state ────────────────────────────────────────────────────────
if ( snapshotsLoading || snapshotLoading ) {
return (
< div className = "space-y-4" >
< Skeleton className = "h-24 w-full rounded-lg" / >
< Skeleton className = "h-48 w-full rounded-lg" / >
< Skeleton className = "h-48 w-full rounded-lg" / >
< / div >
)
}
// ─── Empty state ──────────────────────────────────────────────────────────
if ( ! latestSnapshotId ) {
return (
2026-03-02 10:46:52 +01:00
< div className = "space-y-4" >
< Card >
< CardContent className = "flex flex-col items-center justify-center gap-4 py-12 text-center" >
{ rankingInProgress ? (
< >
< Loader2 className = "h-10 w-10 text-blue-500 animate-spin" / >
< div >
2026-03-02 20:24:17 +01:00
< p className = "font-medium" > Ranking in progress & hellip ; < / p >
2026-03-02 10:46:52 +01:00
< p className = "mt-1 text-sm text-muted-foreground" >
This may take a minute . You can continue working — results will appear automatically .
< / p >
< / div >
< div className = "h-2 w-48 rounded-full bg-blue-100 dark:bg-blue-900 overflow-hidden" >
< div className = "h-full w-full rounded-full bg-blue-500 animate-pulse" / >
< / div >
< / >
2026-02-27 09:48:06 +01:00
) : (
2026-03-02 10:46:52 +01:00
< >
< BarChart3 className = "h-10 w-10 text-muted-foreground" / >
< div >
< p className = "font-medium" > No ranking available yet < / p >
< p className = "mt-1 text-sm text-muted-foreground" >
Run ranking from the Config tab to generate results , or trigger it now .
< / p >
< / div >
< Button
onClick = { ( ) = > triggerRankMutation . mutate ( { roundId } ) }
disabled = { triggerRankMutation . isPending }
>
2026-03-02 20:24:17 +01:00
{ isFormulaMode ? (
< Calculator className = "mr-2 h-4 w-4" / >
) : (
< Sparkles className = "mr-2 h-4 w-4" / >
) }
{ isFormulaMode ? 'Run Ranking Now' : 'Run AI Ranking Now' }
2026-03-02 10:46:52 +01:00
< / Button >
< / >
2026-02-27 09:48:06 +01:00
) }
2026-03-02 10:46:52 +01:00
< / CardContent >
< / Card >
< / div >
2026-02-27 09:48:06 +01:00
)
}
// ─── Main content ─────────────────────────────────────────────────────────
const categoryLabels : Record < 'STARTUP' | 'BUSINESS_CONCEPT' , string > = {
STARTUP : 'Startups' ,
BUSINESS_CONCEPT : 'Business Concepts' ,
}
return (
< >
< div className = "space-y-6" >
{ /* Header card */ }
< Card >
< CardHeader className = "flex flex-row items-start justify-between gap-4" >
< div className = "flex-1 min-w-0" >
< CardTitle className = "text-base" > Latest Ranking Snapshot < / CardTitle >
{ latestSnapshot && (
< CardDescription className = "mt-1 space-y-0.5" >
< span >
Created { ' ' }
{ new Date ( latestSnapshot . createdAt ) . toLocaleString ( undefined , {
dateStyle : 'medium' ,
timeStyle : 'short' ,
} ) }
{ latestSnapshot . triggeredBy ? . name && ` by ${ latestSnapshot . triggeredBy . name } ` }
{ ' · ' }
{ latestSnapshot . triggerType }
< / span >
{ latestSnapshot . criteriaText && (
< span className = "block truncate text-xs" >
Criteria : { latestSnapshot . criteriaText . slice ( 0 , 120 ) }
{ latestSnapshot . criteriaText . length > 120 ? '…' : '' }
< / span >
) }
< / CardDescription >
) }
< / div >
2026-02-27 09:53:49 +01:00
< div className = "flex items-center gap-2 flex-shrink-0" >
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
< Button
size = "sm"
variant = "outline"
onClick = { handleExportScores }
disabled = { exportLoading }
>
{ exportLoading ? (
< Loader2 className = "mr-2 h-4 w-4 animate-spin" / >
) : (
< Download className = "mr-2 h-4 w-4" / >
) }
Export CSV
< / Button >
2026-02-27 09:53:49 +01:00
< Button
size = "sm"
variant = "outline"
onClick = { ( ) = > triggerRankMutation . mutate ( { roundId } ) }
2026-03-02 10:46:52 +01:00
disabled = { rankingInProgress }
2026-02-27 09:53:49 +01:00
>
2026-03-02 10:46:52 +01:00
{ rankingInProgress ? (
< >
< Loader2 className = "mr-2 h-4 w-4 animate-spin" / >
Ranking & hellip ;
< / >
2026-03-02 20:24:17 +01:00
) : isFormulaMode ? (
2026-03-02 10:46:52 +01:00
< >
2026-03-02 20:24:17 +01:00
< Calculator className = "mr-2 h-4 w-4" / >
2026-03-02 10:46:52 +01:00
Run Ranking
< / >
2026-03-02 20:24:17 +01:00
) : (
< >
< Sparkles className = "mr-2 h-4 w-4" / >
Run AI Ranking
< / >
2026-02-27 09:53:49 +01:00
) }
< / Button >
2026-03-03 22:10:04 +01:00
{ /* Advance Top N removed — use Finalization tab instead */ }
2026-02-27 09:53:49 +01:00
< / div >
2026-02-27 09:48:06 +01:00
< / CardHeader >
< / Card >
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
{ /* Ranking Configuration: criteria text + weights */ }
< Collapsible open = { weightsOpen } onOpenChange = { setWeightsOpen } >
< Card >
< CollapsibleTrigger asChild >
< CardHeader className = "cursor-pointer flex flex-row items-center justify-between gap-4 hover:bg-muted/50 transition-colors" >
< div className = "flex items-center gap-2" >
< Settings2 className = "h-4 w-4 text-muted-foreground" / >
< div >
< CardTitle className = "text-base" > Ranking Configuration < / CardTitle >
< CardDescription className = "mt-0.5" > Criteria text , per - criterion weights , and bias correction < / CardDescription >
< / div >
< / div >
< ChevronDown className = { cn ( 'h-4 w-4 text-muted-foreground transition-transform' , weightsOpen && 'rotate-180' ) } / >
< / CardHeader >
< / CollapsibleTrigger >
< CollapsibleContent >
< CardContent className = "space-y-5 pt-0" >
2026-03-02 20:24:17 +01:00
{ /* Score vs Pass Rate weights */ }
< div className = "space-y-3" >
< div >
< Label > Formula Weights < / Label >
< p className = "text-xs text-muted-foreground" >
Control the balance between evaluation scores and yes / no pass rate in the composite ranking
< / p >
< / div >
< div className = "space-y-3" >
< div className = "flex items-center gap-4" >
< span className = "text-sm w-40 flex-shrink-0" > Score Weight < / span >
< Slider
min = { 0 }
max = { 10 }
step = { 1 }
value = { [ localScoreWeight ] }
onValueChange = { ( [ v ] ) = > setLocalScoreWeight ( v ) }
className = "flex-1"
/ >
< span className = "text-sm font-mono w-6 text-right" > { localScoreWeight } < / span >
< / div >
< div className = "flex items-center gap-4" >
< span className = "text-sm w-40 flex-shrink-0" > Pass Rate Weight < / span >
< Slider
min = { 0 }
max = { 10 }
step = { 1 }
value = { [ localPassRateWeight ] }
onValueChange = { ( [ v ] ) = > setLocalPassRateWeight ( v ) }
className = "flex-1"
/ >
< span className = "text-sm font-mono w-6 text-right" > { localPassRateWeight } < / span >
< / div >
< / div >
< / div >
{ /* Ranking criteria text (optional — triggers AI mode) */ }
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
< div className = "space-y-2" >
2026-03-02 20:24:17 +01:00
< Label htmlFor = "rankingCriteria" > AI Ranking Criteria ( optional ) < / Label >
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
< p className = "text-xs text-muted-foreground" >
2026-03-02 20:24:17 +01:00
Optional : describe special ranking criteria for AI - assisted ranking .
Leave empty for formula - based ranking ( faster , no AI cost ) .
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
< / p >
< Textarea
id = "rankingCriteria"
rows = { 3 }
placeholder = 'E.g. "Prioritize innovation and ocean impact. Filter out projects with pass rate below 50%."'
value = { localCriteriaText }
onChange = { ( e ) = > setLocalCriteriaText ( e . target . value ) }
className = "resize-y"
/ >
2026-03-02 20:24:17 +01:00
{ isFormulaMode ? (
< p className = "text-xs text-emerald-600 flex items-center gap-1" >
< Calculator className = "h-3 w-3" / > Formula mode — ranking uses weights only , no AI calls
< / p >
) : (
< p className = "text-xs text-amber-600 flex items-center gap-1" >
< Sparkles className = "h-3 w-3" / > AI mode — criteria will be parsed and used for ranking ( uses API credits )
< / p >
) }
feat: weighted criteria in AI ranking, z-score normalization, threshold advancement, CSV export
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10)
- Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages,
z-score normalize juror scores to correct grading bias, send weighted criteria to AI
- Update AI prompts with criteria_definitions and per-project criteria_scores
- compositeScore uses weighted criteria when configured, falls back to globalScore
- Add collapsible ranking config section to dashboard (criteria text + weight sliders)
- Move rankingCriteria textarea from eval config tab to ranking dashboard
- Store criteriaWeights in ranking snapshot parsedRulesJson for audit
- Enhance projectScores CSV export with per-criterion averages, category, country
- Add Export CSV button to ranking dashboard header
- Add threshold-based advancement mode (decimal score threshold, e.g. 6.5)
alongside existing top-N mode in advance dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:24:14 +01:00
< / div >
{ /* Per-criterion weights */ }
{ numericCriteria . length > 0 && (
< div className = "space-y-3" >
< div >
< Label > Criteria Weights < / Label >
< p className = "text-xs text-muted-foreground" >
Set relative importance of each evaluation criterion ( 0 = ignore , 10 = highest priority )
< / p >
< / div >
< div className = "space-y-3" >
{ numericCriteria . map ( ( c ) = > (
< div key = { c . id } className = "flex items-center gap-4" >
< span className = "text-sm w-40 truncate flex-shrink-0" title = { c . label } > { c . label } < / span >
< Slider
min = { 0 }
max = { 10 }
step = { 1 }
value = { [ localWeights [ c . id ] ? ? 1 ] }
onValueChange = { ( [ v ] ) = > setLocalWeights ( ( prev ) = > ( { . . . prev , [ c . id ] : v } ) ) }
className = "flex-1"
/ >
< span className = "text-sm font-mono w-6 text-right" > { localWeights [ c . id ] ? ? 1 } < / span >
< / div >
) ) }
< / div >
< / div >
) }
< div className = "flex items-center gap-2 pt-2 border-t" >
< Button
size = "sm"
onClick = { saveRankingConfig }
disabled = { updateRoundMutation . isPending }
>
{ updateRoundMutation . isPending ? < Loader2 className = "h-4 w-4 mr-2 animate-spin" / > : null }
Save Configuration
< / Button >
< p className = "text-xs text-muted-foreground" >
Weights are applied when ranking is run . Z - score normalization corrects for juror bias automatically .
< / p >
< / div >
< / CardContent >
< / CollapsibleContent >
< / Card >
< / Collapsible >
2026-03-02 10:46:52 +01:00
{ /* Ranking in-progress banner */ }
{ rankingInProgress && (
< Card className = "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30" >
< CardContent className = "flex items-center gap-3 py-4" >
< Loader2 className = "h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" / >
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium text-blue-900 dark:text-blue-200" >
2026-03-02 20:24:17 +01:00
Ranking in progress & hellip ;
2026-03-02 10:46:52 +01:00
< / p >
< p className = "text-xs text-blue-700 dark:text-blue-400" >
This may take a minute . You can continue working — results will appear automatically .
< / p >
< / div >
< div className = "h-1.5 w-32 rounded-full bg-blue-200 dark:bg-blue-800 overflow-hidden flex-shrink-0" >
< div className = "h-full w-full rounded-full bg-blue-500 animate-pulse" / >
< / div >
< / CardContent >
< / Card >
) }
2026-02-27 09:48:06 +01:00
{ /* Per-category sections */ }
{ ( [ 'STARTUP' , 'BUSINESS_CONCEPT' ] as const ) . map ( ( category ) = > (
< Card key = { category } >
< CardHeader >
< CardTitle className = "text-sm font-semibold uppercase tracking-wide text-muted-foreground" >
{ categoryLabels [ category ] }
2026-03-02 13:25:49 +01:00
{ evalConfig && evalConfig . advanceMode === 'threshold' && evalConfig . advanceScoreThreshold != null ? (
< span className = "ml-2 text-xs font-normal normal-case" >
( Score & ge ; { evalConfig . advanceScoreThreshold } advance )
< / span >
) : evalConfig && ( category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount ) > 0 ? (
2026-03-01 14:34:32 +01:00
< span className = "ml-2 text-xs font-normal normal-case" >
( Top { category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount } advance )
< / span >
2026-03-02 13:25:49 +01:00
) : null }
2026-02-27 09:48:06 +01:00
< / CardTitle >
< / CardHeader >
< CardContent >
{ localOrder [ category ] . length === 0 ? (
< p className = "text-sm text-muted-foreground" >
No { category === 'STARTUP' ? 'startup' : 'business concept' } projects ranked .
< / p >
2026-03-02 19:34:31 +01:00
) : ( ( ) = > {
// Precompute cutoff index so it only shows ONCE
const isThresholdMode = evalConfig ? . advanceMode === 'threshold' && evalConfig . advanceScoreThreshold != null
const advanceCount = isThresholdMode ? 0 : ( category === 'STARTUP'
? ( evalConfig ? . startupAdvanceCount ? ? 0 )
: ( evalConfig ? . conceptAdvanceCount ? ? 0 ) )
const threshold = evalConfig ? . advanceScoreThreshold ? ? 0
2026-04-26 15:33:56 +02:00
// Effective ranking score = balanced (juror-corrected) average,
// falling back to raw avgGlobalScore. Both the sort and the
// threshold check use this same value so the cutoff lands in
// the right spot.
const effectiveScore = ( id : string ) = > {
const e = rankingMap . get ( id )
return evalScores ? . balanced [ id ] ? . balancedAverage ? ? e ? . avgGlobalScore ? ? 0
}
2026-03-02 19:34:31 +01:00
let cutoffIndex = - 1
if ( isThresholdMode ) {
2026-03-02 20:43:49 +01:00
// Find the FIRST project that does NOT meet the threshold — cutoff goes before it.
2026-04-26 15:33:56 +02:00
const firstFailIdx = localOrder [ category ] . findIndex ( ( id ) = > effectiveScore ( id ) < threshold )
2026-03-02 20:43:49 +01:00
if ( firstFailIdx === - 1 ) {
// All meet threshold — cutoff after the last one
cutoffIndex = localOrder [ category ] . length - 1
} else if ( firstFailIdx > 0 ) {
cutoffIndex = firstFailIdx - 1
2026-03-02 19:34:31 +01:00
}
} else if ( advanceCount > 0 ) {
cutoffIndex = advanceCount - 1
}
// Check if admin has reordered this category
const reorders = ( snapshot ? . reordersJson as Array < {
category : 'STARTUP' | 'BUSINESS_CONCEPT'
orderedProjectIds : string [ ]
} > | null ) ? ? [ ]
const hasReorders = reorders . some ( ( r ) = > r . category === category )
return (
2026-02-27 09:48:06 +01:00
< DndContext
sensors = { sensors }
collisionDetection = { closestCenter }
onDragEnd = { ( event ) = > handleDragEnd ( category , event ) }
>
< SortableContext
items = { localOrder [ category ] }
strategy = { verticalListSortingStrategy }
>
< AnimatePresence initial = { false } >
< div className = "space-y-2" >
2026-03-01 14:34:32 +01:00
{ localOrder [ category ] . map ( ( projectId , index ) = > {
2026-04-26 15:33:56 +02:00
const projectScore = effectiveScore ( projectId )
2026-03-02 13:25:49 +01:00
const isAdvancing = isThresholdMode
2026-04-26 15:33:56 +02:00
? projectScore >= threshold
2026-03-02 13:25:49 +01:00
: ( advanceCount > 0 && index < advanceCount )
2026-03-02 19:34:31 +01:00
const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex
2026-03-01 14:34:32 +01:00
return (
< React.Fragment key = { projectId } >
< motion.div
layout
initial = { { opacity : 0 , y : 20 } }
animate = { { opacity : 1 , y : 0 } }
exit = { { opacity : 0 , y : - 20 } }
2026-03-02 21:20:11 +01:00
className = { isAdvancing
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400 dark:bg-emerald-950/20 dark:border-emerald-600'
: '' }
2026-03-01 14:34:32 +01:00
>
< SortableProjectRow
projectId = { projectId }
currentRank = { index + 1 }
entry = { rankingMap . get ( projectId ) }
projectInfo = { projectInfoMap . get ( projectId ) }
2026-04-24 16:19:00 +02:00
jurorScores = { evalScores ? . byProject [ projectId ] }
balancedScore = { evalScores ? . balanced [ projectId ] ? . balancedAverage ? ? null }
2026-03-01 14:34:32 +01:00
onSelect = { ( ) = > setSelectedProjectId ( projectId ) }
isSelected = { selectedProjectId === projectId }
2026-03-02 19:34:31 +01:00
originalRank = { hasReorders ? snapshotOrder [ projectId ] : undefined }
2026-03-01 14:34:32 +01:00
/ >
< / motion.div >
{ isCutoffRow && (
< div className = "flex items-center gap-2 py-1" >
< div className = "flex-1 border-t-2 border-dashed border-emerald-400/60" / >
< span className = "text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap" >
2026-03-02 13:25:49 +01:00
Advancement cutoff — { isThresholdMode ? ` Score ≥ ${ threshold } ` : ` Top ${ advanceCount } ` }
2026-03-01 14:34:32 +01:00
< / span >
< div className = "flex-1 border-t-2 border-dashed border-emerald-400/60" / >
< / div >
) }
< / React.Fragment >
)
} ) }
2026-02-27 09:48:06 +01:00
< / div >
< / AnimatePresence >
< / SortableContext >
< / DndContext >
2026-03-02 19:34:31 +01:00
)
} ) ( ) }
2026-02-27 09:48:06 +01:00
< / CardContent >
< / Card >
) ) }
< / div >
2026-03-03 22:10:04 +01:00
{ /* Advance dialog removed — use Finalization tab */ }
2026-02-27 09:53:49 +01:00
2026-02-27 09:48:06 +01:00
{ /* Side panel Sheet */ }
< Sheet
open = { ! ! selectedProjectId }
onOpenChange = { ( open ) = > {
if ( ! open ) setSelectedProjectId ( null )
} }
>
< SheetContent className = "w-[480px] sm:max-w-[480px] overflow-y-auto" >
< SheetHeader >
< SheetTitle > { projectDetail ? . project . title ? ? 'Project Details' } < / SheetTitle >
< SheetDescription >
{ selectedProjectId ? ` ID: … ${ selectedProjectId . slice ( - 8 ) } ` : '' }
< / SheetDescription >
2026-03-01 14:34:32 +01:00
{ selectedProjectId && (
< a
href = { ` /admin/projects/ ${ selectedProjectId } ` }
target = "_blank"
rel = "noopener noreferrer"
className = "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
>
< ExternalLink className = "h-3.5 w-3.5" / >
View Project Page
< / a >
) }
2026-02-27 09:48:06 +01:00
< / SheetHeader >
{ detailLoading ? (
< div className = "mt-6 space-y-3" >
< Skeleton className = "h-16 w-full" / >
< Skeleton className = "h-24 w-full" / >
< Skeleton className = "h-24 w-full" / >
< / div >
) : projectDetail ? (
< div className = "mt-6 space-y-6" >
{ /* Stats summary */ }
{ projectDetail . stats && (
< div className = "grid grid-cols-3 gap-3" >
< div className = "rounded-lg border p-3 text-center" >
< p className = "text-xs text-muted-foreground" > Avg Score < / p >
< p className = "mt-1 text-lg font-semibold" >
{ projectDetail . stats . averageGlobalScore ? . toFixed ( 1 ) ? ? '—' }
< / p >
< / div >
< div className = "rounded-lg border p-3 text-center" >
< p className = "text-xs text-muted-foreground" > Pass Rate < / p >
< p className = "mt-1 text-lg font-semibold" >
{ projectDetail . stats . totalEvaluations > 0
? ` ${ Math . round ( ( projectDetail . stats . yesVotes / projectDetail . stats . totalEvaluations ) * 100 ) } % `
: '—' }
< / p >
< / div >
< div className = "rounded-lg border p-3 text-center" >
< p className = "text-xs text-muted-foreground" > Evaluators < / p >
< p className = "mt-1 text-lg font-semibold" >
{ projectDetail . stats . totalEvaluations }
< / p >
< / div >
< / div >
) }
{ /* Per-juror evaluations */ }
< div >
< h4 className = "mb-3 text-sm font-semibold" > Juror Evaluations < / h4 >
{ ( ( ) = > {
const submitted = projectDetail . assignments . filter (
( a ) = > a . evaluation ? . status === 'SUBMITTED' && a . round . id === roundId ,
)
if ( submitted . length === 0 ) {
return (
< p className = "text-sm text-muted-foreground" >
No submitted evaluations for this round .
< / p >
)
}
return (
< div className = "space-y-3" >
2026-03-01 14:34:32 +01:00
{ submitted . map ( ( a ) = > {
const isExpanded = expandedReviews . has ( a . id )
return (
< div
key = { a . id }
className = "rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
onClick = { ( ) = > setExpandedReviews ( prev = > {
const next = new Set ( prev )
next . has ( a . id ) ? next . delete ( a . id ) : next . add ( a . id )
return next
} ) }
>
< div className = "flex items-center justify-between" >
< span className = "font-medium text-sm" > { a . user ? . name ? ? a . user ? . email ? ? 'Unknown' } < / span >
< div className = "flex items-center gap-2" >
{ a . evaluation ? . binaryDecision != null && (
< Badge
variant = { a . evaluation . binaryDecision ? 'default' : 'destructive' }
className = { a . evaluation . binaryDecision ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100' : '' }
>
{ a . evaluation . binaryDecision ? 'Yes' : 'No' }
< / Badge >
) }
< Badge variant = "outline" > Score : { a . evaluation ? . globalScore ? . toFixed ( 1 ) ? ? '—' } < / Badge >
< / div >
2026-02-27 09:48:06 +01:00
< / div >
2026-03-01 14:34:32 +01:00
{ isExpanded && a . evaluation ? . feedbackText && (
< p className = "mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2" >
{ a . evaluation . feedbackText }
< / p >
) }
2026-02-27 09:48:06 +01:00
< / div >
2026-03-01 14:34:32 +01:00
)
} ) }
2026-02-27 09:48:06 +01:00
< / div >
)
} ) ( ) }
< / div >
< / div >
) : null }
< / SheetContent >
< / Sheet >
< / >
)
}