feat: rank projects by balanced score in ranking dashboard
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m0s

Initial ranking order, advancement cutoff line, and per-row "advancing"
highlight now all use the juror-balanced (z-score corrected) average,
falling back to the raw avgGlobalScore when no balanced score exists.
The init effect waits for evalScores so the sort has the data it needs.

Admin drag-reorders still take precedence — saved reorders override the
default sort exactly as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-26 15:33:56 +02:00
parent 2e080a5d09
commit 901d9ba982

View File

@@ -390,8 +390,10 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}, [projectStates]) }, [projectStates])
// ─── localOrder init (once, with useRef guard) ──────────────────────────── // ─── localOrder init (once, with useRef guard) ────────────────────────────
// Wait for evalScores too — the initial sort uses balanced (juror-corrected)
// averages, so we can't initialize until those are loaded.
useEffect(() => { useEffect(() => {
if (!initialized.current && snapshot) { if (!initialized.current && snapshot && evalScores) {
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[] const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[] const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
@@ -407,14 +409,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const dedupedStartup = dedup(startup) const dedupedStartup = dedup(startup)
const dedupedConcept = dedup(concept) const dedupedConcept = dedup(concept)
// Sort by avgGlobalScore descending (the metric displayed to the admin), with // Sort by balanced (juror-corrected) score descending, falling back to raw
// compositeScore as tiebreaker. This ensures the visible ordering matches the // avgGlobalScore when no balanced score is available, then compositeScore as
// numbers on screen AND the threshold cutoff line lands correctly (it checks // a final tiebreaker. The threshold cutoff line uses the same metric so the
// avgGlobalScore, so the list must be sorted by that same metric). // 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
dedupedStartup.sort((a, b) => dedupedStartup.sort((a, b) =>
(b.avgGlobalScore ?? 0) - (a.avgGlobalScore ?? 0) || b.compositeScore - a.compositeScore) scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|| b.compositeScore - a.compositeScore)
dedupedConcept.sort((a, b) => dedupedConcept.sort((a, b) =>
(b.avgGlobalScore ?? 0) - (a.avgGlobalScore ?? 0) || b.compositeScore - a.compositeScore) scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|| b.compositeScore - a.compositeScore)
// Track original order for override detection (same effect = always in sync) // Track original order for override detection (same effect = always in sync)
const order: Record<string, number> = {} const order: Record<string, number> = {}
@@ -455,7 +461,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
initialized.current = true initialized.current = true
} }
}, [snapshot]) }, [snapshot, evalScores])
// ─── numericCriteria from eval form ───────────────────────────────────── // ─── numericCriteria from eval form ─────────────────────────────────────
const numericCriteria = useMemo(() => { const numericCriteria = useMemo(() => {
@@ -864,14 +870,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
: (evalConfig?.conceptAdvanceCount ?? 0)) : (evalConfig?.conceptAdvanceCount ?? 0))
const threshold = evalConfig?.advanceScoreThreshold ?? 0 const threshold = evalConfig?.advanceScoreThreshold ?? 0
// 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
}
let cutoffIndex = -1 let cutoffIndex = -1
if (isThresholdMode) { if (isThresholdMode) {
// Find the FIRST project that does NOT meet the threshold — cutoff goes before it. // Find the FIRST project that does NOT meet the threshold — cutoff goes before it.
// Works correctly because localOrder is sorted by avgGlobalScore (the same metric). const firstFailIdx = localOrder[category].findIndex((id) => effectiveScore(id) < threshold)
const firstFailIdx = localOrder[category].findIndex((id) => {
const e = rankingMap.get(id)
return (e?.avgGlobalScore ?? 0) < threshold
})
if (firstFailIdx === -1) { if (firstFailIdx === -1) {
// All meet threshold — cutoff after the last one // All meet threshold — cutoff after the last one
cutoffIndex = localOrder[category].length - 1 cutoffIndex = localOrder[category].length - 1
@@ -902,10 +913,9 @@ 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) => {
const entry = rankingMap.get(projectId) const projectScore = effectiveScore(projectId)
const projectAvg = entry?.avgGlobalScore ?? 0
const isAdvancing = isThresholdMode const isAdvancing = isThresholdMode
? projectAvg >= threshold ? projectScore >= threshold
: (advanceCount > 0 && index < advanceCount) : (advanceCount > 0 && index < advanceCount)
const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex