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, 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user