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,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -40,9 +41,13 @@ import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50]
|
||||
@@ -52,6 +57,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [page, setPage] = useState(1)
|
||||
const [perPage, setPerPage] = useState(20)
|
||||
|
||||
@@ -75,18 +82,44 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSort = (column: 'title' | 'score' | 'evaluations') => {
|
||||
if (sortBy === column) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(column)
|
||||
setSortDir(column === 'title' ? 'asc' : 'desc')
|
||||
}
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
|
||||
if (sortBy !== column) return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
|
||||
return sortDir === 'asc'
|
||||
? <ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
: <ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
}
|
||||
|
||||
// Fetch programs/rounds for the filter dropdown
|
||||
const { data: programs } = trpc.program.list.useQuery({})
|
||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap((p) =>
|
||||
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({
|
||||
(p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
programName: `${p.year} Edition`,
|
||||
status: r.status,
|
||||
roundType: r.roundType,
|
||||
}))
|
||||
) || []
|
||||
|
||||
// Default to the active round
|
||||
useEffect(() => {
|
||||
if (rounds.length && selectedRoundId === 'all') {
|
||||
const active = rounds.find((r) => r.status === 'ROUND_ACTIVE')
|
||||
if (active) setSelectedRoundId(active.id)
|
||||
}
|
||||
}, [rounds.length]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch dashboard stats
|
||||
const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
|
||||
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
|
||||
@@ -98,18 +131,14 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
roundId: roundIdParam,
|
||||
search: debouncedSearch || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
sortBy,
|
||||
sortDir,
|
||||
page,
|
||||
perPage,
|
||||
})
|
||||
|
||||
// Fetch recent rounds for jury completion
|
||||
const { data: recentRoundsData } = trpc.program.list.useQuery({})
|
||||
const recentRounds = recentRoundsData?.flatMap((p) =>
|
||||
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({
|
||||
...r,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
)?.slice(0, 5) || []
|
||||
// Recent rounds for jury completion (reuse existing programs data)
|
||||
const recentRounds = rounds.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -152,7 +181,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
<SelectItem value="all">All Rounds</SelectItem>
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{round.programName} - {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -175,83 +204,93 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.programCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<FolderKanban className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.jurorCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active members</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.submittedEvaluations}</p>
|
||||
<div className="mt-2">
|
||||
<Progress value={stats.completionRate} className="h-2" gradient />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stats.completionRate}% completion rate
|
||||
<div className="space-y-4">
|
||||
{/* Universal stats: Programs + Projects */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.programCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<FolderKanban className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Round-type-aware stats */}
|
||||
{selectedRoundId !== 'all' ? (
|
||||
<RoundTypeStatsCards roundId={selectedRoundId} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.jurorCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active members</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.submittedEvaluations}</p>
|
||||
<div className="mt-2">
|
||||
<Progress value={stats.completionRate} className="h-2" gradient />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stats.completionRate}% completion rate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -320,12 +359,24 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>
|
||||
<button type="button" onClick={() => handleSort('title')} className="inline-flex items-center hover:text-foreground transition-colors">
|
||||
Title<SortIcon column="title" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Avg Score</TableHead>
|
||||
<TableHead className="text-right">Evaluations</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<button type="button" onClick={() => handleSort('score')} className="inline-flex items-center hover:text-foreground transition-colors">
|
||||
Avg Score<SortIcon column="score" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<button type="button" onClick={() => handleSort('evaluations')} className="inline-flex items-center hover:text-foreground transition-colors">
|
||||
Evaluations<SortIcon column="evaluations" />
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -441,14 +492,24 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const maxCount = Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
|
||||
const colors = ['bg-green-500', 'bg-emerald-400', 'bg-amber-400', 'bg-orange-400', 'bg-red-400']
|
||||
return stats.scoreDistribution.map((bucket, i) => (
|
||||
<div key={bucket.label} className="flex items-center gap-3">
|
||||
// Score-based colors: high scores = brand dark blue, low = brand red
|
||||
const scoreColors: Record<string, string> = {
|
||||
'9-10': '#053d57',
|
||||
'7-8': '#1e7a8a',
|
||||
'5-6': '#557f8c',
|
||||
'3-4': '#c4453a',
|
||||
'1-2': '#de0f1e',
|
||||
}
|
||||
return stats.scoreDistribution.map((bucket) => (
|
||||
<div key={bucket.label} className="flex items-center gap-3" role="img" aria-label={`Score ${bucket.label}: ${bucket.count} evaluations`}>
|
||||
<span className="text-sm w-16 text-right tabular-nums">{bucket.label}</span>
|
||||
<div className="flex-1 h-6 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', colors[i])}
|
||||
style={{ width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%` }}
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%`,
|
||||
backgroundColor: scoreColors[bucket.label] || '#557f8c',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm tabular-nums w-8">{bucket.count}</span>
|
||||
@@ -477,13 +538,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentRounds.map((round) => (
|
||||
<div
|
||||
<Link
|
||||
key={round.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
href={`/observer/reports?round=${round.id}`}
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm hover:bg-muted/50"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{round.name}</p>
|
||||
{round.roundType && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{round.roundType.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
round.status === 'ROUND_ACTIVE'
|
||||
@@ -500,13 +567,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
||||
{round.programName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p>Round details</p>
|
||||
<p className="text-muted-foreground">
|
||||
View analytics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user