Files
MOPC-Portal/src/components/charts/juror-consistency.tsx

206 lines
8.0 KiB
TypeScript
Raw Normal View History

'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react'
import { scoreGradient } from './chart-theme'
interface JurorMetric {
userId: string
name: string
evaluationCount: number
averageScore: number
stddev: number
deviationFromOverall: number
isOutlier: boolean
}
interface JurorConsistencyProps {
data: {
overallAverage: number
jurors: JurorMetric[]
}
}
function ScoreDot({ score, maxScore = 10 }: { score: number; maxScore?: number }) {
const pct = ((score / maxScore) * 100).toFixed(1)
return (
<div className="flex items-center gap-2 w-full min-w-[120px]">
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${pct}%`,
backgroundColor: scoreGradient(score),
}}
/>
</div>
<span className="text-xs tabular-nums font-medium w-8 text-right">{score.toFixed(1)}</span>
</div>
)
}
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
if (!data?.jurors?.length) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No juror consistency data available</p>
</CardContent>
</Card>
)
}
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
const sorted = [...data.jurors].sort((a, b) => b.averageScore - a.averageScore)
return (
<div className="space-y-6">
{/* Juror Scoring Patterns — bar-based visual instead of scatter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between flex-wrap gap-2">
<span className="text-base">Juror Scoring Patterns</span>
<span className="text-sm font-normal text-muted-foreground flex items-center gap-2">
Overall Avg: {data.overallAverage.toFixed(2)}
{outlierCount > 0 && (
<Badge variant="destructive">
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
</Badge>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{sorted.map((juror) => (
<div
key={juror.userId}
className={`flex items-center gap-3 rounded-md px-3 py-2 ${juror.isOutlier ? 'bg-destructive/5 border border-destructive/20' : 'hover:bg-muted/50'}`}
>
<div className="w-36 shrink-0 truncate">
<span className="text-sm font-medium">{juror.name}</span>
</div>
<div className="flex-1">
<ScoreDot score={juror.averageScore} />
</div>
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
<span className="tabular-nums">&sigma; {juror.stddev.toFixed(1)}</span>
<span className="tabular-nums">{juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''}</span>
</div>
{juror.isOutlier && (
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
)}
</div>
))}
</div>
{/* Overall average line */}
<p className="text-xs text-muted-foreground mt-4 text-center">
Bars show average score per juror. &sigma; = standard deviation. Outliers deviate 2+ points from the overall mean.
</p>
</CardContent>
</Card>
{/* Juror details table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Juror Consistency Details</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead className="text-right">Evaluations</TableHead>
<TableHead className="text-right">Avg Score</TableHead>
<TableHead className="text-right">Std Dev</TableHead>
<TableHead className="text-right">Deviation</TableHead>
<TableHead className="text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((juror) => (
<TableRow
key={juror.userId}
className={juror.isOutlier ? 'bg-destructive/5' : ''}
>
<TableCell className="font-medium">{juror.name}</TableCell>
<TableCell className="text-right tabular-nums">
{juror.evaluationCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.stddev.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.deviationFromOverall >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary">Normal</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Mobile card stack */}
<div className="space-y-2 md:hidden">
{sorted.map((juror) => (
<div
key={juror.userId}
className={`rounded-md border p-3 space-y-1 ${juror.isOutlier ? 'bg-destructive/5 border-destructive/20' : ''}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{juror.name}</span>
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1 text-[10px]">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Normal</Badge>
)}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<p className="text-muted-foreground">Avg Score</p>
<p className="font-medium tabular-nums">{juror.averageScore.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Std Dev</p>
<p className="font-medium tabular-nums">{juror.stddev.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Evals</p>
<p className="font-medium tabular-nums">{juror.evaluationCount}</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}