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,5 +1,6 @@
import { requireRole } from '@/lib/auth-redirect' import { requireRole } from '@/lib/auth-redirect'
import { ObserverNav } from '@/components/layouts/observer-nav' import { ObserverNav } from '@/components/layouts/observer-nav'
import { EditionProvider } from '@/components/observer/observer-edition-context'
export default async function ObserverLayout({ export default async function ObserverLayout({
children, children,
@@ -10,13 +11,15 @@ export default async function ObserverLayout({
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<ObserverNav <EditionProvider>
user={{ <ObserverNav
name: session.user.name, user={{
email: session.user.email, name: session.user.name,
}} email: session.user.email,
/> }}
<main className="container-app py-6">{children}</main> />
<main className="container-app py-6">{children}</main>
</EditionProvider>
</div> </div>
) )
} }

View File

@@ -504,47 +504,90 @@ function JurorsTab({ selectedValue }: { selectedValue: string }) {
{isLoading ? ( {isLoading ? (
<Skeleton className="h-[400px]" /> <Skeleton className="h-[400px]" />
) : jurors.length > 0 ? ( ) : jurors.length > 0 ? (
<Card> <>
<CardContent className="p-0"> {/* Desktop Table */}
<Table> <Card className="hidden md:block">
<TableHeader> <CardContent className="p-0">
<TableRow> <Table>
<TableHead>Juror</TableHead> <TableHeader>
<TableHead className="text-right">Assigned</TableHead> <TableRow>
<TableHead className="text-right">Completed</TableHead> <TableHead>Juror</TableHead>
<TableHead className="min-w-[140px]">Rate</TableHead> <TableHead className="text-right">Assigned</TableHead>
<TableHead className="text-right">Avg Score</TableHead> <TableHead className="text-right">Completed</TableHead>
<TableHead className="text-right">Std Dev</TableHead> <TableHead className="min-w-[140px]">Rate</TableHead>
<TableHead>Status</TableHead> <TableHead className="text-right">Avg Score</TableHead>
</TableRow> <TableHead className="text-right">Std Dev</TableHead>
</TableHeader> <TableHead>Status</TableHead>
<TableBody>
{jurors.map((j) => (
<TableRow key={j.userId}>
<TableCell className="font-medium">{j.name}</TableCell>
<TableCell className="text-right">{j.assigned}</TableCell>
<TableCell className="text-right">{j.completed}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={j.completionRate} className="h-2 w-20" />
<span className="text-sm tabular-nums">{j.completionRate}%</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{j.averageScore.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">{j.stddev.toFixed(2)}</TableCell>
<TableCell>
{j.isOutlier ? (
<Badge variant="destructive">Outlier</Badge>
) : (
<Badge variant="outline">Normal</Badge>
)}
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {jurors.map((j) => (
</CardContent> <TableRow key={j.userId}>
</Card> <TableCell className="font-medium">{j.name}</TableCell>
<TableCell className="text-right">{j.assigned}</TableCell>
<TableCell className="text-right">{j.completed}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={j.completionRate} className="h-2 w-20" />
<span className="text-sm tabular-nums">{j.completionRate}%</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{j.averageScore.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">{j.stddev.toFixed(2)}</TableCell>
<TableCell>
{j.isOutlier ? (
<Badge variant="destructive">Outlier</Badge>
) : (
<Badge variant="outline">Normal</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Mobile Cards */}
<div className="space-y-3 md:hidden">
{jurors.map((j) => (
<Card key={j.userId}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium text-sm truncate">{j.name}</p>
{j.isOutlier ? (
<Badge variant="destructive" className="shrink-0">Outlier</Badge>
) : (
<Badge variant="outline" className="shrink-0">Normal</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Progress value={j.completionRate} className="h-2 flex-1" />
<span className="text-sm tabular-nums shrink-0">{j.completionRate}%</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Assigned</span>
<span className="tabular-nums">{j.assigned}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Completed</span>
<span className="tabular-nums">{j.completed}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg Score</span>
<span className="tabular-nums">{j.averageScore.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Std Dev</span>
<span className="tabular-nums">{j.stddev.toFixed(2)}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</>
) : hasSelection ? ( ) : hasSelection ? (
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">

View File

@@ -25,14 +25,14 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
const chartData = data.map((d) => ({ const chartData = data.map((d) => ({
criterion: criterion:
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name, d.name.length > 40 ? d.name.substring(0, 40) + '...' : d.name,
'Avg Score': parseFloat(d.averageScore.toFixed(2)), 'Avg Score': parseFloat(d.averageScore.toFixed(2)),
})) }))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<span>Score by Evaluation Criteria</span> <span>Score by Evaluation Criteria</span>
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground">
Overall Avg: {overallAverage.toFixed(2)} Overall Avg: {overallAverage.toFixed(2)}
@@ -46,10 +46,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
categories={['Avg Score']} categories={['Avg Score']}
colors={[BRAND_TEAL] as string[]} colors={[BRAND_TEAL] as string[]}
maxValue={10} maxValue={10}
yAxisWidth={40} layout="vertical"
yAxisWidth={160}
showLegend={false} showLegend={false}
className="h-[300px]" className="h-[300px]"
rotateLabelX={{ angle: -45, xAxisHeight: 60 }}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -2,11 +2,40 @@
import { BarChart3, Home, FolderKanban } from 'lucide-react' import { BarChart3, Home, FolderKanban } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { useEditionContext } from '@/components/observer/observer-edition-context'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface ObserverNavProps { interface ObserverNavProps {
user: RoleNavUser user: RoleNavUser
} }
function EditionSelector() {
const { programs, selectedProgramId, setSelectedProgramId } = useEditionContext()
if (programs.length <= 1) return null
return (
<Select value={selectedProgramId} onValueChange={setSelectedProgramId}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder="Select edition" />
</SelectTrigger>
<SelectContent>
{programs.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.year ? `${p.year} Edition` : p.name ?? p.id}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function ObserverNav({ user }: ObserverNavProps) { export function ObserverNav({ user }: ObserverNavProps) {
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ {
@@ -32,6 +61,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
roleName="Observer" roleName="Observer"
user={user} user={user}
basePath="/observer" basePath="/observer"
editionSelector={<EditionSelector />}
/> />
) )
} }

View File

@@ -41,13 +41,15 @@ type RoleNavProps = {
basePath: string basePath: string
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */ /** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
statusBadge?: React.ReactNode statusBadge?: React.ReactNode
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
editionSelector?: React.ReactNode
} }
function isNavItemActive(pathname: string, href: string, basePath: string): boolean { function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href)) return pathname === href || (href !== basePath && pathname.startsWith(href))
} }
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) { export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
const pathname = usePathname() const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession() const { status: sessionStatus } = useSession()
@@ -93,6 +95,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
{/* User menu & mobile toggle */} {/* User menu & mobile toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
{mounted && ( {mounted && (
<Button <Button
variant="ghost" variant="ghost"
@@ -161,42 +164,54 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
</div> </div>
</div> </div>
{/* Mobile menu */} {/* Mobile menu — animated with CSS grid */}
{isMobileMenuOpen && ( <div
<div className="border-t md:hidden"> className={cn(
<nav className="container-app py-4 space-y-1"> 'grid md:hidden transition-[grid-template-rows] duration-200 ease-out',
{navigation.map((item) => { isMobileMenuOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
const isActive = isNavItemActive(pathname, item.href, basePath) )}
return ( >
<Link <div className="overflow-hidden">
key={item.name} <div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
href={item.href as Route} <nav className="container-app py-4 space-y-1">
onClick={() => setIsMobileMenuOpen(false)} {navigation.map((item) => {
className={cn( const isActive = isNavItemActive(pathname, item.href, basePath)
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors', return (
isActive <Link
? 'bg-primary/10 text-primary' key={item.name}
: 'text-muted-foreground hover:bg-muted hover:text-foreground' href={item.href as Route}
)} onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
{editionSelector && (
<div className="border-t pt-4 mt-4 px-3">
{editionSelector}
</div>
)}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
> >
<item.icon className="h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
{item.name} Sign Out
</Link> </Button>
) </div>
})} </nav>
<div className="border-t pt-4 mt-4"> </div>
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</div>
</nav>
</div> </div>
)} </div>
</header> </header>
) )
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, Fragment } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@@ -14,13 +14,6 @@ import {
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Table, Table,
TableBody, TableBody,
@@ -32,6 +25,7 @@ import {
import { StatusBadge } from '@/components/shared/status-badge' import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card' import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
import { useEditionContext } from '@/components/observer/observer-edition-context'
import { import {
ClipboardList, ClipboardList,
BarChart3, BarChart3,
@@ -41,7 +35,13 @@ import {
Globe, Globe,
ChevronRight, ChevronRight,
Activity, Activity,
RefreshCw, ChevronDown,
ChevronUp,
ArrowRight,
Lock,
Clock,
CheckCircle,
XCircle,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -76,14 +76,50 @@ function computeAvgScore(scoreDistribution: { label: string; count: number }[]):
return (weightedSum / total).toFixed(1) return (weightedSum / total).toFixed(1)
} }
const ACTIVITY_DOT_COLORS: Record<string, string> = { const ACTIVITY_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
ROUND_ACTIVATED: 'bg-emerald-500', ROUND_ACTIVATED: { icon: Clock, color: 'text-emerald-500' },
ROUND_CLOSED: 'bg-slate-500', ROUND_CLOSED: { icon: Lock, color: 'text-slate-500' },
EVALUATION_SUBMITTED: 'bg-blue-500', 'round.reopened': { icon: Clock, color: 'text-emerald-500' },
ASSIGNMENT_CREATED: 'bg-violet-500', 'round.closed': { icon: Lock, color: 'text-slate-500' },
PROJECT_ADVANCED: 'bg-teal-500', EVALUATION_SUBMITTED: { icon: CheckCircle, color: 'text-blue-500' },
PROJECT_REJECTED: 'bg-rose-500', ASSIGNMENT_CREATED: { icon: ArrowRight, color: 'text-violet-500' },
RESULT_LOCKED: 'bg-amber-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'> = { 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 }) { export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [selectedProgramId, setSelectedProgramId] = useState<string>('') const { programs, selectedProgramId, activeRoundId } = useEditionContext()
const [selectedRoundId, setSelectedRoundId] = useState<string>('') const [expandedJurorId, setExpandedJurorId] = useState<string | null>(null)
const { data: programs } = trpc.program.list.useQuery( const roundIdParam = activeRoundId || undefined
{ 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 { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery( const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
{ roundId: roundIdParam }, { roundId: roundIdParam },
{ refetchInterval: 30_000 }, { 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 competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery( const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
@@ -167,37 +189,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div>
<div> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
<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> </div>
{/* Six Stat Tiles */} {/* Six Stat Tiles */}
@@ -411,23 +405,53 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{topJurors.length > 0 ? ( {topJurors.length > 0 ? (
<div className="space-y-4"> <div className="space-y-3">
{topJurors.map((juror) => ( {topJurors.map((juror) => {
<div key={juror.id} className="space-y-1"> const isExpanded = expandedJurorId === juror.id
<div className="flex items-center justify-between text-sm"> return (
<span className="truncate font-medium" title={juror.name ?? ''}> <div key={juror.id}>
{juror.name ?? 'Unknown'} <button
</span> type="button"
<span className="ml-2 shrink-0 text-xs tabular-nums text-muted-foreground"> className="w-full text-left space-y-1 rounded-md px-1 -mx-1 py-1 hover:bg-muted/50 transition-colors"
{juror.completionRate}% onClick={() => setExpandedJurorId(isExpanded ? null : juror.id)}
</span> >
<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> </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>
) : ( ) : (
<div className="space-y-4"> <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"> <div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-500" /> <ClipboardList className="h-4 w-4 text-emerald-500" />
</div> </div>
Recent Projects Recently Reviewed
</CardTitle> </CardTitle>
<CardDescription>Latest project activity</CardDescription> <CardDescription>Latest project reviews</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{projectsData && projectsData.projects.length > 0 ? ( {projectsData && projectsData.projects.length > 0 ? (
@@ -486,7 +510,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<TableRow> <TableRow>
<TableHead>Project</TableHead> <TableHead>Project</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Score</TableHead> <TableHead className="text-right whitespace-nowrap">Score</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -505,10 +529,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={project.status} /> <StatusBadge status={project.observerStatus ?? project.status} />
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-sm"> <TableCell className="text-right tabular-nums text-sm whitespace-nowrap">
{project.averageScore !== null ? project.averageScore.toFixed(1) : '—'} {project.evaluationCount > 0 && project.averageScore !== null
? project.averageScore.toFixed(1)
: '—'}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -549,32 +575,22 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<CardContent> <CardContent>
{activityFeed && activityFeed.length > 0 ? ( {activityFeed && activityFeed.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{activityFeed.map((item) => ( {activityFeed.slice(0, 5).map((item) => {
<div key={item.id} className="flex items-start gap-3"> const iconDef = ACTIVITY_ICONS[item.eventType]
<span const IconComponent = iconDef?.icon ?? Activity
className={cn( const iconColor = iconDef?.color ?? 'text-slate-400'
'mt-1.5 h-2 w-2 shrink-0 rounded-full', return (
ACTIVITY_DOT_COLORS[item.eventType] ?? 'bg-slate-400', <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">
<div className="min-w-0 flex-1"> {humanizeActivity(item)}
<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>
)}
</p> </p>
{item.actorName && ( <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
<p className="text-[11px] text-muted-foreground">by {item.actorName}</p> {relativeTime(item.createdAt)}
)} </span>
</div> </div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground"> )
{relativeTime(item.createdAt)} })}
</span>
</div>
))}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -0,0 +1,67 @@
'use client'
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { trpc } from '@/lib/trpc/client'
type Program = {
id: string
name: string | null
year?: number
rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }>
}
type EditionContextValue = {
programs: Program[]
selectedProgramId: string
setSelectedProgramId: (id: string) => void
activeRoundId: string
}
const EditionContext = createContext<EditionContextValue | null>(null)
export function useEditionContext() {
const ctx = useContext(EditionContext)
if (!ctx) throw new Error('useEditionContext must be used within EditionProvider')
return ctx
}
function findBestRound(rounds: Array<{ id: string; status: string }>): string {
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
if (active) return active.id
const closed = [...rounds].filter(r => r.status === 'ROUND_CLOSED').pop()
if (closed) return closed.id
return rounds[0]?.id ?? ''
}
export function EditionProvider({ children }: { children: ReactNode }) {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const { data: programs } = trpc.program.list.useQuery(
{ includeStages: true },
{ refetchInterval: 30_000 },
)
useEffect(() => {
if (programs && programs.length > 0 && !selectedProgramId) {
setSelectedProgramId(programs[0].id)
}
}, [programs, selectedProgramId])
const typedPrograms = (programs ?? []) as Program[]
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }>
const activeRoundId = findBestRound(rounds)
return (
<EditionContext.Provider
value={{
programs: typedPrograms,
selectedProgramId,
setSelectedProgramId,
activeRoundId,
}}
>
{children}
</EditionContext.Provider>
)
}

View File

@@ -39,7 +39,7 @@ import {
Sparkles, Sparkles,
MessageSquare, MessageSquare,
} from 'lucide-react' } from 'lucide-react'
import { formatDate, formatDateOnly } from '@/lib/utils' import { cn, formatDate, formatDateOnly } from '@/lib/utils'
export function ObserverProjectDetail({ projectId }: { projectId: string }) { export function ObserverProjectDetail({ projectId }: { projectId: string }) {
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery( const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
@@ -85,9 +85,13 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
) )
} }
const { project, assignments, stats, competitionRounds, allRequirements } = const { project, assignments, stats, competitionRounds, projectRoundStates, allRequirements } =
data data
const roundStateMap = new Map(
(projectRoundStates ?? []).map((s) => [s.roundId, s]),
)
const criteriaMap = new Map< const criteriaMap = new Map<
string, string,
{ label: string; type: string; trueLabel?: string; falseLabel?: string } { label: string; type: string; trueLabel?: string; falseLabel?: string }
@@ -338,26 +342,12 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
: '-'} : '-'}
</p> </p>
</div> </div>
<div>
<p className="text-xs text-muted-foreground">Org Type</p>
<p className="text-sm font-medium">
{project.institution || '-'}
</p>
</div>
<div> <div>
<p className="text-xs text-muted-foreground">Country</p> <p className="text-xs text-muted-foreground">Country</p>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{project.country || project.geographicZone || '-'} {project.country || project.geographicZone || '-'}
</p> </p>
</div> </div>
<div>
<p className="text-xs text-muted-foreground">Budget</p>
<p className="text-sm font-medium">-</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Duration</p>
<p className="text-sm font-medium">-</p>
</div>
<div> <div>
<p className="text-xs text-muted-foreground">AI Score</p> <p className="text-xs text-muted-foreground">AI Score</p>
<p className="text-sm font-medium">-</p> <p className="text-sm font-medium">-</p>
@@ -421,72 +411,102 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
</AnimatedCard> </AnimatedCard>
{/* Round History */} {/* Round History */}
{competitionRounds.length > 0 && ( {competitionRounds.length > 0 && (() => {
<AnimatedCard index={3}> const passedCount = competitionRounds.filter((r) => {
<Card> const s = roundStateMap.get(r.id)
<CardHeader> return s && (s.state === 'PASSED' || s.state === 'COMPLETED')
<CardTitle className="flex items-center gap-2.5 text-lg"> }).length
<div className="rounded-lg bg-violet-500/10 p-1.5"> const rejectedRound = competitionRounds.find((r) => {
<Calendar className="h-4 w-4 text-violet-500" /> const s = roundStateMap.get(r.id)
</div> return s?.state === 'REJECTED'
Round History })
</CardTitle> return (
</CardHeader> <AnimatedCard index={3}>
<CardContent> <Card>
<ol className="space-y-4"> <CardHeader>
{competitionRounds.map((round, idx) => { <CardTitle className="flex items-center gap-2.5 text-lg">
// Determine round status from assignments <div className="rounded-lg bg-violet-500/10 p-1.5">
const roundAssignments = assignments.filter( <Calendar className="h-4 w-4 text-violet-500" />
(a) => a.roundId === round.id, </div>
) Round History
const hasInProgressAssignments = roundAssignments.some( </CardTitle>
(a) => a.evaluation?.status === 'DRAFT', <CardDescription>
) {rejectedRound
const allSubmitted = ? `Rejected at ${rejectedRound.name}`
roundAssignments.length > 0 && : `Passed ${passedCount} of ${competitionRounds.length} rounds`}
roundAssignments.every( </CardDescription>
(a) => a.evaluation?.status === 'SUBMITTED', </CardHeader>
<CardContent>
<ol className="space-y-4">
{competitionRounds.map((round) => {
const roundState = roundStateMap.get(round.id)
const state = roundState?.state
const roundAssignments = assignments.filter(
(a) => a.roundId === round.id,
) )
const isPast = idx < competitionRounds.length - 1 && allSubmitted
const isActive = hasInProgressAssignments || (!isPast && roundAssignments.length > 0 && !allSubmitted) let icon: React.ReactNode
return ( let statusLabel: string | null = null
<li key={round.id} className="flex items-start gap-3"> if (state === 'PASSED' || state === 'COMPLETED') {
{isPast || allSubmitted ? ( icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" /> statusLabel = 'Passed'
) : isActive ? ( } else if (state === 'REJECTED') {
icon = <AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
statusLabel = 'Rejected at this round'
} else if (state === 'IN_PROGRESS') {
icon = (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center"> <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<span className="relative flex h-3 w-3"> <span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" /> <span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
</span> </span>
</span> </span>
) : ( )
<Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" /> statusLabel = 'Active'
)} } else if (state === 'PENDING') {
<div className="min-w-0"> icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
<p className="text-sm font-medium">{round.name}</p> statusLabel = 'Pending'
{roundAssignments.length > 0 && ( } else {
<p className="text-xs text-muted-foreground"> icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/20" />
{roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations }
</p>
return (
<li key={round.id} className="flex items-start gap-3">
{icon}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{round.name}</p>
{statusLabel && (
<p className={cn(
'text-xs',
state === 'REJECTED' ? 'text-destructive' : 'text-muted-foreground',
)}>
{statusLabel}
</p>
)}
{roundAssignments.length > 0 && (
<p className="text-xs text-muted-foreground">
{roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations
</p>
)}
</div>
{state === 'IN_PROGRESS' && (
<Badge
variant="outline"
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
>
Active
</Badge>
)} )}
</div> </li>
{isActive && ( )
<Badge })}
variant="outline" </ol>
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs" </CardContent>
> </Card>
Active </AnimatedCard>
</Badge> )
)} })()}
</li>
)
})}
</ol>
</CardContent>
</Card>
</AnimatedCard>
)}
</TabsContent> </TabsContent>
{/* ── Evaluations Tab ── */} {/* ── Evaluations Tab ── */}
@@ -496,7 +516,9 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-10 w-10 text-muted-foreground/40" /> <Users className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
No jury assignments yet {project.status === 'ASSIGNED'
? 'Awaiting jury evaluation — assigned and pending review'
: 'No jury assignments yet'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -251,8 +251,9 @@ export function ObserverProjectsContent() {
<SelectContent> <SelectContent>
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="SUBMITTED">Submitted</SelectItem> <SelectItem value="SUBMITTED">Submitted</SelectItem>
<SelectItem value="ELIGIBLE">Eligible</SelectItem> <SelectItem value="NOT_REVIEWED">Not Reviewed</SelectItem>
<SelectItem value="ASSIGNED">Assigned</SelectItem> <SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
<SelectItem value="REVIEWED">Reviewed</SelectItem>
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem> <SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem> <SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem> <SelectItem value="REJECTED">Rejected</SelectItem>
@@ -344,10 +345,10 @@ export function ObserverProjectsContent() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={project.status} /> <StatusBadge status={project.observerStatus ?? project.status} />
</TableCell> </TableCell>
<TableCell> <TableCell>
{project.averageScore !== null ? ( {project.evaluationCount > 0 && project.averageScore !== null ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="tabular-nums w-8 text-sm"> <span className="tabular-nums w-8 text-sm">
{project.averageScore.toFixed(1)} {project.averageScore.toFixed(1)}
@@ -404,24 +405,26 @@ export function ObserverProjectsContent() {
</p> </p>
)} )}
</div> </div>
<StatusBadge status={project.status} /> <StatusBadge status={project.observerStatus ?? project.status} />
</div> </div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.roundName} {project.roundName}
</Badge> </Badge>
<div className="flex gap-3"> {project.evaluationCount > 0 && (
<span> <div className="flex gap-3">
Score:{' '} <span>
{project.averageScore !== null Score:{' '}
? project.averageScore.toFixed(1) {project.averageScore !== null
: '-'} ? project.averageScore.toFixed(1)
</span> : '-'}
<span> </span>
{project.evaluationCount} eval <span>
{project.evaluationCount !== 1 ? 's' : ''} {project.evaluationCount} eval
</span> {project.evaluationCount !== 1 ? 's' : ''}
</div> </span>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -24,6 +24,10 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
REJECTED: { variant: 'destructive' }, REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' }, WITHDRAWN: { variant: 'secondary' },
// Observer-derived statuses
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
// Evaluation statuses // Evaluation statuses
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' }, IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' }, COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
@@ -43,7 +47,14 @@ type StatusBadgeProps = {
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) { export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || { variant: 'secondary' as const } const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ') const LABEL_OVERRIDES: Record<string, string> = {
NONE: 'NOT INVITED',
NOT_REVIEWED: 'Not Reviewed',
UNDER_REVIEW: 'Under Review',
REVIEWED: 'Reviewed',
SEMIFINALIST: 'Semi-finalist',
}
const label = LABEL_OVERRIDES[status] ?? status.replace(/_/g, ' ')
return ( return (
<Badge <Badge

View File

@@ -123,6 +123,7 @@ export const analyticsRouter = router({
where: assignmentWhere(input), where: assignmentWhere(input),
include: { include: {
user: { select: { name: true } }, user: { select: { name: true } },
project: { select: { id: true, title: true } },
evaluation: { evaluation: {
select: { id: true, status: true }, select: { id: true, status: true },
}, },
@@ -132,7 +133,7 @@ export const analyticsRouter = router({
// Group by user // Group by user
const byUser: Record< const byUser: Record<
string, string,
{ name: string; assigned: number; completed: number } { name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string }[] }
> = {} > = {}
assignments.forEach((assignment) => { assignments.forEach((assignment) => {
@@ -142,12 +143,19 @@ export const analyticsRouter = router({
name: assignment.user.name || 'Unknown', name: assignment.user.name || 'Unknown',
assigned: 0, assigned: 0,
completed: 0, completed: 0,
projects: [],
} }
} }
byUser[userId].assigned++ byUser[userId].assigned++
if (assignment.evaluation?.status === 'SUBMITTED') { const evalStatus = assignment.evaluation?.status
if (evalStatus === 'SUBMITTED') {
byUser[userId].completed++ byUser[userId].completed++
} }
byUser[userId].projects.push({
id: assignment.project.id,
title: assignment.project.title,
evalStatus: evalStatus === 'SUBMITTED' ? 'REVIEWED' : evalStatus === 'DRAFT' ? 'UNDER_REVIEW' : 'NOT_REVIEWED',
})
}) })
return Object.entries(byUser) return Object.entries(byUser)
@@ -676,7 +684,7 @@ export const analyticsRouter = router({
]) ])
const completionRate = totalAssignments > 0 const completionRate = totalAssignments > 0
? Math.round((submittedEvaluations / totalAssignments) * 100) ? Math.min(100, Math.round((submittedEvaluations / totalAssignments) * 100))
: 0 : 0
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null) const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
@@ -850,9 +858,11 @@ export const analyticsRouter = router({
const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0) const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
const totalAssignments = assignmentCountByRound.get(round.id) || 0 const totalAssignments = assignmentCountByRound.get(round.id) || 0
const completedEvaluations = completedEvalsByRound.get(round.id) || 0 const completedEvaluations = completedEvalsByRound.get(round.id) || 0
const completionRate = totalAssignments > 0 const completionRate = (round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED')
? Math.round((completedEvaluations / totalAssignments) * 100) ? 100
: 0 : totalAssignments > 0
? Math.min(100, Math.round((completedEvaluations / totalAssignments) * 100))
: 0
return { return {
roundId: round.id, roundId: round.id,
@@ -914,7 +924,8 @@ export const analyticsRouter = router({
where.projectRoundStates = { some: { roundId: input.roundId } } where.projectRoundStates = { some: { roundId: input.roundId } }
} }
if (input.status) { const OBSERVER_DERIVED_STATUSES = ['NOT_REVIEWED', 'UNDER_REVIEW', 'REVIEWED']
if (input.status && !OBSERVER_DERIVED_STATUSES.includes(input.status)) {
where.status = input.status where.status = input.status
} }
@@ -942,16 +953,25 @@ export const analyticsRouter = router({
assignments: { assignments: {
select: { select: {
roundId: true, roundId: true,
round: { select: { id: true, name: true } }, round: { select: { id: true, name: true, sortOrder: true } },
evaluation: { evaluation: {
select: { globalScore: true, status: true }, select: { globalScore: true, status: true },
}, },
}, },
}, },
projectRoundStates: {
select: {
roundId: true,
state: true,
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: { round: { sortOrder: 'desc' } },
take: 1,
},
}, },
orderBy: prismaOrderBy, orderBy: prismaOrderBy,
// When sorting by computed fields, fetch all then slice in JS // When sorting by computed fields or filtering by observer-derived status, fetch all then slice in JS
...(input.sortBy === 'title' ...(input.sortBy === 'title' && !OBSERVER_DERIVED_STATUSES.includes(input.status ?? '')
? { skip: (input.page - 1) * input.perPage, take: input.perPage } ? { skip: (input.page - 1) * input.perPage, take: input.perPage }
: {}), : {}),
}), }),
@@ -962,6 +982,9 @@ export const analyticsRouter = router({
const submitted = p.assignments const submitted = p.assignments
.map((a) => a.evaluation) .map((a) => a.evaluation)
.filter((e) => e?.status === 'SUBMITTED') .filter((e) => e?.status === 'SUBMITTED')
const drafts = p.assignments
.map((a) => a.evaluation)
.filter((e) => e?.status === 'DRAFT')
const scores = submitted const scores = submitted
.map((e) => e?.globalScore) .map((e) => e?.globalScore)
.filter((s): s is number => s !== null) .filter((s): s is number => s !== null)
@@ -970,51 +993,74 @@ export const analyticsRouter = router({
? scores.reduce((a, b) => a + b, 0) / scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length
: null : null
// Filter assignments to the queried round so we show the correct round name // Show the furthest round the project reached (from projectRoundStates, ordered by sortOrder desc)
const furthestRoundState = p.projectRoundStates[0]
// Fallback to assignment round if no round states
const roundAssignment = input.roundId const roundAssignment = input.roundId
? p.assignments.find((a) => a.roundId === input.roundId) ? p.assignments.find((a) => a.roundId === input.roundId)
: p.assignments[0] : p.assignments[0]
// Derive observer-friendly status
let observerStatus: string
if (p.status === 'REJECTED') observerStatus = 'REJECTED'
else if (p.status === 'SEMIFINALIST') observerStatus = 'SEMIFINALIST'
else if (p.status === 'FINALIST') observerStatus = 'FINALIST'
else if (p.status === 'SUBMITTED') observerStatus = 'SUBMITTED'
else if (submitted.length > 0) observerStatus = 'REVIEWED'
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
else observerStatus = 'NOT_REVIEWED'
return { return {
id: p.id, id: p.id,
title: p.title, title: p.title,
teamName: p.teamName, teamName: p.teamName,
status: p.status, status: p.status,
observerStatus,
country: p.country, country: p.country,
roundId: roundAssignment?.round?.id ?? '', roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '',
roundName: roundAssignment?.round?.name ?? '', roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '',
averageScore, averageScore,
evaluationCount: submitted.length, evaluationCount: submitted.length,
} }
}) })
// Filter by observer-derived status in JS
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)
? input.status
: null
const filtered = observerStatusFilter
? mapped.filter((p) => p.observerStatus === observerStatusFilter)
: mapped
const filteredTotal = observerStatusFilter ? filtered.length : total
// Sort by computed fields (score, evaluations) in JS // Sort by computed fields (score, evaluations) in JS
let sorted = mapped let sorted = filtered
if (input.sortBy === 'score') { if (input.sortBy === 'score') {
sorted = mapped.sort((a, b) => { sorted = filtered.sort((a, b) => {
const sa = a.averageScore ?? -1 const sa = a.averageScore ?? -1
const sb = b.averageScore ?? -1 const sb = b.averageScore ?? -1
return input.sortDir === 'asc' ? sa - sb : sb - sa return input.sortDir === 'asc' ? sa - sb : sb - sa
}) })
} else if (input.sortBy === 'evaluations') { } else if (input.sortBy === 'evaluations') {
sorted = mapped.sort((a, b) => sorted = filtered.sort((a, b) =>
input.sortDir === 'asc' input.sortDir === 'asc'
? a.evaluationCount - b.evaluationCount ? a.evaluationCount - b.evaluationCount
: b.evaluationCount - a.evaluationCount : b.evaluationCount - a.evaluationCount
) )
} }
// Paginate in JS for computed-field sorts // Paginate in JS for computed-field sorts or observer status filter
const paginated = input.sortBy !== 'title' const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter
const paginated = needsJsPagination
? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage) ? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage)
: sorted : sorted
return { return {
projects: paginated, projects: paginated,
total, total: filteredTotal,
page: input.page, page: input.page,
perPage: input.perPage, perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage), totalPages: Math.ceil(filteredTotal / input.perPage),
} }
}), }),
@@ -1256,15 +1302,21 @@ export const analyticsRouter = router({
} }
// Get competition rounds for file grouping // Get competition rounds for file grouping
let competitionRounds: { id: string; name: string }[] = [] let competitionRounds: { id: string; name: string; roundType: string }[] = []
const competition = await ctx.prisma.competition.findFirst({ const competition = await ctx.prisma.competition.findFirst({
where: { programId: projectRaw.programId }, where: { programId: projectRaw.programId },
include: { rounds: { select: { id: true, name: true }, orderBy: { sortOrder: 'asc' } } }, include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
}) })
if (competition) { if (competition) {
competitionRounds = competition.rounds competitionRounds = competition.rounds
} }
// Get project round states for round history
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
where: { projectId: input.id },
select: { roundId: true, state: true, enteredAt: true, exitedAt: true },
})
// Get file requirements for all rounds // Get file requirements for all rounds
let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = [] let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
if (competitionRounds.length > 0) { if (competitionRounds.length > 0) {
@@ -1306,6 +1358,7 @@ export const analyticsRouter = router({
assignments: assignmentsWithAvatars, assignments: assignmentsWithAvatars,
stats, stats,
competitionRounds, competitionRounds,
projectRoundStates,
allRequirements, allRequirements,
} }
}), }),