Observer platform: mobile fixes, data/UX overhaul, animated nav
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s

- Fix dashboard default round selection to target active round instead of R1
- Move edition selector from dashboard header to hamburger menu via shared context
- Add observer-friendly status labels (Not Reviewed / Under Review / Reviewed)
- Fix pipeline completion: closed rounds show 100%, cap all rates at 100%
- Round badge on projects list shows furthest round reached
- Hide scores/evals for projects with zero evaluations
- Enhance project detail round history with pass/reject indicators from ProjectRoundState
- Remove irrelevant fields (Org Type, Budget, Duration) from project detail
- Clickable juror workload with expandable project assignments
- Humanize activity feed with icons and readable messages
- Fix jurors table: responsive card layout on mobile
- Fix criteria chart: horizontal bars for readable labels on mobile
- Animate hamburger menu open/close with CSS grid transition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 22:45:56 +01:00
parent 5eea430ebd
commit 213efdba87
11 changed files with 576 additions and 313 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, Fragment } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -14,13 +14,6 @@ import {
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -32,6 +25,7 @@ import {
import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
import { useEditionContext } from '@/components/observer/observer-edition-context'
import {
ClipboardList,
BarChart3,
@@ -41,7 +35,13 @@ import {
Globe,
ChevronRight,
Activity,
RefreshCw,
ChevronDown,
ChevronUp,
ArrowRight,
Lock,
Clock,
CheckCircle,
XCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -76,14 +76,50 @@ function computeAvgScore(scoreDistribution: { label: string; count: number }[]):
return (weightedSum / total).toFixed(1)
}
const ACTIVITY_DOT_COLORS: Record<string, string> = {
ROUND_ACTIVATED: 'bg-emerald-500',
ROUND_CLOSED: 'bg-slate-500',
EVALUATION_SUBMITTED: 'bg-blue-500',
ASSIGNMENT_CREATED: 'bg-violet-500',
PROJECT_ADVANCED: 'bg-teal-500',
PROJECT_REJECTED: 'bg-rose-500',
RESULT_LOCKED: 'bg-amber-500',
const ACTIVITY_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' },
ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' },
'round.reopened': { icon: Clock, color: 'text-emerald-500' },
'round.closed': { icon: Lock, color: 'text-slate-500' },
EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' },
ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' },
PROJECT_ADVANCED: { icon: ArrowRight, color: 'text-teal-500' },
PROJECT_REJECTED: { icon: XCircle, color: 'text-rose-500' },
RESULT_LOCKED: { icon: Lock, color: 'text-amber-500' },
}
function humanizeActivity(item: { eventType: string; actorName?: string | null; details?: Record<string, unknown> | null }): string {
const actor = item.actorName ?? 'System'
const details = item.details ?? {}
const projectName = (details.projectTitle ?? details.projectName ?? '') as string
const roundName = (details.roundName ?? '') as string
switch (item.eventType) {
case 'EVALUATION_SUBMITTED':
return projectName
? `${actor} submitted a review for ${projectName}`
: `${actor} submitted a review`
case 'ROUND_ACTIVATED':
case 'round.reopened':
return roundName ? `${roundName} was opened` : 'A round was opened'
case 'ROUND_CLOSED':
case 'round.closed':
return roundName ? `${roundName} was closed` : 'A round was closed'
case 'ASSIGNMENT_CREATED':
return projectName
? `${projectName} was assigned to a juror`
: 'A project was assigned'
case 'PROJECT_ADVANCED':
return projectName
? `${projectName} advanced${roundName ? ` to ${roundName}` : ''}`
: 'A project advanced'
case 'PROJECT_REJECTED':
return projectName ? `${projectName} was rejected` : 'A project was rejected'
case 'RESULT_LOCKED':
return roundName ? `Results locked for ${roundName}` : 'Results were locked'
default:
return `${actor}: ${item.eventType.replace(/_/g, ' ').toLowerCase()}`
}
}
const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
@@ -94,31 +130,17 @@ const STATUS_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'outline'>
}
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const { programs, selectedProgramId, activeRoundId } = useEditionContext()
const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
const { data: programs } = trpc.program.list.useQuery(
{ includeStages: true },
{ refetchInterval: 30_000 },
)
useEffect(() => {
if (programs && programs.length > 0 && !selectedProgramId) {
const firstProgram = programs[0]
setSelectedProgramId(firstProgram.id)
const firstRound = (firstProgram.rounds ?? [])[0]
if (firstRound) setSelectedRoundId(firstRound.id)
}
}, [programs, selectedProgramId])
const roundIdParam = selectedRoundId || undefined
const roundIdParam = activeRoundId || undefined
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
{ roundId: roundIdParam },
{ refetchInterval: 30_000 },
)
const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
const selectedProgram = programs.find((p) => p.id === selectedProgramId)
const competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
@@ -167,37 +189,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
Auto-refresh
</div>
<Select
value={selectedProgramId}
onValueChange={(val) => {
setSelectedProgramId(val)
const prog = programs?.find((p) => p.id === val)
const firstRound = (prog?.rounds ?? [])[0]
setSelectedRoundId(firstRound?.id ?? '')
}}
>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Select edition" />
</SelectTrigger>
<SelectContent>
{(programs ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{(p as { year?: number }).year ? `${(p as { year?: number }).year} Edition` : p.name ?? p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
</div>
{/* Six Stat Tiles */}
@@ -411,23 +405,53 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</CardHeader>
<CardContent>
{topJurors.length > 0 ? (
<div className="space-y-4">
{topJurors.map((juror) => (
<div key={juror.id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium" title={juror.name ?? ''}>
{juror.name ?? 'Unknown'}
</span>
<span className="ml-2 shrink-0 text-xs tabular-nums text-muted-foreground">
{juror.completionRate}%
</span>
<div className="space-y-3">
{topJurors.map((juror) => {
const isExpanded = expandedJurorId === juror.id
return (
<div key={juror.id}>
<button
type="button"
className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
>
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium" title={juror.name ?? ''}>
{juror.name ?? 'Unknown'}
</span>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
<span className="text-xs tabular-nums text-muted-foreground">
{juror.completionRate}%
</span>
{isExpanded ? (
<ChevronUp className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</div>
</div>
<Progress value={juror.completionRate} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{juror.completed} / {juror.assigned} evaluations
</p>
</button>
{isExpanded && juror.projects && (
<div className="ml-1 mt-1 space-y-1 border-l-2 border-muted pl-3">
{juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => (
<Link
key={proj.id}
href={`/observer/projects/${proj.id}` as Route}
className="flex items-center justify-between gap-2 rounded py-1 text-xs hover:underline"
>
<span className="truncate">{proj.title}</span>
<StatusBadge status={proj.evalStatus} size="sm" />
</Link>
))}
</div>
)}
</div>
<Progress value={juror.completionRate} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{juror.completed} / {juror.assigned} evaluations
</p>
</div>
))}
)
})}
</div>
) : (
<div className="space-y-4">
@@ -474,9 +498,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-500" />
</div>
Recent Projects
Recently Reviewed
</CardTitle>
<CardDescription>Latest project activity</CardDescription>
<CardDescription>Latest project reviews</CardDescription>
</CardHeader>
<CardContent className="p-0">
{projectsData && projectsData.projects.length > 0 ? (
@@ -486,7 +510,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead className="text-right whitespace-nowrap">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -505,10 +529,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
)}
</TableCell>
<TableCell>
<StatusBadge status={project.status} />
<StatusBadge status={project.observerStatus ?? project.status} />
</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{project.averageScore !== null ? project.averageScore.toFixed(1) : '—'}
<TableCell className="text-right tabular-nums text-sm whitespace-nowrap">
{project.evaluationCount > 0 && project.averageScore !== null
? project.averageScore.toFixed(1)
: '—'}
</TableCell>
</TableRow>
))}
@@ -549,32 +575,22 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<CardContent>
{activityFeed && activityFeed.length > 0 ? (
<div className="space-y-3">
{activityFeed.map((item) => (
<div key={item.id} className="flex items-start gap-3">
<span
className={cn(
'mt-1.5 h-2 w-2 shrink-0 rounded-full',
ACTIVITY_DOT_COLORS[item.eventType] ?? 'bg-slate-400',
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug">
<span className="font-medium">
{item.eventType.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase())}
</span>
{item.entityType && (
<span className="text-muted-foreground"> {item.entityType.replace(/_/g, ' ').toLowerCase()}</span>
)}
{activityFeed.slice(0, 5).map((item) => {
const iconDef = ACTIVITY_ICONS[item.eventType]
const IconComponent = iconDef?.icon ?? Activity
const iconColor = iconDef?.color ?? 'text-slate-400'
return (
<div key={item.id} className="flex items-start gap-3">
<IconComponent className={cn('mt-0.5 h-4 w-4 shrink-0', iconColor)} />
<p className="min-w-0 flex-1 text-sm leading-snug">
{humanizeActivity(item)}
</p>
{item.actorName && (
<p className="text-[11px] text-muted-foreground">by {item.actorName}</p>
)}
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{relativeTime(item.createdAt)}
</span>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{relativeTime(item.createdAt)}
</span>
</div>
))}
)
})}
</div>
) : (
<div className="space-y-3">