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'
import { useState, useEffect, useRef, useMemo } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
@@ -49,6 +49,7 @@ import {
Loader2,
RefreshCw,
Trophy,
ExternalLink,
} from 'lucide-react'
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 [includeReject, setIncludeReject] = useState(false)
// ─── Expandable review state ──────────────────────────────────────────────
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
// ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors(
useSensor(PointerSensor),
@@ -220,6 +224,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{ enabled: !!selectedProjectId },
)
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
// ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils()
@@ -261,6 +267,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
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) ──────────────────────────────────────────────
const rankingMap = useMemo(() => {
const map = new Map<string, RankedProjectEntry>()
@@ -298,6 +317,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
}, [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 ────────────────────────────────────────────────────────
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
const { active, over } = event
@@ -448,6 +475,11 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<CardHeader>
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{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>
</CardHeader>
<CardContent>
@@ -467,25 +499,43 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
>
<AnimatePresence initial={false}>
<div className="space-y-2">
{localOrder[category].map((projectId, index) => (
<motion.div
key={projectId}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<SortableProjectRow
projectId={projectId}
currentRank={index + 1}
entry={rankingMap.get(projectId)}
projectInfo={projectInfoMap.get(projectId)}
onSelect={() => setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId}
/>
</motion.div>
))}
{localOrder[category].map((projectId, index) => {
const advanceCount = category === 'STARTUP'
? (evalConfig?.startupAdvanceCount ?? 0)
: (evalConfig?.conceptAdvanceCount ?? 0)
const isAdvancing = advanceCount > 0 && index < advanceCount
const isCutoffRow = advanceCount > 0 && index === advanceCount - 1
return (
<React.Fragment key={projectId}>
<motion.div
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
>
<SortableProjectRow
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>
</AnimatePresence>
</SortableContext>
@@ -617,6 +667,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<SheetDescription>
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
</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>
{detailLoading ? (
@@ -669,37 +730,40 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}
return (
<div className="space-y-3">
{submitted.map((a) => (
<div key={a.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
<div className="flex items-center gap-2 flex-shrink-0">
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
<Badge variant="outline" className="text-xs">
Score: {a.evaluation.globalScore.toFixed(1)}
</Badge>
)}
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
<Badge
className={cn(
'text-xs',
a.evaluation.binaryDecision
? 'bg-green-100 text-green-700 hover:bg-green-100'
: 'bg-red-100 text-red-700 hover:bg-red-100',
)}
>
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
</Badge>
)}
{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>
</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>
{a.evaluation?.feedbackText && (
<p className="text-xs text-muted-foreground leading-relaxed">
{a.evaluation.feedbackText}
</p>
)}
</div>
))}
)
})}
</div>
)
})()}