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,17 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
|
||||
interface ProjectRankingData {
|
||||
id: string
|
||||
@@ -27,93 +18,119 @@ interface ProjectRankingsProps {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// Generate color based on score (red to green gradient)
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
type RankingBarDatum = {
|
||||
project: string
|
||||
score: number
|
||||
fullTitle: string
|
||||
teamName: string
|
||||
evaluationCount: number
|
||||
}
|
||||
|
||||
export function ProjectRankingsChart({
|
||||
data,
|
||||
limit = 20,
|
||||
}: ProjectRankingsProps) {
|
||||
const displayData = data.slice(0, limit).map((d, index) => ({
|
||||
...d,
|
||||
rank: index + 1,
|
||||
displayTitle:
|
||||
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
|
||||
score: d.averageScore || 0,
|
||||
}))
|
||||
const scoredData = data.filter(
|
||||
(d): d is ProjectRankingData & { averageScore: number } =>
|
||||
d.averageScore !== null,
|
||||
)
|
||||
|
||||
const averageScore =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
|
||||
scoredData.length > 0
|
||||
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
|
||||
scoredData.length
|
||||
: 0
|
||||
|
||||
const displayData = scoredData.slice(0, limit)
|
||||
|
||||
const chartData: RankingBarDatum[] = displayData.map((d) => ({
|
||||
project:
|
||||
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
|
||||
score: d.averageScore,
|
||||
fullTitle: d.title,
|
||||
teamName: d.teamName ?? '',
|
||||
evaluationCount: d.evaluationCount,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Project Rankings</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Top {displayData.length} of {data.length} projects
|
||||
Top {displayData.length} of {scoredData.length} scored projects
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[500px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={displayData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" domain={[0, 10]} />
|
||||
<YAxis
|
||||
dataKey="displayTitle"
|
||||
type="category"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as ProjectRankingData & {
|
||||
rank: number
|
||||
}
|
||||
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={averageScore}
|
||||
stroke="#666"
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${averageScore.toFixed(1)}`,
|
||||
position: 'top',
|
||||
fill: '#666',
|
||||
<div
|
||||
style={{
|
||||
height: `${Math.max(400, displayData.length * 30)}px`,
|
||||
}}
|
||||
>
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['score']}
|
||||
indexBy="project"
|
||||
layout="horizontal"
|
||||
theme={nivoTheme}
|
||||
colors={(bar) => scoreGradient(bar.data.score as number)}
|
||||
valueScale={{ type: 'linear', max: 10 }}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
label={(d) => {
|
||||
const v = d.value
|
||||
return v != null ? Number(v).toFixed(1) : ''
|
||||
}}
|
||||
labelSkipWidth={30}
|
||||
labelTextColor="#ffffff"
|
||||
margin={{ top: 10, right: 30, bottom: 30, left: 200 }}
|
||||
padding={0.2}
|
||||
markers={[
|
||||
{
|
||||
axis: 'x',
|
||||
value: averageScore,
|
||||
lineStyle: {
|
||||
stroke: '#6b7280',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 4',
|
||||
},
|
||||
legend: `Avg: ${averageScore.toFixed(1)}`,
|
||||
legendPosition: 'top',
|
||||
textStyle: {
|
||||
fill: '#6b7280',
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
]}
|
||||
tooltip={({ 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,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{displayData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{rowData.fullTitle}</strong>
|
||||
{rowData.teamName && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
{rowData.teamName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
Score: {Number(rowData.score).toFixed(2)}
|
||||
<br />
|
||||
Evaluations: {rowData.evaluationCount}
|
||||
</div>
|
||||
)}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user