diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx index 4c6f25a..7e673ce 100644 --- a/src/app/(admin)/admin/reports/page.tsx +++ b/src/app/(admin)/admin/reports/page.tsx @@ -657,7 +657,7 @@ function CrossStageTab() { className="cursor-pointer text-sm py-1.5 px-3" onClick={() => toggleRound(stage.id)} > - {stage.programName} - {stage.name} + {stage.name} ) })} @@ -740,7 +740,7 @@ function JurorConsistencyTab() { ))} {stages.map((stage) => ( - {stage.programName} - {stage.name} + {stage.name} ))} @@ -814,7 +814,7 @@ function DiversityTab() { ))} {stages.map((stage) => ( - {stage.programName} - {stage.name} + {stage.name} ))} @@ -992,7 +992,7 @@ export default function ReportsPage() { {pdfStages.map((stage) => ( - {stage.programName} - {stage.name} + {stage.name} ))} diff --git a/src/app/(observer)/observer/projects/[projectId]/page.tsx b/src/app/(observer)/observer/projects/[projectId]/page.tsx new file mode 100644 index 0000000..8aa4689 --- /dev/null +++ b/src/app/(observer)/observer/projects/[projectId]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import { ObserverProjectDetail } from '@/components/observer/observer-project-detail' + +export const metadata: Metadata = { title: 'Project Detail' } +export const dynamic = 'force-dynamic' + +export default async function ObserverProjectDetailPage({ + params, +}: { + params: Promise<{ projectId: string }> +}) { + const { projectId } = await params + + return +} diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index 748e0b2..776e349 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -508,9 +508,9 @@ function CrossStageTab() { size="sm" pressed={selectedRoundIds.includes(stage.id)} onPressedChange={() => toggleRound(stage.id)} - aria-label={`${stage.programName} - ${stage.name}`} + aria-label={stage.name} > - {stage.programName} - {stage.name} + {stage.name} ))} @@ -644,7 +644,7 @@ export default function ObserverReportsPage() { ))} {stages.map((stage) => ( - {stage.programName} - {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''} + {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''} ))} diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts index a5378af..a225813 100644 --- a/src/components/charts/chart-theme.ts +++ b/src/components/charts/chart-theme.ts @@ -36,7 +36,7 @@ export const STATUS_COLORS: Record = { export const STATUS_LABELS: Record = { SUBMITTED: 'Submitted', ELIGIBLE: 'Eligible', - ASSIGNED: 'Assigned', + ASSIGNED: 'Special Award', SEMIFINALIST: 'Semi-finalist', FINALIST: 'Finalist', REJECTED: 'Rejected', diff --git a/src/components/charts/diversity-metrics.tsx b/src/components/charts/diversity-metrics.tsx index 44922e5..78d2f22 100644 --- a/src/components/charts/diversity-metrics.tsx +++ b/src/components/charts/diversity-metrics.tsx @@ -61,7 +61,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { : topCountries const nivoPieData = countryPieData.map((c) => ({ - id: c.country, + id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(), label: getCountryName(c.country), value: c.count, })) diff --git a/src/components/charts/project-rankings.tsx b/src/components/charts/project-rankings.tsx index df02806..909ff9d 100644 --- a/src/components/charts/project-rankings.tsx +++ b/src/components/charts/project-rankings.tsx @@ -30,13 +30,13 @@ export function ProjectRankingsChart({ data, limit = 20, }: ProjectRankingsProps) { - if (!data?.length) return null - - const scoredData = data.filter( + const scoredData = (data ?? []).filter( (d): d is ProjectRankingData & { averageScore: number } => d.averageScore !== null, ) + if (!scoredData.length) return null + const averageScore = scoredData.length > 0 ? scoredData.reduce((sum, d) => sum + d.averageScore, 0) / diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx index f669cc6..00239fd 100644 --- a/src/components/observer/observer-dashboard-content.tsx +++ b/src/components/observer/observer-dashboard-content.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' +import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' import { Card, @@ -150,26 +151,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {

- {/* Observer Notice */} -
-
-
- -
-
-
-

Observer Mode

- - Read-Only - -
-

- You have read-only access to view platform statistics and reports. -

-
-
-
- {/* Round Filter */}
@@ -181,7 +162,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { All Rounds {rounds.map((round) => ( - {round.programName} - {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''} + {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''} ))} @@ -364,7 +345,6 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { Title - Team Round Status @@ -381,11 +361,12 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { {projectsData.projects.map((project) => ( - - - {project.title} + window.location.href = `/observer/projects/${project.id}`}> + + e.stopPropagation()}> + {project.title} + - {project.teamName || '-'} {project.roundName} @@ -411,26 +392,25 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { {/* Mobile Cards */}
{projectsData.projects.map((project) => ( - - -
-

{project.title}

- -
- {project.teamName && ( -

{project.teamName}

- )} -
- - {project.roundName} - -
- Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'} - {project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''} + + + +
+

{project.title}

+
-
- - +
+ + {project.roundName} + +
+ Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'} + {project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''} +
+
+ + + ))}
diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx new file mode 100644 index 0000000..6a9f9a7 --- /dev/null +++ b/src/components/observer/observer-project-detail.tsx @@ -0,0 +1,1051 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { FileViewer } from '@/components/shared/file-viewer' +import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' +import { UserAvatar } from '@/components/shared/user-avatar' +import { StatusBadge } from '@/components/shared/status-badge' +import { AnimatedCard } from '@/components/shared/animated-container' +import { + ArrowLeft, + AlertCircle, + Users, + FileText, + Calendar, + CheckCircle2, + Circle, + BarChart3, + ThumbsUp, + ThumbsDown, + MapPin, + Waves, + GraduationCap, + Heart, + Crown, + Eye, + MessageSquare, +} from 'lucide-react' +import { formatDate, formatDateOnly } from '@/lib/utils' + +export function ObserverProjectDetail({ projectId }: { projectId: string }) { + const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery( + { id: projectId }, + { refetchInterval: 30_000 }, + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [selectedAssignment, setSelectedAssignment] = useState(null) + + if (isLoading) { + return + } + + if (!data) { + return ( +
+ + + + +

Project Not Found

+ +
+
+
+ ) + } + + const { project, assignments, stats, competitionRounds, allRequirements } = data + + return ( +
+ {/* Back */} + + + {/* Header */} +
+ +
+
+

+ {project.title} +

+ +
+ {project.teamName && ( +

{project.teamName}

+ )} +
+
+ + + + {/* Stats */} + {stats && ( + +
+ + + + Average Score + +
+ +
+
+ +
+ {stats.averageGlobalScore?.toFixed(1) || '-'} +
+

+ Range: {stats.minScore ?? '-'} - {stats.maxScore ?? '-'} ({stats.totalEvaluations} evaluation{stats.totalEvaluations !== 1 ? 's' : ''}) +

+
+
+ + + + + Recommendations + +
+ +
+
+ +
+ {stats.yesPercentage?.toFixed(0) || 0}% +
+

+ {stats.yesVotes} yes / {stats.noVotes} no +

+
+
+
+
+ )} + + {/* Project Info */} + + + + +
+ +
+ Project Information +
+
+ + {/* Category & Ocean Issue badges */} +
+ {project.competitionCategory && ( + + + {project.competitionCategory === 'STARTUP' + ? 'Start-up' + : 'Business Concept'} + + )} + {project.oceanIssue && ( + + + {project.oceanIssue.replace(/_/g, ' ')} + + )} + {project.wantsMentorship && ( + + + Wants Mentorship + + )} +
+ + {project.description && ( +
+

+ Description +

+

+ {project.description} +

+
+ )} + + {/* Location & Institution */} +
+ {(project.country || project.geographicZone) && ( +
+ +
+

+ Location +

+

+ {project.geographicZone || project.country} +

+
+
+ )} + {project.institution && ( +
+ +
+

+ Institution +

+

{project.institution}

+
+
+ )} + {project.foundedAt && ( +
+ +
+

+ Founded +

+

{formatDateOnly(project.foundedAt)}

+
+
+ )} +
+ + {/* Submission URLs */} + {(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && ( +
+

+ Submission Links +

+
+ {project.phase1SubmissionUrl && ( + + )} + {project.phase2SubmissionUrl && ( + + )} +
+
+ )} + + {/* Expertise Tags */} + {project.projectTags && project.projectTags.length > 0 && ( +
+

+ Expertise Tags +

+
+ {project.projectTags.map((pt) => ( + + {pt.tag.name} + {pt.confidence < 1 && ( + + {Math.round(pt.confidence * 100)}% + + )} + + ))} +
+
+ )} + +
+
+ Created:{' '} + {formatDateOnly(project.createdAt)} +
+
+ Updated:{' '} + {formatDateOnly(project.updatedAt)} +
+
+
+
+
+ + {/* Team Members */} + {project.teamMembers && project.teamMembers.length > 0 && ( + + + + +
+ +
+ Team Members ({project.teamMembers.length}) +
+
+ +
+ {project.teamMembers.map( + (member: { + id: string + role: string + title: string | null + user: { + id: string + name: string | null + email: string + avatarUrl?: string | null + } + }) => ( +
+ {member.role === 'LEAD' ? ( +
+ +
+ ) : ( + + )} +
+
+

+ {member.user.name || 'Unnamed'} +

+ + {member.role === 'LEAD' + ? 'Lead' + : member.role === 'ADVISOR' + ? 'Advisor' + : 'Member'} + +
+

+ {member.user.email} +

+ {member.title && ( +

+ {member.title} +

+ )} +
+
+ ), + )} +
+
+
+
+ )} + + {/* Files Section */} + + + + +
+ +
+ Files +
+ + Project documents organized by competition round + +
+ + {/* Requirements organized by round */} + {competitionRounds.length > 0 && allRequirements.length > 0 ? ( + <> + {competitionRounds.map((round) => { + const roundRequirements = allRequirements.filter( + (req) => req.roundId === round.id, + ) + if (roundRequirements.length === 0) return null + + return ( +
+
+

+ {round.name} +

+ + {roundRequirements.length} requirement + {roundRequirements.length !== 1 ? 's' : ''} + +
+
+ {roundRequirements.map((req) => { + const fulfilledFile = project.files?.find( + (f) => f.requirementId === req.id, + ) + const isFulfilled = !!fulfilledFile + + return ( +
+
+ {isFulfilled ? ( + + ) : ( + + )} +
+
+

+ {req.name} +

+ {req.isRequired && ( + + Required + + )} +
+ {req.description && ( +

+ {req.description} +

+ )} +
+ {req.acceptedMimeTypes?.length > 0 && ( + + {req.acceptedMimeTypes + .map((mime) => { + if ( + mime === 'application/pdf' + ) + return 'PDF' + if (mime === 'image/*') + return 'Images' + if (mime === 'video/*') + return 'Video' + if ( + mime.includes( + 'wordprocessing', + ) + ) + return 'Word' + if ( + mime.includes('spreadsheet') + ) + return 'Excel' + if ( + mime.includes('presentation') + ) + return 'PowerPoint' + return ( + mime.split('/')[1] || mime + ) + }) + .join(', ')} + + )} + {req.maxSizeMB && ( + + Max {req.maxSizeMB}MB + + )} +
+ {isFulfilled && fulfilledFile && ( +

+ {fulfilledFile.fileName} +

+ )} +
+
+ {!isFulfilled && ( + + Missing + + )} +
+ ) + }, + )} +
+
+ ) + }, + )} + + + ) : null} + + {/* All uploaded files viewer */} + {project.files && project.files.length > 0 ? ( +
+

+ {allRequirements.length > 0 + ? 'All Uploaded Files' + : 'Uploaded Files'} +

+ ({ + id: f.id, + fileName: f.fileName, + fileType: f.fileType as + | 'EXEC_SUMMARY' + | 'PRESENTATION' + | 'VIDEO' + | 'OTHER' + | 'BUSINESS_PLAN' + | 'VIDEO_PITCH' + | 'SUPPORTING_DOC', + mimeType: f.mimeType, + size: f.size, + bucket: f.bucket, + objectKey: f.objectKey, + pageCount: f.pageCount, + textPreview: f.textPreview, + detectedLang: f.detectedLang, + langConfidence: f.langConfidence, + analyzedAt: f.analyzedAt + ? String(f.analyzedAt) + : null, + requirementId: f.requirementId, + requirement: f.requirement + ? { + id: f.requirement.id, + name: f.requirement.name, + description: f.requirement.description, + isRequired: f.requirement.isRequired, + } + : null, + }))} + /> +
+ ) : ( +
+ +

+ No files uploaded yet +

+
+ )} +
+
+
+ + {/* Jury Assignments & Evaluations */} + {assignments && assignments.length > 0 && ( + + + + +
+ +
+ Jury Evaluations +
+ + { + assignments.filter( + (a) => a.evaluation?.status === 'SUBMITTED', + ).length + }{' '} + of {assignments.length} evaluations completed + +
+ + {/* Desktop Table */} +
+ + + + Juror + Round + Status + Score + Decision + + + + + {assignments.map((assignment) => ( + { + if ( + assignment.evaluation?.status === 'SUBMITTED' + ) { + setSelectedAssignment(assignment) + } + }} + > + +
+ +
+

+ {assignment.user.name || 'Unnamed'} +

+

+ {assignment.user.email} +

+
+
+
+ + + {assignment.round.name} + + + + + + + {assignment.evaluation?.globalScore != null ? ( + + {assignment.evaluation.globalScore}/10 + + ) : ( + - + )} + + + {assignment.evaluation?.binaryDecision != null ? ( + assignment.evaluation.binaryDecision ? ( +
+ + Yes +
+ ) : ( +
+ + No +
+ ) + ) : ( + - + )} +
+ + {assignment.evaluation?.status === 'SUBMITTED' && ( + + )} + +
+ ), + )} +
+
+
+ + {/* Mobile Cards */} +
+ {assignments.map((assignment) => ( + + ), + )} +
+
+
+
+ )} + + {/* Evaluation Detail Sheet */} + { + if (!open) setSelectedAssignment(null) + }} + /> +
+ ) +} + +function EvaluationDetailSheet({ + assignment, + open, + onOpenChange, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assignment: any + open: boolean + onOpenChange: (open: boolean) => void +}) { + if (!assignment?.evaluation) return null + + const ev = assignment.evaluation + const criterionScores = (ev.criterionScoresJson || {}) as Record< + string, + number | boolean | string + > + const hasScores = Object.keys(criterionScores).length > 0 + + // Get evaluation form for criterion labels + const roundId = assignment.roundId as string | undefined + const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( + { roundId: roundId ?? '' }, + { enabled: !!roundId && open }, + ) + + const criteriaMap = new Map< + string, + { + label: string + type: string + trueLabel?: string + falseLabel?: string + } + >() + if (activeForm?.criteriaJson) { + for (const c of activeForm.criteriaJson as Array<{ + id: string + label: string + type?: string + trueLabel?: string + falseLabel?: string + }>) { + criteriaMap.set(c.id, { + label: c.label, + type: c.type || 'numeric', + trueLabel: c.trueLabel, + falseLabel: c.falseLabel, + }) + } + } + + return ( + + + + + + {assignment.user.name || assignment.user.email} + + + {ev.submittedAt + ? `Submitted ${formatDate(ev.submittedAt)}` + : 'Evaluation details'} + + + +
+ {/* Global stats */} +
+
+

Score

+

+ {ev.globalScore != null ? `${ev.globalScore}/10` : '-'} +

+
+
+

Decision

+
+ {ev.binaryDecision != null ? ( + ev.binaryDecision ? ( +
+ + Yes +
+ ) : ( +
+ + No +
+ ) + ) : ( + - + )} +
+
+
+ + {/* Criterion Scores */} + {hasScores && ( +
+

+ + Criterion Scores +

+
+ {Object.entries(criterionScores).map(([key, value]) => { + const meta = criteriaMap.get(key) + const label = meta?.label || key + const type = + meta?.type || + (typeof value === 'boolean' + ? 'boolean' + : typeof value === 'string' + ? 'text' + : 'numeric') + + if (type === 'section_header') return null + + if (type === 'boolean') { + return ( +
+ {label} + {value === true ? ( + + + {meta?.trueLabel || 'Yes'} + + ) : ( + + + {meta?.falseLabel || 'No'} + + )} +
+ ) + } + + if (type === 'text') { + return ( +
+ {label} +
+ {typeof value === 'string' ? value : String(value)} +
+
+ ) + } + + // Numeric + return ( +
+ {label} +
+
+
+
+ + {typeof value === 'number' ? value : '-'} + +
+
+ ) + })} +
+
+ )} + + {/* Feedback Text */} + {ev.feedbackText && ( +
+

+ + Feedback +

+
+ {ev.feedbackText} +
+
+ )} +
+ + + ) +} + +function ProjectDetailSkeleton() { + return ( +
+ +
+
+ + + +
+
+ +
+ {[1, 2].map((i) => ( + + + + + + + + + ))} +
+ + + + + + + + +
+ ) +} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 245b1f6..5c73421 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { router, observerProcedure } from '../trpc' import { normalizeCountryToCode } from '@/lib/countries' +import { getUserAvatarUrl } from '../utils/avatar-url' const editionOrRoundInput = z.object({ roundId: z.string().optional(), @@ -1237,4 +1238,136 @@ export const analyticsRouter = router({ return { roundType, stats: {} } } }), + + /** + * Observer-accessible project detail: project info + assignments with evaluations + competition rounds + files. + * Read-only combined endpoint to avoid multiple round-trips. + */ + getProjectDetail: observerProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([ + ctx.prisma.project.findUniqueOrThrow({ + where: { id: input.id }, + include: { + files: { + select: { + id: true, fileName: true, fileType: true, mimeType: true, size: true, + bucket: true, objectKey: true, pageCount: true, textPreview: true, + detectedLang: true, langConfidence: true, analyzedAt: true, + requirementId: true, + requirement: { select: { id: true, name: true, description: true, isRequired: true } }, + }, + }, + teamMembers: { + include: { + user: { + select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true }, + }, + }, + orderBy: { joinedAt: 'asc' }, + }, + }, + }), + ctx.prisma.projectTag.findMany({ + where: { projectId: input.id }, + include: { tag: { select: { id: true, name: true, category: true, color: true } } }, + orderBy: { confidence: 'desc' }, + }).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]), + ctx.prisma.assignment.findMany({ + where: { projectId: input.id }, + include: { + user: { select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true } }, + round: { select: { id: true, name: true } }, + evaluation: { + select: { + id: true, status: true, submittedAt: true, globalScore: true, + binaryDecision: true, criterionScoresJson: true, feedbackText: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + ctx.prisma.evaluation.findMany({ + where: { + status: 'SUBMITTED', + assignment: { projectId: input.id }, + }, + }), + ]) + + // Compute evaluation stats + let stats = null + if (submittedEvaluations.length > 0) { + const globalScores = submittedEvaluations + .map((e) => e.globalScore) + .filter((s): s is number => s !== null) + const yesVotes = submittedEvaluations.filter((e) => e.binaryDecision === true).length + stats = { + totalEvaluations: submittedEvaluations.length, + averageGlobalScore: globalScores.length > 0 + ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length + : null, + minScore: globalScores.length > 0 ? Math.min(...globalScores) : null, + maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null, + yesVotes, + noVotes: submittedEvaluations.length - yesVotes, + yesPercentage: (yesVotes / submittedEvaluations.length) * 100, + } + } + + // Get competition rounds for file grouping + let competitionRounds: { id: string; name: string }[] = [] + const competition = await ctx.prisma.competition.findFirst({ + where: { programId: projectRaw.programId }, + include: { rounds: { select: { id: true, name: true }, orderBy: { sortOrder: 'asc' } } }, + }) + if (competition) { + competitionRounds = competition.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 }[] = [] + if (competitionRounds.length > 0) { + allRequirements = await ctx.prisma.fileRequirement.findMany({ + where: { roundId: { in: competitionRounds.map((r) => r.id) } }, + select: { id: true, roundId: true, name: true, description: true, isRequired: true, acceptedMimeTypes: true, maxSizeMB: true }, + orderBy: { sortOrder: 'asc' }, + }) + } + + // Attach avatar URLs + const [teamMembersWithAvatars, assignmentsWithAvatars] = await Promise.all([ + Promise.all( + projectRaw.teamMembers.map(async (member) => ({ + ...member, + user: { + ...member.user, + avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider), + }, + })) + ), + Promise.all( + assignments.map(async (a) => ({ + ...a, + user: { + ...a.user, + avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider), + }, + })) + ), + ]) + + return { + project: { + ...projectRaw, + projectTags, + teamMembers: teamMembersWithAvatars, + }, + assignments: assignmentsWithAvatars, + stats, + competitionRounds, + allRequirements, + } + }), })