From e12f26092a1a6b64eea725599b4c3c257961cb27 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 27 Apr 2026 13:22:11 +0200 Subject: [PATCH] feat: list sort respects useBalancedRanking toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/round/ranking-dashboard.tsx | 70 ++++++++++++++++--- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 32428da..a3eec6c 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -411,12 +411,15 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const dedupedStartup = dedup(startup) const dedupedConcept = dedup(concept) - // 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 + // Sort by balanced (juror-corrected) score descending when the toggle is + // on, otherwise by raw. compositeScore is the final tiebreaker. The + // threshold cutoff line uses the same metric so the cutoff lands in the + // right spot regardless of which score type is used. + const scoreFor = (projectId: string, raw: number | null | undefined) => { + const balanced = evalScores.balanced[projectId]?.balancedAverage + if (useBalanced && balanced != null) return balanced + return raw ?? 0 + } dedupedStartup.sort((a, b) => scoreFor(b.projectId, b.avgGlobalScore) - scoreFor(a.projectId, a.avgGlobalScore) || b.compositeScore - a.compositeScore) @@ -465,6 +468,49 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran } }, [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() + 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 ───────────────────────────────────── const numericCriteria = useMemo(() => { if (!evalForm?.criteriaJson) return [] @@ -886,13 +932,15 @@ 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. + // Effective ranking score respects the per-round + // useBalancedRanking toggle. Both the sort and the threshold + // check read from the same helper 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 + const balanced = evalScores?.balanced[id]?.balancedAverage + if (useBalanced && balanced != null) return balanced + return e?.avgGlobalScore ?? 0 } let cutoffIndex = -1