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,16 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar, type ComputedDatum } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme } from './chart-theme'
|
||||
|
||||
interface JurorWorkloadData {
|
||||
id: string
|
||||
@@ -24,18 +16,32 @@ interface JurorWorkloadProps {
|
||||
data: JurorWorkloadData[]
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
// Truncate names for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
|
||||
}))
|
||||
type WorkloadBarDatum = {
|
||||
juror: string
|
||||
completed: number
|
||||
remaining: number
|
||||
completionRate: number
|
||||
fullName: string
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
|
||||
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
|
||||
const overallRate =
|
||||
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
|
||||
|
||||
const sortedData = [...data].sort(
|
||||
(a, b) => b.completionRate - a.completionRate,
|
||||
)
|
||||
|
||||
const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({
|
||||
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||
completed: d.completed,
|
||||
remaining: d.assigned - d.completed,
|
||||
completionRate: d.completionRate,
|
||||
fullName: d.name,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -47,54 +53,65 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="displayName"
|
||||
type="category"
|
||||
width={90}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
<div
|
||||
style={{ height: `${Math.max(300, data.length * 35)}px` }}
|
||||
>
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['completed', 'remaining']}
|
||||
indexBy="juror"
|
||||
layout="horizontal"
|
||||
theme={nivoTheme}
|
||||
colors={['#053d57', '#e5e7eb']}
|
||||
borderRadius={2}
|
||||
enableLabel={true}
|
||||
label={(d: ComputedDatum<WorkloadBarDatum>) => {
|
||||
if (d.id === 'completed') {
|
||||
return `${d.data.completionRate}%`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
labelSkipWidth={40}
|
||||
labelTextColor={(d) => {
|
||||
const datum = d as unknown as { data: ComputedDatum<WorkloadBarDatum> }
|
||||
return datum.data.id === 'completed' ? '#ffffff' : '#374151'
|
||||
}}
|
||||
margin={{ top: 10, right: 30, bottom: 30, left: 160 }}
|
||||
padding={0.25}
|
||||
groupMode="stacked"
|
||||
tooltip={({ id, value, data: rowData }) => (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e5e7eb',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as JurorWorkloadData
|
||||
return `${item.name} (${item.completionRate}% complete)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="assigned"
|
||||
name="Assigned"
|
||||
fill="#8884d8"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name="Completed"
|
||||
fill="#82ca9d"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{rowData.fullName}</strong>
|
||||
<br />
|
||||
{id === 'completed' ? 'Completed' : 'Remaining'}: {value}
|
||||
<br />
|
||||
Completion: {rowData.completionRate}%
|
||||
</div>
|
||||
)}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
translateY: 30,
|
||||
itemsSpacing: 20,
|
||||
itemWidth: 100,
|
||||
itemHeight: 18,
|
||||
symbolSize: 12,
|
||||
symbolShape: 'square',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user