feat: rank projects by balanced score in ranking dashboard
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m0s
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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user