From a26870b8a10ce4371e63599d8a3bca4aa862b6d0 Mon Sep 17 00:00:00 2001 From: Jack Zheng Date: Thu, 5 Mar 2026 15:47:54 -0800 Subject: [PATCH 01/14] feat: replace numeric table assignment with dynamic two-floor lettered seating system --- .../_utils/csv-ingestion/csvAlgorithm.ts | 86 +++++++++++++++++-- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 7cfddb72d..a2bdb354f 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -329,6 +329,83 @@ export function sortTracks( return ordered; } +// Table number assignment +const ALL_ROWS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + +// Max teams per row on Floor 1 (Prioritize hardware teams)) +const FLOOR1_MAX_PER_ROW = 5; + +// Max teams per row on Floor 2 +const FLOOR2_MAX_PER_ROW = 10; + +/** + * Spreads teams evenly across rows, filling earlier rows first when there is a remainder + * + * Each team's tableNumber is set to e.g. "A3", "B1" + */ + +function distributeAcrossRows(teams: ParsedRecord[], rows: string[]): void { + if (teams.length === 0 || rows.length === 0) return; + const baseCount = Math.floor(teams.length / rows.length); + const remainder = teams.length % rows.length; + + let i = 0; + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + const letter = rows[rowIdx]; + const rowSize = baseCount + (rowIdx < remainder ? 1 : 0); + for (let seat = 1; seat <= rowSize; seat++) { + teams[i].tableNumber = `${letter}${seat}` as any; + i++; + } + } +} + +/** + * Assigns two-floor lettered table numbers: + * + * Floor 1 — Hardware Hack teams first, with leftover seats filled by other teams in .csv order. + * + * Floor 2 — remaining other teams after floor 1 spillover is accounted for. + * Rows start immediately after floor 1's last letter. + */ +function assignTableNumbers( + hardwareTeams: ParsedRecord[], + otherTeams: ParsedRecord[] +): void { + // Total row count for floor 1 for hardware teams + const floor1RowCount = Math.max( + 1, + Math.ceil(hardwareTeams.length / FLOOR1_MAX_PER_ROW) + ); + + // How many seats are available on floor 1 vs how many hardware teams fill them. + const floor1Capacity = floor1RowCount * FLOOR1_MAX_PER_ROW; + const floor1Spillover = floor1Capacity - hardwareTeams.length; + + // Pull enough other teams to fill the leftover floor 1 seats. + const floor1OtherTeams = otherTeams.slice(0, floor1Spillover); + const floor2Teams = otherTeams.slice(floor1Spillover); + + const floor1Teams = [...hardwareTeams, ...floor1OtherTeams]; + + // Total row count for floor 2 + const floor2RowCount = Math.max( + 1, + Math.ceil(floor2Teams.length / FLOOR2_MAX_PER_ROW) + ); + + // Deligate table letters based on row counts for each floor + const floor1Rows = ALL_ROWS.slice(0, floor1RowCount); + const floor2Rows = ALL_ROWS.slice( + floor1RowCount, + floor1RowCount + floor2RowCount + ); + + // Distribute teams across each floor's rows + distributeAcrossRows(floor1Teams, floor1Rows); + distributeAcrossRows(floor2Teams, floor2Rows); +} + export async function validateCsvBlob(blob: Blob): Promise<{ ok: boolean; body: ParsedRecord[] | null; @@ -473,13 +550,10 @@ export async function validateCsvBlob(blob: Blob): Promise<{ (team) => !team.tracks.includes('Best Hardware Hack') ); - const orderedTeams = [...bestHardwareTeams, ...otherTeams]; - - orderedTeams.forEach((team, index) => { - team.tableNumber = index + 1; - }); + assignTableNumbers(bestHardwareTeams, otherTeams); - resolve(orderedTeams); + // Hardware teams first in the returned list for display ordering. + resolve([...bestHardwareTeams, ...otherTeams]); }) .on('error', (error) => reject(error)); }; From e6a215287719c4af51ebeb82c73d310f515af2fa Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Tue, 24 Mar 2026 10:32:19 -0700 Subject: [PATCH 02/14] update migration, pre-set row and team number limit, added numeric comparison --- .../_utils/csv-ingestion/csvAlgorithm.ts | 70 ++++------ .../_utils/matching/randomizeProjects.ts | 29 +++- app/_types/parsedRecord.ts | 2 +- app/_types/team.ts | 2 +- ...0-update-table-number-to-letter-number.mjs | 132 ++++++++++++++++++ 5 files changed, 186 insertions(+), 49 deletions(-) create mode 100644 migrations/20260324120000-update-table-number-to-letter-number.mjs diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index a2bdb354f..b327a43fd 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -329,14 +329,13 @@ export function sortTracks( return ordered; } -// Table number assignment -const ALL_ROWS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); - -// Max teams per row on Floor 1 (Prioritize hardware teams)) -const FLOOR1_MAX_PER_ROW = 5; +// Max teams per row on Floor 1 (Prioritize hardware teams) +const FLOOR1_ROWS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; +const FLOOR1_TEAMS_PER_ROW = 13; // Max teams per row on Floor 2 -const FLOOR2_MAX_PER_ROW = 10; +const FLOOR2_ROWS = ['I', 'J', 'K', 'L']; +const FLOOR2_TEAMS_PER_ROW = 15; /** * Spreads teams evenly across rows, filling earlier rows first when there is a remainder @@ -344,18 +343,19 @@ const FLOOR2_MAX_PER_ROW = 10; * Each team's tableNumber is set to e.g. "A3", "B1" */ -function distributeAcrossRows(teams: ParsedRecord[], rows: string[]): void { +function distributeAcrossRows( + teams: ParsedRecord[], + rows: string[], + maxSeats: number +) { if (teams.length === 0 || rows.length === 0) return; - const baseCount = Math.floor(teams.length / rows.length); - const remainder = teams.length % rows.length; - - let i = 0; - for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { - const letter = rows[rowIdx]; - const rowSize = baseCount + (rowIdx < remainder ? 1 : 0); - for (let seat = 1; seat <= rowSize; seat++) { - teams[i].tableNumber = `${letter}${seat}` as any; - i++; + let teamIdx = 0; + for (const rowLabel of rows) { + for (let seat = 1; seat <= maxSeats; seat++) { + if (teamIdx >= teams.length) return; + // Assign the string label (e.g., "A13" or "I15") + teams[teamIdx].tableNumber = `${rowLabel}${seat}`; + teamIdx++; } } } @@ -372,38 +372,20 @@ function assignTableNumbers( hardwareTeams: ParsedRecord[], otherTeams: ParsedRecord[] ): void { - // Total row count for floor 1 for hardware teams - const floor1RowCount = Math.max( - 1, - Math.ceil(hardwareTeams.length / FLOOR1_MAX_PER_ROW) - ); - + const allTeams = [...hardwareTeams, ...otherTeams]; // How many seats are available on floor 1 vs how many hardware teams fill them. - const floor1Capacity = floor1RowCount * FLOOR1_MAX_PER_ROW; - const floor1Spillover = floor1Capacity - hardwareTeams.length; - - // Pull enough other teams to fill the leftover floor 1 seats. - const floor1OtherTeams = otherTeams.slice(0, floor1Spillover); - const floor2Teams = otherTeams.slice(floor1Spillover); - - const floor1Teams = [...hardwareTeams, ...floor1OtherTeams]; - - // Total row count for floor 2 - const floor2RowCount = Math.max( - 1, - Math.ceil(floor2Teams.length / FLOOR2_MAX_PER_ROW) - ); + const floor1Capacity = FLOOR1_ROWS.length * FLOOR1_TEAMS_PER_ROW; - // Deligate table letters based on row counts for each floor - const floor1Rows = ALL_ROWS.slice(0, floor1RowCount); - const floor2Rows = ALL_ROWS.slice( - floor1RowCount, - floor1RowCount + floor2RowCount + // Slice teams based on physical floor capacity + const floor1Teams = allTeams.slice(0, floor1Capacity); + const floor2Teams = allTeams.slice( + floor1Capacity, + floor1Capacity + FLOOR2_ROWS.length * FLOOR2_TEAMS_PER_ROW ); // Distribute teams across each floor's rows - distributeAcrossRows(floor1Teams, floor1Rows); - distributeAcrossRows(floor2Teams, floor2Rows); + distributeAcrossRows(floor1Teams, FLOOR1_ROWS, FLOOR1_SEATS_PER_ROW); + distributeAcrossRows(floor2Teams, FLOOR2_ROWS, FLOOR2_SEATS_PER_ROW); } export async function validateCsvBlob(blob: Blob): Promise<{ diff --git a/app/(api)/_utils/matching/randomizeProjects.ts b/app/(api)/_utils/matching/randomizeProjects.ts index 8f6587f0e..08b3822c0 100644 --- a/app/(api)/_utils/matching/randomizeProjects.ts +++ b/app/(api)/_utils/matching/randomizeProjects.ts @@ -29,8 +29,27 @@ const groupByJudge = ( return acc; }; +// Helper to convert table letter-number format to a comparable numeric value (ex: A1 -> 1001, B3 -> 2003) +function toComparableTableNumber(tableNumber: string): number | null { + const raw = tableNumber.trim(); + if (!raw) return null; + + // If table number is already numeric + const numeric = Number(raw); + if (Number.isFinite(numeric)) return numeric; + + const match = raw.match(/^([A-Za-z]+)(\d+)$/); + if (!match) return null; + + const row = match[1].toUpperCase(); + const seat = Number(match[2]); + const rowValue = row.charCodeAt(0) - 64; + return rowValue * 1000 + seat; +} + export default async function randomizeProjects( - secondFloorStart: number = 100 + // I1 is the first table on floor 2. + secondFloorStart: number = 9001 ) { try { const subRes = await getManySubmissions(); @@ -54,7 +73,7 @@ export default async function randomizeProjects( const teams: Team[] = teamRes.body; - const tableNumbers = new Map(); + const tableNumbers = new Map(); for (const team of teams) { if (team._id) tableNumbers.set(team._id, team.tableNumber); } @@ -66,7 +85,11 @@ export default async function randomizeProjects( const floor = Object.groupBy(submissions, ({ team_id }) => { const tableNumber = tableNumbers.get(team_id); if (!tableNumber) return 'missing'; - return tableNumber < secondFloorStart ? 'first' : 'second'; + + const comparable = toComparableTableNumber(tableNumber); + if (comparable === null) return 'missing'; + + return comparable < secondFloorStart ? 'first' : 'second'; }); const missing = floor.missing; diff --git a/app/_types/parsedRecord.ts b/app/_types/parsedRecord.ts index fb48f55fc..959a40a77 100644 --- a/app/_types/parsedRecord.ts +++ b/app/_types/parsedRecord.ts @@ -1,7 +1,7 @@ interface ParsedRecord { name: string; teamNumber: number; - tableNumber: number; + tableNumber: string; tracks: string[]; active: boolean; } diff --git a/app/_types/team.ts b/app/_types/team.ts index 8ceb34a12..b89ddc7d1 100644 --- a/app/_types/team.ts +++ b/app/_types/team.ts @@ -1,7 +1,7 @@ interface Team { _id?: string; teamNumber: number; - tableNumber: number; + tableNumber: string; name: string; tracks: string[]; reports: { diff --git a/migrations/20260324120000-update-table-number-to-letter-number.mjs b/migrations/20260324120000-update-table-number-to-letter-number.mjs new file mode 100644 index 000000000..00edc72fe --- /dev/null +++ b/migrations/20260324120000-update-table-number-to-letter-number.mjs @@ -0,0 +1,132 @@ +import fs from 'fs'; +import path from 'path'; + +const dataPath = path.resolve( + process.cwd(), + 'app/_data/db_validation_data.json' +); +const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); +const tracks = [...new Set(data.tracks)]; + +export const up = async (db) => { + await db.command({ + collMod: 'teams', + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Teams Object Validation', + required: ['teamNumber', 'tableNumber', 'name', 'tracks', 'active'], + properties: { + _id: { + bsonType: 'objectId', + description: '_id must be an ObjectId', + }, + teamNumber: { + bsonType: 'int', + description: 'teamNumber must be an integer', + }, + tableNumber: { + bsonType: 'string', + description: 'tableNumber must be a string', + }, + name: { + bsonType: 'string', + description: 'name must be a string', + }, + tracks: { + bsonType: 'array', + items: { + enum: tracks, + description: 'track must be one of the valid tracks', + }, + description: 'tracks must be an array of strings', + }, + reports: { + bsonType: 'array', + items: { + bsonType: 'object', + required: ['timestamp', 'judge_id'], + properties: { + timestamp: { + bsonType: 'number', + description: 'Timestamp in milliseconds since epoch', + }, + judge_id: { + bsonType: 'string', + description: 'ID of the judge', + }, + }, + }, + }, + active: { + bsonType: 'bool', + description: 'active must be a boolean', + }, + }, + additionalProperties: false, + }, + }, + }); +}; + +export const down = async (db) => { + // Re-introduce tableNumber as an integer if needed. + await db.command({ + collMod: 'teams', + validator: { + $jsonSchema: { + bsonType: 'object', + title: 'Teams Object Validation', + required: ['teamNumber', 'tableNumber', 'name', 'tracks', 'active'], + properties: { + _id: { + bsonType: 'objectId', + description: '_id must be an ObjectId', + }, + teamNumber: { + bsonType: 'int', + description: 'teamNumber must be an integer', + }, + tableNumber: { + bsonType: 'int', + description: 'tableNumber must be an integer', + }, + name: { + bsonType: 'string', + description: 'name must be a string', + }, + tracks: { + bsonType: 'array', + items: { + enum: tracks, + description: 'track must be one of the valid tracks', + }, + description: 'tracks must be an array of strings', + }, + reports: { + bsonType: 'array', + items: { + bsonType: 'object', + required: ['timestamp', 'judge_id'], + properties: { + timestamp: { + bsonType: 'number', + description: 'Timestamp in milliseconds since epoch', + }, + judge_id: { + bsonType: 'string', + description: 'ID of the judge', + }, + }, + }, + }, + active: { + bsonType: 'bool', + description: 'active must be a boolean', + }, + }, + additionalProperties: false, + }, + }, + }); +}; From 07027f007a78c082ec33e13c5e651431e3cca56c Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Tue, 24 Mar 2026 10:36:36 -0700 Subject: [PATCH 03/14] update var --- app/(api)/_utils/csv-ingestion/csvAlgorithm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index b327a43fd..2aeb737d6 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -384,8 +384,8 @@ function assignTableNumbers( ); // Distribute teams across each floor's rows - distributeAcrossRows(floor1Teams, FLOOR1_ROWS, FLOOR1_SEATS_PER_ROW); - distributeAcrossRows(floor2Teams, FLOOR2_ROWS, FLOOR2_SEATS_PER_ROW); + distributeAcrossRows(floor1Teams, FLOOR1_ROWS, FLOOR1_TEAMS_PER_ROW); + distributeAcrossRows(floor2Teams, FLOOR2_ROWS, FLOOR2_TEAMS_PER_ROW); } export async function validateCsvBlob(blob: Blob): Promise<{ From 5274bc65e87e25294df8bf51ca4381a1fce71f54 Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Tue, 24 Mar 2026 11:04:25 -0700 Subject: [PATCH 04/14] capacity error, cleaner 2nd floor handlig, changed str | num value --- .../_utils/csv-ingestion/csvAlgorithm.ts | 20 +++++++++++-- .../_utils/matching/randomizeProjects.ts | 4 ++- app/(api)/_utils/scoring/rankTeams.ts | 28 ------------------- .../_hooks/useTeamJudgesFromTableNumber.ts | 2 +- ...0-update-table-number-to-letter-number.mjs | 4 ++- 5 files changed, 25 insertions(+), 33 deletions(-) diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 2aeb737d6..05ccc66ed 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -338,7 +338,7 @@ const FLOOR2_ROWS = ['I', 'J', 'K', 'L']; const FLOOR2_TEAMS_PER_ROW = 15; /** - * Spreads teams evenly across rows, filling earlier rows first when there is a remainder + * Assigns teams to seats row by row, filling earlier rows and lower seat numbers first * * Each team's tableNumber is set to e.g. "A3", "B1" */ @@ -375,6 +375,8 @@ function assignTableNumbers( const allTeams = [...hardwareTeams, ...otherTeams]; // How many seats are available on floor 1 vs how many hardware teams fill them. const floor1Capacity = FLOOR1_ROWS.length * FLOOR1_TEAMS_PER_ROW; + const floor2Capacity = FLOOR2_ROWS.length * FLOOR2_TEAMS_PER_ROW; + const totalCapacity = floor1Capacity + floor2Capacity; // Slice teams based on physical floor capacity const floor1Teams = allTeams.slice(0, floor1Capacity); @@ -386,6 +388,20 @@ function assignTableNumbers( // Distribute teams across each floor's rows distributeAcrossRows(floor1Teams, FLOOR1_ROWS, FLOOR1_TEAMS_PER_ROW); distributeAcrossRows(floor2Teams, FLOOR2_ROWS, FLOOR2_TEAMS_PER_ROW); + + // If there are more teams than total capacity, assign "WAIT-{n}" table numbers to the overflow teams. + const overflowTeams = allTeams.slice(totalCapacity); + if (overflowTeams.length > 0) { + overflowTeams.forEach((team, index) => { + team.tableNumber = `WAIT-${index + 1}`; + }); + console.error( + `[CSV ingestion]: Total teams (${allTeams.length}) exceed capacity (${totalCapacity}).` + ); + throw new Error( + `Capacity Exceeded: CSV has ${allTeams.length} teams, but venue only has ${totalCapacity} seats.` + ); + } } export async function validateCsvBlob(blob: Blob): Promise<{ @@ -517,7 +533,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ output.push({ name: projectTitle, teamNumber: parsedTeamNumber, - tableNumber: 0, // assigned after ordering + tableNumber: '0', // assigned after ordering tracks: canonicalTracks, active: true, _rowIndex: rowIndex, // Store original CSV row index for error filtering diff --git a/app/(api)/_utils/matching/randomizeProjects.ts b/app/(api)/_utils/matching/randomizeProjects.ts index 08b3822c0..e2afb2323 100644 --- a/app/(api)/_utils/matching/randomizeProjects.ts +++ b/app/(api)/_utils/matching/randomizeProjects.ts @@ -49,7 +49,7 @@ function toComparableTableNumber(tableNumber: string): number | null { export default async function randomizeProjects( // I1 is the first table on floor 2. - secondFloorStart: number = 9001 + secondFloorStartLabel: string = 'I' ) { try { const subRes = await getManySubmissions(); @@ -81,6 +81,8 @@ export default async function randomizeProjects( const updatedSubmissions: object[] = []; const submissionsWithoutTeams: Submission[] = []; + const rowLetter = secondFloorStartLabel.trim().toUpperCase().charAt(0); + const secondFloorStart = (rowLetter.charCodeAt(0) - 64) * 1000; for (const submissions of Object.values(submissionsByJudge)) { const floor = Object.groupBy(submissions, ({ team_id }) => { const tableNumber = tableNumbers.get(team_id); diff --git a/app/(api)/_utils/scoring/rankTeams.ts b/app/(api)/_utils/scoring/rankTeams.ts index 0a4d66ef9..af15573f2 100644 --- a/app/(api)/_utils/scoring/rankTeams.ts +++ b/app/(api)/_utils/scoring/rankTeams.ts @@ -1,34 +1,6 @@ import Submission from '@typeDefs/submission'; import { optedHDTracks, bestHackForSocialGood } from '@data/tracks'; -// interface Team { -// _id?: string; -// teamNumber: number; -// tableNumber: number; -// name: string; -// tracks: string[]; -// active: boolean; -// } - -// export interface TrackScore { -// trackName: string; -// rawScores: {[question: string] : number}; -// finalTrackScore: number | null; -// } - -// export default interface Submission { -// _id?: string; -// judge_id: string; -// team_id: string; -// social_good: number; -// creativity: number; -// presentation: number; -// scores: TrackScore[]; -// comments?: string; -// is_scored: boolean; -// queuePosition: number | null; -// } - function calculateSubmissionScore(submission: Submission) { return submission.scores .map((track_score) => { diff --git a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts index 7dc4d7d15..2e133f5ca 100644 --- a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts +++ b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts @@ -14,7 +14,7 @@ interface HydratedJudge extends User { isScored: boolean; } -export function useTeamJudgesFromTableNumber(tableNumber: number): any { +export function useTeamJudgesFromTableNumber(tableNumber: string): any { const [team, setTeam] = useState(null); const [judges, setJudges] = useState(null); const [loading, setLoading] = useState(true); diff --git a/migrations/20260324120000-update-table-number-to-letter-number.mjs b/migrations/20260324120000-update-table-number-to-letter-number.mjs index 00edc72fe..e18d4a65d 100644 --- a/migrations/20260324120000-update-table-number-to-letter-number.mjs +++ b/migrations/20260324120000-update-table-number-to-letter-number.mjs @@ -27,7 +27,9 @@ export const up = async (db) => { }, tableNumber: { bsonType: 'string', - description: 'tableNumber must be a string', + pattern: '^[A-L]\\d+$', + description: + 'tableNumber must be a string in the format Letter+Number (e.g., "A13")', }, name: { bsonType: 'string', From a726e19b3f612cdfe06424a53bfecb5215e1b92f Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Tue, 24 Mar 2026 11:09:07 -0700 Subject: [PATCH 05/14] fix tableNumber --- .../_components/HomeJudging/_components/JudgeBanners.tsx | 2 +- app/(pages)/admin/randomize-projects/page.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx b/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx index 8435bd413..e50d6dd5e 100644 --- a/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx +++ b/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx @@ -33,7 +33,7 @@ export default function JudgeBanners({ }: JudgeBannersProps) { const { storedValue: tableNumber } = useTableNumberContext(); const { team, judges, loading, error, fetchTeamJudges } = - useTeamJudgesFromTableNumber(tableNumber ?? -1); + useTeamJudgesFromTableNumber(String(tableNumber ?? '')); const allScored = judges?.every((judge: HydratedJudge) => judge.isScored); useEffect(() => { diff --git a/app/(pages)/admin/randomize-projects/page.tsx b/app/(pages)/admin/randomize-projects/page.tsx index c3ffed6dc..0923a6f87 100644 --- a/app/(pages)/admin/randomize-projects/page.tsx +++ b/app/(pages)/admin/randomize-projects/page.tsx @@ -18,8 +18,10 @@ export default function Page() { setMissingTeams(null); const formData = new FormData(e.currentTarget); - const secondFloor = parseInt(formData.get('secondFloor') as string); - if (isNaN(secondFloor)) throw new Error('Enter an integer.'); + const secondFloor = String(formData.get('secondFloor') ?? '') + .trim() + .toUpperCase(); + if (!secondFloor) throw new Error('Enter a row letter, e.g. I.'); const submissionsWithoutTeams = await randomizeProjects(secondFloor); setMissingTeams(JSON.stringify(submissionsWithoutTeams.body, null, 2)); @@ -39,7 +41,7 @@ export default function Page() { style={{ display: 'flex', flexDirection: 'column' }} > From b779693ae21e2d062c8d819fb62746c549874e9f Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Tue, 24 Mar 2026 11:13:14 -0700 Subject: [PATCH 06/14] update tableNumber in tests --- __tests__/logic/ingestTeams.test.ts | 14 +++++++------- __tests__/logic/validateCSV.test.ts | 12 ++++++------ __tests__/parseInviteCSV.test.ts | 6 ++---- __tests__/processBulkInvites.test.ts | 10 ++++++---- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/__tests__/logic/ingestTeams.test.ts b/__tests__/logic/ingestTeams.test.ts index 78e862aff..bd37df41d 100644 --- a/__tests__/logic/ingestTeams.test.ts +++ b/__tests__/logic/ingestTeams.test.ts @@ -23,14 +23,14 @@ describe('ingestTeams', () => { { name: 'Team 1', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, { name: 'Team 2', teamNumber: 2, - tableNumber: 2, + tableNumber: 'A2', tracks: ['Track B'], active: true, }, @@ -74,7 +74,7 @@ describe('ingestTeams', () => { { name: 'Invalid Team', teamNumber: 999, - tableNumber: 999, + tableNumber: '999', tracks: ['Invalid Track'], active: true, }, @@ -99,14 +99,14 @@ describe('ingestTeams', () => { { name: 'Team 1', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, { name: 'Team 1 Duplicate', teamNumber: 1, - tableNumber: 2, + tableNumber: 'A2', tracks: ['Track B'], active: true, }, @@ -131,7 +131,7 @@ describe('ingestTeams', () => { { name: 'Solo Team', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A', 'Track B'], active: true, }, @@ -157,7 +157,7 @@ describe('ingestTeams', () => { { name: 'Complete Team', teamNumber: 42, - tableNumber: 10, + tableNumber: 'A10', tracks: ['Track A', 'Track B', 'Track C'], active: false, }, diff --git a/__tests__/logic/validateCSV.test.ts b/__tests__/logic/validateCSV.test.ts index cfa1fb591..816d7293a 100644 --- a/__tests__/logic/validateCSV.test.ts +++ b/__tests__/logic/validateCSV.test.ts @@ -40,7 +40,7 @@ describe('validateCSV', () => { { name: 'Test Project', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, @@ -134,7 +134,7 @@ describe('validateCSV', () => { { name: 'Test', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, @@ -178,7 +178,7 @@ describe('validateCSV', () => { { name: 'Test', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, @@ -218,14 +218,14 @@ describe('validateCSV', () => { { name: 'Valid Team', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, { name: 'Invalid Team', teamNumber: NaN, - tableNumber: 2, + tableNumber: 'A2', tracks: ['Track B'], active: true, }, @@ -235,7 +235,7 @@ describe('validateCSV', () => { { name: 'Valid Team', teamNumber: 1, - tableNumber: 1, + tableNumber: 'A1', tracks: ['Track A'], active: true, }, diff --git a/__tests__/parseInviteCSV.test.ts b/__tests__/parseInviteCSV.test.ts index dec3e9ef3..04f4dbb12 100644 --- a/__tests__/parseInviteCSV.test.ts +++ b/__tests__/parseInviteCSV.test.ts @@ -36,8 +36,7 @@ describe('parseInviteCSV', () => { }); it('detects header with "first" keyword', () => { - const csv = - 'First,Last,Contact\nAlice,Smith,alice@example.com\n'; + const csv = 'First,Last,Contact\nAlice,Smith,alice@example.com\n'; const result = parseInviteCSV(csv); expect(result.ok).toBe(true); @@ -165,8 +164,7 @@ describe('parseInviteCSV', () => { it('handles quoted fields with commas', () => { const csv = - 'First Name,Last Name,Email\n' + - '"Alice, Jr.",Smith,alice@example.com\n'; + 'First Name,Last Name,Email\n' + '"Alice, Jr.",Smith,alice@example.com\n'; const result = parseInviteCSV(csv); expect(result.ok).toBe(true); diff --git a/__tests__/processBulkInvites.test.ts b/__tests__/processBulkInvites.test.ts index 4653059d2..3063d40f6 100644 --- a/__tests__/processBulkInvites.test.ts +++ b/__tests__/processBulkInvites.test.ts @@ -107,10 +107,12 @@ describe('processBulkInvites', () => { it('uses preprocess to filter items and include early results', async () => { mockParseCSV.mockReturnValue({ ok: true, body: [ALICE, BOB, CHARLIE] }); - const processOne = jest.fn(async (item: InviteData): Promise => ({ - email: item.email, - success: true, - })); + const processOne = jest.fn( + async (item: InviteData): Promise => ({ + email: item.email, + success: true, + }) + ); const result = await processBulkInvites('csv', { label: 'Test', From 1a324b554147918cd52a03e3113688940c72a98b Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Tue, 24 Mar 2026 12:34:26 -0700 Subject: [PATCH 07/14] temp add test --- .../csvAlgorithm.tableAssignment.test.ts | 114 ++++++++++++++++++ .../_utils/csv-ingestion/csvAlgorithm.ts | 20 ++- .../_utils/matching/randomizeProjects.ts | 4 +- app/(pages)/admin/randomize-projects/page.tsx | 4 +- babel.config.js | 1 + ...0-update-table-number-to-letter-number.mjs | 4 +- 6 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 __tests__/csvAlgorithm.tableAssignment.test.ts create mode 100644 babel.config.js diff --git a/__tests__/csvAlgorithm.tableAssignment.test.ts b/__tests__/csvAlgorithm.tableAssignment.test.ts new file mode 100644 index 000000000..f3cc4118d --- /dev/null +++ b/__tests__/csvAlgorithm.tableAssignment.test.ts @@ -0,0 +1,114 @@ +import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm'; + +type TeamRow = { + tableNumber: number; + projectTitle: string; + status: string; + primaryTrack: string; +}; + +function buildCsv(rows: TeamRow[]): string { + const header = [ + 'Table Number', + 'Project Status', + 'Project Title', + 'Track #1 (Primary Track)', + 'Track #2', + 'Track #3', + 'Opt-In Prizes', + ].join(','); + + const lines = rows.map((row) => + [ + String(row.tableNumber), + row.status, + row.projectTitle, + row.primaryTrack, + '', + '', + '', + ].join(',') + ); + + return `${header}\n${lines.join('\n')}`; +} + +describe('csvAlgorithm table assignment', () => { + it('assigns the first hardware team to A1', async () => { + const csv = buildCsv([ + { + tableNumber: 1, + status: 'Submitted', + projectTitle: 'Hardware Team', + primaryTrack: 'Best Hardware Hack', + }, + { + tableNumber: 2, + status: 'Submitted', + projectTitle: 'Other Team', + primaryTrack: 'Best AI/ML Hack', + }, + ]); + + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + expect(res.ok).toBe(true); + expect(res.body).not.toBeNull(); + + const hardwareTeam = res.body?.find((t) => t.name === 'Hardware Team'); + expect(hardwareTeam?.tableNumber).toBe('A1'); + }); + + it('starts floor 2 at I1 after floor 1 capacity is filled', async () => { + // Floor 1: 8 rows * 13 seats = 104. Floor 2 starts at 105th team + const rows: TeamRow[] = Array.from({ length: 105 }, (_, i) => ({ + tableNumber: i + 1, + status: 'Submitted', + projectTitle: `Team ${i + 1}`, + primaryTrack: 'Best AI/ML Hack', + })); + + const csv = buildCsv(rows); + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + if (!res.ok) { + console.error('Unexpected error:', res.error); + } + expect(res.ok).toBe(true); + expect(res.body).not.toBeNull(); + expect(res.body?.length).toBe(105); + // Last team on floor 1 + expect(res.body?.[103].tableNumber).toBe('H13'); + // First team on floor 2 + expect(res.body?.[104].tableNumber).toBe('I1'); + }); + + it('returns a validation error when team count exceeds venue capacity', async () => { + // Total capacity: 104 (F1) + 60 (F2) = 164 + // We provide 167 teams to trigger the "WAIT-n" logic and the error message + const rows: TeamRow[] = Array.from({ length: 167 }, (_, i) => ({ + tableNumber: i + 1, + status: 'Submitted', + projectTitle: `Team ${i + 1}`, + primaryTrack: 'Best AI/ML Hack', + })); + + const csv = buildCsv(rows); + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + // The Promise should now resolve (ok: false) instead of timing out + expect(res.ok).toBe(false); + + // Verify the error message is passed to the Admin UI + expect(res.error).toContain('Capacity Exceeded'); + expect(res.error).toContain('167 teams'); + expect(res.error).toContain('164 seats'); + + // Verify that even with an error, the data was processed (no silent drops) + expect(res.body).toHaveLength(167); + + // Verify the last team got a WAIT label + const lastTeam = res.body?.[166]; + expect(lastTeam?.tableNumber).toBe('WAIT-3'); + }); +}); diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 05ccc66ed..f0f3b1ad0 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -378,6 +378,12 @@ function assignTableNumbers( const floor2Capacity = FLOOR2_ROWS.length * FLOOR2_TEAMS_PER_ROW; const totalCapacity = floor1Capacity + floor2Capacity; + if (hardwareTeams.length > floor1Capacity) { + console.warn( + `[CSV ingestion]: More hardware teams (${hardwareTeams.length}) than floor 1 capacity (${floor1Capacity}).` + ); + } + // Slice teams based on physical floor capacity const floor1Teams = allTeams.slice(0, floor1Capacity); const floor2Teams = allTeams.slice( @@ -398,9 +404,9 @@ function assignTableNumbers( console.error( `[CSV ingestion]: Total teams (${allTeams.length}) exceed capacity (${totalCapacity}).` ); - throw new Error( - `Capacity Exceeded: CSV has ${allTeams.length} teams, but venue only has ${totalCapacity} seats.` - ); + ( + allTeams as any + ).capacityError = `Capacity Exceeded: CSV has ${allTeams.length} teams, but venue only has ${totalCapacity} seats.`; } } @@ -549,6 +555,14 @@ export async function validateCsvBlob(blob: Blob): Promise<{ ); assignTableNumbers(bestHardwareTeams, otherTeams); + const finalResults = [...bestHardwareTeams, ...otherTeams]; + + // Check if assignTableNumbers flagged a capacity issue + if ((finalResults as any).capacityError) { + reject(new Error((finalResults as any).capacityError)); + } else { + resolve(finalResults); + } // Hardware teams first in the returned list for display ordering. resolve([...bestHardwareTeams, ...otherTeams]); diff --git a/app/(api)/_utils/matching/randomizeProjects.ts b/app/(api)/_utils/matching/randomizeProjects.ts index e2afb2323..3f0fce4fb 100644 --- a/app/(api)/_utils/matching/randomizeProjects.ts +++ b/app/(api)/_utils/matching/randomizeProjects.ts @@ -38,11 +38,13 @@ function toComparableTableNumber(tableNumber: string): number | null { const numeric = Number(raw); if (Number.isFinite(numeric)) return numeric; - const match = raw.match(/^([A-Za-z]+)(\d+)$/); + // Expect a single row letter (A–L) followed by a positive seat number + const match = raw.match(/^([A-L])(\d+)$/i); if (!match) return null; const row = match[1].toUpperCase(); const seat = Number(match[2]); + if (!Number.isInteger(seat) || seat < 1) return null; const rowValue = row.charCodeAt(0) - 64; return rowValue * 1000 + seat; } diff --git a/app/(pages)/admin/randomize-projects/page.tsx b/app/(pages)/admin/randomize-projects/page.tsx index 0923a6f87..6770b9483 100644 --- a/app/(pages)/admin/randomize-projects/page.tsx +++ b/app/(pages)/admin/randomize-projects/page.tsx @@ -21,7 +21,9 @@ export default function Page() { const secondFloor = String(formData.get('secondFloor') ?? '') .trim() .toUpperCase(); - if (!secondFloor) throw new Error('Enter a row letter, e.g. I.'); + if (!/^[A-L]$/.test(secondFloor)) { + throw new Error('Enter a single row letter from A to L, e.g. I.'); + } const submissionsWithoutTeams = await randomizeProjects(secondFloor); setMissingTeams(JSON.stringify(submissionsWithoutTeams.body, null, 2)); diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 000000000..bc19428bd --- /dev/null +++ b/babel.config.js @@ -0,0 +1 @@ +module.exports = { presets: [["@babel/preset-env"]] }; diff --git a/migrations/20260324120000-update-table-number-to-letter-number.mjs b/migrations/20260324120000-update-table-number-to-letter-number.mjs index e18d4a65d..00edc72fe 100644 --- a/migrations/20260324120000-update-table-number-to-letter-number.mjs +++ b/migrations/20260324120000-update-table-number-to-letter-number.mjs @@ -27,9 +27,7 @@ export const up = async (db) => { }, tableNumber: { bsonType: 'string', - pattern: '^[A-L]\\d+$', - description: - 'tableNumber must be a string in the format Letter+Number (e.g., "A13")', + description: 'tableNumber must be a string', }, name: { bsonType: 'string', From baa608b3ec5b23a8d0cc83548d092fba6b6ae88c Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 22:19:10 -0700 Subject: [PATCH 08/14] added capacity error --- .../_utils/csv-ingestion/csvAlgorithm.ts | 33 ++++++++++--------- babel.config.js | 1 - 2 files changed, 18 insertions(+), 16 deletions(-) delete mode 100644 babel.config.js diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index f0f3b1ad0..4d98e6840 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -401,12 +401,9 @@ function assignTableNumbers( overflowTeams.forEach((team, index) => { team.tableNumber = `WAIT-${index + 1}`; }); - console.error( + console.warn( `[CSV ingestion]: Total teams (${allTeams.length}) exceed capacity (${totalCapacity}).` ); - ( - allTeams as any - ).capacityError = `Capacity Exceeded: CSV has ${allTeams.length} teams, but venue only has ${totalCapacity} seats.`; } } @@ -556,16 +553,8 @@ export async function validateCsvBlob(blob: Blob): Promise<{ assignTableNumbers(bestHardwareTeams, otherTeams); const finalResults = [...bestHardwareTeams, ...otherTeams]; - - // Check if assignTableNumbers flagged a capacity issue - if ((finalResults as any).capacityError) { - reject(new Error((finalResults as any).capacityError)); - } else { - resolve(finalResults); - } - // Hardware teams first in the returned list for display ordering. - resolve([...bestHardwareTeams, ...otherTeams]); + resolve(finalResults); }) .on('error', (error) => reject(error)); }; @@ -573,8 +562,19 @@ export async function validateCsvBlob(blob: Blob): Promise<{ parseBlob().catch(reject); }); + const overflowCount = results.filter((t) => + String(t.tableNumber).startsWith('WAIT-') + ).length; //count how many teams have no table seating const errorRows = issues.filter((i) => i.severity === 'error').length; const warningRows = issues.filter((i) => i.severity === 'warning').length; + const finalErrorCount = errorRows + (overflowCount > 0 ? 1 : 0); //count overflow teams as 1 error + + const capacityErrorMessage = + overflowCount > 0 + ? `Capacity Exceeded: CSV has ${ + results.length + } teams, but venue only has ${results.length - overflowCount} seats.` + : null; // Use rowIndex-based filtering to avoid NaN equality issues with Set.has() const errorRowIndexes = new Set( @@ -601,7 +601,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ const report: CsvValidationReport = { totalTeamsParsed: cleanResults.length, validTeams: cleanValidBody.length, - errorRows, + errorRows: finalErrorCount, warningRows, unknownTracks: Array.from(unknownTrackSet).sort(), issues, @@ -613,7 +613,10 @@ export async function validateCsvBlob(blob: Blob): Promise<{ body: cleanResults, validBody: cleanValidBody, report, - error: ok ? null : 'CSV validation failed. Fix errors and re-validate.', + error: ok + ? null + : capacityErrorMessage || + (ok ? null : 'CSV validation failed. Fix errors and re-validate.'), }; } catch (e) { const error = e as Error; diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index bc19428bd..000000000 --- a/babel.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { presets: [["@babel/preset-env"]] }; From 0ea4d5d2c654ef220dc9fc25746fc9b929610758 Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 22:21:31 -0700 Subject: [PATCH 09/14] updated string context --- .../HomeJudging/_components/JudgeBanners.tsx | 2 +- app/(pages)/_contexts/TableNumberContext.tsx | 21 +++++++++++++++---- .../_hooks/useTeamJudgesFromTableNumber.ts | 6 ++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx b/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx index e50d6dd5e..aee755712 100644 --- a/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx +++ b/app/(pages)/(hackers)/_components/HomeJudging/_components/JudgeBanners.tsx @@ -33,7 +33,7 @@ export default function JudgeBanners({ }: JudgeBannersProps) { const { storedValue: tableNumber } = useTableNumberContext(); const { team, judges, loading, error, fetchTeamJudges } = - useTeamJudgesFromTableNumber(String(tableNumber ?? '')); + useTeamJudgesFromTableNumber(tableNumber ?? ''); const allScored = judges?.every((judge: HydratedJudge) => judge.isScored); useEffect(() => { diff --git a/app/(pages)/_contexts/TableNumberContext.tsx b/app/(pages)/_contexts/TableNumberContext.tsx index 84f4b5077..462449e6b 100644 --- a/app/(pages)/_contexts/TableNumberContext.tsx +++ b/app/(pages)/_contexts/TableNumberContext.tsx @@ -4,8 +4,8 @@ import { createContext } from 'react'; import { useLocalStorage } from '@pages/_hooks/useLocalStorage'; interface TableNumberContextValue { - storedValue: number | null; - setValue: (val: any) => void; + storedValue: string | null; + setValue: (val: string) => void; fetchValue: () => void; loading: boolean; } @@ -14,7 +14,7 @@ export type { TableNumberContextValue }; export const TableNumberContext = createContext({ storedValue: null, - setValue: (_: any) => {}, + setValue: (_: string) => {}, fetchValue: () => {}, loading: false, }); @@ -27,8 +27,21 @@ export default function TableNumberContextProvider({ const { storedValue, setValue, fetchValue, loading } = useLocalStorage('tableNumber'); + const normalizedStoredValue = (() => { + if (storedValue === null) return null; + const normalized = String(storedValue).trim(); + if ( + normalized.length === 0 || + normalized === 'null' || + normalized === 'undefined' + ) { + return null; + } + return normalized; + })(); + const value = { - storedValue: Number(storedValue), + storedValue: normalizedStoredValue, setValue, fetchValue, loading, diff --git a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts index 2e133f5ca..8f603e928 100644 --- a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts +++ b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts @@ -21,6 +21,12 @@ export function useTeamJudgesFromTableNumber(tableNumber: string): any { const [error, setError] = useState(null); const fetchTeamJudges = useCallback(async () => { + if (!tableNumber) { + setLoading(false); + setError(null); + return; + } + try { const teamRes = await getManyTeams({ tableNumber }); From 129d20281ea779ab780d8816f4319d9d21a4a799 Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 22:24:08 -0700 Subject: [PATCH 10/14] fix build error --- .../_components/TableNumberCheckin/TableNumberCheckin.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx b/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx index be0cd5ec1..7e31dcf2c 100644 --- a/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx +++ b/app/(pages)/(hackers)/_components/TableNumberCheckin/TableNumberCheckin.tsx @@ -133,7 +133,11 @@ export default function TableNumberCheckin() { From 19b4224d7c90f8fd523162fe871c131d743ecc90 Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 22:36:20 -0700 Subject: [PATCH 11/14] convert capacity overflow to warning --- __tests__/csvAlgorithm.tableAssignment.test.ts | 14 ++++++-------- app/(api)/_utils/csv-ingestion/csvAlgorithm.ts | 18 +++++------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/__tests__/csvAlgorithm.tableAssignment.test.ts b/__tests__/csvAlgorithm.tableAssignment.test.ts index f3cc4118d..f7515cb08 100644 --- a/__tests__/csvAlgorithm.tableAssignment.test.ts +++ b/__tests__/csvAlgorithm.tableAssignment.test.ts @@ -83,7 +83,7 @@ describe('csvAlgorithm table assignment', () => { expect(res.body?.[104].tableNumber).toBe('I1'); }); - it('returns a validation error when team count exceeds venue capacity', async () => { + it('returns a validation warning when team count exceeds venue capacity', async () => { // Total capacity: 104 (F1) + 60 (F2) = 164 // We provide 167 teams to trigger the "WAIT-n" logic and the error message const rows: TeamRow[] = Array.from({ length: 167 }, (_, i) => ({ @@ -96,13 +96,11 @@ describe('csvAlgorithm table assignment', () => { const csv = buildCsv(rows); const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); - // The Promise should now resolve (ok: false) instead of timing out - expect(res.ok).toBe(false); - - // Verify the error message is passed to the Admin UI - expect(res.error).toContain('Capacity Exceeded'); - expect(res.error).toContain('167 teams'); - expect(res.error).toContain('164 seats'); + // Capacity overflow is non-blocking and counted as a warning. + expect(res.ok).toBe(true); + expect(res.error).toBeNull(); + expect(res.report.errorRows).toBe(0); + expect(res.report.warningRows).toBe(1); // Verify that even with an error, the data was processed (no silent drops) expect(res.body).toHaveLength(167); diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 4d98e6840..85df299f5 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -566,15 +566,10 @@ export async function validateCsvBlob(blob: Blob): Promise<{ String(t.tableNumber).startsWith('WAIT-') ).length; //count how many teams have no table seating const errorRows = issues.filter((i) => i.severity === 'error').length; - const warningRows = issues.filter((i) => i.severity === 'warning').length; - const finalErrorCount = errorRows + (overflowCount > 0 ? 1 : 0); //count overflow teams as 1 error - - const capacityErrorMessage = - overflowCount > 0 - ? `Capacity Exceeded: CSV has ${ - results.length - } teams, but venue only has ${results.length - overflowCount} seats.` - : null; + const warningRows = + issues.filter((i) => i.severity === 'warning').length + + (overflowCount > 0 ? 1 : 0); // count overflow as 1 warning + const finalErrorCount = errorRows; // Use rowIndex-based filtering to avoid NaN equality issues with Set.has() const errorRowIndexes = new Set( @@ -613,10 +608,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ body: cleanResults, validBody: cleanValidBody, report, - error: ok - ? null - : capacityErrorMessage || - (ok ? null : 'CSV validation failed. Fix errors and re-validate.'), + error: ok ? null : 'CSV validation failed. Fix errors and re-validate.', }; } catch (e) { const error = e as Error; From e004d7b6e50e24921a177d57b563027a37b2fe24 Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 22:52:56 -0700 Subject: [PATCH 12/14] add global warning --- .../csvAlgorithm.tableAssignment.test.ts | 112 ------------------ .../_utils/csv-ingestion/csvAlgorithm.ts | 17 ++- 2 files changed, 14 insertions(+), 115 deletions(-) delete mode 100644 __tests__/csvAlgorithm.tableAssignment.test.ts diff --git a/__tests__/csvAlgorithm.tableAssignment.test.ts b/__tests__/csvAlgorithm.tableAssignment.test.ts deleted file mode 100644 index f7515cb08..000000000 --- a/__tests__/csvAlgorithm.tableAssignment.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm'; - -type TeamRow = { - tableNumber: number; - projectTitle: string; - status: string; - primaryTrack: string; -}; - -function buildCsv(rows: TeamRow[]): string { - const header = [ - 'Table Number', - 'Project Status', - 'Project Title', - 'Track #1 (Primary Track)', - 'Track #2', - 'Track #3', - 'Opt-In Prizes', - ].join(','); - - const lines = rows.map((row) => - [ - String(row.tableNumber), - row.status, - row.projectTitle, - row.primaryTrack, - '', - '', - '', - ].join(',') - ); - - return `${header}\n${lines.join('\n')}`; -} - -describe('csvAlgorithm table assignment', () => { - it('assigns the first hardware team to A1', async () => { - const csv = buildCsv([ - { - tableNumber: 1, - status: 'Submitted', - projectTitle: 'Hardware Team', - primaryTrack: 'Best Hardware Hack', - }, - { - tableNumber: 2, - status: 'Submitted', - projectTitle: 'Other Team', - primaryTrack: 'Best AI/ML Hack', - }, - ]); - - const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); - - expect(res.ok).toBe(true); - expect(res.body).not.toBeNull(); - - const hardwareTeam = res.body?.find((t) => t.name === 'Hardware Team'); - expect(hardwareTeam?.tableNumber).toBe('A1'); - }); - - it('starts floor 2 at I1 after floor 1 capacity is filled', async () => { - // Floor 1: 8 rows * 13 seats = 104. Floor 2 starts at 105th team - const rows: TeamRow[] = Array.from({ length: 105 }, (_, i) => ({ - tableNumber: i + 1, - status: 'Submitted', - projectTitle: `Team ${i + 1}`, - primaryTrack: 'Best AI/ML Hack', - })); - - const csv = buildCsv(rows); - const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); - - if (!res.ok) { - console.error('Unexpected error:', res.error); - } - expect(res.ok).toBe(true); - expect(res.body).not.toBeNull(); - expect(res.body?.length).toBe(105); - // Last team on floor 1 - expect(res.body?.[103].tableNumber).toBe('H13'); - // First team on floor 2 - expect(res.body?.[104].tableNumber).toBe('I1'); - }); - - it('returns a validation warning when team count exceeds venue capacity', async () => { - // Total capacity: 104 (F1) + 60 (F2) = 164 - // We provide 167 teams to trigger the "WAIT-n" logic and the error message - const rows: TeamRow[] = Array.from({ length: 167 }, (_, i) => ({ - tableNumber: i + 1, - status: 'Submitted', - projectTitle: `Team ${i + 1}`, - primaryTrack: 'Best AI/ML Hack', - })); - - const csv = buildCsv(rows); - const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); - - // Capacity overflow is non-blocking and counted as a warning. - expect(res.ok).toBe(true); - expect(res.error).toBeNull(); - expect(res.report.errorRows).toBe(0); - expect(res.report.warningRows).toBe(1); - - // Verify that even with an error, the data was processed (no silent drops) - expect(res.body).toHaveLength(167); - - // Verify the last team got a WAIT label - const lastTeam = res.body?.[166]; - expect(lastTeam?.tableNumber).toBe('WAIT-3'); - }); -}); diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 85df299f5..3cda8bebf 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -40,6 +40,7 @@ export type CsvValidationReport = { warningRows: number; unknownTracks: string[]; issues: CsvRowIssue[]; + globalWarnings?: string[]; }; type TrackMatchCandidate = { @@ -565,10 +566,18 @@ export async function validateCsvBlob(blob: Blob): Promise<{ const overflowCount = results.filter((t) => String(t.tableNumber).startsWith('WAIT-') ).length; //count how many teams have no table seating + const globalWarnings = + overflowCount > 0 + ? [ + `Capacity Exceeded: CSV has ${ + results.length + } teams, but venue only has ${ + results.length - overflowCount + } seats. Overflow teams were labeled WAIT-*.`, + ] + : []; const errorRows = issues.filter((i) => i.severity === 'error').length; - const warningRows = - issues.filter((i) => i.severity === 'warning').length + - (overflowCount > 0 ? 1 : 0); // count overflow as 1 warning + const warningRows = issues.filter((i) => i.severity === 'warning').length; const finalErrorCount = errorRows; // Use rowIndex-based filtering to avoid NaN equality issues with Set.has() @@ -600,6 +609,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ warningRows, unknownTracks: Array.from(unknownTrackSet).sort(), issues, + globalWarnings, }; const ok = report.errorRows === 0; @@ -619,6 +629,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ warningRows: 0, unknownTracks: [], issues: [], + globalWarnings: [], }; return { ok: false, From 5ca213962cc88a5c5dd139eac62c35d059dfdf0e Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 22:53:50 -0700 Subject: [PATCH 13/14] revert errorRows --- app/(api)/_utils/csv-ingestion/csvAlgorithm.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts index 3cda8bebf..ea18fff1e 100644 --- a/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts +++ b/app/(api)/_utils/csv-ingestion/csvAlgorithm.ts @@ -578,7 +578,6 @@ export async function validateCsvBlob(blob: Blob): Promise<{ : []; const errorRows = issues.filter((i) => i.severity === 'error').length; const warningRows = issues.filter((i) => i.severity === 'warning').length; - const finalErrorCount = errorRows; // Use rowIndex-based filtering to avoid NaN equality issues with Set.has() const errorRowIndexes = new Set( @@ -605,7 +604,7 @@ export async function validateCsvBlob(blob: Blob): Promise<{ const report: CsvValidationReport = { totalTeamsParsed: cleanResults.length, validTeams: cleanValidBody.length, - errorRows: finalErrorCount, + errorRows, warningRows, unknownTracks: Array.from(unknownTrackSet).sort(), issues, From cdf644313ea78a23a803812557320c7fd51bea3d Mon Sep 17 00:00:00 2001 From: michelleyeoh Date: Thu, 26 Mar 2026 23:03:34 -0700 Subject: [PATCH 14/14] add back test --- .../csvAlgorithm.tableAssignment.test.ts | 114 ++++++++++++++++++ .../_hooks/useTeamJudgesFromTableNumber.ts | 2 + app/(pages)/admin/randomize-projects/page.tsx | 2 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 __tests__/csvAlgorithm.tableAssignment.test.ts diff --git a/__tests__/csvAlgorithm.tableAssignment.test.ts b/__tests__/csvAlgorithm.tableAssignment.test.ts new file mode 100644 index 000000000..b01013250 --- /dev/null +++ b/__tests__/csvAlgorithm.tableAssignment.test.ts @@ -0,0 +1,114 @@ +import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm'; + +type TeamRow = { + tableNumber: number; + projectTitle: string; + status: string; + primaryTrack: string; +}; + +function buildCsv(rows: TeamRow[]): string { + const header = [ + 'Table Number', + 'Project Status', + 'Project Title', + 'Track #1 (Primary Track)', + 'Track #2', + 'Track #3', + 'Opt-In Prizes', + ].join(','); + + const lines = rows.map((row) => + [ + String(row.tableNumber), + row.status, + row.projectTitle, + row.primaryTrack, + '', + '', + '', + ].join(',') + ); + + return `${header}\n${lines.join('\n')}`; +} + +describe('csvAlgorithm table assignment', () => { + it('assigns the first hardware team to A1', async () => { + const csv = buildCsv([ + { + tableNumber: 1, + status: 'Submitted', + projectTitle: 'Hardware Team', + primaryTrack: 'Best Hardware Hack', + }, + { + tableNumber: 2, + status: 'Submitted', + projectTitle: 'Other Team', + primaryTrack: 'Best AI/ML Hack', + }, + ]); + + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + expect(res.ok).toBe(true); + expect(res.body).not.toBeNull(); + + const hardwareTeam = res.body?.find((t) => t.name === 'Hardware Team'); + expect(hardwareTeam?.tableNumber).toBe('A1'); + }); + + it('starts floor 2 at I1 after floor 1 capacity is filled', async () => { + // Floor 1: 8 rows * 13 seats = 104. Floor 2 starts at 105th team + const rows: TeamRow[] = Array.from({ length: 105 }, (_, i) => ({ + tableNumber: i + 1, + status: 'Submitted', + projectTitle: `Team ${i + 1}`, + primaryTrack: 'Best AI/ML Hack', + })); + + const csv = buildCsv(rows); + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + if (!res.ok) { + console.error('Unexpected error:', res.error); + } + expect(res.ok).toBe(true); + expect(res.body).not.toBeNull(); + expect(res.body?.length).toBe(105); + // Last team on floor 1 + expect(res.body?.[103].tableNumber).toBe('H13'); + // First team on floor 2 + expect(res.body?.[104].tableNumber).toBe('I1'); + }); + + it('returns a global warning when team count exceeds venue capacity', async () => { + // Total capacity: 104 (F1) + 60 (F2) = 164 + // We provide 167 teams to trigger WAIT-n overflow labels. + const rows: TeamRow[] = Array.from({ length: 167 }, (_, i) => ({ + tableNumber: i + 1, + status: 'Submitted', + projectTitle: `Team ${i + 1}`, + primaryTrack: 'Best AI/ML Hack', + })); + + const csv = buildCsv(rows); + const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' })); + + // Capacity overflow is non-blocking and surfaced as a global warning. + expect(res.ok).toBe(true); + expect(res.error).toBeNull(); + expect(res.report.errorRows).toBe(0); + expect(res.report.warningRows).toBe(0); + expect(res.report.globalWarnings).toBeDefined(); + expect(res.report.globalWarnings?.[0]).toContain('Capacity Exceeded'); + + // Verify that even with overflow, data was processed. + expect(res.body).toHaveLength(167); + + // Verify the last team got a WAIT label. + const lastTeam = res.body?.[166]; + expect(lastTeam?.tableNumber).toBe('WAIT-3'); + }); +}); diff --git a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts index 8f603e928..199f608bf 100644 --- a/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts +++ b/app/(pages)/_hooks/useTeamJudgesFromTableNumber.ts @@ -22,6 +22,8 @@ export function useTeamJudgesFromTableNumber(tableNumber: string): any { const fetchTeamJudges = useCallback(async () => { if (!tableNumber) { + setTeam(null); + setJudges(null); setLoading(false); setError(null); return; diff --git a/app/(pages)/admin/randomize-projects/page.tsx b/app/(pages)/admin/randomize-projects/page.tsx index 6770b9483..f1bb9b6f1 100644 --- a/app/(pages)/admin/randomize-projects/page.tsx +++ b/app/(pages)/admin/randomize-projects/page.tsx @@ -45,7 +45,7 @@ export default function Page() { - +

The following submissions don't have a team associated with them: