From b7a4eac2b1f72842ff84423e49934d095da1139d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 12:21:52 +0200 Subject: [PATCH] fix(applicant-feedback): correct scales, hide jury-internal criteria, declutter UI - globalScore is /10 (was hardcoded /100); use real round.name (was 'Round N') - Render criteria by type: numeric uses parsed scale (1-10/0-10/1-5), text shows as quoted block, boolean/advance hidden as jury-internal - Drop redundant cross-round stat strip and per-round Score Comparison - Plain language: 'Lowest/Highest' instead of 'Range', 'reviews' not 'evaluations' - Settings toggles update optimistically (was waiting for refresh) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../applicant/evaluations/page.tsx | 390 ++++++++---------- src/components/settings/settings-content.tsx | 13 +- src/server/routers/applicant.ts | 15 +- 3 files changed, 179 insertions(+), 239 deletions(-) diff --git a/src/app/(applicant)/applicant/evaluations/page.tsx b/src/app/(applicant)/applicant/evaluations/page.tsx index fb4975d..8db12b1 100644 --- a/src/app/(applicant)/applicant/evaluations/page.tsx +++ b/src/app/(applicant)/applicant/evaluations/page.tsx @@ -8,69 +8,72 @@ import { CardTitle, } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { AnimatedCard } from '@/components/shared/animated-container' -import { - Star, - MessageSquare, - Trophy, - Vote, - TrendingUp, - BarChart3, - Award, - ShieldCheck, -} from 'lucide-react' +import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react' import { cn } from '@/lib/utils' +type Criterion = { + id?: string + type?: string + label?: string + name?: string + scale?: string + maxScore?: number +} + +type Evaluation = { + id: string + submittedAt: Date | null + globalScore: number | null + criterionScores: unknown + feedbackText: string | null + criteria: unknown +} + type EvaluationRound = { roundId: string roundName: string roundType: string evaluationCount: number - evaluations: Array<{ - id: string - submittedAt: Date | null - globalScore: number | null - criterionScores: unknown - feedbackText: string | null - criteria: unknown - }> + evaluations: Evaluation[] } -function computeRoundStats(round: EvaluationRound) { - const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100 +const HIDDEN_CRITERION_TYPES = new Set(['boolean', 'advance']) + +function parseScaleMax(scale: string | undefined, fallback = 10): number { + if (!scale) return fallback + const m = scale.match(/^\s*\d+\s*-\s*(\d+)\s*$/) + if (m) return Number(m[1]) + return fallback +} + +function getCriterionMax(c: Criterion): number { + if (typeof c.maxScore === 'number' && c.maxScore > 0) return c.maxScore + return parseScaleMax(c.scale) +} + +function visibleCriteria(criteria: unknown): Criterion[] { + if (!Array.isArray(criteria)) return [] + return (criteria as Criterion[]).filter((c) => { + if (!c) return false + if (!c.id && !c.label && !c.name) return false + if (c.type && HIDDEN_CRITERION_TYPES.has(c.type)) return false + return true + }) +} + +function globalScoreSummary(round: EvaluationRound) { + if (round.roundType === 'DELIBERATION') return null const scores = round.evaluations .map((ev) => ev.globalScore) .filter((s): s is number => s !== null) - const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null - const highest = scores.length > 0 ? Math.max(...scores) : null - const lowest = scores.length > 0 ? Math.min(...scores) : null - return { maxScore, avg, highest, lowest, scores } -} - -function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) { - const pct = (score / maxScore) * 100 - return ( -
-
-
-
- {score} -
- ) -} - -function getScoreColor(score: number, maxScore: number): string { - const pct = score / maxScore - if (pct >= 0.8) return '#053d57' - if (pct >= 0.6) return '#1e7a8a' - if (pct >= 0.4) return '#557f8c' - if (pct >= 0.2) return '#c4453a' - return '#de0f1e' + if (scores.length === 0) return null + const max = 10 + const avg = scores.reduce((a, b) => a + b, 0) / scores.length + const lowest = Math.min(...scores) + const highest = Math.max(...scores) + return { avg, lowest, highest, max } } function RoundIcon({ roundType, className }: { roundType: string; className?: string }) { @@ -85,6 +88,48 @@ function roundIconBg(roundType: string) { return 'bg-yellow-500/10' } +function CriterionBar({ value, max }: { value: number; max: number }) { + const pct = Math.max(0, Math.min(100, (value / max) * 100)) + return ( +
+
+
+ ) +} + +function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) { + return ( +
+
+ {label} + + {score !== undefined ? score : '—'} + / {max} + +
+ {score !== undefined && } +
+ ) +} + +function TextCriterion({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+

+ {value} +

+
+
+ ) +} + export default function ApplicantEvaluationsPage() { const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery() @@ -95,14 +140,6 @@ export default function ApplicantEvaluationsPage() {

Jury Feedback

Anonymous evaluations from jury members

-
- {[1, 2, 3].map((i) => ( -
- - -
- ))} -
{[1, 2].map((i) => ( @@ -119,34 +156,14 @@ export default function ApplicantEvaluationsPage() { const hasEvaluations = rounds && rounds.length > 0 - // Compute global stats - const allScores: number[] = [] - let totalEvaluations = 0 - if (rounds) { - for (const round of rounds) { - totalEvaluations += round.evaluationCount - for (const ev of round.evaluations) { - if (ev.globalScore !== null && round.roundType !== 'DELIBERATION') { - // Normalize to 0-100 for live final scores - const normalized = round.roundType === 'LIVE_FINAL' - ? ev.globalScore * 10 - : ev.globalScore - allScores.push(normalized) - } - } - } - } - const globalAvg = allScores.length > 0 - ? allScores.reduce((a, b) => a + b, 0) / allScores.length - : null - const globalHighest = allScores.length > 0 ? Math.max(...allScores) : null - return (

Jury Feedback

- Anonymous evaluations from jury members + {hasEvaluations + ? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.` + : 'Anonymous evaluations from jury members.'}

@@ -164,174 +181,100 @@ export default function ApplicantEvaluationsPage() { ) : (
- {/* Stats Summary Strip */} - - -
-
-
- - Reviews -
-

{totalEvaluations}

-
-
-
- - Avg Score -
-

- {globalAvg !== null ? globalAvg.toFixed(1) : '—'} - {globalAvg !== null && / 100} -

-
-
-
- - Highest -
-

- {globalHighest !== null ? globalHighest : '—'} - {globalHighest !== null && / 100} -

-
-
-
-
- - {/* Per-Round Cards */} {rounds.map((round, roundIdx) => { - const { maxScore, avg, highest, lowest } = computeRoundStats(round) + const summary = globalScoreSummary(round) return ( - + -
+
{round.roundName} - {avg !== null && round.roundType !== 'DELIBERATION' && ( + {summary && (

- Average: {avg.toFixed(1)} / {maxScore} - {highest !== null && lowest !== null && highest !== lowest && ( - - Range: {lowest}–{highest} - + Average {summary.avg.toFixed(1)} / {summary.max} + {summary.lowest !== summary.highest && ( + · Lowest {summary.lowest} · Highest {summary.highest} )}

)}
- - {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''} + + {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
- {/* Score Overview Bar — visual comparison across evaluators */} - {round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && ( -
-
-

Score Comparison

- {round.evaluations.map((ev, idx) => { - if (ev.globalScore === null) return null - return ( -
- - #{idx + 1} - - -
- ) - })} -
-
- )} -
- {round.evaluations.map((ev, idx) => ( -
-
- - {round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`} - -
- {ev.globalScore !== null && round.roundType !== 'DELIBERATION' && ( - - - {ev.globalScore} - / {maxScore} - - )} - {ev.submittedAt && ( - - {new Date(ev.submittedAt).toLocaleDateString()} - - )} + {round.evaluations.map((ev, idx) => { + const criteria = visibleCriteria(ev.criteria) + const scores = (ev.criterionScores ?? {}) as Record + + return ( +
+
+ + {round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`} + +
+ {ev.globalScore !== null && round.roundType !== 'DELIBERATION' && ( + + + {ev.globalScore} + / 10 + + )} + {ev.submittedAt && ( + + {new Date(ev.submittedAt).toLocaleDateString()} + + )} +
+ + {criteria.length > 0 && ( +
+ {criteria.map((c, ci) => { + const key = c.id || String(ci) + const label = c.label || c.name || `Criterion ${ci + 1}` + const raw = scores[key] + + if (c.type === 'text') { + if (typeof raw !== 'string' || raw.trim() === '') return null + return + } + + // numeric (default) + const score = typeof raw === 'number' ? raw : undefined + const max = getCriterionMax(c) + return + })} +
+ )} + + {ev.feedbackText && ( +
+
+ + {round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'} +
+
+

+ {ev.feedbackText} +

+
+
+ )}
- - {ev.criterionScores && ev.criteria && ( -
-

Criteria Breakdown

-
- {(() => { - const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }> - const scores = ev.criterionScores as Record - return criteria - .filter((c) => c.id || c.label || c.name) - .map((c, ci) => { - const key = c.id || String(ci) - const score = scores[key] - const cMax = c.maxScore || 10 - const pct = score !== undefined ? (score / cMax) * 100 : 0 - return ( -
-
- {c.label || c.name || `Criterion ${ci + 1}`} - - {score !== undefined ? score : '—'} - / {cMax} - -
- {score !== undefined && ( - - )} -
- ) - }) - })()} -
-
- )} - - {ev.feedbackText && ( -
-
- - {round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'} -
-
-

- {ev.feedbackText} -

-
-
- )} -
- ))} + ) + })}
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() { ) })} - {/* Confidentiality Footer */}

diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 2c76fea..3856651 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -624,8 +624,9 @@ function SettingToggle({ settingKey: string value: string }) { + const [optimistic, setOptimistic] = useState(null) const mutation = useSettingsMutation() - const isChecked = value === 'true' + const isChecked = optimistic ?? value === 'true' return (

@@ -638,9 +639,13 @@ function SettingToggle({ - mutation.mutate({ key: settingKey, value: String(checked) }) - } + onCheckedChange={(checked) => { + setOptimistic(checked) + mutation.mutate( + { key: settingKey, value: String(checked) }, + { onError: () => setOptimistic(null) }, + ) + }} />
) diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index a4a615c..a21e54e 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1949,7 +1949,7 @@ export const applicantRouter = router({ results.push({ roundId: round.id, - roundName: `Evaluation Round ${i + 1}`, + roundName: round.name, roundType: 'EVALUATION', evaluationCount: evaluations.length, evaluations: evaluations.map((ev) => ({ @@ -1997,13 +1997,8 @@ export const applicantRouter = router({ orderBy: { sortOrder: 'asc' }, }) - let evalCounter = 0 - let liveFinalCounter = 0 - let deliberationCounter = 0 - for (const round of rounds) { if (round.roundType === 'EVALUATION') { - evalCounter++ const evaluations = await ctx.prisma.evaluation.findMany({ where: { assignment: { projectId: project.id, roundId: round.id }, @@ -2023,7 +2018,7 @@ export const applicantRouter = router({ results.push({ roundId: round.id, - roundName: `Evaluation Round ${evalCounter}`, + roundName: round.name, roundType: 'EVALUATION', evaluationCount: evaluations.length, evaluations: evaluations.map((ev) => ({ @@ -2036,7 +2031,6 @@ export const applicantRouter = router({ })), }) } else if (round.roundType === 'LIVE_FINAL') { - liveFinalCounter++ // LiveVote scores — anonymized // Only show jury votes, not audience votes const votes = await ctx.prisma.liveVote.findMany({ @@ -2056,7 +2050,7 @@ export const applicantRouter = router({ results.push({ roundId: round.id, - roundName: `Live Final ${liveFinalCounter}`, + roundName: round.name, roundType: 'LIVE_FINAL', evaluationCount: votes.length, evaluations: votes.map((v) => ({ @@ -2069,7 +2063,6 @@ export const applicantRouter = router({ })), }) } else if (round.roundType === 'DELIBERATION') { - deliberationCounter++ // DeliberationVote — per-juror votes for this project const votes = await ctx.prisma.deliberationVote.findMany({ where: { @@ -2089,7 +2082,7 @@ export const applicantRouter = router({ results.push({ roundId: round.id, - roundName: `Deliberation ${deliberationCounter}`, + roundName: round.name, roundType: 'DELIBERATION', evaluationCount: votes.length, evaluations: votes.map((v) => ({