- {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 || '-'}
-
-
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 (
- -
- {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 (
+
-
+ {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,
}
}),