'use client'
import { useState, useEffect, useRef, useMemo } from 'react'
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'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
GripVertical,
BarChart3,
Loader2,
RefreshCw,
Trophy,
} from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
// ─── Types ────────────────────────────────────────────────────────────────────
type RankingDashboardProps = {
competitionId: string
roundId: string
}
type ProjectInfo = {
title: string
teamName: string | null
country: string | null
}
type SortableProjectRowProps = {
projectId: string
currentRank: number
entry: RankedProjectEntry | undefined
projectInfo: ProjectInfo | undefined
onSelect: () => void
isSelected: boolean
}
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
function SortableProjectRow({
projectId,
currentRank,
entry,
projectInfo,
onSelect,
isSelected,
}: SortableProjectRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: projectId })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
// isOverridden: current position differs from AI-assigned rank
const isOverridden = entry !== undefined && currentRank !== entry.rank
return (
{/* Drag handle */}
{/* Rank badge */}
{isOverridden ? (
#{currentRank} (override)
) : (
#{currentRank}
)}
{/* Project info */}
{projectInfo?.title ?? `Project …${projectId.slice(-6)}`}
{projectInfo?.teamName && (
{projectInfo.teamName}
{projectInfo.country ? ` · ${projectInfo.country}` : ''}
)}
{/* Stats */}
{entry && (
{Math.round(entry.compositeScore * 100)}%
{entry.avgGlobalScore !== null && (
Avg {entry.avgGlobalScore.toFixed(1)}
)}
Yes {Math.round(entry.passRate * 100)}%
{entry.evaluatorCount} juror{entry.evaluatorCount !== 1 ? 's' : ''}
)}
)
}
// ─── Main component ────────────────────────────────────────────────────────────
export function RankingDashboard({ competitionId: _competitionId, roundId }: RankingDashboardProps) {
// ─── State ────────────────────────────────────────────────────────────────
const [selectedProjectId, setSelectedProjectId] = useState(null)
const [localOrder, setLocalOrder] = useState>({
STARTUP: [],
BUSINESS_CONCEPT: [],
})
const initialized = useRef(false)
const pendingReorderCount = useRef(0)
// ─── Advance dialog state ─────────────────────────────────────────────────
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [topNStartup, setTopNStartup] = useState(3)
const [topNConceptual, setTopNConceptual] = useState(3)
const [includeReject, setIncludeReject] = useState(false)
// ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
// ─── tRPC queries ─────────────────────────────────────────────────────────
const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const latestSnapshotId = snapshots?.[0]?.id ?? null
const latestSnapshot = snapshots?.[0] ?? null
const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery(
{ snapshotId: latestSnapshotId! },
{ enabled: !!latestSnapshotId },
)
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
)
const { data: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery(
{ id: selectedProjectId! },
{ enabled: !!selectedProjectId },
)
// ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils()
const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
onMutate: () => { pendingReorderCount.current++ },
onSettled: () => { pendingReorderCount.current-- },
onError: (err) => toast.error(`Failed to save order: ${err.message}`),
// Do NOT invalidate getSnapshot — would reset localOrder
})
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
onSuccess: () => {
toast.success('Ranking complete. Reload to see results.')
initialized.current = false // allow re-init on next snapshot load
void utils.ranking.listSnapshots.invalidate({ roundId })
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (data) => {
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
void utils.roundEngine.getProjectStates.invalidate({ roundId })
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({
onSuccess: (data) => {
// MEMORY.md: use .length, not direct value comparison
toast.success(`Rejected ${data.succeeded.length} project(s)`)
if (data.failed.length > 0) {
toast.warning(`${data.failed.length} project(s) could not be rejected`)
}
void utils.roundEngine.getProjectStates.invalidate({ roundId })
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
// ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
const rankingMap = useMemo(() => {
const map = new Map()
if (!snapshot) return map
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
;[...startup, ...concept].forEach((entry) => map.set(entry.projectId, entry))
return map
}, [snapshot])
// ─── projectInfoMap (O(1) lookup by projectId) ────────────────────────────
const projectInfoMap = useMemo(() => {
const map = new Map()
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])
// ─── localOrder init (once, with useRef guard) ────────────────────────────
useEffect(() => {
if (!initialized.current && snapshot) {
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
setLocalOrder({
STARTUP: startup.map((r) => r.projectId),
BUSINESS_CONCEPT: concept.map((r) => r.projectId),
})
initialized.current = true
}
}, [snapshot])
// ─── 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 }
})
}
// ─── handleAdvance ────────────────────────────────────────────────────────
function handleAdvance() {
const advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
const advanceSet = new Set(advanceIds)
advanceMutation.mutate({ roundId, projectIds: advanceIds })
if (includeReject) {
const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter(
(id) => !advanceSet.has(id),
)
if (rejectIds.length > 0) {
batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' })
}
}
}
// ─── Loading state ────────────────────────────────────────────────────────
if (snapshotsLoading || snapshotLoading) {
return (
)
}
// ─── Empty state ──────────────────────────────────────────────────────────
if (!latestSnapshotId) {
return (
No ranking available yet
Run ranking from the Config tab to generate results, or trigger it now.
)
}
// ─── Main content ─────────────────────────────────────────────────────────
const categoryLabels: Record<'STARTUP' | 'BUSINESS_CONCEPT', string> = {
STARTUP: 'Startups',
BUSINESS_CONCEPT: 'Business Concepts',
}
return (
<>
{/* Header card */}
Latest Ranking Snapshot
{latestSnapshot && (
Created{' '}
{new Date(latestSnapshot.createdAt).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
{latestSnapshot.triggeredBy?.name && ` by ${latestSnapshot.triggeredBy.name}`}
{' · '}
{latestSnapshot.triggerType}
{latestSnapshot.criteriaText && (
Criteria: {latestSnapshot.criteriaText.slice(0, 120)}
{latestSnapshot.criteriaText.length > 120 ? '…' : ''}
)}
)}
{/* Per-category sections */}
{(['STARTUP', 'BUSINESS_CONCEPT'] as const).map((category) => (
{categoryLabels[category]}
{localOrder[category].length === 0 ? (
No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
) : (
handleDragEnd(category, event)}
>
{localOrder[category].map((projectId, index) => (
setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId}
/>
))}
)}
))}
{/* Advance Top N dialog */}
{/* Side panel Sheet */}
{
if (!open) setSelectedProjectId(null)
}}
>
{projectDetail?.project.title ?? 'Project Details'}
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
{detailLoading ? (
) : projectDetail ? (
{/* Stats summary */}
{projectDetail.stats && (
Avg Score
{projectDetail.stats.averageGlobalScore?.toFixed(1) ?? '—'}
Pass Rate
{projectDetail.stats.totalEvaluations > 0
? `${Math.round((projectDetail.stats.yesVotes / projectDetail.stats.totalEvaluations) * 100)}%`
: '—'}
Evaluators
{projectDetail.stats.totalEvaluations}
)}
{/* Per-juror evaluations */}
Juror Evaluations
{(() => {
const submitted = projectDetail.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED' && a.round.id === roundId,
)
if (submitted.length === 0) {
return (
No submitted evaluations for this round.
)
}
return (
{submitted.map((a) => (
{a.user.name ?? a.user.email}
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
Score: {a.evaluation.globalScore.toFixed(1)}
)}
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
)}
{a.evaluation?.feedbackText && (
{a.evaluation.feedbackText}
)}
))}
)
})()}
) : null}
>
)
}