fix(applicant-feedback): correct scales, hide jury-internal criteria, declutter UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s

- 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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-07 12:21:52 +02:00
parent 55e6abc161
commit b7a4eac2b1
3 changed files with 179 additions and 239 deletions

View File

@@ -8,69 +8,72 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { import { Star, MessageSquare, Trophy, Vote, ShieldCheck } from 'lucide-react'
Star,
MessageSquare,
Trophy,
Vote,
TrendingUp,
BarChart3,
Award,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type EvaluationRound = { type Criterion = {
roundId: string id?: string
roundName: string type?: string
roundType: string label?: string
evaluationCount: number name?: string
evaluations: Array<{ scale?: string
maxScore?: number
}
type Evaluation = {
id: string id: string
submittedAt: Date | null submittedAt: Date | null
globalScore: number | null globalScore: number | null
criterionScores: unknown criterionScores: unknown
feedbackText: string | null feedbackText: string | null
criteria: unknown criteria: unknown
}>
} }
function computeRoundStats(round: EvaluationRound) { type EvaluationRound = {
const maxScore = round.roundType === 'LIVE_FINAL' ? 10 : 100 roundId: string
roundName: string
roundType: string
evaluationCount: number
evaluations: Evaluation[]
}
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 const scores = round.evaluations
.map((ev) => ev.globalScore) .map((ev) => ev.globalScore)
.filter((s): s is number => s !== null) .filter((s): s is number => s !== null)
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null if (scores.length === 0) return null
const highest = scores.length > 0 ? Math.max(...scores) : null const max = 10
const lowest = scores.length > 0 ? Math.min(...scores) : null const avg = scores.reduce((a, b) => a + b, 0) / scores.length
return { maxScore, avg, highest, lowest, scores } const lowest = Math.min(...scores)
} const highest = Math.max(...scores)
return { avg, lowest, highest, max }
function ScoreBar({ score, maxScore, color }: { score: number; maxScore: number; color: string }) {
const pct = (score / maxScore) * 100
return (
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 overflow-hidden rounded-full bg-muted" style={{ height: 10 }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
<span className="text-sm font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
)
}
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'
} }
function RoundIcon({ roundType, className }: { roundType: string; className?: string }) { function RoundIcon({ roundType, className }: { roundType: string; className?: string }) {
@@ -85,6 +88,48 @@ function roundIconBg(roundType: string) {
return 'bg-yellow-500/10' 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 (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-brand-dark transition-all"
style={{ width: `${pct}%` }}
/>
</div>
)
}
function NumericCriterion({ label, score, max }: { label: string; score: number | undefined; max: number }) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">
{score !== undefined ? score : '—'}
<span className="text-muted-foreground font-normal text-xs"> / {max}</span>
</span>
</div>
{score !== undefined && <CriterionBar value={score} max={max} />}
</div>
)
}
function TextCriterion({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</div>
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
<p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
{value}
</p>
</div>
</div>
)
}
export default function ApplicantEvaluationsPage() { export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery() const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
@@ -95,14 +140,6 @@ export default function ApplicantEvaluationsPage() {
<h1 className="text-2xl font-bold">Jury Feedback</h1> <h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">Anonymous evaluations from jury members</p> <p className="text-muted-foreground">Anonymous evaluations from jury members</p>
</div> </div>
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-card p-4">
<Skeleton className="h-5 w-20 mb-2" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
<div className="space-y-4"> <div className="space-y-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<Card key={i}> <Card key={i}>
@@ -119,34 +156,14 @@ export default function ApplicantEvaluationsPage() {
const hasEvaluations = rounds && rounds.length > 0 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold">Jury Feedback</h1> <h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Anonymous evaluations from jury members {hasEvaluations
? `Anonymous evaluations from jury members across ${rounds.length} round${rounds.length === 1 ? '' : 's'}.`
: 'Anonymous evaluations from jury members.'}
</p> </p>
</div> </div>
@@ -164,105 +181,44 @@ export default function ApplicantEvaluationsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats Summary Strip */}
<AnimatedCard index={0}>
<Card className="p-0 overflow-hidden">
<div className="grid grid-cols-3 divide-x divide-border">
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<BarChart3 className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Reviews</span>
</div>
<p className="text-2xl font-bold tabular-nums">{totalEvaluations}</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Avg Score</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalAvg !== null ? globalAvg.toFixed(1) : '—'}
{globalAvg !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
<div className="p-4 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<Award className="h-3.5 w-3.5 text-amber-500" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Highest</span>
</div>
<p className="text-2xl font-bold tabular-nums">
{globalHighest !== null ? globalHighest : '—'}
{globalHighest !== null && <span className="text-sm font-normal text-muted-foreground"> / 100</span>}
</p>
</div>
</div>
</Card>
</AnimatedCard>
{/* Per-Round Cards */}
{rounds.map((round, roundIdx) => { {rounds.map((round, roundIdx) => {
const { maxScore, avg, highest, lowest } = computeRoundStats(round) const summary = globalScoreSummary(round)
return ( return (
<AnimatedCard key={round.roundId} index={roundIdx + 1}> <AnimatedCard key={round.roundId} index={roundIdx}>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-3">
<CardTitle className="flex items-center gap-2.5"> <CardTitle className="flex items-center gap-2.5">
<div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}> <div className={cn('rounded-lg p-1.5', roundIconBg(round.roundType))}>
<RoundIcon roundType={round.roundType} /> <RoundIcon roundType={round.roundType} />
</div> </div>
<div> <div>
<span>{round.roundName}</span> <span>{round.roundName}</span>
{avg !== null && round.roundType !== 'DELIBERATION' && ( {summary && (
<p className="text-sm font-normal text-muted-foreground mt-0.5"> <p className="text-sm font-normal text-muted-foreground mt-0.5">
Average: <span className="font-semibold text-foreground">{avg.toFixed(1)}</span> / {maxScore} Average <span className="font-semibold text-foreground">{summary.avg.toFixed(1)}</span> / {summary.max}
{highest !== null && lowest !== null && highest !== lowest && ( {summary.lowest !== summary.highest && (
<span className="ml-2"> <span className="ml-2">· Lowest {summary.lowest} · Highest {summary.highest}</span>
Range: {lowest}{highest}
</span>
)} )}
</p> </p>
)} )}
</div> </div>
</CardTitle> </CardTitle>
<Badge variant="secondary"> <Badge variant="secondary" className="shrink-0">
{round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''} {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'review'}{round.evaluationCount !== 1 ? 's' : ''}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
{/* Score Overview Bar — visual comparison across evaluators */}
{round.roundType !== 'DELIBERATION' && round.evaluations.some((ev) => ev.globalScore !== null) && (
<div className="px-6 pb-3">
<div className="rounded-lg bg-muted/40 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Score Comparison</p>
{round.evaluations.map((ev, idx) => {
if (ev.globalScore === null) return null
return (
<div key={ev.id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-6 text-right shrink-0 tabular-nums">
#{idx + 1}
</span>
<ScoreBar
score={ev.globalScore}
maxScore={maxScore}
color={getScoreColor(ev.globalScore, maxScore)}
/>
</div>
)
})}
</div>
</div>
)}
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y"> <div className="divide-y">
{round.evaluations.map((ev, idx) => ( {round.evaluations.map((ev, idx) => {
<div const criteria = visibleCriteria(ev.criteria)
key={ev.id} const scores = (ev.criterionScores ?? {}) as Record<string, unknown>
className="px-6 py-4 space-y-3"
> return (
<div key={ev.id} className="px-6 py-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-sm"> <span className="font-medium text-sm">
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`} {round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
@@ -272,7 +228,7 @@ export default function ApplicantEvaluationsPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 text-yellow-500" /> <Star className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-sm font-bold tabular-nums">{ev.globalScore}</span> <span className="text-sm font-bold tabular-nums">{ev.globalScore}</span>
<span className="text-xs text-muted-foreground">/ {maxScore}</span> <span className="text-xs text-muted-foreground">/ 10</span>
</span> </span>
)} )}
{ev.submittedAt && ( {ev.submittedAt && (
@@ -283,37 +239,23 @@ export default function ApplicantEvaluationsPage() {
</div> </div>
</div> </div>
{ev.criterionScores && ev.criteria && ( {criteria.length > 0 && (
<div className="space-y-2"> <div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Criteria Breakdown</p> {criteria.map((c, ci) => {
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci) const key = c.id || String(ci)
const score = scores[key] const label = c.label || c.name || `Criterion ${ci + 1}`
const cMax = c.maxScore || 10 const raw = scores[key]
const pct = score !== undefined ? (score / cMax) * 100 : 0
return ( if (c.type === 'text') {
<div key={ci} className="space-y-1"> if (typeof raw !== 'string' || raw.trim() === '') return null
<div className="flex items-center justify-between text-sm"> return <TextCriterion key={key} label={label} value={raw} />
<span className="text-muted-foreground">{c.label || c.name || `Criterion ${ci + 1}`}</span> }
<span className="font-semibold tabular-nums">
{score !== undefined ? score : '—'} // numeric (default)
<span className="text-muted-foreground font-normal text-xs"> / {cMax}</span> const score = typeof raw === 'number' ? raw : undefined
</span> const max = getCriterionMax(c)
</div> return <NumericCriterion key={key} label={label} score={score} max={max} />
{score !== undefined && ( })}
<Progress value={pct} className="h-1.5" />
)}
</div>
)
})
})()}
</div>
</div> </div>
)} )}
@@ -324,14 +266,15 @@ export default function ApplicantEvaluationsPage() {
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'} {round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
</div> </div>
<div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal"> <div className="rounded-lg bg-muted/40 px-4 py-3 border-l-3 border-brand-teal">
<p className="text-sm italic text-muted-foreground leading-relaxed"> <p className="text-sm italic text-muted-foreground leading-relaxed whitespace-pre-wrap">
{ev.feedbackText} {ev.feedbackText}
</p> </p>
</div> </div>
</div> </div>
)} )}
</div> </div>
))} )
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -339,7 +282,6 @@ export default function ApplicantEvaluationsPage() {
) )
})} })}
{/* Confidentiality Footer */}
<div className="flex items-center justify-center gap-2 py-2"> <div className="flex items-center justify-center gap-2 py-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground/60" /> <ShieldCheck className="h-4 w-4 text-muted-foreground/60" />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -624,8 +624,9 @@ function SettingToggle({
settingKey: string settingKey: string
value: string value: string
}) { }) {
const [optimistic, setOptimistic] = useState<boolean | null>(null)
const mutation = useSettingsMutation() const mutation = useSettingsMutation()
const isChecked = value === 'true' const isChecked = optimistic ?? value === 'true'
return ( return (
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
@@ -638,9 +639,13 @@ function SettingToggle({
<Switch <Switch
checked={isChecked} checked={isChecked}
disabled={mutation.isPending} disabled={mutation.isPending}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
mutation.mutate({ key: settingKey, value: String(checked) }) setOptimistic(checked)
} mutation.mutate(
{ key: settingKey, value: String(checked) },
{ onError: () => setOptimistic(null) },
)
}}
/> />
</div> </div>
) )

View File

@@ -1949,7 +1949,7 @@ export const applicantRouter = router({
results.push({ results.push({
roundId: round.id, roundId: round.id,
roundName: `Evaluation Round ${i + 1}`, roundName: round.name,
roundType: 'EVALUATION', roundType: 'EVALUATION',
evaluationCount: evaluations.length, evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({ evaluations: evaluations.map((ev) => ({
@@ -1997,13 +1997,8 @@ export const applicantRouter = router({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}) })
let evalCounter = 0
let liveFinalCounter = 0
let deliberationCounter = 0
for (const round of rounds) { for (const round of rounds) {
if (round.roundType === 'EVALUATION') { if (round.roundType === 'EVALUATION') {
evalCounter++
const evaluations = await ctx.prisma.evaluation.findMany({ const evaluations = await ctx.prisma.evaluation.findMany({
where: { where: {
assignment: { projectId: project.id, roundId: round.id }, assignment: { projectId: project.id, roundId: round.id },
@@ -2023,7 +2018,7 @@ export const applicantRouter = router({
results.push({ results.push({
roundId: round.id, roundId: round.id,
roundName: `Evaluation Round ${evalCounter}`, roundName: round.name,
roundType: 'EVALUATION', roundType: 'EVALUATION',
evaluationCount: evaluations.length, evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({ evaluations: evaluations.map((ev) => ({
@@ -2036,7 +2031,6 @@ export const applicantRouter = router({
})), })),
}) })
} else if (round.roundType === 'LIVE_FINAL') { } else if (round.roundType === 'LIVE_FINAL') {
liveFinalCounter++
// LiveVote scores — anonymized // LiveVote scores — anonymized
// Only show jury votes, not audience votes // Only show jury votes, not audience votes
const votes = await ctx.prisma.liveVote.findMany({ const votes = await ctx.prisma.liveVote.findMany({
@@ -2056,7 +2050,7 @@ export const applicantRouter = router({
results.push({ results.push({
roundId: round.id, roundId: round.id,
roundName: `Live Final ${liveFinalCounter}`, roundName: round.name,
roundType: 'LIVE_FINAL', roundType: 'LIVE_FINAL',
evaluationCount: votes.length, evaluationCount: votes.length,
evaluations: votes.map((v) => ({ evaluations: votes.map((v) => ({
@@ -2069,7 +2063,6 @@ export const applicantRouter = router({
})), })),
}) })
} else if (round.roundType === 'DELIBERATION') { } else if (round.roundType === 'DELIBERATION') {
deliberationCounter++
// DeliberationVote — per-juror votes for this project // DeliberationVote — per-juror votes for this project
const votes = await ctx.prisma.deliberationVote.findMany({ const votes = await ctx.prisma.deliberationVote.findMany({
where: { where: {
@@ -2089,7 +2082,7 @@ export const applicantRouter = router({
results.push({ results.push({
roundId: round.id, roundId: round.id,
roundName: `Deliberation ${deliberationCounter}`, roundName: round.name,
roundType: 'DELIBERATION', roundType: 'DELIBERATION',
evaluationCount: votes.length, evaluationCount: votes.length,
evaluations: votes.map((v) => ({ evaluations: votes.map((v) => ({