Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s

- 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>
This commit is contained in:
2026-02-20 21:45:01 +01:00
parent 77cbc64b33
commit 8125ca6567
24 changed files with 3412 additions and 3401 deletions

View File

@@ -1,11 +1,6 @@
'use client'
import { ResponsiveScatterPlot } from '@nivo/scatterplot'
import type {
ScatterPlotDatum,
ScatterPlotNodeProps,
} from '@nivo/scatterplot'
import { animated } from '@react-spring/web'
import { ScatterChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
@@ -17,7 +12,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react'
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
import { BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
interface JurorMetric {
userId: string
@@ -36,60 +31,6 @@ 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) {
if (!data?.jurors?.length) {
return (
@@ -101,21 +42,17 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
)
}
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
const scatterData = data.jurors.map((j) => ({
'Average Score': parseFloat(j.averageScore.toFixed(2)),
'Std Deviation': parseFloat(j.stddev.toFixed(2)),
category: j.isOutlier ? 'Outlier' : 'Normal',
name: j.name,
evaluations: j.evaluationCount,
size: Math.max(8, Math.min(20, j.evaluationCount * 2)),
}))
return (
<div className="space-y-6">
{/* Scatter: Average Score vs Standard Deviation */}
@@ -134,60 +71,15 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
</CardTitle>
</CardHeader>
<CardContent>
<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)',
}}
>
<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>
<ScatterChart
data={scatterData}
x="Average Score"
y="Std Deviation"
category="category"
size="size"
colors={[BRAND_DARK_BLUE, BRAND_RED] as string[]}
className="h-[400px]"
/>
<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).