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,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -10,6 +11,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Toggle } from '@/components/ui/toggle'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
@@ -61,11 +63,21 @@ function parseSelection(value: string | null): { roundId?: string; programId?: s
return { roundId: value }
}
const ROUND_TYPE_LABELS: Record<string, string> = {
INTAKE: 'Intake',
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
SUBMISSION: 'Submission',
MENTORING: 'Mentoring',
LIVE_FINAL: 'Live Final',
DELIBERATION: 'Deliberation',
}
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
(p.stages as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programName: `${p.year} Edition`,
}))
@@ -100,9 +112,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
)
}
// Count distinct projects by collecting unique IDs, not summing per-round states
const totalProjects = overviewStats?.projectCount ?? stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalProjects = overviewStats?.projectCount ?? 0
const activeRounds = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalPrograms = programs?.length || 0
return (
@@ -114,10 +125,10 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Stages</p>
<p className="text-sm font-medium text-muted-foreground">Total Rounds</p>
<p className="text-2xl font-bold mt-1">{stages.length}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeStages} active
{activeRounds} active
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -135,7 +146,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all stages</p>
<p className="text-xs text-muted-foreground mt-1">Across all rounds</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
@@ -150,8 +161,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Active Stages</p>
<p className="text-2xl font-bold mt-1">{activeStages}</p>
<p className="text-sm font-medium text-muted-foreground">Active Rounds</p>
<p className="text-2xl font-bold mt-1">{activeRounds}</p>
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
@@ -255,14 +266,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
{/* Stages Table - Desktop */}
<Card className="hidden md:block">
<CardHeader>
<CardTitle>Stage Reports</CardTitle>
<CardDescription>Progress overview for each stage</CardDescription>
<CardTitle>Round Reports</CardTitle>
<CardDescription>Progress overview for each round</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Status</TableHead>
@@ -305,7 +316,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
{/* Stages Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Stage Reports</h2>
<h2 className="text-lg font-semibold">Round Reports</h2>
{stages.map((stage) => (
<Card key={stage.id}>
<CardContent className="pt-4 space-y-3">
@@ -485,25 +496,27 @@ function CrossStageTab() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>Choose at least 2 stages</CardDescription>
<CardTitle>Select Rounds to Compare</CardTitle>
<CardDescription>Choose at least 2 rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2" role="group" aria-label="Select rounds to compare">
{stages.map((stage) => (
<Badge
<Toggle
key={stage.id}
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)}
variant="outline"
size="sm"
pressed={selectedRoundIds.includes(stage.id)}
onPressedChange={() => toggleRound(stage.id)}
aria-label={`${stage.programName} - ${stage.name}`}
>
{stage.programName} - {stage.name}
</Badge>
</Toggle>
))}
</div>
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
Select at least 2 rounds to enable comparison
</p>
)}
</CardContent>
@@ -541,7 +554,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
data={consistency as {
overallAverage: number
jurors: Array<{
userId: string; name: string; email: string
userId: string; name: string
evaluationCount: number; averageScore: number
stddev: number; deviationFromOverall: number; isOutlier: boolean
}>
@@ -578,19 +591,21 @@ function DiversityTab({ selectedValue }: { selectedValue: string }) {
}
export default function ObserverReportsPage() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const searchParams = useSearchParams()
const roundFromUrl = searchParams.get('round')
const [selectedValue, setSelectedValue] = useState<string | null>(roundFromUrl)
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
(p.stages as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
// Set default selected stage — prefer the active round, fall back to first
// Set default selected round — prefer URL param, then active round, then first
useEffect(() => {
if (stages.length && !selectedValue) {
const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
@@ -613,29 +628,29 @@ export default function ObserverReportsPage() {
{/* Stage Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Stage:</label>
<label className="text-sm font-medium">Select Round:</label>
{stagesLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" />
) : stages.length > 0 ? (
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a stage" />
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages
{p.year} Edition All Rounds
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.programName} - {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No stages available</p>
<p className="text-sm text-muted-foreground">No rounds available</p>
)}
</div>