feat: per-round balanced-scoring toggle in side sheet

A Switch at the top of the project side panel writes
useBalancedRanking onto Round.configJson via the existing round.update
mutation. The flip is shared across all viewers because the value
lives in the round's persisted config; hydration runs on every
roundData refetch so the UI converges quickly when another admin
flips it.

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

View File

@@ -37,6 +37,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import {
Collapsible,
CollapsibleContent,
@@ -271,6 +272,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const [localCriteriaText, setLocalCriteriaText] = useState<string>('')
const [localScoreWeight, setLocalScoreWeight] = useState(5)
const [localPassRateWeight, setLocalPassRateWeight] = useState(5)
const [useBalanced, setUseBalanced] = useState(true)
const weightsInitialized = useRef(false)
// ─── Sensors ──────────────────────────────────────────────────────────────
@@ -471,18 +473,32 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
}, [evalForm])
// ─── Init local weights + criteriaText from round config ──────────────────
// useBalanced is hydrated on every roundData refetch (it has its own toggle
// that persists immediately), so it sits outside the once-only guard.
useEffect(() => {
if (!weightsInitialized.current && roundData?.configJson) {
const cfg = roundData.configJson as Record<string, unknown>
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
setLocalWeights(saved)
setLocalCriteriaText((cfg.rankingCriteria as string) ?? '')
setLocalScoreWeight((cfg.scoreWeight as number) ?? 5)
setLocalPassRateWeight((cfg.passRateWeight as number) ?? 5)
weightsInitialized.current = true
}
if (!roundData?.configJson) return
const cfg = roundData.configJson as Record<string, unknown>
setUseBalanced((cfg.useBalancedRanking as boolean | undefined) ?? true)
if (weightsInitialized.current) return
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
setLocalWeights(saved)
setLocalCriteriaText((cfg.rankingCriteria as string) ?? '')
setLocalScoreWeight((cfg.scoreWeight as number) ?? 5)
setLocalPassRateWeight((cfg.passRateWeight as number) ?? 5)
weightsInitialized.current = true
}, [roundData])
// ─── Persist the balanced-ranking toggle immediately ─────────────────────
const persistUseBalanced = (next: boolean) => {
setUseBalanced(next)
if (!roundData?.configJson) return
const cfg = roundData.configJson as Record<string, unknown>
updateRoundMutation.mutate({
id: roundId,
configJson: { ...cfg, useBalancedRanking: next },
})
}
// ─── Save weights + criteria text to round config ─────────────────────────
const saveRankingConfig = () => {
if (!roundData?.configJson) return
@@ -1001,6 +1017,16 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</div>
) : projectDetail ? (
<div className="mt-6 space-y-6">
{/* Balanced-ranking toggle (per-round; persists across viewers) */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex flex-col">
<span className="text-sm font-medium">Use balanced scoring for ranking</span>
<span className="text-xs text-muted-foreground">
Corrects for per-juror grading style. Off uses raw averages.
</span>
</div>
<Switch checked={useBalanced} onCheckedChange={persistUseBalanced} />
</div>
{/* Stats summary */}
{projectDetail.stats && (
<div className="grid grid-cols-3 gap-3">