2026-02-14 15:26:42 +01:00
|
|
|
'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'
|
2026-02-21 10:12:21 +01:00
|
|
|
import { scoreGradient } from './chart-theme'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
interface JurorMetric {
|
|
|
|
|
userId: string
|
|
|
|
|
name: string
|
|
|
|
|
evaluationCount: number
|
|
|
|
|
averageScore: number
|
|
|
|
|
stddev: number
|
|
|
|
|
deviationFromOverall: number
|
|
|
|
|
isOutlier: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface JurorConsistencyProps {
|
|
|
|
|
data: {
|
|
|
|
|
overallAverage: number
|
|
|
|
|
jurors: JurorMetric[]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 10:12:21 +01:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
2026-02-20 13:42:31 +01:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
2026-02-21 10:12:21 +01:00
|
|
|
const sorted = [...data.jurors].sort((a, b) => b.averageScore - a.averageScore)
|
Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart)
- Remove @nivo/*, @react-spring/web dependencies (45 packages removed)
- Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed
- Add new /observer/projects page with search, filters, sorting, pagination, CSV export
- Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export
- Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files)
- Update loading skeletons to match new layouts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:45:01 +01:00
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
2026-02-21 10:12:21 +01:00
|
|
|
{/* Juror Scoring Patterns — bar-based visual instead of scatter */}
|
2026-02-14 15:26:42 +01:00
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
2026-02-21 10:12:21 +01:00
|
|
|
<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">
|
2026-02-14 15:26:42 +01:00
|
|
|
Overall Avg: {data.overallAverage.toFixed(2)}
|
|
|
|
|
{outlierCount > 0 && (
|
2026-02-21 10:12:21 +01:00
|
|
|
<Badge variant="destructive">
|
2026-02-14 15:26:42 +01:00
|
|
|
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
2026-02-21 10:12:21 +01:00
|
|
|
<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.
|
2026-02-14 15:26:42 +01:00
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Juror details table */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
2026-02-21 10:12:21 +01:00
|
|
|
<CardTitle className="text-base">Juror Consistency Details</CardTitle>
|
2026-02-14 15:26:42 +01:00
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
2026-02-21 10:12:21 +01:00
|
|
|
{/* 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>
|
2026-02-14 15:26:42 +01:00
|
|
|
</TableRow>
|
2026-02-21 10:12:21 +01:00
|
|
|
</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>
|
2026-02-14 15:26:42 +01:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|