Observer platform overhaul: Nivo charts, round-type stats, UX improvements
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:
Matt
2026-02-19 21:44:38 +01:00
parent 8ae8145d86
commit 9d945c33f9
18 changed files with 2095 additions and 1082 deletions

View File

@@ -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>