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])
// ─── 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(() => {
if (!initialized.current && snapshot) {
if (!initialized.current && snapshot && evalScores) {
const startup = (snapshot.startupRankingJson ?? []) 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 dedupedConcept = dedup(concept)
// Sort by avgGlobalScore descending (the metric displayed to the admin), with
// compositeScore as tiebreaker. This ensures the visible ordering matches the
// numbers on screen AND the threshold cutoff line lands correctly (it checks
// avgGlobalScore, so the list must be sorted by that same metric).
// 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
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) =>
(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)
const order: Record<string, number> = {}
@@ -455,7 +461,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
initialized.current = true
}
}, [snapshot])
}, [snapshot, evalScores])
// ─── numericCriteria from eval form ─────────────────────────────────────
const numericCriteria = useMemo(() => {
@@ -864,14 +870,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
: (evalConfig?.conceptAdvanceCount ?? 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
if (isThresholdMode) {
// 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) => {
const e = rankingMap.get(id)
return (e?.avgGlobalScore ?? 0) < threshold
})
const firstFailIdx = localOrder[category].findIndex((id) => effectiveScore(id) < threshold)
if (firstFailIdx === -1) {
// All meet threshold — cutoff after the last one
cutoffIndex = localOrder[category].length - 1
@@ -902,10 +913,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
<AnimatePresence initial={false}>
<div className="space-y-2">
{localOrder[category].map((projectId, index) => {
const entry = rankingMap.get(projectId)
const projectAvg = entry?.avgGlobalScore ?? 0
const projectScore = effectiveScore(projectId)
const isAdvancing = isThresholdMode
? projectAvg >= threshold
? projectScore >= threshold
: (advanceCount > 0 && index < advanceCount)
const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex