From 1b475a078cdfd33d54f2290c40f56fc43039855c Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 00:11:02 +0200 Subject: [PATCH 1/7] feat: add application statistics dashboard and fix sponsor permissions - Add Statistics page with dropdown field response breakdowns, filterable by status - Add GET API route for statistics data - Add getApplicationStatistics getter querying option counts per form field - Add Statistics tab to dashboard (admin-only) - Fix sponsors getter to scope applications by hackathonId (permissions bug) Co-Authored-By: Claude Sonnet 4.6 --- .../[hackathonId]/statistics/route.ts | 23 +++ .../[hackathonId]/statistics/page.tsx | 26 +++ .../components/DashboardTabs.tsx | 10 +- .../scenes/Statistics/Statistics.tsx | 147 ++++++++++++++++ .../statistics/getApplicationStatistics.ts | 158 ++++++++++++++++++ .../sponsors/getApplicationsForSponsors.ts | 3 + 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 src/app/api/dashboard/[hackathonId]/statistics/route.ts create mode 100644 src/app/dashboard/[hackathonId]/statistics/page.tsx create mode 100644 src/scenes/Dashboard/scenes/Statistics/Statistics.tsx create mode 100644 src/server/getters/dashboard/statistics/getApplicationStatistics.ts diff --git a/src/app/api/dashboard/[hackathonId]/statistics/route.ts b/src/app/api/dashboard/[hackathonId]/statistics/route.ts new file mode 100644 index 0000000..deeddac --- /dev/null +++ b/src/app/api/dashboard/[hackathonId]/statistics/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import getApplicationStatistics from "@/server/getters/dashboard/statistics/getApplicationStatistics"; +import { ApplicationStatus } from "@/services/types/applicationStatus"; + +export const GET = async ( + request: NextRequest, + { params }: { params: { hackathonId: string } } +) => { + try { + const hackathonId = Number(params.hackathonId); + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as ApplicationStatus | "all" | null; + + const data = await getApplicationStatistics( + hackathonId, + status ?? "all" + ); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } +}; diff --git a/src/app/dashboard/[hackathonId]/statistics/page.tsx b/src/app/dashboard/[hackathonId]/statistics/page.tsx new file mode 100644 index 0000000..f8888fe --- /dev/null +++ b/src/app/dashboard/[hackathonId]/statistics/page.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import Statistics from "@/scenes/Dashboard/scenes/Statistics/Statistics"; +import { disallowVolunteer } from "@/services/helpers/disallowVolunteer"; +import getApplicationStatistics from "@/server/getters/dashboard/statistics/getApplicationStatistics"; + +export const metadata = { + title: "Statistics", +}; + +const StatisticsPage = async ({ + params: { hackathonId }, +}: { + params: { hackathonId: string }; +}) => { + await disallowVolunteer(hackathonId); + const initialData = await getApplicationStatistics(Number(hackathonId), "all"); + + return ( + + ); +}; + +export default StatisticsPage; diff --git a/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx b/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx index 73e3c3c..625e516 100644 --- a/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx +++ b/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx @@ -18,7 +18,8 @@ type TabValue = | "reimbursements" | "checkin" | "tables" - | "judging"; + | "judging" + | "statistics"; const getTabValue = ( path: string, @@ -34,6 +35,7 @@ const getTabValue = ( if (path.startsWith(`/dashboard/${hackathonId}/check-in`)) return "checkin"; if (path.startsWith(`/dashboard/${hackathonId}/tables`)) return "tables"; if (path.startsWith(`/dashboard/${hackathonId}/judging`)) return "judging"; + if (path.startsWith(`/dashboard/${hackathonId}/statistics`)) return "statistics"; return undefined; }; @@ -75,6 +77,9 @@ const DashboardTabs = ({ case "judging": push(`/dashboard/${hackathonId}/judging`); break; + case "statistics": + push(`/dashboard/${hackathonId}/statistics`); + break; } }; @@ -94,13 +99,14 @@ const DashboardTabs = ({ > Applications Travel reimbursements Check-in Judging + {isAdmin && Statistics} {isAdmin && Application form} {isAdmin && Teams & Tables} {isAdmin && Hackathon info} diff --git a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx new file mode 100644 index 0000000..fd821b0 --- /dev/null +++ b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx @@ -0,0 +1,147 @@ +"use client"; + +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Stack } from "@/components/ui/stack"; +import { Text } from "@/components/ui/text"; +import { + ApplicationStatisticsData, + FieldStatistic, +} from "@/server/getters/dashboard/statistics/getApplicationStatistics"; +import { ApplicationStatus } from "@/services/types/applicationStatus"; + +type StatisticsProps = { + initialData: ApplicationStatisticsData; + hackathonId: number; +}; + +type FilterOption = ApplicationStatus | "all"; + +const Statistics = ({ initialData, hackathonId }: StatisticsProps) => { + const [statusFilter, setStatusFilter] = useState("all"); + const [data, setData] = useState(initialData); + const [loading, setLoading] = useState(false); + + const handleFilterChange = async (newStatus: FilterOption) => { + setStatusFilter(newStatus); + setLoading(true); + + try { + const response = await fetch( + `/api/dashboard/${hackathonId}/statistics?status=${newStatus}`, + { + method: "GET", + } + ); + + if (response.ok) { + const newData = await response.json(); + setData(newData); + } + } catch (error) { + console.error("Failed to fetch statistics:", error); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Application Statistics + + + + + + + + +
+ + Total Applications: {data.totalApplications} + +
+ + {data.stepStatistics.length === 0 ? ( + + No dropdown fields found or no responses yet. + + ) : ( + + {data.stepStatistics.map((step) => ( +
+ + {step.stepTitle} + + + {step.fields.map((field) => ( + + + + {field.fieldLabel} + + + Total responses: {field.totalResponses} + + + + + {field.options.map((option) => ( +
+
+ + {option.optionValue} + +
+
+ + {option.count} + + + {option.percentage.toFixed(1)}% + +
+
+
+
+
+ ))} + + + + ))} + +
+ ))} +
+ )} +
+ + +
+ ); +}; + +export default Statistics; diff --git a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts new file mode 100644 index 0000000..74f6278 --- /dev/null +++ b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts @@ -0,0 +1,158 @@ +import { prisma } from "@/services/prisma"; +import requireOrganizerSession from "@/server/services/helpers/auth/requireOrganizerSession"; +import { ApplicationStatus } from "@/services/types/applicationStatus"; + +export type FieldOptionStatistic = { + optionValue: string; + count: number; + percentage: number; +}; + +export type FieldStatistic = { + fieldId: number; + fieldLabel: string; + totalResponses: number; + options: FieldOptionStatistic[]; +}; + +export type StepStatistic = { + stepId: number; + stepTitle: string; + fields: FieldStatistic[]; +}; + +export type ApplicationStatisticsData = { + totalApplications: number; + stepStatistics: StepStatistic[]; +}; + +const getApplicationStatistics = async ( + hackathonId: number, + status?: ApplicationStatus | "all" +): Promise => { + await requireOrganizerSession(); + + const whereStatus = + status && status !== "all" + ? { + status: { + name: status, + }, + } + : {}; + + const totalApplications = await prisma.application.count({ + where: { + hacker: { + hackathonId, + }, + ...whereStatus, + }, + }); + + // Get all form steps with their dropdown fields (fields that have an optionList) + const steps = await prisma.applicationFormStep.findMany({ + select: { + id: true, + title: true, + formFields: { + select: { + id: true, + label: true, + optionList: { + select: { + options: { + select: { + id: true, + value: true, + }, + }, + }, + }, + }, + where: { + optionListId: { + not: null, + }, + }, + orderBy: { + position: "asc", + }, + }, + }, + where: { + hackathonId, + }, + orderBy: { + position: "asc", + }, + }); + + const stepStatistics: StepStatistic[] = []; + + for (const step of steps) { + if (step.formFields.length === 0) continue; + + const fields: FieldStatistic[] = []; + + for (const field of step.formFields) { + if (!field.optionList) continue; + + const allOptions = field.optionList.options; + + // Count responses for each option + const optionCounts = await Promise.all( + allOptions.map(async (option) => { + const count = await prisma.applicationFormFieldValue.count({ + where: { + fieldId: field.id, + optionId: option.id, + application: { + hacker: { + hackathonId, + }, + ...whereStatus, + }, + }, + }); + return { optionValue: option.value, count }; + }) + ); + + const totalResponses = optionCounts.reduce((sum, o) => sum + o.count, 0); + + const options: FieldOptionStatistic[] = optionCounts + .filter((o) => o.count > 0) + .map((o) => ({ + optionValue: o.optionValue, + count: o.count, + percentage: totalResponses > 0 ? (o.count / totalResponses) * 100 : 0, + })) + .sort((a, b) => b.count - a.count); + + if (totalResponses > 0) { + fields.push({ + fieldId: field.id, + fieldLabel: field.label, + totalResponses, + options, + }); + } + } + + if (fields.length > 0) { + stepStatistics.push({ + stepId: step.id, + stepTitle: step.title, + fields, + }); + } + } + + return { + totalApplications, + stepStatistics, + }; +}; + +export default getApplicationStatistics; diff --git a/src/server/getters/sponsors/getApplicationsForSponsors.ts b/src/server/getters/sponsors/getApplicationsForSponsors.ts index f090f41..d266952 100644 --- a/src/server/getters/sponsors/getApplicationsForSponsors.ts +++ b/src/server/getters/sponsors/getApplicationsForSponsors.ts @@ -93,6 +93,9 @@ const getApplicationsForSponsors = async ( statusId: { in: [confirmedStatusId.id, attendedStatusId.id], }, + hacker: { + hackathonId, + }, }, }); From 32185f8d9fe9b1a959227ae9b98b14ba1aabcde4 Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 00:19:32 +0200 Subject: [PATCH 2/7] feat: add file field support, sponsor search, statistics page, and CLAUDE.md - Add file field handling to createFormValuesObject (option > file.name > value) - Add file.name to formValues select in applicationList and getApplicationsForSponsors - Add email/team name search and rows per page selector to SponsorsApplicationsTable - Rewrite getApplicationStatistics to use FormFieldType value for select fields - Add requireAdmin() guard to statistics page - Fix statistics API route error handling - Add CLAUDE.md project documentation Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 244 ++++++++++++++++++ .../[hackathonId]/statistics/route.ts | 27 +- .../[hackathonId]/statistics/page.tsx | 12 +- .../components/SponsorsApplicationsTable.tsx | 169 ++++++++---- .../getters/dashboard/applicationList.ts | 6 + .../statistics/getApplicationStatistics.ts | 219 +++++++++------- .../sponsors/getApplicationsForSponsors.ts | 1 + .../applications/createFormValuesObject.ts | 11 +- 8 files changed, 523 insertions(+), 166 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..673da05 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,244 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +HackPortal is a comprehensive hackathon management platform built with Next.js and Prisma. It handles: +- Hacker applications and team management +- Form builder with dynamic application forms (multi-step) +- Sponsor portal with application viewing and filtering +- Check-in system with QR codes +- Judging and voting systems +- Travel reimbursements +- Dashboard for organizers + +## Tech Stack + +- **Framework**: Next.js 14 (App Router) +- **Database**: Prisma ORM (supports SQLite, PostgreSQL, MySQL, etc.) +- **Authentication**: NextAuth.js with OAuth (GitHub, Google) +- **UI Components**: Radix UI + custom components with Tailwind CSS +- **Forms**: React Hook Form + Zod validation +- **Tables**: TanStack React Table +- **Testing**: Jest (unit), Playwright (E2E) +- **Monitoring**: Sentry, Datadog +- **File Storage**: Cloudflare R2 +- **Email**: Brevo API +- **QR Codes**: qrcode.react + +## Development Commands + +### Setup & Installation +```bash +npm install +cp .env.template .env # Configure with your environment variables +npm run prisma:migrate-dev # Run database migrations +npm run prisma:seed # Seed initial data (enums, defaults) +``` + +### Development +```bash +npm run dev # Start dev server (http://localhost:3000), hot-reload enabled +npm run dev:turbopack # Faster dev server with Turbopack +npm run types # Check TypeScript types without emitting +npm run lint # Run ESLint +``` + +### Database +```bash +npm run prisma:generate # Generate Prisma client after schema changes +npm run prisma:migrate-dev # Create migration and apply it in dev +npm run prisma:migrate-prod # Apply migrations in production +npm run prisma:seed # Seed database with initial values +``` + +### Testing +```bash +npm run test:jest # Run Jest unit tests +npm run test:jest:coverage # Run tests with coverage report +npm run test:e2e # Run Playwright E2E tests +npm run test:local-e2e-docker # Run E2E tests locally with Docker +``` + +### Build & Production +```bash +npm run build # Build for production +npm run start # Start production server +export PORT=3003 && npm run start # Run on custom port +``` + +### Other +```bash +npm run storybook # Start Storybook component library (port 6006) +npm run build-storybook # Build Storybook +``` + +## Project Architecture + +### Directory Structure + +``` +src/ +├── app/ # Next.js App Router pages and layouts +│ ├── api/ # API routes and NextAuth +│ ├── dashboard/ # Organizer dashboard (protected routes) +│ ├── sponsors/ # Sponsor portal (protected routes) +│ ├── application/ # Hacker application form (multi-step) +│ ├── signin/signup/ # Authentication pages +│ └── [page].tsx # Static pages +├── scenes/ # Feature-specific React components (organized by feature) +│ ├── Dashboard/ # Dashboard pages and sub-features +│ ├── Application/ # Application form components +│ ├── Sponsors/ # Sponsor portal components +│ └── ... +├── components/ # Reusable UI components +│ ├── ui/ # Shadcn/Radix UI primitives +│ ├── common/ # Shared components (Navbar, dialogs, etc.) +│ └── stories/ # Storybook stories +├── server/ # Server-side functions +│ ├── actions/ # Server Actions (used by client components) +│ ├── getters/ # Data fetching functions (read-only) +│ ├── schemas/ # Zod schemas for validation +│ └── services/ # Business logic and helpers +├── services/ # Shared utilities and helpers +├── styles/ # Global CSS and Tailwind config +└── prisma/ # Database schema and migrations +``` + +### Key Patterns + +**Server Actions**: Located in `src/server/actions/`, these are async functions marked with `"use server"` that handle mutations (create, update, delete). Called from client components via function import. + +**Data Getters**: Located in `src/server/getters/`, these fetch read-only data. Organized by feature (e.g., `sponsors/getApplicationsForSponsors.ts`, `dashboard/tables/getChallengeList.ts`). + +**Types**: TypeScript types are defined in server getter files (e.g., `ApplicationPropertySponsorList`), imported where needed, and re-exported for client use. + +**Form Handling**: Most forms use `react-hook-form` with `Zod` validation. Form fields are rendered dynamically based on Prisma models. + +**Protected Routes**: Middleware and helper functions in `src/server/services/helpers/auth/` handle role-based access (organizer, sponsor, hacker). + +## Database Schema (Key Models) + +- **User**: Authentication + profile info +- **Hackathon**: The event (one per instance) +- **Hacker**: User participating in hackathon, may own/join a team +- **Team**: Group of hackers working together +- **Application**: Hacker's application with form responses +- **ApplicationFormStep**: Multi-step application form definition +- **FormField**: Individual form fields (shown in sponsors view if `shownInSponsorsViewTable: true`) +- **FormValue**: Hacker's answers to form fields +- **Sponsor**: Sponsor user with associated hackathon +- **Organizer**: Organizer user +- **Challenge**: Sponsor challenge for teams to tackle +- **Table**: Physical tables at event for team check-in +- **JudgingSlot**: Judging schedule slots +- **TeamJudging**: Team's judging results + +See `prisma/schema.prisma` for full schema. + +## Common Development Workflows + +### Adding a New Hackathon Feature + +1. **Update Prisma schema** (`prisma/schema.prisma`) if new data models needed +2. **Run migration** (`npm run prisma:migrate-dev`) +3. **Create getter** in `src/server/getters/` for data fetching +4. **Create action(s)** in `src/server/actions/` for mutations +5. **Create page** in `src/app/` following App Router conventions +6. **Create scene component(s)** in `src/scenes/` for feature logic +7. **Add tests** for new functionality + +### Modifying Application Forms + +- Form structure is defined in database (`ApplicationFormStep`, `FormField`) +- Form responses stored in `FormValue` with reference to `FormField` +- Dynamic form rendering happens in `FormRenderer` component +- Sponsors see only fields with `shownInSponsorsViewTable: true` + +### Adding a New Sponsor Portal Feature + +Sponsors view applications filtered by status (confirmed/attended): +- Get applications via `getApplicationsForSponsors()` in `src/server/getters/sponsors/` +- Display in table via `SponsorsApplicationsTable` component +- Add filtering/searching logic in the table component or getter + +### Working with Authentication + +- NextAuth configured at `src/app/api/auth/[...nextauth]/route.ts` +- Session checking via `useSession()` hook (client) or `auth()` (server) +- Role validation via helpers: `requireOrganizerSession()`, `requireSponsorSession()` + +## Testing Approach + +- **Jest**: Unit and component tests in `__tests__/` directories +- **Playwright**: E2E tests in `e2e/` folder, fixtures for reusable page objects +- **Fixtures**: Located in `e2e/fixtures/` for page interactions (e.g., `DashboardPage.ts`) + +Run E2E locally: `npm run test:local-e2e-docker` (requires Docker) + +## Environment Setup + +Key `.env` variables needed: +- `DATABASE_URL` - SQLite file path or connection string +- `NEXTAUTH_URL` & `NEXTAUTH_SECRET` - Auth configuration +- `GITHUB_CLIENT_ID/SECRET`, `GOOGLE_CLIENT_ID/SECRET` - OAuth +- `CLOUDFLARE_R2_*` - File storage (optional for development) +- `BREVO_API_KEY` - Email sending (can disable with `EMAILS_ENABLED=false`) + +For local development, use the example `.env` from README with OAuth IDs/secrets. + +## Common Issues & Debugging + +**Fields not displaying in sponsor table**: Check that `shownInSponsorsViewTable: true` is set on the `FormField` in database. Fields are dynamically rendered from `ApplicationPropertySponsorList` type. + +**Type errors with form values**: Form values are flexible (`string | null | number | ApplicationStatus`). For complex types (arrays, objects), stringify in database and parse on client. + +**Migrations failing**: Ensure database file exists and is writable. For SQLite: `DATABASE_URL="file:./portal.db"`. Run `npm run prisma:generate` if client is out of sync. + +**NextAuth session issues**: Verify `NEXTAUTH_SECRET` is set and consistent. Check user exists in database with correct role (User → Organizer/Sponsor/Hacker). + +## Performance Considerations + +- Tables use TanStack React Table with pagination (default 20 rows per page) +- Column visibility saved to localStorage per hackathon +- Use server actions for mutations to avoid exposing business logic +- Prisma queries should explicitly select needed fields (avoid N+1) +- File uploads stored in Cloudflare R2, accessed via signed URLs + +## Useful Patterns + +**Creating a protected API route**: +```ts +// src/server/actions/example.ts +"use server" +import { requireOrganizerSession } from "@/server/services/helpers/auth/requireOrganizerSession" + +export async function exampleAction(data: ExampleInput) { + const session = await requireOrganizerSession() + // Your logic here +} +``` + +**Fetching data in a server component**: +```ts +// In src/app pages +import getExampleData from "@/server/getters/example" + +export default async function Page({ params }: Props) { + const data = await getExampleData(params.id) + return +} +``` + +**Client component with form**: +```tsx +"use client" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +export default function Form() { + const form = useForm({ resolver: zodResolver(schema) }) + return
+} +``` diff --git a/src/app/api/dashboard/[hackathonId]/statistics/route.ts b/src/app/api/dashboard/[hackathonId]/statistics/route.ts index deeddac..79886c3 100644 --- a/src/app/api/dashboard/[hackathonId]/statistics/route.ts +++ b/src/app/api/dashboard/[hackathonId]/statistics/route.ts @@ -1,23 +1,30 @@ import { NextRequest, NextResponse } from "next/server"; +import requireOrganizerSession from "@/server/services/helpers/auth/requireOrganizerSession"; import getApplicationStatistics from "@/server/getters/dashboard/statistics/getApplicationStatistics"; import { ApplicationStatus } from "@/services/types/applicationStatus"; -export const GET = async ( +export async function GET( request: NextRequest, { params }: { params: { hackathonId: string } } -) => { +) { try { + await requireOrganizerSession(); + + const searchParams = request.nextUrl.searchParams; + const status = (searchParams.get("status") || "all") as + | ApplicationStatus + | "all"; + const hackathonId = Number(params.hackathonId); - const { searchParams } = new URL(request.url); - const status = searchParams.get("status") as ApplicationStatus | "all" | null; - const data = await getApplicationStatistics( - hackathonId, - status ?? "all" - ); + const data = await getApplicationStatistics(hackathonId, status); return NextResponse.json(data); } catch (error) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + console.error("Statistics API error:", error); + return NextResponse.json( + { error: "Failed to fetch statistics" }, + { status: 500 } + ); } -}; +} diff --git a/src/app/dashboard/[hackathonId]/statistics/page.tsx b/src/app/dashboard/[hackathonId]/statistics/page.tsx index f8888fe..76047a9 100644 --- a/src/app/dashboard/[hackathonId]/statistics/page.tsx +++ b/src/app/dashboard/[hackathonId]/statistics/page.tsx @@ -1,18 +1,18 @@ -import React from "react"; import Statistics from "@/scenes/Dashboard/scenes/Statistics/Statistics"; +import requireAdmin from "@/services/helpers/requireAdmin"; import { disallowVolunteer } from "@/services/helpers/disallowVolunteer"; import getApplicationStatistics from "@/server/getters/dashboard/statistics/getApplicationStatistics"; -export const metadata = { - title: "Statistics", -}; - const StatisticsPage = async ({ params: { hackathonId }, }: { - params: { hackathonId: string }; + params: { + hackathonId: string; + }; }) => { await disallowVolunteer(hackathonId); + await requireAdmin(); + const initialData = await getApplicationStatistics(Number(hackathonId), "all"); return ( diff --git a/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx b/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx index 0aef08d..c05a03f 100644 --- a/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx +++ b/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx @@ -61,6 +61,24 @@ const SponsorsApplicationsTable = ({ hackathonId, applicationProperties, }: ApplicationsTableProps) => { + // Search state + const [searchEmail, setSearchEmail] = React.useState(""); + const [searchName, setSearchName] = React.useState(""); + + // Filter applications based on email and name search + const filteredApplications = useMemo(() => { + return applicationProperties.filter((app) => { + const emailMatch = app.email + .toLowerCase() + .includes(searchEmail.toLowerCase()); + const teamStr = typeof app.team === "string" ? app.team : ""; + const nameMatch = teamStr + .toLowerCase() + .includes(searchName.toLowerCase()); + return emailMatch && nameMatch; + }); + }, [applicationProperties, searchEmail, searchName]); + const columns: ColumnDef[] = useMemo( () => [ ...Object.keys(applicationProperties[0] ?? {}).map((key) => ({ @@ -102,11 +120,10 @@ const SponsorsApplicationsTable = ({ [] ); const [sorting, setSorting] = React.useState([]); - const [filterValue, setFilterValue] = React.useState("all"); const [columnVisibility, setColumnVisibility] = React.useState({}); const table = useReactTable({ - data: applicationProperties, + data: filteredApplications, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), @@ -150,42 +167,68 @@ const SponsorsApplicationsTable = ({ return ( <> - -
- - - - - - - {table - .getAllColumns() - .filter( - (column) => column.getCanHide() && column.id !== "Actions" - ) - .map((column) => { - return ( - { - column.toggleVisibility(value); - saveColumnVisibility(column.id, value); - }} - onSelect={(event) => event.preventDefault()} - > - {column.id} - - ); - })} - - - -
+ + +
+
+ + setSearchEmail(e.target.value)} + className="mt-1 px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +
+
+ + setSearchName(e.target.value)} + className="mt-1 px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +
+
+
+ +
+ + + + + + + {table + .getAllColumns() + .filter( + (column) => column.getCanHide() && column.id !== "Actions" + ) + .map((column) => { + return ( + { + column.toggleVisibility(value); + saveColumnVisibility(column.id, value); + }} + onSelect={(event) => event.preventDefault()} + > + {column.id} + + ); + })} + + + +
+
@@ -240,23 +283,39 @@ const SponsorsApplicationsTable = ({
-
- - +
+
+ + +
+
+ + +
); diff --git a/src/server/getters/dashboard/applicationList.ts b/src/server/getters/dashboard/applicationList.ts index db2cd9a..9dcbbfb 100644 --- a/src/server/getters/dashboard/applicationList.ts +++ b/src/server/getters/dashboard/applicationList.ts @@ -66,6 +66,12 @@ const getApplicationsList = async ( value: true, }, }, + file: { + select: { + name: true, + path: true, + }, + }, }, }, votes: { diff --git a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts index 74f6278..0d01695 100644 --- a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts +++ b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts @@ -1,8 +1,12 @@ import { prisma } from "@/services/prisma"; -import requireOrganizerSession from "@/server/services/helpers/auth/requireOrganizerSession"; -import { ApplicationStatus } from "@/services/types/applicationStatus"; - -export type FieldOptionStatistic = { +import { + ApplicationStatus, + ApplicationStatusEnum, +} from "@/services/types/applicationStatus"; +import { Prisma } from ".prisma/client"; +import SortOrder = Prisma.SortOrder; + +export type OptionCount = { optionValue: string; count: number; percentage: number; @@ -11,11 +15,12 @@ export type FieldOptionStatistic = { export type FieldStatistic = { fieldId: number; fieldLabel: string; + fieldType: string; + options: OptionCount[]; totalResponses: number; - options: FieldOptionStatistic[]; }; -export type StepStatistic = { +export type StepStatistics = { stepId: number; stepTitle: string; fields: FieldStatistic[]; @@ -23,134 +28,160 @@ export type StepStatistic = { export type ApplicationStatisticsData = { totalApplications: number; - stepStatistics: StepStatistic[]; + stepStatistics: StepStatistics[]; }; const getApplicationStatistics = async ( hackathonId: number, - status?: ApplicationStatus | "all" + statusFilter: ApplicationStatus | "all" ): Promise => { - await requireOrganizerSession(); - - const whereStatus = - status && status !== "all" - ? { - status: { - name: status, - }, - } - : {}; - - const totalApplications = await prisma.application.count({ - where: { - hacker: { - hackathonId, - }, - ...whereStatus, + // Fetch applications with their form values + const whereClause: Prisma.ApplicationWhereInput = { + hacker: { + hackathonId, }, - }); + }; + + if (statusFilter !== "all") { + const status = await prisma.applicationStatus.findUnique({ + where: { name: statusFilter }, + select: { id: true }, + }); + if (status) { + whereClause.statusId = status.id; + } + } - // Get all form steps with their dropdown fields (fields that have an optionList) - const steps = await prisma.applicationFormStep.findMany({ + const applications = await prisma.application.findMany({ select: { id: true, - title: true, - formFields: { + formValues: { select: { - id: true, - label: true, - optionList: { + field: { select: { - options: { + id: true, + label: true, + type: { select: { - id: true, value: true, }, }, }, }, - }, - where: { - optionListId: { - not: null, + option: { + select: { + value: true, + }, }, }, - orderBy: { - position: "asc", + }, + }, + where: whereClause, + }); + + // Get all form fields that are select/dropdown types, grouped by step + const formFields = await prisma.formField.findMany({ + select: { + id: true, + label: true, + type: { + select: { + value: true, + }, + }, + step: { + select: { + id: true, + title: true, + position: true, }, }, }, where: { - hackathonId, - }, - orderBy: { - position: "asc", + AND: [ + { + step: { + hackathonId, + }, + }, + { + type: { + value: { + in: ["select", "multi_select"], + }, + }, + }, + ], }, + orderBy: [ + { + step: { + position: SortOrder.asc, + }, + }, + { + position: SortOrder.asc, + }, + ], }); - const stepStatistics: StepStatistic[] = []; - - for (const step of steps) { - if (step.formFields.length === 0) continue; - - const fields: FieldStatistic[] = []; + // Group fields by step + const stepMap = new Map(); - for (const field of step.formFields) { - if (!field.optionList) continue; + for (const field of formFields) { + const optionMap = new Map(); + let totalResponses = 0; - const allOptions = field.optionList.options; - - // Count responses for each option - const optionCounts = await Promise.all( - allOptions.map(async (option) => { - const count = await prisma.applicationFormFieldValue.count({ - where: { - fieldId: field.id, - optionId: option.id, - application: { - hacker: { - hackathonId, - }, - ...whereStatus, - }, - }, - }); - return { optionValue: option.value, count }; - }) + for (const application of applications) { + const fieldValue = application.formValues.find( + (fv) => fv.field.id === field.id ); + if (fieldValue && fieldValue.option) { + const optionValue = fieldValue.option.value; + optionMap.set(optionValue, (optionMap.get(optionValue) ?? 0) + 1); + totalResponses++; + } + } - const totalResponses = optionCounts.reduce((sum, o) => sum + o.count, 0); - - const options: FieldOptionStatistic[] = optionCounts - .filter((o) => o.count > 0) - .map((o) => ({ - optionValue: o.optionValue, - count: o.count, - percentage: totalResponses > 0 ? (o.count / totalResponses) * 100 : 0, + if (totalResponses > 0) { + const options: OptionCount[] = Array.from(optionMap.entries()) + .map(([optionValue, count]) => ({ + optionValue, + count, + percentage: (count / totalResponses) * 100, })) .sort((a, b) => b.count - a.count); - if (totalResponses > 0) { - fields.push({ - fieldId: field.id, - fieldLabel: field.label, - totalResponses, - options, + const fieldStatistic: FieldStatistic = { + fieldId: field.id, + fieldLabel: field.label, + fieldType: field.type.value, + options, + totalResponses, + }; + + if (!stepMap.has(field.step.id)) { + stepMap.set(field.step.id, { + title: field.step.title, + fields: [], }); } - } - if (fields.length > 0) { - stepStatistics.push({ - stepId: step.id, - stepTitle: step.title, - fields, - }); + stepMap.get(field.step.id)!.fields.push(fieldStatistic); } } + // Convert map to array, preserving order + const stepStatistics: StepStatistics[] = Array.from(stepMap.entries()).map( + ([stepId, data]) => ({ + stepId, + stepTitle: data.title, + fields: data.fields, + }) + ); + return { - totalApplications, + totalApplications: applications.length, stepStatistics, }; }; diff --git a/src/server/getters/sponsors/getApplicationsForSponsors.ts b/src/server/getters/sponsors/getApplicationsForSponsors.ts index d266952..6aecf43 100644 --- a/src/server/getters/sponsors/getApplicationsForSponsors.ts +++ b/src/server/getters/sponsors/getApplicationsForSponsors.ts @@ -83,6 +83,7 @@ const getApplicationsForSponsors = async ( file: { select: { id: true, + name: true, path: true, }, }, diff --git a/src/server/services/helpers/applications/createFormValuesObject.ts b/src/server/services/helpers/applications/createFormValuesObject.ts index ddd1ba5..f767f0a 100644 --- a/src/server/services/helpers/applications/createFormValuesObject.ts +++ b/src/server/services/helpers/applications/createFormValuesObject.ts @@ -1,6 +1,7 @@ type FormValues = { value: string | null; option: { value: string } | null; + file: { name: string; path: string } | null; field: { id: number }; }[]; @@ -23,7 +24,15 @@ const createFormValuesObject = ( (formValue) => formValue.field.id === formField.id ); if (formValue) { - const value = formValue.option?.value ?? formValue.value; + // Handle option, file, then fallback to value (same logic as getFormFieldValue) + let value: string | null = null; + if (formValue.option) { + value = formValue.option.value; + } else if (formValue.file) { + value = formValue.file.name; + } else { + value = formValue.value; + } result[formField.label] = value ?? ""; } else { result[formField.label] = null; From 5abf173f93b4f833401218c9594e457427712ab9 Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 00:24:29 +0200 Subject: [PATCH 3/7] fix: correct statistics field filter, API validation, and unused imports - Use FormFieldTypesWithOptions (radio/select/combobox) instead of non-existent "multi_select" - Separate auth errors (401) from server errors (500) in statistics API route - Validate hackathonId is a number and status is a known value before processing - Remove file.id over-fetch in getApplicationsForSponsors - Remove unused ApplicationStatusEnum and FieldStatistic imports Co-Authored-By: Claude Sonnet 4.6 --- .../[hackathonId]/statistics/route.ts | 24 +++++++++++++------ .../scenes/Statistics/Statistics.tsx | 5 +--- .../statistics/getApplicationStatistics.ts | 8 +++---- .../sponsors/getApplicationsForSponsors.ts | 1 - 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/app/api/dashboard/[hackathonId]/statistics/route.ts b/src/app/api/dashboard/[hackathonId]/statistics/route.ts index 79886c3..b47913f 100644 --- a/src/app/api/dashboard/[hackathonId]/statistics/route.ts +++ b/src/app/api/dashboard/[hackathonId]/statistics/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import requireOrganizerSession from "@/server/services/helpers/auth/requireOrganizerSession"; import getApplicationStatistics from "@/server/getters/dashboard/statistics/getApplicationStatistics"; -import { ApplicationStatus } from "@/services/types/applicationStatus"; +import { + ApplicationStatus, + ApplicationStatusEnum, +} from "@/services/types/applicationStatus"; export async function GET( request: NextRequest, @@ -9,16 +12,23 @@ export async function GET( ) { try { await requireOrganizerSession(); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - const searchParams = request.nextUrl.searchParams; - const status = (searchParams.get("status") || "all") as - | ApplicationStatus - | "all"; + const hackathonId = Number(params.hackathonId); + if (isNaN(hackathonId)) { + return NextResponse.json({ error: "Invalid hackathonId" }, { status: 400 }); + } - const hackathonId = Number(params.hackathonId); + const rawStatus = request.nextUrl.searchParams.get("status") ?? "all"; + const validStatuses: string[] = [...Object.values(ApplicationStatusEnum), "all"]; + const status = validStatuses.includes(rawStatus) + ? (rawStatus as ApplicationStatus | "all") + : "all"; + try { const data = await getApplicationStatistics(hackathonId, status); - return NextResponse.json(data); } catch (error) { console.error("Statistics API error:", error); diff --git a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx index fd821b0..dfae63f 100644 --- a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx +++ b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx @@ -4,10 +4,7 @@ import React, { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Stack } from "@/components/ui/stack"; import { Text } from "@/components/ui/text"; -import { - ApplicationStatisticsData, - FieldStatistic, -} from "@/server/getters/dashboard/statistics/getApplicationStatistics"; +import { ApplicationStatisticsData } from "@/server/getters/dashboard/statistics/getApplicationStatistics"; import { ApplicationStatus } from "@/services/types/applicationStatus"; type StatisticsProps = { diff --git a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts index 0d01695..aa6ee03 100644 --- a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts +++ b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts @@ -1,8 +1,6 @@ import { prisma } from "@/services/prisma"; -import { - ApplicationStatus, - ApplicationStatusEnum, -} from "@/services/types/applicationStatus"; +import { ApplicationStatus } from "@/services/types/applicationStatus"; +import { FormFieldTypesWithOptions } from "@/services/types/formFields"; import { Prisma } from ".prisma/client"; import SortOrder = Prisma.SortOrder; @@ -107,7 +105,7 @@ const getApplicationStatistics = async ( { type: { value: { - in: ["select", "multi_select"], + in: FormFieldTypesWithOptions, }, }, }, diff --git a/src/server/getters/sponsors/getApplicationsForSponsors.ts b/src/server/getters/sponsors/getApplicationsForSponsors.ts index 6aecf43..a2e5550 100644 --- a/src/server/getters/sponsors/getApplicationsForSponsors.ts +++ b/src/server/getters/sponsors/getApplicationsForSponsors.ts @@ -82,7 +82,6 @@ const getApplicationsForSponsors = async ( }, file: { select: { - id: true, name: true, path: true, }, From e752abe397f68f4321172b0a5163b1ba09ca4182 Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 00:36:19 +0200 Subject: [PATCH 4/7] fix: resolve critical and major bugs across statistics and sponsor portal Critical: - Statistics.tsx: only commit statusFilter on successful fetch to prevent desync; show loading indicator and error message on failure - SponsorsApplicationsTable.tsx: wrap JSON.parse(localStorage) in try/catch to prevent crash on corrupted data; remove `table` from useEffect deps to prevent re-initialization on every render - getApplicationsForSponsors + applicationList: move createFormValuesObject spread before named properties so id/email/status can never be overwritten by a form field with a colliding label Major: - getApplicationStatistics: replace separate status DB lookup with Prisma relation filter (eliminates extra round-trip and silent filter-drop on missing status row) - statistics/page.tsx: add NaN guard on hackathonId before calling getter - getApplicationsForSponsors: add sponsor hackathon ownership check so a sponsor cannot access another hackathon's applications; fix misleading error messages to identify which status is missing Co-Authored-By: Claude Sonnet 4.6 --- .../[hackathonId]/statistics/page.tsx | 10 ++++- .../scenes/Statistics/Statistics.tsx | 13 +++++- .../components/SponsorsApplicationsTable.tsx | 40 ++++++++++--------- .../getters/dashboard/applicationList.ts | 2 +- .../statistics/getApplicationStatistics.ts | 16 +------- .../sponsors/getApplicationsForSponsors.ts | 15 +++++-- 6 files changed, 55 insertions(+), 41 deletions(-) diff --git a/src/app/dashboard/[hackathonId]/statistics/page.tsx b/src/app/dashboard/[hackathonId]/statistics/page.tsx index 76047a9..4a5dda8 100644 --- a/src/app/dashboard/[hackathonId]/statistics/page.tsx +++ b/src/app/dashboard/[hackathonId]/statistics/page.tsx @@ -1,3 +1,4 @@ +import { notFound } from "next/navigation"; import Statistics from "@/scenes/Dashboard/scenes/Statistics/Statistics"; import requireAdmin from "@/services/helpers/requireAdmin"; import { disallowVolunteer } from "@/services/helpers/disallowVolunteer"; @@ -10,15 +11,20 @@ const StatisticsPage = async ({ hackathonId: string; }; }) => { + const hackathonIdNum = Number(hackathonId); + if (isNaN(hackathonIdNum)) { + notFound(); + } + await disallowVolunteer(hackathonId); await requireAdmin(); - const initialData = await getApplicationStatistics(Number(hackathonId), "all"); + const initialData = await getApplicationStatistics(hackathonIdNum, "all"); return ( ); }; diff --git a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx index dfae63f..baac388 100644 --- a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx +++ b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx @@ -18,10 +18,11 @@ const Statistics = ({ initialData, hackathonId }: StatisticsProps) => { const [statusFilter, setStatusFilter] = useState("all"); const [data, setData] = useState(initialData); const [loading, setLoading] = useState(false); + const [fetchError, setFetchError] = useState(null); const handleFilterChange = async (newStatus: FilterOption) => { - setStatusFilter(newStatus); setLoading(true); + setFetchError(null); try { const response = await fetch( @@ -34,9 +35,13 @@ const Statistics = ({ initialData, hackathonId }: StatisticsProps) => { if (response.ok) { const newData = await response.json(); setData(newData); + setStatusFilter(newStatus); + } else { + setFetchError("Failed to load statistics. Please try again."); } } catch (error) { console.error("Failed to fetch statistics:", error); + setFetchError("Failed to load statistics. Please try again."); } finally { setLoading(false); } @@ -62,7 +67,13 @@ const Statistics = ({ initialData, hackathonId }: StatisticsProps) => { + {loading && ( + Loading... + )} + {fetchError && ( +

{fetchError}

+ )} diff --git a/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx b/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx index c05a03f..1494637 100644 --- a/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx +++ b/src/scenes/Sponsors/ApplicationList/components/SponsorsApplicationsTable.tsx @@ -139,31 +139,33 @@ const SponsorsApplicationsTable = ({ onColumnVisibilityChange: setColumnVisibility, }); + const localStorageKey = `hackathon-sponsors-${hackathonId}-column-visibility`; + const saveColumnVisibility = (columnName: string, visibility: boolean) => { - const oldColumnVisibility = - localStorage.getItem( - `hackathon-sponsors-${hackathonId}-column-visibility` - ) ?? "{}"; - const oldColumnVisibilityObject = JSON.parse(oldColumnVisibility); - const newColumnVisibilityObject = { - ...oldColumnVisibilityObject, - [columnName]: visibility, - }; - localStorage.setItem( - `hackathon-sponsors-${hackathonId}-column-visibility`, - JSON.stringify(newColumnVisibilityObject) - ); + try { + const raw = localStorage.getItem(localStorageKey) ?? "{}"; + const existing = JSON.parse(raw); + localStorage.setItem( + localStorageKey, + JSON.stringify({ ...existing, [columnName]: visibility }) + ); + } catch { + localStorage.removeItem(localStorageKey); + } }; useEffect(() => { table.setPageSize(20); - const savedColumnVisibility = localStorage.getItem( - `hackathon-sponsors-${hackathonId}-column-visibility` - ); - if (savedColumnVisibility) { - setColumnVisibility(JSON.parse(savedColumnVisibility)); + const saved = localStorage.getItem(localStorageKey); + if (saved) { + try { + setColumnVisibility(JSON.parse(saved)); + } catch { + localStorage.removeItem(localStorageKey); + } } - }, [hackathonId, table]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hackathonId]); return ( <> diff --git a/src/server/getters/dashboard/applicationList.ts b/src/server/getters/dashboard/applicationList.ts index 9dcbbfb..e732489 100644 --- a/src/server/getters/dashboard/applicationList.ts +++ b/src/server/getters/dashboard/applicationList.ts @@ -124,10 +124,10 @@ const getApplicationsList = async ( const applications = applicationsDb.map((application) => ({ properties: { + ...createFormValuesObject(application.formValues, formFields), id: application.id, hackerId: application.hacker.id, email: application.hacker.user.email, - ...createFormValuesObject(application.formValues, formFields), score: calculateApplicationScore({ votes: application.votes }), team: application.hacker.team?.name ?? "", status: application.status.name as ApplicationStatus, diff --git a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts index aa6ee03..1e04d23 100644 --- a/src/server/getters/dashboard/statistics/getApplicationStatistics.ts +++ b/src/server/getters/dashboard/statistics/getApplicationStatistics.ts @@ -33,23 +33,11 @@ const getApplicationStatistics = async ( hackathonId: number, statusFilter: ApplicationStatus | "all" ): Promise => { - // Fetch applications with their form values const whereClause: Prisma.ApplicationWhereInput = { - hacker: { - hackathonId, - }, + hacker: { hackathonId }, + ...(statusFilter !== "all" ? { status: { name: statusFilter } } : {}), }; - if (statusFilter !== "all") { - const status = await prisma.applicationStatus.findUnique({ - where: { name: statusFilter }, - select: { id: true }, - }); - if (status) { - whereClause.statusId = status.id; - } - } - const applications = await prisma.application.findMany({ select: { id: true, diff --git a/src/server/getters/sponsors/getApplicationsForSponsors.ts b/src/server/getters/sponsors/getApplicationsForSponsors.ts index a2e5550..a1853bd 100644 --- a/src/server/getters/sponsors/getApplicationsForSponsors.ts +++ b/src/server/getters/sponsors/getApplicationsForSponsors.ts @@ -26,7 +26,11 @@ export type ApplicationListDataSponsorList = { const getApplicationsForSponsors = async ( hackathonId: number ): Promise => { - await requireSponsorSession(); + const sponsor = await requireSponsorSession(); + + if (sponsor.hackathonId !== hackathonId) { + throw new Error("Sponsor does not have access to this hackathon"); + } const confirmedStatusId = await prisma.applicationStatus.findUnique({ select: { @@ -46,8 +50,11 @@ const getApplicationsForSponsors = async ( }, }); - if (!confirmedStatusId || !attendedStatusId) { - throw new Error("Confirmed status not found"); + if (!confirmedStatusId) { + throw new Error("Application status 'confirmed' not found in DB"); + } + if (!attendedStatusId) { + throw new Error("Application status 'attended' not found in DB"); } const applicationsDb = await prisma.application.findMany({ @@ -130,10 +137,10 @@ const getApplicationsForSponsors = async ( const applications = applicationsDb.map((application) => ({ properties: { + ...createFormValuesObject(application.formValues, formFields), id: application.id, email: application.hacker.user.email, team: application.hacker.team?.name ?? "", - ...createFormValuesObject(application.formValues, formFields), }, })); From 4b37fe62193f79a98256eeb876276e7a6ee443c7 Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 00:50:19 +0200 Subject: [PATCH 5/7] fix: resolve all lint and prettier errors from CI - route.ts: format validStatuses array with trailing comma - statistics/page.tsx: inline Statistics JSX to match prettier line length - DashboardTabs.tsx: break statistics path check to new line - Statistics.tsx: format onChange handler across multiple lines - SponsorsApplicationsTable.tsx: break long label text to new line - getApplicationStatistics.ts: format Map generic type, replace non-null assertion with safe stepEntry guard Co-Authored-By: Claude Sonnet 4.6 --- .../api/dashboard/[hackathonId]/statistics/route.ts | 5 ++++- src/app/dashboard/[hackathonId]/statistics/page.tsx | 5 +---- .../components/DashboardTabs.tsx | 3 ++- src/scenes/Dashboard/scenes/Statistics/Statistics.tsx | 4 +++- .../components/SponsorsApplicationsTable.tsx | 4 +++- .../dashboard/statistics/getApplicationStatistics.ts | 10 ++++++++-- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/app/api/dashboard/[hackathonId]/statistics/route.ts b/src/app/api/dashboard/[hackathonId]/statistics/route.ts index b47913f..8bde9b1 100644 --- a/src/app/api/dashboard/[hackathonId]/statistics/route.ts +++ b/src/app/api/dashboard/[hackathonId]/statistics/route.ts @@ -22,7 +22,10 @@ export async function GET( } const rawStatus = request.nextUrl.searchParams.get("status") ?? "all"; - const validStatuses: string[] = [...Object.values(ApplicationStatusEnum), "all"]; + const validStatuses: string[] = [ + ...Object.values(ApplicationStatusEnum), + "all", + ]; const status = validStatuses.includes(rawStatus) ? (rawStatus as ApplicationStatus | "all") : "all"; diff --git a/src/app/dashboard/[hackathonId]/statistics/page.tsx b/src/app/dashboard/[hackathonId]/statistics/page.tsx index 4a5dda8..f6f7e5a 100644 --- a/src/app/dashboard/[hackathonId]/statistics/page.tsx +++ b/src/app/dashboard/[hackathonId]/statistics/page.tsx @@ -22,10 +22,7 @@ const StatisticsPage = async ({ const initialData = await getApplicationStatistics(hackathonIdNum, "all"); return ( - + ); }; diff --git a/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx b/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx index 625e516..80b2e65 100644 --- a/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx +++ b/src/scenes/Dashboard/scenes/HackathonDashboardLayout/components/DashboardTabs.tsx @@ -35,7 +35,8 @@ const getTabValue = ( if (path.startsWith(`/dashboard/${hackathonId}/check-in`)) return "checkin"; if (path.startsWith(`/dashboard/${hackathonId}/tables`)) return "tables"; if (path.startsWith(`/dashboard/${hackathonId}/judging`)) return "judging"; - if (path.startsWith(`/dashboard/${hackathonId}/statistics`)) return "statistics"; + if (path.startsWith(`/dashboard/${hackathonId}/statistics`)) + return "statistics"; return undefined; }; diff --git a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx index baac388..f7860c6 100644 --- a/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx +++ b/src/scenes/Dashboard/scenes/Statistics/Statistics.tsx @@ -56,7 +56,9 @@ const Statistics = ({ initialData, hackathonId }: StatisticsProps) => { (); + const stepMap = new Map< + number, + { title: string; fields: FieldStatistic[] } + >(); for (const field of formFields) { const optionMap = new Map(); @@ -153,7 +156,10 @@ const getApplicationStatistics = async ( }); } - stepMap.get(field.step.id)!.fields.push(fieldStatistic); + const stepEntry = stepMap.get(field.step.id); + if (stepEntry) { + stepEntry.fields.push(fieldStatistic); + } } } From 42d0e01d216fd048f827f6c3b80fea7a02f16a2f Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 00:55:30 +0200 Subject: [PATCH 6/7] fix: remove return parens in page.tsx and update deprecated CI actions to v4 - statistics/page.tsx: remove wrapping parens from single-element return (prettier) - e2e.yml: upload-artifact@v3 -> v4, setup-node@v3 -> v4 - unit.yml: setup-node@v3 -> v4 - static-checks.yml: setup-node@v3 -> v4 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/e2e.yml | 4 ++-- .github/workflows/static-checks.yml | 4 ++-- .github/workflows/unit.yml | 2 +- src/app/dashboard/[hackathonId]/statistics/page.tsx | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ace6bc9..7683f71 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,7 +30,7 @@ jobs: NEXT_PUBLIC_LOG_DEBUG: false steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 cache: npm @@ -47,7 +47,7 @@ jobs: run: npm run build - name: Run Playwright tests run: npm run test:e2e - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml index b69276b..ec79c27 100644 --- a/.github/workflows/static-checks.yml +++ b/.github/workflows/static-checks.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 cache: npm @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 cache: npm diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index a840f0c..0a67541 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -15,7 +15,7 @@ jobs: CLOUDFLARE_R2_SECRET_ACCESS_KEY: test-value steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 cache: npm diff --git a/src/app/dashboard/[hackathonId]/statistics/page.tsx b/src/app/dashboard/[hackathonId]/statistics/page.tsx index f6f7e5a..7033f71 100644 --- a/src/app/dashboard/[hackathonId]/statistics/page.tsx +++ b/src/app/dashboard/[hackathonId]/statistics/page.tsx @@ -21,9 +21,7 @@ const StatisticsPage = async ({ const initialData = await getApplicationStatistics(hackathonIdNum, "all"); - return ( - - ); + return ; }; export default StatisticsPage; From d99a21581d328784050fb0672297113c0844cea5 Mon Sep 17 00:00:00 2001 From: MatejMa2ur Date: Fri, 17 Apr 2026 01:01:16 +0200 Subject: [PATCH 7/7] fix(ci): use ubuntu-22.04 for Playwright to avoid missing package errors Ubuntu 24.04 (Noble) no longer provides libasound2, libffi7, and libx264-163 which the current Playwright version requires for browser installation. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7683f71..42dc562 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,7 +7,7 @@ on: jobs: playwright-tests: timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 env: DATABASE_URL: "file:./prod.db" NEXTAUTH_SECRET: playwright-secret