feat: ranking UI improvements - highlight advancing projects, expandable reviews, view project link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 14:34:32 +01:00
parent 36560a1837
commit 2f1136646e

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -49,6 +49,7 @@ import {
Loader2, Loader2,
RefreshCw, RefreshCw,
Trophy, Trophy,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking' import type { RankedProjectEntry } from '@/server/services/ai-ranking'
@@ -191,6 +192,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const [topNConceptual, setTopNConceptual] = useState(3) const [topNConceptual, setTopNConceptual] = useState(3)
const [includeReject, setIncludeReject] = useState(false) const [includeReject, setIncludeReject] = useState(false)
// ─── Expandable review state ──────────────────────────────────────────────
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
// ─── Sensors ────────────────────────────────────────────────────────────── // ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@@ -220,6 +224,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{ enabled: !!selectedProjectId }, { enabled: !!selectedProjectId },
) )
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
// ─── tRPC mutations ─────────────────────────────────────────────────────── // ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -261,6 +267,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
// ─── 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 {
startupAdvanceCount: (advConfig?.startupCount ?? config.startupAdvanceCount ?? 0) as number,
conceptAdvanceCount: (advConfig?.conceptCount ?? config.conceptAdvanceCount ?? 0) as number,
}
} catch { return null }
}, [roundData])
// ─── rankingMap (O(1) lookup) ────────────────────────────────────────────── // ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
const rankingMap = useMemo(() => { const rankingMap = useMemo(() => {
const map = new Map<string, RankedProjectEntry>() const map = new Map<string, RankedProjectEntry>()
@@ -298,6 +317,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
} }
}, [snapshot]) }, [snapshot])
// ─── sync advance dialog defaults from config ────────────────────────────
useEffect(() => {
if (evalConfig) {
if (evalConfig.startupAdvanceCount > 0) setTopNStartup(evalConfig.startupAdvanceCount)
if (evalConfig.conceptAdvanceCount > 0) setTopNConceptual(evalConfig.conceptAdvanceCount)
}
}, [evalConfig])
// ─── handleDragEnd ──────────────────────────────────────────────────────── // ─── handleDragEnd ────────────────────────────────────────────────────────
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) { function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
const { active, over } = event const { active, over } = event
@@ -448,6 +475,11 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{categoryLabels[category]} {categoryLabels[category]}
{evalConfig && (category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount) > 0 && (
<span className="ml-2 text-xs font-normal normal-case">
(Top {category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount} advance)
</span>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -467,25 +499,43 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
> >
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
<div className="space-y-2"> <div className="space-y-2">
{localOrder[category].map((projectId, index) => ( {localOrder[category].map((projectId, index) => {
<motion.div const advanceCount = category === 'STARTUP'
key={projectId} ? (evalConfig?.startupAdvanceCount ?? 0)
layout : (evalConfig?.conceptAdvanceCount ?? 0)
initial={{ opacity: 0 }} const isAdvancing = advanceCount > 0 && index < advanceCount
animate={{ opacity: 1 }} const isCutoffRow = advanceCount > 0 && index === advanceCount - 1
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }} return (
> <React.Fragment key={projectId}>
<SortableProjectRow <motion.div
projectId={projectId} layout
currentRank={index + 1} initial={{ opacity: 0, y: 20 }}
entry={rankingMap.get(projectId)} animate={{ opacity: 1, y: 0 }}
projectInfo={projectInfoMap.get(projectId)} exit={{ opacity: 0, y: -20 }}
onSelect={() => setSelectedProjectId(projectId)} className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
isSelected={selectedProjectId === projectId} >
/> <SortableProjectRow
</motion.div> projectId={projectId}
))} currentRank={index + 1}
entry={rankingMap.get(projectId)}
projectInfo={projectInfoMap.get(projectId)}
onSelect={() => setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId}
/>
</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">
Advancement cutoff Top {advanceCount}
</span>
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
</div>
)}
</React.Fragment>
)
})}
</div> </div>
</AnimatePresence> </AnimatePresence>
</SortableContext> </SortableContext>
@@ -617,6 +667,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<SheetDescription> <SheetDescription>
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''} {selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
</SheetDescription> </SheetDescription>
{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>
)}
</SheetHeader> </SheetHeader>
{detailLoading ? ( {detailLoading ? (
@@ -669,37 +730,40 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{submitted.map((a) => ( {submitted.map((a) => {
<div key={a.id} className="rounded-lg border p-3 space-y-2"> const isExpanded = expandedReviews.has(a.id)
<div className="flex items-center justify-between gap-2"> return (
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p> <div
<div className="flex items-center gap-2 flex-shrink-0"> key={a.id}
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && ( className="rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
<Badge variant="outline" className="text-xs"> onClick={() => setExpandedReviews(prev => {
Score: {a.evaluation.globalScore.toFixed(1)} const next = new Set(prev)
</Badge> next.has(a.id) ? next.delete(a.id) : next.add(a.id)
)} return next
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && ( })}
<Badge >
className={cn( <div className="flex items-center justify-between">
'text-xs', <span className="font-medium text-sm">{a.user?.name ?? a.user?.email ?? 'Unknown'}</span>
a.evaluation.binaryDecision <div className="flex items-center gap-2">
? 'bg-green-100 text-green-700 hover:bg-green-100' {a.evaluation?.binaryDecision != null && (
: 'bg-red-100 text-red-700 hover:bg-red-100', <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> {a.evaluation.binaryDecision ? 'Yes' : 'No'}
)} </Badge>
)}
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
</div>
</div> </div>
{isExpanded && a.evaluation?.feedbackText && (
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2">
{a.evaluation.feedbackText}
</p>
)}
</div> </div>
{a.evaluation?.feedbackText && ( )
<p className="text-xs text-muted-foreground leading-relaxed"> })}
{a.evaluation.feedbackText}
</p>
)}
</div>
))}
</div> </div>
) )
})()} })()}