feat: list sort respects useBalancedRanking toggle

The two existing sort sites (initial init + threshold cutoff) now read
from the local toggle. A second effect re-sorts the list when the
toggle flips, but only when no manual reorder is pinned to the
snapshot — persisted manual reorders always win, matching prior
behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-27 13:22:11 +02:00
parent 387f84c338
commit e12f26092a

View File

@@ -411,12 +411,15 @@ 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 balanced (juror-corrected) score descending, falling back to raw // Sort by balanced (juror-corrected) score descending when the toggle is
// avgGlobalScore when no balanced score is available, then compositeScore as // on, otherwise by raw. compositeScore is the final tiebreaker. The
// a final tiebreaker. The threshold cutoff line uses the same metric so the // threshold cutoff line uses the same metric so the cutoff lands in the
// cutoff lands in the correct spot regardless of which score type is used. // right spot regardless of which score type is used.
const scoreFor = (projectId: string, raw: number | null | undefined) => const scoreFor = (projectId: string, raw: number | null | undefined) => {
evalScores.balanced[projectId]?.balancedAverage ?? raw ?? 0 const balanced = evalScores.balanced[projectId]?.balancedAverage
if (useBalanced && balanced != null) return balanced
return raw ?? 0
}
dedupedStartup.sort((a, b) => dedupedStartup.sort((a, b) =>
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore) scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|| b.compositeScore - a.compositeScore) || b.compositeScore - a.compositeScore)
@@ -465,6 +468,49 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
} }
}, [snapshot, evalScores]) }, [snapshot, evalScores])
// ─── Re-sort on toggle flip (after init) ─────────────────────────────────
// Only resorts when no server-side manual reorder is pinned for the snapshot;
// persisted manual reorders always win regardless of the score being used.
useEffect(() => {
if (!initialized.current || !snapshot || !evalScores) return
const reorders = (snapshot.reordersJson as Array<{
category: 'STARTUP' | 'BUSINESS_CONCEPT'
orderedProjectIds: string[]
}> | null) ?? []
const hasManualReorder =
reorders.some((r) => r.category === 'STARTUP') ||
reorders.some((r) => r.category === 'BUSINESS_CONCEPT')
if (hasManualReorder) return
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
const dedup = (arr: RankedProjectEntry[]): RankedProjectEntry[] => {
const seen = new Set<string>()
return arr.filter((r) => {
if (seen.has(r.projectId)) return false
seen.add(r.projectId)
return true
})
}
const scoreFor = (projectId: string, raw: number | null | undefined) => {
const balanced = evalScores.balanced[projectId]?.balancedAverage
if (useBalanced && balanced != null) return balanced
return raw ?? 0
}
const sortedStartup = dedup(startup).sort((a, b) =>
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|| b.compositeScore - a.compositeScore)
const sortedConcept = dedup(concept).sort((a, b) =>
scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore)
|| b.compositeScore - a.compositeScore)
setLocalOrder({
STARTUP: sortedStartup.map((r) => r.projectId),
BUSINESS_CONCEPT: sortedConcept.map((r) => r.projectId),
})
// Eslint disable: snapshot/evalScores are read but the resort should only
// run on toggle flip, not on every snapshot/scores refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useBalanced])
// ─── numericCriteria from eval form ───────────────────────────────────── // ─── numericCriteria from eval form ─────────────────────────────────────
const numericCriteria = useMemo(() => { const numericCriteria = useMemo(() => {
if (!evalForm?.criteriaJson) return [] if (!evalForm?.criteriaJson) return []
@@ -886,13 +932,15 @@ 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, // Effective ranking score respects the per-round
// falling back to raw avgGlobalScore. Both the sort and the // useBalancedRanking toggle. Both the sort and the threshold
// threshold check use this same value so the cutoff lands in // check read from the same helper so the cutoff lands in the
// the right spot. // right spot.
const effectiveScore = (id: string) => { const effectiveScore = (id: string) => {
const e = rankingMap.get(id) const e = rankingMap.get(id)
return evalScores?.balanced[id]?.balancedAverage ?? e?.avgGlobalScore ?? 0 const balanced = evalScores?.balanced[id]?.balancedAverage
if (useBalanced && balanced != null) return balanced
return e?.avgGlobalScore ?? 0
} }
let cutoffIndex = -1 let cutoffIndex = -1