Observer platform overhaul: Nivo charts, round-type stats, UX improvements
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
Phase 1: Fix 6 backend data bugs in analytics.ts (roundName filtering, unscored projects, criteria scores, activeRoundCount scoping, email privacy leaks in juror consistency + workload) Phase 2-3: Migrate all 9 chart components from Recharts to Nivo (@nivo/bar, @nivo/line, @nivo/pie, @nivo/scatterplot) with shared brand theme, scoreGradient colors, and STATUS_COLORS map. Fixes scatter plot outlier coloring and pie chart label visibility bugs. Phase 4: Add round-type-aware stats (getRoundTypeStats backend + RoundTypeStatsCards component) showing appropriate metrics per round type (intake/filtering/evaluation/submission/mentoring/live/deliberation). Phase 5: UX improvements — Stage→Round terminology, clickable dashboard round links, URL-based round selection (?round=), round type indicators in selectors, accessible Toggle-based cross-round comparison, sortable project table columns (title/score/evaluations), brand score colors on dashboard bar chart with aria labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { ResponsiveScatterPlot } from '@nivo/scatterplot'
|
||||
import type {
|
||||
ScatterPlotDatum,
|
||||
ScatterPlotNodeProps,
|
||||
} from '@nivo/scatterplot'
|
||||
import { animated } from '@react-spring/web'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
@@ -21,11 +17,11 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
|
||||
|
||||
interface JurorMetric {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
evaluationCount: number
|
||||
averageScore: number
|
||||
stddev: number
|
||||
@@ -40,14 +36,73 @@ interface JurorConsistencyProps {
|
||||
}
|
||||
}
|
||||
|
||||
interface JurorDatum extends ScatterPlotDatum {
|
||||
x: number
|
||||
y: number
|
||||
name: string
|
||||
evaluations: number
|
||||
isOutlier: boolean
|
||||
}
|
||||
|
||||
function CustomNode({
|
||||
node,
|
||||
style,
|
||||
blendMode,
|
||||
isInteractive,
|
||||
onMouseEnter,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
}: ScatterPlotNodeProps<JurorDatum>) {
|
||||
const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE
|
||||
|
||||
return (
|
||||
<animated.circle
|
||||
cx={style.x}
|
||||
cy={style.y}
|
||||
r={style.size.to((s: number) => s / 2)}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.7}
|
||||
stroke={fillColor}
|
||||
strokeWidth={1}
|
||||
style={{ mixBlendMode: blendMode }}
|
||||
onMouseEnter={
|
||||
isInteractive && onMouseEnter
|
||||
? (event) => onMouseEnter(node, event)
|
||||
: undefined
|
||||
}
|
||||
onMouseMove={
|
||||
isInteractive && onMouseMove
|
||||
? (event) => onMouseMove(node, event)
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
isInteractive && onMouseLeave
|
||||
? (event) => onMouseLeave(node, event)
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
isInteractive && onClick
|
||||
? (event) => onClick(node, event)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
const scatterData = data.jurors.map((j) => ({
|
||||
name: j.name,
|
||||
avgScore: parseFloat(j.averageScore.toFixed(2)),
|
||||
stddev: parseFloat(j.stddev.toFixed(2)),
|
||||
evaluations: j.evaluationCount,
|
||||
isOutlier: j.isOutlier,
|
||||
}))
|
||||
const scatterData = [
|
||||
{
|
||||
id: 'Jurors',
|
||||
data: data.jurors.map((j) => ({
|
||||
x: parseFloat(j.averageScore.toFixed(2)),
|
||||
y: parseFloat(j.stddev.toFixed(2)),
|
||||
name: j.name,
|
||||
evaluations: j.evaluationCount,
|
||||
isOutlier: j.isOutlier,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
||||
|
||||
@@ -69,51 +124,63 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="avgScore"
|
||||
name="Average Score"
|
||||
domain={[0, 10]}
|
||||
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="stddev"
|
||||
name="Std Deviation"
|
||||
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
<div style={{ height: '400px' }}>
|
||||
<ResponsiveScatterPlot<JurorDatum>
|
||||
data={scatterData}
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_DARK_BLUE]}
|
||||
xScale={{ type: 'linear', min: 0, max: 10 }}
|
||||
yScale={{ type: 'linear', min: 0, max: 'auto' }}
|
||||
axisBottom={{
|
||||
legend: 'Average Score',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: 40,
|
||||
}}
|
||||
axisLeft={{
|
||||
legend: 'Std Deviation',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: -50,
|
||||
}}
|
||||
useMesh={true}
|
||||
nodeSize={(node) =>
|
||||
Math.max(8, Math.min(20, node.data.evaluations * 2))
|
||||
}
|
||||
nodeComponent={CustomNode}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
|
||||
tooltip={({ node }) => (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={data.overallAverage}
|
||||
stroke="#de0f1e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
|
||||
/>
|
||||
<Scatter data={scatterData} fill="#053d57">
|
||||
{scatterData.map((entry, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
r={Math.max(4, entry.evaluations)}
|
||||
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{node.data.name}</strong>
|
||||
<div>Avg Score: {node.data.x}</div>
|
||||
<div>Std Dev: {node.data.y}</div>
|
||||
<div>Evaluations: {node.data.evaluations}</div>
|
||||
</div>
|
||||
)}
|
||||
markers={[
|
||||
{
|
||||
axis: 'x',
|
||||
value: data.overallAverage,
|
||||
lineStyle: {
|
||||
stroke: BRAND_RED,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 4',
|
||||
},
|
||||
legend: `Avg: ${data.overallAverage.toFixed(1)}`,
|
||||
legendPosition: 'top',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
|
||||
Dot size represents number of evaluations. Red dots indicate outlier
|
||||
jurors (2+ points from mean).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -131,22 +198,30 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
<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 from Mean</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Deviation from Mean
|
||||
</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.jurors.map((juror) => (
|
||||
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
|
||||
<TableRow
|
||||
key={juror.userId}
|
||||
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{juror.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{juror.email}</p>
|
||||
</div>
|
||||
<p className="font-medium">{juror.name}</p>
|
||||
</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.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.toFixed(2)}
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user