All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Rewrite diversity metrics: horizontal bar charts for ocean issues and geographic distribution (replaces unreadable vertical/donut charts) - Rewrite juror score heatmap: expandable table with score distribution - Rewrite juror consistency: horizontal bar visual with juror names - Merge filtering tabs into single screening view with per-project AI reasoning and expandable rows - Add project preview dialog for juror performance table - Fix status breakdown for evaluation rounds (Fully/Partially/Not Reviewed) - Show active round name instead of count on observer dashboard - Move Global tab to last position, default to first round-specific tab - Add 4-card stats layout for evaluation with reviews/project ratio - Fix oceanIssue field (singular) and remove non-existent aiSummary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
8.0 KiB
TypeScript
206 lines
8.0 KiB
TypeScript
'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">σ {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. σ = 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>
|
|
)
|
|
}
|