From 213efdba87298c50acd98de256fbdf0fa3533011 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Feb 2026 22:45:56 +0100 Subject: [PATCH] Observer platform: mobile fixes, data/UX overhaul, animated nav - 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 --- src/app/(observer)/layout.tsx | 17 +- src/app/(observer)/observer/reports/page.tsx | 123 ++++++--- src/components/charts/criteria-scores.tsx | 8 +- src/components/layouts/observer-nav.tsx | 30 +++ src/components/layouts/role-nav.tsx | 85 ++++--- .../observer/observer-dashboard-content.tsx | 240 ++++++++++-------- .../observer/observer-edition-context.tsx | 67 +++++ .../observer/observer-project-detail.tsx | 172 +++++++------ .../observer/observer-projects-content.tsx | 37 +-- src/components/shared/status-badge.tsx | 13 +- src/server/routers/analytics.ts | 97 +++++-- 11 files changed, 576 insertions(+), 313 deletions(-) create mode 100644 src/components/observer/observer-edition-context.tsx diff --git a/src/app/(observer)/layout.tsx b/src/app/(observer)/layout.tsx index 9763596..b10b63e 100644 --- a/src/app/(observer)/layout.tsx +++ b/src/app/(observer)/layout.tsx @@ -1,5 +1,6 @@ import { requireRole } from '@/lib/auth-redirect' import { ObserverNav } from '@/components/layouts/observer-nav' +import { EditionProvider } from '@/components/observer/observer-edition-context' export default async function ObserverLayout({ children, @@ -10,13 +11,15 @@ export default async function ObserverLayout({ return (
- -
{children}
+ + +
{children}
+
) } diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index df421d0..83a026e 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -504,47 +504,90 @@ function JurorsTab({ selectedValue }: { selectedValue: string }) { {isLoading ? ( ) : jurors.length > 0 ? ( - - - - - - Juror - Assigned - Completed - Rate - Avg Score - Std Dev - Status - - - - {jurors.map((j) => ( - - {j.name} - {j.assigned} - {j.completed} - -
- - {j.completionRate}% -
-
- {j.averageScore.toFixed(2)} - {j.stddev.toFixed(2)} - - {j.isOutlier ? ( - Outlier - ) : ( - Normal - )} - + <> + {/* Desktop Table */} + + +
+ + + Juror + Assigned + Completed + Rate + Avg Score + Std Dev + Status - ))} - -
-
-
+ + + {jurors.map((j) => ( + + {j.name} + {j.assigned} + {j.completed} + +
+ + {j.completionRate}% +
+
+ {j.averageScore.toFixed(2)} + {j.stddev.toFixed(2)} + + {j.isOutlier ? ( + Outlier + ) : ( + Normal + )} + +
+ ))} +
+ + + + + {/* Mobile Cards */} +
+ {jurors.map((j) => ( + + +
+

{j.name}

+ {j.isOutlier ? ( + Outlier + ) : ( + Normal + )} +
+
+ + {j.completionRate}% +
+
+
+ Assigned + {j.assigned} +
+
+ Completed + {j.completed} +
+
+ Avg Score + {j.averageScore.toFixed(2)} +
+
+ Std Dev + {j.stddev.toFixed(2)} +
+
+
+
+ ))} +
+ ) : hasSelection ? ( diff --git a/src/components/charts/criteria-scores.tsx b/src/components/charts/criteria-scores.tsx index b1d1d93..f82b9ce 100644 --- a/src/components/charts/criteria-scores.tsx +++ b/src/components/charts/criteria-scores.tsx @@ -25,14 +25,14 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) { const chartData = data.map((d) => ({ 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)), })) return ( - + Score by Evaluation Criteria Overall Avg: {overallAverage.toFixed(2)} @@ -46,10 +46,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) { categories={['Avg Score']} colors={[BRAND_TEAL] as string[]} maxValue={10} - yAxisWidth={40} + layout="vertical" + yAxisWidth={160} showLegend={false} className="h-[300px]" - rotateLabelX={{ angle: -45, xAxisHeight: 60 }} /> diff --git a/src/components/layouts/observer-nav.tsx b/src/components/layouts/observer-nav.tsx index e58a561..9ad95e4 100644 --- a/src/components/layouts/observer-nav.tsx +++ b/src/components/layouts/observer-nav.tsx @@ -2,11 +2,40 @@ import { BarChart3, Home, FolderKanban } from 'lucide-react' 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 { user: RoleNavUser } +function EditionSelector() { + const { programs, selectedProgramId, setSelectedProgramId } = useEditionContext() + + if (programs.length <= 1) return null + + return ( + + ) +} + export function ObserverNav({ user }: ObserverNavProps) { const navigation: NavItem[] = [ { @@ -32,6 +61,7 @@ export function ObserverNav({ user }: ObserverNavProps) { roleName="Observer" user={user} basePath="/observer" + editionSelector={} /> ) } diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index 19ba8fb..68f866b 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -41,13 +41,15 @@ type RoleNavProps = { basePath: string /** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */ 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 { 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 [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const { status: sessionStatus } = useSession() @@ -93,6 +95,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R {/* User menu & mobile toggle */}
+ {editionSelector &&
{editionSelector}
} {mounted && (
- {/* Mobile menu */} - {isMobileMenuOpen && ( -
- +
- )} + ) } diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx index 89f2006..642f171 100644 --- a/src/components/observer/observer-dashboard-content.tsx +++ b/src/components/observer/observer-dashboard-content.tsx @@ -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 = { - 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 = { + 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 | 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 = { @@ -94,31 +130,17 @@ const STATUS_BADGE_VARIANT: Record } export function ObserverDashboardContent({ userName }: { userName?: string }) { - const [selectedProgramId, setSelectedProgramId] = useState('') - const [selectedRoundId, setSelectedRoundId] = useState('') + const { programs, selectedProgramId, activeRoundId } = useEditionContext() + const [expandedJurorId, setExpandedJurorId] = useState(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 (
{/* Header */} -
-
-

Dashboard

-

Welcome, {userName || 'Observer'}

-
-
-
- - Auto-refresh -
- -
+
+

Dashboard

+

Welcome, {userName || 'Observer'}

{/* Six Stat Tiles */} @@ -411,23 +405,53 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { {topJurors.length > 0 ? ( -
- {topJurors.map((juror) => ( -
-
- - {juror.name ?? 'Unknown'} - - - {juror.completionRate}% - +
+ {topJurors.map((juror) => { + const isExpanded = expandedJurorId === juror.id + return ( +
+ + {isExpanded && juror.projects && ( +
+ {juror.projects.map((proj: { id: string; title: string; evalStatus: string }) => ( + + {proj.title} + + + ))} +
+ )}
- -

- {juror.completed} / {juror.assigned} evaluations -

-
- ))} + ) + })}
) : (
@@ -474,9 +498,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
- Recent Projects + Recently Reviewed - Latest project activity + Latest project reviews {projectsData && projectsData.projects.length > 0 ? ( @@ -486,7 +510,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { Project Status - Score + Score @@ -505,10 +529,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { )} - + - - {project.averageScore !== null ? project.averageScore.toFixed(1) : '—'} + + {project.evaluationCount > 0 && project.averageScore !== null + ? project.averageScore.toFixed(1) + : '—'} ))} @@ -549,32 +575,22 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { {activityFeed && activityFeed.length > 0 ? (
- {activityFeed.map((item) => ( -
- -
-

- - {item.eventType.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase())} - - {item.entityType && ( - — {item.entityType.replace(/_/g, ' ').toLowerCase()} - )} + {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 ( +

+ +

+ {humanizeActivity(item)}

- {item.actorName && ( -

by {item.actorName}

- )} + + {relativeTime(item.createdAt)} +
- - {relativeTime(item.createdAt)} - -
- ))} + ) + })}
) : (
diff --git a/src/components/observer/observer-edition-context.tsx b/src/components/observer/observer-edition-context.tsx new file mode 100644 index 0000000..e73b382 --- /dev/null +++ b/src/components/observer/observer-edition-context.tsx @@ -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(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('') + + 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 ( + + {children} + + ) +} diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx index a93288a..7ddae49 100644 --- a/src/components/observer/observer-project-detail.tsx +++ b/src/components/observer/observer-project-detail.tsx @@ -39,7 +39,7 @@ import { Sparkles, MessageSquare, } from 'lucide-react' -import { formatDate, formatDateOnly } from '@/lib/utils' +import { cn, formatDate, formatDateOnly } from '@/lib/utils' export function ObserverProjectDetail({ projectId }: { projectId: string }) { 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 + const roundStateMap = new Map( + (projectRoundStates ?? []).map((s) => [s.roundId, s]), + ) + const criteriaMap = new Map< string, { label: string; type: string; trueLabel?: string; falseLabel?: string } @@ -338,26 +342,12 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { : '-'}

-
-

Org Type

-

- {project.institution || '-'} -

-

Country

{project.country || project.geographicZone || '-'}

-
-

Budget

-

-

-
-
-

Duration

-

-

-

AI Score

-

@@ -421,72 +411,102 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { {/* Round History */} - {competitionRounds.length > 0 && ( - - - - -
- -
- Round History -
-
- -
    - {competitionRounds.map((round, idx) => { - // Determine round status from assignments - const roundAssignments = assignments.filter( - (a) => a.roundId === round.id, - ) - const hasInProgressAssignments = roundAssignments.some( - (a) => a.evaluation?.status === 'DRAFT', - ) - const allSubmitted = - roundAssignments.length > 0 && - roundAssignments.every( - (a) => a.evaluation?.status === 'SUBMITTED', + {competitionRounds.length > 0 && (() => { + const passedCount = competitionRounds.filter((r) => { + const s = roundStateMap.get(r.id) + return s && (s.state === 'PASSED' || s.state === 'COMPLETED') + }).length + const rejectedRound = competitionRounds.find((r) => { + const s = roundStateMap.get(r.id) + return s?.state === 'REJECTED' + }) + return ( + + + + +
    + +
    + Round History +
    + + {rejectedRound + ? `Rejected at ${rejectedRound.name}` + : `Passed ${passedCount} of ${competitionRounds.length} rounds`} + +
    + +
      + {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) - return ( -
    1. - {isPast || allSubmitted ? ( - - ) : isActive ? ( + + let icon: React.ReactNode + let statusLabel: string | null = null + if (state === 'PASSED' || state === 'COMPLETED') { + icon = + statusLabel = 'Passed' + } else if (state === 'REJECTED') { + icon = + statusLabel = 'Rejected at this round' + } else if (state === 'IN_PROGRESS') { + icon = ( - ) : ( - - )} -
      -

      {round.name}

      - {roundAssignments.length > 0 && ( -

      - {roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations -

      + ) + statusLabel = 'Active' + } else if (state === 'PENDING') { + icon = + statusLabel = 'Pending' + } else { + icon = + } + + return ( +
    2. + {icon} +
      +

      {round.name}

      + {statusLabel && ( +

      + {statusLabel} +

      + )} + {roundAssignments.length > 0 && ( +

      + {roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations +

      + )} +
      + {state === 'IN_PROGRESS' && ( + + Active + )} -
- {isActive && ( - - Active - - )} - - ) - })} - - - - - )} + + ) + })} + + + + + ) + })()} {/* ── Evaluations Tab ── */} @@ -496,7 +516,9 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {

- No jury assignments yet + {project.status === 'ASSIGNED' + ? 'Awaiting jury evaluation — assigned and pending review' + : 'No jury assignments yet'}

diff --git a/src/components/observer/observer-projects-content.tsx b/src/components/observer/observer-projects-content.tsx index f86109b..80c0db8 100644 --- a/src/components/observer/observer-projects-content.tsx +++ b/src/components/observer/observer-projects-content.tsx @@ -251,8 +251,9 @@ export function ObserverProjectsContent() { All Statuses Submitted - Eligible - Assigned + Not Reviewed + Under Review + Reviewed Semi-finalist Finalist Rejected @@ -344,10 +345,10 @@ export function ObserverProjectsContent() { - + - {project.averageScore !== null ? ( + {project.evaluationCount > 0 && project.averageScore !== null ? (
{project.averageScore.toFixed(1)} @@ -404,24 +405,26 @@ export function ObserverProjectsContent() {

)}
- +
{project.roundName} -
- - Score:{' '} - {project.averageScore !== null - ? project.averageScore.toFixed(1) - : '-'} - - - {project.evaluationCount} eval - {project.evaluationCount !== 1 ? 's' : ''} - -
+ {project.evaluationCount > 0 && ( +
+ + Score:{' '} + {project.averageScore !== null + ? project.averageScore.toFixed(1) + : '-'} + + + {project.evaluationCount} eval + {project.evaluationCount !== 1 ? 's' : ''} + +
+ )}
diff --git a/src/components/shared/status-badge.tsx b/src/components/shared/status-badge.tsx index 10a6575..6ae60de 100644 --- a/src/components/shared/status-badge.tsx +++ b/src/components/shared/status-badge.tsx @@ -24,6 +24,10 @@ const STATUS_STYLES: Record = { + 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 ( = {} assignments.forEach((assignment) => { @@ -142,12 +143,19 @@ export const analyticsRouter = router({ name: assignment.user.name || 'Unknown', assigned: 0, completed: 0, + projects: [], } } byUser[userId].assigned++ - if (assignment.evaluation?.status === 'SUBMITTED') { + const evalStatus = assignment.evaluation?.status + if (evalStatus === 'SUBMITTED') { 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) @@ -676,7 +684,7 @@ export const analyticsRouter = router({ ]) const completionRate = totalAssignments > 0 - ? Math.round((submittedEvaluations / totalAssignments) * 100) + ? Math.min(100, Math.round((submittedEvaluations / totalAssignments) * 100)) : 0 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 totalAssignments = assignmentCountByRound.get(round.id) || 0 const completedEvaluations = completedEvalsByRound.get(round.id) || 0 - const completionRate = totalAssignments > 0 - ? Math.round((completedEvaluations / totalAssignments) * 100) - : 0 + const completionRate = (round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED') + ? 100 + : totalAssignments > 0 + ? Math.min(100, Math.round((completedEvaluations / totalAssignments) * 100)) + : 0 return { roundId: round.id, @@ -914,7 +924,8 @@ export const analyticsRouter = router({ 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 } @@ -942,16 +953,25 @@ export const analyticsRouter = router({ assignments: { select: { roundId: true, - round: { select: { id: true, name: true } }, + round: { select: { id: true, name: true, sortOrder: true } }, evaluation: { 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, - // When sorting by computed fields, fetch all then slice in JS - ...(input.sortBy === 'title' + // When sorting by computed fields or filtering by observer-derived status, fetch all then slice in JS + ...(input.sortBy === 'title' && !OBSERVER_DERIVED_STATUSES.includes(input.status ?? '') ? { skip: (input.page - 1) * input.perPage, take: input.perPage } : {}), }), @@ -962,6 +982,9 @@ export const analyticsRouter = router({ const submitted = p.assignments .map((a) => a.evaluation) .filter((e) => e?.status === 'SUBMITTED') + const drafts = p.assignments + .map((a) => a.evaluation) + .filter((e) => e?.status === 'DRAFT') const scores = submitted .map((e) => e?.globalScore) .filter((s): s is number => s !== null) @@ -970,51 +993,74 @@ export const analyticsRouter = router({ ? scores.reduce((a, b) => a + b, 0) / scores.length : 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 ? p.assignments.find((a) => a.roundId === input.roundId) : 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 { id: p.id, title: p.title, teamName: p.teamName, status: p.status, + observerStatus, country: p.country, - roundId: roundAssignment?.round?.id ?? '', - roundName: roundAssignment?.round?.name ?? '', + roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '', + roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '', averageScore, 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 - let sorted = mapped + let sorted = filtered if (input.sortBy === 'score') { - sorted = mapped.sort((a, b) => { + sorted = filtered.sort((a, b) => { const sa = a.averageScore ?? -1 const sb = b.averageScore ?? -1 return input.sortDir === 'asc' ? sa - sb : sb - sa }) } else if (input.sortBy === 'evaluations') { - sorted = mapped.sort((a, b) => + sorted = filtered.sort((a, b) => input.sortDir === 'asc' ? a.evaluationCount - b.evaluationCount : b.evaluationCount - a.evaluationCount ) } - // Paginate in JS for computed-field sorts - const paginated = input.sortBy !== 'title' + // Paginate in JS for computed-field sorts or observer status filter + const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter + const paginated = needsJsPagination ? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage) : sorted return { projects: paginated, - total, + total: filteredTotal, page: input.page, 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 - let competitionRounds: { id: string; name: string }[] = [] + let competitionRounds: { id: string; name: string; roundType: string }[] = [] const competition = await ctx.prisma.competition.findFirst({ 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) { 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 let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = [] if (competitionRounds.length > 0) { @@ -1306,6 +1358,7 @@ export const analyticsRouter = router({ assignments: assignmentsWithAvatars, stats, competitionRounds, + projectRoundStates, allRequirements, } }),