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])
|
}, [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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user