From b7090d383f07aa454b9b3c06802fd44e98db909f Mon Sep 17 00:00:00 2001 From: rapidcoderx Date: Tue, 17 Mar 2026 21:57:37 -0600 Subject: [PATCH 01/14] Completed three tasks:1.4.4 Schedule Generation Module 1.4.7 REST API Module 1.2.4 API Design (3 endpoints) --- backend/.env.example | 2 +- .../src/controllers/tournamentController.ts | 123 +++++++++++ backend/src/database/BracketBeaverDB.sql | 43 ++++ backend/src/index.ts | 4 +- backend/src/models/tournamentModel.ts | 209 ++++++++++++++++++ backend/src/routes/tournamentRoute.ts | 14 ++ backend/src/services/tournamentService.ts | 201 +++++++++++++++++ 7 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 backend/src/controllers/tournamentController.ts create mode 100644 backend/src/models/tournamentModel.ts create mode 100644 backend/src/routes/tournamentRoute.ts create mode 100644 backend/src/services/tournamentService.ts diff --git a/backend/.env.example b/backend/.env.example index c901e2f..c6e83b7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1 @@ -DATABASE_URL=postgresql://:@:/ \ No newline at end of file +DATABASE_URL=postgresql://postgres:@localhost:5432/bracket_beaver \ No newline at end of file diff --git a/backend/src/controllers/tournamentController.ts b/backend/src/controllers/tournamentController.ts new file mode 100644 index 0000000..e818f79 --- /dev/null +++ b/backend/src/controllers/tournamentController.ts @@ -0,0 +1,123 @@ +import type { Request, Response } from "express"; +import { + createTournament, + generateTournamentSchedule, + getTournamentSchedule, + type CreateTournamentPayload, +} from "../services/tournamentService.js"; + +const parseTournamentId = (idParam: string): number | null => { + const parsed = Number(idParam); + if (!Number.isInteger(parsed) || parsed <= 0) { + return null; + } + + return parsed; +}; + +export const createTournamentHandler = async (req: Request, res: Response) => { + try { + const payload = req.body as Partial; + + if ( + typeof payload.name !== "string" || + typeof payload.sport !== "string" || + (payload.bracketType !== "single_elimination" && payload.bracketType !== "round_robin") || + typeof payload.startDate !== "string" || + typeof payload.endDate !== "string" || + typeof payload.createdBy !== "number" || + !Array.isArray(payload.teams) || + !Array.isArray(payload.venues) + ) { + return res.status(400).json({ + success: false, + message: "Invalid request body.", + }); + } + + const result = await createTournament({ + name: payload.name, + sport: payload.sport, + bracketType: payload.bracketType, + startDate: payload.startDate, + endDate: payload.endDate, + createdBy: payload.createdBy, + teams: payload.teams, + venues: payload.venues, + }); + + return res.status(201).json({ + success: true, + tournamentId: result.tournamentId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Server error"; + return res.status(400).json({ success: false, message }); + } +}; + +export const generateScheduleHandler = async (req: Request, res: Response) => { + const idParam = req.params.id; + + if (typeof idParam !== "string") { + return res.status(400).json({ + success: false, + message: "Invalid tournament id.", + }); + } + + const tournamentId = parseTournamentId(idParam); + + if (!tournamentId) { + return res.status(400).json({ + success: false, + message: "Invalid tournament id.", + }); + } + + try { + const result = await generateTournamentSchedule(tournamentId); + return res.status(200).json({ + success: true, + generatedMatches: result.generatedMatches, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Server error"; + const statusCode = message === "Tournament not found." ? 404 : 400; + return res.status(statusCode).json({ success: false, message }); + } +}; + +export const getScheduleHandler = async (req: Request, res: Response) => { + const idParam = req.params.id; + + if (typeof idParam !== "string") { + return res.status(400).json({ + success: false, + message: "Invalid tournament id.", + }); + } + + const tournamentId = parseTournamentId(idParam); + + if (!tournamentId) { + return res.status(400).json({ + success: false, + message: "Invalid tournament id.", + }); + } + + try { + const schedule = await getTournamentSchedule(tournamentId); + + return res.status(200).json({ + success: true, + schedule, + }); + } catch { + return res.status(500).json({ + success: false, + message: "Server error", + }); + } +}; diff --git a/backend/src/database/BracketBeaverDB.sql b/backend/src/database/BracketBeaverDB.sql index 7a1b990..b8d23b1 100644 --- a/backend/src/database/BracketBeaverDB.sql +++ b/backend/src/database/BracketBeaverDB.sql @@ -15,3 +15,46 @@ INSERT INTO users (username, password, role) VALUES ('alice', 'alice123', 'organizer'), ('admin', 'admin123', 'admin'); +CREATE TABLE tournaments ( + tournamentID SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + sport VARCHAR(50) NOT NULL, + bracket_type VARCHAR(30) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(userID) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE teams ( + teamID SERIAL PRIMARY KEY, + tournamentID INTEGER NOT NULL REFERENCES tournaments(tournamentID) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tournamentID, name) +); + +CREATE TABLE venues ( + venueID SERIAL PRIMARY KEY, + tournamentID INTEGER NOT NULL REFERENCES tournaments(tournamentID) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tournamentID, name) +); + +CREATE TABLE matches ( + matchID SERIAL PRIMARY KEY, + tournamentID INTEGER NOT NULL REFERENCES tournaments(tournamentID) ON DELETE CASCADE, + round_number INTEGER NOT NULL, + home_team_id INTEGER NOT NULL REFERENCES teams(teamID) ON DELETE RESTRICT, + away_team_id INTEGER NOT NULL REFERENCES teams(teamID) ON DELETE RESTRICT, + venue_id INTEGER NOT NULL REFERENCES venues(venueID) ON DELETE RESTRICT, + match_time TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (home_team_id <> away_team_id), + UNIQUE (tournamentID, round_number, home_team_id, away_team_id), + UNIQUE (tournamentID, venue_id, match_time), + UNIQUE (tournamentID, home_team_id, match_time), + UNIQUE (tournamentID, away_team_id, match_time) +); + diff --git a/backend/src/index.ts b/backend/src/index.ts index 3aa8f0d..192215e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ import express from "express"; import cors from "cors"; import authRoutes from "./routes/authRoute.js"; +import tournamentRoutes from "./routes/tournamentRoute.js"; const app = express(); @@ -26,4 +27,5 @@ app.get("/api/hello", (req, res) => { res.json({message: "Hello world!"}); }) -app.use("/api", authRoutes); \ No newline at end of file +app.use("/api", authRoutes); +app.use("/api", tournamentRoutes); \ No newline at end of file diff --git a/backend/src/models/tournamentModel.ts b/backend/src/models/tournamentModel.ts new file mode 100644 index 0000000..dff605c --- /dev/null +++ b/backend/src/models/tournamentModel.ts @@ -0,0 +1,209 @@ +import { pool } from "../database/database.js"; + +export type CreateTournamentInput = { + name: string; + sport: string; + bracketType: string; + startDate: string; + endDate: string; + createdBy: number; + teams: string[]; + venues: string[]; +}; + +export type TeamRow = { + teamid: number; + name: string; +}; + +export type VenueRow = { + venueid: number; + name: string; +}; + +export type TournamentScheduleContext = { + tournamentId: number; + startDate: string; + teams: TeamRow[]; + venues: VenueRow[]; +}; + +export type ScheduledMatchInsert = { + tournamentId: number; + roundNumber: number; + homeTeamId: number; + awayTeamId: number; + venueId: number; + matchTime: string; +}; + +export type ScheduleRow = { + matchid: number; + round_number: number; + home_team: string; + away_team: string; + venue: string; + match_time: string; +}; + +export const createTournamentWithDetails = async ( + input: CreateTournamentInput +): Promise => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const tournamentResult = await client.query<{ tournamentid: number }>( + `INSERT INTO tournaments (name, sport, bracket_type, start_date, end_date, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING tournamentID`, + [ + input.name, + input.sport, + input.bracketType, + input.startDate, + input.endDate, + input.createdBy, + ] + ); + + const tournamentRow = tournamentResult.rows[0]; + + if (!tournamentRow) { + throw new Error("Failed to create tournament."); + } + + const tournamentId = tournamentRow.tournamentid; + + for (const teamName of input.teams) { + await client.query( + `INSERT INTO teams (tournamentID, name) + VALUES ($1, $2)`, + [tournamentId, teamName] + ); + } + + for (const venueName of input.venues) { + await client.query( + `INSERT INTO venues (tournamentID, name) + VALUES ($1, $2)`, + [tournamentId, venueName] + ); + } + + await client.query("COMMIT"); + return tournamentId; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +export const getTournamentScheduleContext = async ( + tournamentId: number +): Promise => { + const tournamentResult = await pool.query<{ tournamentid: number; start_date: string }>( + `SELECT tournamentID, start_date + FROM tournaments + WHERE tournamentID = $1`, + [tournamentId] + ); + + if (tournamentResult.rowCount === 0) { + return null; + } + + const tournamentRow = tournamentResult.rows[0]; + + if (!tournamentRow) { + return null; + } + + const teamsResult = await pool.query( + `SELECT teamID, name + FROM teams + WHERE tournamentID = $1 + ORDER BY teamID ASC`, + [tournamentId] + ); + + const venuesResult = await pool.query( + `SELECT venueID, name + FROM venues + WHERE tournamentID = $1 + ORDER BY venueID ASC`, + [tournamentId] + ); + + return { + tournamentId, + startDate: tournamentRow.start_date, + teams: teamsResult.rows, + venues: venuesResult.rows, + }; +}; + +export const replaceTournamentSchedule = async ( + tournamentId: number, + matches: ScheduledMatchInsert[] +): Promise => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + await client.query( + `DELETE FROM matches + WHERE tournamentID = $1`, + [tournamentId] + ); + + for (const match of matches) { + await client.query( + `INSERT INTO matches (tournamentID, round_number, home_team_id, away_team_id, venue_id, match_time) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + match.tournamentId, + match.roundNumber, + match.homeTeamId, + match.awayTeamId, + match.venueId, + match.matchTime, + ] + ); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +export const getScheduleByTournament = async ( + tournamentId: number +): Promise => { + const result = await pool.query( + `SELECT + m.matchID, + m.round_number, + th.name AS home_team, + ta.name AS away_team, + v.name AS venue, + m.match_time::text AS match_time + FROM matches m + JOIN teams th ON m.home_team_id = th.teamID + JOIN teams ta ON m.away_team_id = ta.teamID + JOIN venues v ON m.venue_id = v.venueID + WHERE m.tournamentID = $1 + ORDER BY m.round_number ASC, m.match_time ASC, m.matchID ASC`, + [tournamentId] + ); + + return result.rows; +}; diff --git a/backend/src/routes/tournamentRoute.ts b/backend/src/routes/tournamentRoute.ts new file mode 100644 index 0000000..8db745e --- /dev/null +++ b/backend/src/routes/tournamentRoute.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { + createTournamentHandler, + generateScheduleHandler, + getScheduleHandler, +} from "../controllers/tournamentController.js"; + +const router = express.Router(); + +router.post("/tournaments", createTournamentHandler); +router.post("/tournaments/:id/schedule/generate", generateScheduleHandler); +router.get("/tournaments/:id/schedule", getScheduleHandler); + +export default router; diff --git a/backend/src/services/tournamentService.ts b/backend/src/services/tournamentService.ts new file mode 100644 index 0000000..ce08e21 --- /dev/null +++ b/backend/src/services/tournamentService.ts @@ -0,0 +1,201 @@ +import { + createTournamentWithDetails, + getScheduleByTournament, + getTournamentScheduleContext, + replaceTournamentSchedule, + type CreateTournamentInput, + type ScheduledMatchInsert, +} from "../models/tournamentModel.js"; + +export type CreateTournamentPayload = { + name: string; + sport: string; + bracketType: "single_elimination" | "round_robin"; + startDate: string; + endDate: string; + createdBy: number; + teams: string[]; + venues: string[]; +}; + +const sanitizeList = (items: string[]): string[] => { + return items + .map((item) => item.trim()) + .filter((item) => item.length > 0); +}; + +const validateCreatePayload = (payload: CreateTournamentPayload): string | null => { + if (!payload.name.trim()) return "Tournament name is required."; + if (!payload.sport.trim()) return "Sport is required."; + if (!payload.startDate || !payload.endDate) return "Start and end dates are required."; + if (!Number.isInteger(payload.createdBy) || payload.createdBy <= 0) { + return "createdBy must be a positive integer."; + } + + const teams = sanitizeList(payload.teams); + const venues = sanitizeList(payload.venues); + + if (teams.length < 2) return "At least 2 teams are required."; + if (venues.length < 1) return "At least 1 venue is required."; + + if (new Set(teams.map((team) => team.toLowerCase())).size !== teams.length) { + return "Team names must be unique."; + } + + if (new Set(venues.map((venue) => venue.toLowerCase())).size !== venues.length) { + return "Venue names must be unique."; + } + + return null; +}; + +export const createTournament = async ( + payload: CreateTournamentPayload +): Promise<{ tournamentId: number }> => { + const validationError = validateCreatePayload(payload); + if (validationError) { + throw new Error(validationError); + } + + const input: CreateTournamentInput = { + ...payload, + teams: sanitizeList(payload.teams), + venues: sanitizeList(payload.venues), + }; + + const tournamentId = await createTournamentWithDetails(input); + + return { tournamentId }; +}; + +type Pairing = { + homeTeamId: number; + awayTeamId: number; +}; + +const generateRoundRobinPairings = (teamIds: number[]): Pairing[][] => { + const ids = [...teamIds]; + + if (ids.length % 2 !== 0) { + ids.push(-1); + } + + const rounds: Pairing[][] = []; + const totalRounds = ids.length - 1; + const half = ids.length / 2; + + for (let round = 0; round < totalRounds; round++) { + const pairings: Pairing[] = []; + + for (let index = 0; index < half; index++) { + const home = ids[index]; + const away = ids[ids.length - 1 - index]; + + if (typeof home === "number" && typeof away === "number" && home !== -1 && away !== -1) { + pairings.push({ + homeTeamId: home, + awayTeamId: away, + }); + } + } + + rounds.push(pairings); + + const fixed = ids[0]; + const rotating = ids.slice(1); + const last = rotating.pop(); + + if (last === undefined) { + break; + } + + if (fixed === undefined) { + break; + } + + ids.splice(0, ids.length, fixed, last, ...rotating); + } + + return rounds; +}; + +const buildRoundStartTime = (startDate: string, roundIndex: number): Date => { + const [year, month, day] = startDate.split("-").map(Number); + const base = new Date(Date.UTC(year, month - 1, day, 10, 0, 0, 0)); + base.setUTCDate(base.getUTCDate() + roundIndex); + return base; +}; + +export const generateTournamentSchedule = async ( + tournamentId: number +): Promise<{ generatedMatches: number }> => { + const context = await getTournamentScheduleContext(tournamentId); + + if (!context) { + throw new Error("Tournament not found."); + } + + if (context.teams.length < 2) { + throw new Error("Tournament requires at least 2 teams."); + } + + if (context.venues.length < 1) { + throw new Error("Tournament requires at least 1 venue."); + } + + let startDateStr = context.startDate; + if (typeof startDateStr !== "string") { + if (startDateStr instanceof Date) { + startDateStr = startDateStr.toISOString().split("T")[0]; + } else { + throw new Error("Invalid start date format."); + } + } + + const rounds = generateRoundRobinPairings(context.teams.map((team) => team.teamid)); + const matches: ScheduledMatchInsert[] = []; + + rounds.forEach((roundPairings, roundIndex) => { + const roundStart = buildRoundStartTime(startDateStr, roundIndex); + + roundPairings.forEach((pairing, pairingIndex) => { + const venue = context.venues[pairingIndex % context.venues.length]; + + if (!venue) { + throw new Error("Tournament requires at least 1 venue."); + } + + const slotOffset = Math.floor(pairingIndex / context.venues.length); + const matchTimestamp = new Date( + roundStart.getUTCFullYear(), + roundStart.getUTCMonth(), + roundStart.getUTCDate(), + roundStart.getUTCHours() + slotOffset * 2, + roundStart.getUTCMinutes(), + roundStart.getUTCSeconds() + ); + + const isoString = matchTimestamp.toISOString(); + if (!isoString || isoString.includes("NaN")) { + throw new Error(`Failed to build valid timestamp for match in round ${roundIndex + 1}`); + } + + matches.push({ + tournamentId, + roundNumber: roundIndex + 1, + homeTeamId: pairing.homeTeamId, + awayTeamId: pairing.awayTeamId, + venueId: venue.venueid, + matchTime: isoString, + }); + }); + }); + + await replaceTournamentSchedule(tournamentId, matches); + + return { generatedMatches: matches.length }; +}; + +export const getTournamentSchedule = async (tournamentId: number) => { + return getScheduleByTournament(tournamentId); +}; From 76edc32f1c9523cc62c7c58d2cb6dc55d9e6390e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:05:56 +0000 Subject: [PATCH 02/14] Initial plan From 1d2ade0e36ba92acf2e7ec6bbb85e5deed855444 Mon Sep 17 00:00:00 2001 From: carson-fn <180336862+carson-fn@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:47:57 -0600 Subject: [PATCH 03/14] Fix .env.example --- backend/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index c6e83b7..c901e2f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1 @@ -DATABASE_URL=postgresql://postgres:@localhost:5432/bracket_beaver \ No newline at end of file +DATABASE_URL=postgresql://:@:/ \ No newline at end of file From 8fc4826be456771a63df8b6387c01ce09a73576c Mon Sep 17 00:00:00 2001 From: Nitish Varshan <130765479+rapidcoderx@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:56:05 -0600 Subject: [PATCH 04/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/controllers/tournamentController.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/tournamentController.ts b/backend/src/controllers/tournamentController.ts index e818f79..9c6f482 100644 --- a/backend/src/controllers/tournamentController.ts +++ b/backend/src/controllers/tournamentController.ts @@ -51,8 +51,12 @@ export const createTournamentHandler = async (req: Request, res: Response) => { tournamentId: result.tournamentId, }); } catch (error) { - const message = error instanceof Error ? error.message : "Server error"; - return res.status(400).json({ success: false, message }); + // Log the underlying error for diagnostics, but do not expose details to the client. + console.error("Failed to create tournament:", error); + return res.status(500).json({ + success: false, + message: "Server error", + }); } }; @@ -82,9 +86,19 @@ export const generateScheduleHandler = async (req: Request, res: Response) => { generatedMatches: result.generatedMatches, }); } catch (error) { - const message = error instanceof Error ? error.message : "Server error"; - const statusCode = message === "Tournament not found." ? 404 : 400; - return res.status(statusCode).json({ success: false, message }); + if (error instanceof Error && error.message === "Tournament not found.") { + return res.status(404).json({ + success: false, + message: error.message, + }); + } + + // Log unexpected errors and return a generic server error response. + console.error("Failed to generate tournament schedule:", error); + return res.status(500).json({ + success: false, + message: "Server error", + }); } }; @@ -114,7 +128,8 @@ export const getScheduleHandler = async (req: Request, res: Response) => { success: true, schedule, }); - } catch { + } catch (error) { + console.error("Failed to get tournament schedule:", error); return res.status(500).json({ success: false, message: "Server error", From 7ace7a713efb7a28eaf6971e63293d7cb9d04c7a Mon Sep 17 00:00:00 2001 From: Nitish Varshan <130765479+rapidcoderx@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:56:20 -0600 Subject: [PATCH 05/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/src/models/tournamentModel.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/src/models/tournamentModel.ts b/backend/src/models/tournamentModel.ts index dff605c..83c007e 100644 --- a/backend/src/models/tournamentModel.ts +++ b/backend/src/models/tournamentModel.ts @@ -24,6 +24,8 @@ export type VenueRow = { export type TournamentScheduleContext = { tournamentId: number; startDate: string; + endDate: string; + bracketType: string; teams: TeamRow[]; venues: VenueRow[]; }; @@ -105,8 +107,13 @@ export const createTournamentWithDetails = async ( export const getTournamentScheduleContext = async ( tournamentId: number ): Promise => { - const tournamentResult = await pool.query<{ tournamentid: number; start_date: string }>( - `SELECT tournamentID, start_date + const tournamentResult = await pool.query<{ + tournamentid: number; + start_date: string; + end_date: string; + bracket_type: string; + }>( + `SELECT tournamentID, start_date, end_date, bracket_type FROM tournaments WHERE tournamentID = $1`, [tournamentId] @@ -141,6 +148,8 @@ export const getTournamentScheduleContext = async ( return { tournamentId, startDate: tournamentRow.start_date, + endDate: tournamentRow.end_date, + bracketType: tournamentRow.bracket_type, teams: teamsResult.rows, venues: venuesResult.rows, }; From 9ca19011948903f808ce0110d4b4f1ba483f1e4a Mon Sep 17 00:00:00 2001 From: Nitish Varshan <130765479+rapidcoderx@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:56:28 -0600 Subject: [PATCH 06/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/src/services/tournamentService.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/services/tournamentService.ts b/backend/src/services/tournamentService.ts index ce08e21..b05820f 100644 --- a/backend/src/services/tournamentService.ts +++ b/backend/src/services/tournamentService.ts @@ -143,6 +143,14 @@ export const generateTournamentSchedule = async ( throw new Error("Tournament requires at least 1 venue."); } + // Determine bracket type, if available, and reject unsupported types. + const bracketType = (context as { bracketType?: string }).bracketType; + if (bracketType && bracketType !== "round_robin") { + throw new Error( + `Schedule generation not supported for bracket type: ${bracketType}` + ); + } + let startDateStr = context.startDate; if (typeof startDateStr !== "string") { if (startDateStr instanceof Date) { From 7b23b268e3e1639af0a2ab53c2eb1483e577f16f Mon Sep 17 00:00:00 2001 From: Nitish Varshan <130765479+rapidcoderx@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:56:36 -0600 Subject: [PATCH 07/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/src/services/tournamentService.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/src/services/tournamentService.ts b/backend/src/services/tournamentService.ts index b05820f..fa8bda5 100644 --- a/backend/src/services/tournamentService.ts +++ b/backend/src/services/tournamentService.ts @@ -24,10 +24,34 @@ const sanitizeList = (items: string[]): string[] => { .filter((item) => item.length > 0); }; +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +const isValidYyyyMmDd = (value: string): boolean => { + if (!DATE_REGEX.test(value)) return false; + + // Use Date parsing to ensure the date actually exists (e.g. reject 2024-02-30). + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return false; + + // Ensure no overflow (e.g. 2024-13-01) by comparing the ISO prefix. + return date.toISOString().startsWith(value); +}; + const validateCreatePayload = (payload: CreateTournamentPayload): string | null => { if (!payload.name.trim()) return "Tournament name is required."; if (!payload.sport.trim()) return "Sport is required."; if (!payload.startDate || !payload.endDate) return "Start and end dates are required."; + + if (!isValidYyyyMmDd(payload.startDate) || !isValidYyyyMmDd(payload.endDate)) { + return "Start and end dates must be valid dates in YYYY-MM-DD format."; + } + + const start = new Date(`${payload.startDate}T00:00:00Z`); + const end = new Date(`${payload.endDate}T00:00:00Z`); + if (end.getTime() < start.getTime()) { + return "End date cannot be before start date."; + } + if (!Number.isInteger(payload.createdBy) || payload.createdBy <= 0) { return "createdBy must be a positive integer."; } From 85ae325889ddb6826d75375cfe383a3eaec53778 Mon Sep 17 00:00:00 2001 From: Nitish Varshan <130765479+rapidcoderx@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:57:26 -0600 Subject: [PATCH 08/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/src/services/tournamentService.ts | 33 ++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/src/services/tournamentService.ts b/backend/src/services/tournamentService.ts index fa8bda5..07bc7ad 100644 --- a/backend/src/services/tournamentService.ts +++ b/backend/src/services/tournamentService.ts @@ -144,8 +144,39 @@ const generateRoundRobinPairings = (teamIds: number[]): Pairing[][] => { }; const buildRoundStartTime = (startDate: string, roundIndex: number): Date => { - const [year, month, day] = startDate.split("-").map(Number); + const parts = startDate.split("-"); + if (parts.length !== 3) { + throw new Error( + `Invalid startDate '${startDate}'; expected format YYYY-MM-DD.` + ); + } + + const [yearRaw, monthRaw, dayRaw] = parts; + const year = Number(yearRaw); + const month = Number(monthRaw); + const day = Number(dayRaw); + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + throw new Error( + `Invalid startDate '${startDate}'; expected format YYYY-MM-DD with a valid calendar date.` + ); + } + const base = new Date(Date.UTC(year, month - 1, day, 10, 0, 0, 0)); + if (Number.isNaN(base.getTime())) { + throw new Error( + `Invalid startDate '${startDate}'; could not construct a valid Date.` + ); + } + base.setUTCDate(base.getUTCDate() + roundIndex); return base; }; From 6d0cb8892a9c099aa93cda86ff1ab0f41453adb8 Mon Sep 17 00:00:00 2001 From: Nitish Varshan <130765479+rapidcoderx@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:57:33 -0600 Subject: [PATCH 09/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/src/services/tournamentService.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/services/tournamentService.ts b/backend/src/services/tournamentService.ts index 07bc7ad..72c74de 100644 --- a/backend/src/services/tournamentService.ts +++ b/backend/src/services/tournamentService.ts @@ -230,12 +230,14 @@ export const generateTournamentSchedule = async ( const slotOffset = Math.floor(pairingIndex / context.venues.length); const matchTimestamp = new Date( - roundStart.getUTCFullYear(), - roundStart.getUTCMonth(), - roundStart.getUTCDate(), - roundStart.getUTCHours() + slotOffset * 2, - roundStart.getUTCMinutes(), - roundStart.getUTCSeconds() + Date.UTC( + roundStart.getUTCFullYear(), + roundStart.getUTCMonth(), + roundStart.getUTCDate(), + roundStart.getUTCHours() + slotOffset * 2, + roundStart.getUTCMinutes(), + roundStart.getUTCSeconds() + ) ); const isoString = matchTimestamp.toISOString(); From e7f2622f3f3ecd097c32b8def458be432ea4cbe1 Mon Sep 17 00:00:00 2001 From: rapidcoderx Date: Thu, 19 Mar 2026 12:21:05 -0600 Subject: [PATCH 10/14] Completed Frontend Tournament Page and Backend Tournament API --- .../src/controllers/tournamentController.ts | 78 +++ backend/src/database/BracketBeaverDB.sql | 24 +- backend/src/database/database.ts | 80 ++- backend/src/index.ts | 20 +- backend/src/models/tournamentModel.ts | 298 +++++++++-- backend/src/routes/tournamentRoute.ts | 4 + backend/src/services/tournamentService.ts | 467 ++++++++++++++++-- frontend/src/App.tsx | 2 + frontend/src/api/tournamentApi.ts | 102 ++++ frontend/src/pages/login/LoginPage.tsx | 4 + .../src/pages/tournaments/TournamentPage.tsx | 399 +++++++++++++++ .../tournaments/styles/tournamentStyles.css | 269 ++++++++++ 12 files changed, 1635 insertions(+), 112 deletions(-) create mode 100644 frontend/src/api/tournamentApi.ts create mode 100644 frontend/src/pages/tournaments/TournamentPage.tsx create mode 100644 frontend/src/pages/tournaments/styles/tournamentStyles.css diff --git a/backend/src/controllers/tournamentController.ts b/backend/src/controllers/tournamentController.ts index 9c6f482..68dc909 100644 --- a/backend/src/controllers/tournamentController.ts +++ b/backend/src/controllers/tournamentController.ts @@ -2,7 +2,9 @@ import type { Request, Response } from "express"; import { createTournament, generateTournamentSchedule, + getTournamentBracket, getTournamentSchedule, + updateTournamentMatchResult, type CreateTournamentPayload, } from "../services/tournamentService.js"; @@ -136,3 +138,79 @@ export const getScheduleHandler = async (req: Request, res: Response) => { }); } }; + +export const getBracketHandler = async (req: Request, res: Response) => { + const idParam = req.params.id; + + if (typeof idParam !== "string") { + return res.status(400).json({ + success: false, + message: "Invalid tournament id.", + }); + } + + const tournamentId = parseTournamentId(idParam); + + if (!tournamentId) { + return res.status(400).json({ + success: false, + message: "Invalid tournament id.", + }); + } + + try { + const bracket = await getTournamentBracket(tournamentId); + return res.status(200).json({ success: true, bracket }); + } catch (error) { + if (error instanceof Error && error.message === "Tournament not found.") { + return res.status(404).json({ success: false, message: error.message }); + } + + console.error("Failed to get tournament bracket:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +export const updateMatchResultHandler = async (req: Request, res: Response) => { + const tournamentIdParam = req.params.id; + const matchIdParam = req.params.matchId; + + if (typeof tournamentIdParam !== "string" || typeof matchIdParam !== "string") { + return res.status(400).json({ success: false, message: "Invalid route parameters." }); + } + + const tournamentId = parseTournamentId(tournamentIdParam); + const matchId = parseTournamentId(matchIdParam); + + if (!tournamentId || !matchId) { + return res.status(400).json({ success: false, message: "Invalid route parameters." }); + } + + const { homeScore, awayScore } = req.body as { homeScore?: number; awayScore?: number }; + + if (typeof homeScore !== "number" || typeof awayScore !== "number") { + return res.status(400).json({ success: false, message: "homeScore and awayScore are required." }); + } + + try { + const result = await updateTournamentMatchResult(tournamentId, matchId, homeScore, awayScore); + return res.status(200).json({ success: true, winnerTeamId: result.winnerTeamId }); + } catch (error) { + if (error instanceof Error) { + const knownMessages = new Set([ + "Match not found.", + "Both teams must be known before submitting a result.", + "Scores must be whole numbers greater than or equal to 0.", + "Matches cannot end in a tie.", + ]); + + if (knownMessages.has(error.message)) { + const statusCode = error.message === "Match not found." ? 404 : 400; + return res.status(statusCode).json({ success: false, message: error.message }); + } + } + + console.error("Failed to update match result:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; diff --git a/backend/src/database/BracketBeaverDB.sql b/backend/src/database/BracketBeaverDB.sql index b8d23b1..753abde 100644 --- a/backend/src/database/BracketBeaverDB.sql +++ b/backend/src/database/BracketBeaverDB.sql @@ -46,15 +46,25 @@ CREATE TABLE matches ( matchID SERIAL PRIMARY KEY, tournamentID INTEGER NOT NULL REFERENCES tournaments(tournamentID) ON DELETE CASCADE, round_number INTEGER NOT NULL, - home_team_id INTEGER NOT NULL REFERENCES teams(teamID) ON DELETE RESTRICT, - away_team_id INTEGER NOT NULL REFERENCES teams(teamID) ON DELETE RESTRICT, + slot_number INTEGER NOT NULL, + match_label VARCHAR(100) NOT NULL, + home_team_id INTEGER REFERENCES teams(teamID) ON DELETE RESTRICT, + away_team_id INTEGER REFERENCES teams(teamID) ON DELETE RESTRICT, + home_source_match_id INTEGER REFERENCES matches(matchID) ON DELETE SET NULL, + away_source_match_id INTEGER REFERENCES matches(matchID) ON DELETE SET NULL, + winner_team_id INTEGER REFERENCES teams(teamID) ON DELETE SET NULL, venue_id INTEGER NOT NULL REFERENCES venues(venueID) ON DELETE RESTRICT, match_time TIMESTAMPTZ NOT NULL, + home_score INTEGER, + away_score INTEGER, + status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CHECK (home_team_id <> away_team_id), - UNIQUE (tournamentID, round_number, home_team_id, away_team_id), - UNIQUE (tournamentID, venue_id, match_time), - UNIQUE (tournamentID, home_team_id, match_time), - UNIQUE (tournamentID, away_team_id, match_time) + CHECK (home_team_id IS NULL OR away_team_id IS NULL OR home_team_id <> away_team_id), + CHECK (status IN ('pending', 'completed')), + UNIQUE (tournamentID, round_number, slot_number), + UNIQUE (tournamentID, venue_id, match_time) ); +CREATE INDEX matches_home_source_idx ON matches (home_source_match_id); +CREATE INDEX matches_away_source_idx ON matches (away_source_match_id); + diff --git a/backend/src/database/database.ts b/backend/src/database/database.ts index c811340..976b923 100644 --- a/backend/src/database/database.ts +++ b/backend/src/database/database.ts @@ -9,4 +9,82 @@ if (process.env.NODE_ENV !== "production") { // DB connection pool export const pool = new Pool({ connectionString: process.env.DATABASE_URL, -}); \ No newline at end of file +}); + +let schemaReadyPromise: Promise | null = null; + +export const ensureDatabaseSchema = async (): Promise => { + if (schemaReadyPromise) { + return schemaReadyPromise; + } + + schemaReadyPromise = (async () => { + await pool.query( + `ALTER TABLE matches ALTER COLUMN home_team_id DROP NOT NULL` + ); + await pool.query( + `ALTER TABLE matches ALTER COLUMN away_team_id DROP NOT NULL` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS slot_number INTEGER` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS match_label VARCHAR(100)` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS home_source_match_id INTEGER REFERENCES matches(matchID) ON DELETE SET NULL` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS away_source_match_id INTEGER REFERENCES matches(matchID) ON DELETE SET NULL` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS winner_team_id INTEGER REFERENCES teams(teamID) ON DELETE SET NULL` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS home_score INTEGER` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS away_score INTEGER` + ); + await pool.query( + `ALTER TABLE matches ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'pending'` + ); + await pool.query( + `WITH ranked AS ( + SELECT + matchID, + ROW_NUMBER() OVER ( + PARTITION BY tournamentID, round_number + ORDER BY match_time ASC, matchID ASC + ) AS computed_slot + FROM matches + ) + UPDATE matches m + SET slot_number = ranked.computed_slot, + match_label = COALESCE(m.match_label, CONCAT('Round ', m.round_number, ' Match ', ranked.computed_slot)), + status = COALESCE(m.status, 'pending') + FROM ranked + WHERE m.matchID = ranked.matchID` + ); + await pool.query( + `ALTER TABLE matches ALTER COLUMN slot_number SET NOT NULL` + ); + await pool.query( + `ALTER TABLE matches ALTER COLUMN match_label SET NOT NULL` + ); + await pool.query( + `CREATE UNIQUE INDEX IF NOT EXISTS matches_tournament_round_slot_idx + ON matches (tournamentID, round_number, slot_number)` + ); + await pool.query( + `CREATE INDEX IF NOT EXISTS matches_home_source_idx + ON matches (home_source_match_id)` + ); + await pool.query( + `CREATE INDEX IF NOT EXISTS matches_away_source_idx + ON matches (away_source_match_id)` + ); + })(); + + return schemaReadyPromise; +}; \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 192215e..a025b10 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ import express from "express"; import cors from "cors"; import authRoutes from "./routes/authRoute.js"; import tournamentRoutes from "./routes/tournamentRoute.js"; +import { ensureDatabaseSchema } from "./database/database.js"; const app = express(); @@ -15,10 +16,6 @@ app.use( app.use(express.json()); -app.listen(PORT, () => { - console.log(`Server is running on port ${PORT}`); -}) - app.get("/", (req, res) => { res.send("Welcome to the server!"); }) @@ -28,4 +25,17 @@ app.get("/api/hello", (req, res) => { }) app.use("/api", authRoutes); -app.use("/api", tournamentRoutes); \ No newline at end of file +app.use("/api", tournamentRoutes); + +const startServer = async () => { + await ensureDatabaseSchema(); + + app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); + }); +}; + +startServer().catch((error) => { + console.error("Failed to start server:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/src/models/tournamentModel.ts b/backend/src/models/tournamentModel.ts index 83c007e..f1ac5b6 100644 --- a/backend/src/models/tournamentModel.ts +++ b/backend/src/models/tournamentModel.ts @@ -1,5 +1,8 @@ +import type { PoolClient, QueryResultRow } from "pg"; import { pool } from "../database/database.js"; +type DbExecutor = PoolClient | typeof pool; + export type CreateTournamentInput = { name: string; sport: string; @@ -23,8 +26,10 @@ export type VenueRow = { export type TournamentScheduleContext = { tournamentId: number; - startDate: string; - endDate: string; + name: string; + sport: string; + startDate: string | Date; + endDate: string | Date; bracketType: string; teams: TeamRow[]; venues: VenueRow[]; @@ -33,21 +38,63 @@ export type TournamentScheduleContext = { export type ScheduledMatchInsert = { tournamentId: number; roundNumber: number; - homeTeamId: number; - awayTeamId: number; + slotNumber: number; + matchLabel: string; + homeTeamId: number | null; + awayTeamId: number | null; + homeSourceMatchId: number | null; + awaySourceMatchId: number | null; + winnerTeamId: number | null; venueId: number; matchTime: string; + homeScore: number | null; + awayScore: number | null; + status: "pending" | "completed"; }; -export type ScheduleRow = { +export type BracketMatchRow = { matchid: number; + tournamentid: number; round_number: number; + slot_number: number; + match_label: string; + status: "pending" | "completed"; + home_team_id: number | null; + away_team_id: number | null; + winner_team_id: number | null; + home_score: number | null; + away_score: number | null; + home_source_match_id: number | null; + away_source_match_id: number | null; home_team: string; away_team: string; + winner_team: string | null; venue: string; match_time: string; }; +export type MatchRecord = { + matchid: number; + tournamentid: number; + bracket_type: string; + round_number: number; + slot_number: number; + home_team_id: number | null; + away_team_id: number | null; + winner_team_id: number | null; + home_score: number | null; + away_score: number | null; + home_source_match_id: number | null; + away_source_match_id: number | null; + status: "pending" | "completed"; +}; + +const query = async ( + executor: DbExecutor, + sql: string, + params: unknown[] = [] +) => executor.query(sql, params); + export const createTournamentWithDetails = async ( input: CreateTournamentInput ): Promise => { @@ -109,11 +156,13 @@ export const getTournamentScheduleContext = async ( ): Promise => { const tournamentResult = await pool.query<{ tournamentid: number; - start_date: string; - end_date: string; + name: string; + sport: string; + start_date: string | Date; + end_date: string | Date; bracket_type: string; }>( - `SELECT tournamentID, start_date, end_date, bracket_type + `SELECT tournamentID, name, sport, start_date, end_date, bracket_type FROM tournaments WHERE tournamentID = $1`, [tournamentId] @@ -147,6 +196,8 @@ export const getTournamentScheduleContext = async ( return { tournamentId, + name: tournamentRow.name, + sport: tournamentRow.sport, startDate: tournamentRow.start_date, endDate: tournamentRow.end_date, bracketType: tournamentRow.bracket_type, @@ -155,64 +206,217 @@ export const getTournamentScheduleContext = async ( }; }; -export const replaceTournamentSchedule = async ( +export const clearTournamentMatchesWithExecutor = async ( tournamentId: number, - matches: ScheduledMatchInsert[] + executor: DbExecutor ): Promise => { - const client = await pool.connect(); - - try { - await client.query("BEGIN"); + await query( + executor, + `DELETE FROM matches + WHERE tournamentID = $1`, + [tournamentId] + ); +}; - await client.query( - `DELETE FROM matches - WHERE tournamentID = $1`, - [tournamentId] - ); +export const insertTournamentMatch = async ( + match: ScheduledMatchInsert, + executor: DbExecutor +): Promise => { + const result = await query<{ matchid: number }>( + executor, + `INSERT INTO matches ( + tournamentID, + round_number, + slot_number, + match_label, + home_team_id, + away_team_id, + home_source_match_id, + away_source_match_id, + winner_team_id, + venue_id, + match_time, + home_score, + away_score, + status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING matchID`, + [ + match.tournamentId, + match.roundNumber, + match.slotNumber, + match.matchLabel, + match.homeTeamId, + match.awayTeamId, + match.homeSourceMatchId, + match.awaySourceMatchId, + match.winnerTeamId, + match.venueId, + match.matchTime, + match.homeScore, + match.awayScore, + match.status, + ] + ); - for (const match of matches) { - await client.query( - `INSERT INTO matches (tournamentID, round_number, home_team_id, away_team_id, venue_id, match_time) - VALUES ($1, $2, $3, $4, $5, $6)`, - [ - match.tournamentId, - match.roundNumber, - match.homeTeamId, - match.awayTeamId, - match.venueId, - match.matchTime, - ] - ); - } + const row = result.rows[0]; - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); + if (!row) { + throw new Error("Failed to insert match."); } + + return row.matchid; }; -export const getScheduleByTournament = async ( +export const getBracketByTournament = async ( tournamentId: number -): Promise => { - const result = await pool.query( +): Promise => { + const result = await pool.query( `SELECT m.matchID, + m.tournamentID, m.round_number, - th.name AS home_team, - ta.name AS away_team, + m.slot_number, + m.match_label, + m.status, + m.home_team_id, + m.away_team_id, + m.winner_team_id, + m.home_score, + m.away_score, + m.home_source_match_id, + m.away_source_match_id, + COALESCE(th.name, 'TBD') AS home_team, + COALESCE(ta.name, 'TBD') AS away_team, + tw.name AS winner_team, v.name AS venue, m.match_time::text AS match_time FROM matches m - JOIN teams th ON m.home_team_id = th.teamID - JOIN teams ta ON m.away_team_id = ta.teamID + LEFT JOIN teams th ON m.home_team_id = th.teamID + LEFT JOIN teams ta ON m.away_team_id = ta.teamID + LEFT JOIN teams tw ON m.winner_team_id = tw.teamID JOIN venues v ON m.venue_id = v.venueID WHERE m.tournamentID = $1 - ORDER BY m.round_number ASC, m.match_time ASC, m.matchID ASC`, + ORDER BY m.round_number ASC, m.slot_number ASC, m.matchID ASC`, [tournamentId] ); return result.rows; }; + +export const getMatchById = async ( + tournamentId: number, + matchId: number, + executor: DbExecutor = pool +): Promise => { + const result = await query( + executor, + `SELECT + m.matchID, + m.tournamentID, + t.bracket_type, + m.round_number, + m.slot_number, + m.home_team_id, + m.away_team_id, + m.winner_team_id, + m.home_score, + m.away_score, + m.home_source_match_id, + m.away_source_match_id, + m.status + FROM matches m + JOIN tournaments t ON m.tournamentID = t.tournamentID + WHERE m.tournamentID = $1 AND m.matchID = $2`, + [tournamentId, matchId] + ); + + return result.rows[0] ?? null; +}; + +export const saveMatchResult = async ( + tournamentId: number, + matchId: number, + homeScore: number, + awayScore: number, + winnerTeamId: number, + executor: DbExecutor +): Promise => { + await query( + executor, + `UPDATE matches + SET home_score = $1, + away_score = $2, + winner_team_id = $3, + status = 'completed' + WHERE tournamentID = $4 AND matchID = $5`, + [homeScore, awayScore, winnerTeamId, tournamentId, matchId] + ); +}; + +export const findDependentMatch = async ( + tournamentId: number, + sourceMatchId: number, + executor: DbExecutor +): Promise => { + const result = await query( + executor, + `SELECT + m.matchID, + m.tournamentID, + t.bracket_type, + m.round_number, + m.slot_number, + m.home_team_id, + m.away_team_id, + m.winner_team_id, + m.home_score, + m.away_score, + m.home_source_match_id, + m.away_source_match_id, + m.status + FROM matches m + JOIN tournaments t ON m.tournamentID = t.tournamentID + WHERE m.tournamentID = $1 + AND (m.home_source_match_id = $2 OR m.away_source_match_id = $2) + ORDER BY m.round_number ASC + LIMIT 1`, + [tournamentId, sourceMatchId] + ); + + return result.rows[0] ?? null; +}; + +export const setMatchParticipant = async ( + matchId: number, + side: "home" | "away", + teamId: number | null, + executor: DbExecutor +): Promise => { + const column = side === "home" ? "home_team_id" : "away_team_id"; + + await query( + executor, + `UPDATE matches + SET ${column} = $1 + WHERE matchID = $2`, + [teamId, matchId] + ); +}; + +export const resetMatchOutcome = async ( + matchId: number, + executor: DbExecutor +): Promise => { + await query( + executor, + `UPDATE matches + SET home_score = NULL, + away_score = NULL, + winner_team_id = NULL, + status = 'pending' + WHERE matchID = $1`, + [matchId] + ); +}; diff --git a/backend/src/routes/tournamentRoute.ts b/backend/src/routes/tournamentRoute.ts index 8db745e..c126191 100644 --- a/backend/src/routes/tournamentRoute.ts +++ b/backend/src/routes/tournamentRoute.ts @@ -2,7 +2,9 @@ import express from "express"; import { createTournamentHandler, generateScheduleHandler, + getBracketHandler, getScheduleHandler, + updateMatchResultHandler, } from "../controllers/tournamentController.js"; const router = express.Router(); @@ -10,5 +12,7 @@ const router = express.Router(); router.post("/tournaments", createTournamentHandler); router.post("/tournaments/:id/schedule/generate", generateScheduleHandler); router.get("/tournaments/:id/schedule", getScheduleHandler); +router.get("/tournaments/:id/bracket", getBracketHandler); +router.patch("/tournaments/:id/matches/:matchId/result", updateMatchResultHandler); export default router; diff --git a/backend/src/services/tournamentService.ts b/backend/src/services/tournamentService.ts index 72c74de..4b43a5b 100644 --- a/backend/src/services/tournamentService.ts +++ b/backend/src/services/tournamentService.ts @@ -1,11 +1,20 @@ +import type { PoolClient } from "pg"; import { + clearTournamentMatchesWithExecutor, createTournamentWithDetails, - getScheduleByTournament, + getBracketByTournament, + getMatchById, getTournamentScheduleContext, - replaceTournamentSchedule, + insertTournamentMatch, + findDependentMatch, + resetMatchOutcome, + saveMatchResult, + setMatchParticipant, type CreateTournamentInput, + type BracketMatchRow, type ScheduledMatchInsert, } from "../models/tournamentModel.js"; +import { pool } from "../database/database.js"; export type CreateTournamentPayload = { name: string; @@ -97,6 +106,10 @@ type Pairing = { awayTeamId: number; }; +type TeamSlot = { + teamId: number | null; +}; + const generateRoundRobinPairings = (teamIds: number[]): Pairing[][] => { const ids = [...teamIds]; @@ -181,6 +194,20 @@ const buildRoundStartTime = (startDate: string, roundIndex: number): Date => { return base; }; +const normalizeDateString = (value: string | Date): string => { + if (typeof value === "string") { + return value; + } + + const isoDate = value.toISOString().split("T")[0]; + + if (!isoDate) { + throw new Error("Invalid start date format."); + } + + return isoDate; +}; + export const generateTournamentSchedule = async ( tournamentId: number ): Promise<{ generatedMatches: number }> => { @@ -198,69 +225,405 @@ export const generateTournamentSchedule = async ( throw new Error("Tournament requires at least 1 venue."); } - // Determine bracket type, if available, and reject unsupported types. - const bracketType = (context as { bracketType?: string }).bracketType; - if (bracketType && bracketType !== "round_robin") { - throw new Error( - `Schedule generation not supported for bracket type: ${bracketType}` - ); + const startDateStr = normalizeDateString(context.startDate); + + if (context.bracketType === "round_robin") { + return generateRoundRobinSchedule(tournamentId, startDateStr, context.teams.map((team) => team.teamid), context.venues.map((venue) => venue.venueid)); } - let startDateStr = context.startDate; - if (typeof startDateStr !== "string") { - if (startDateStr instanceof Date) { - startDateStr = startDateStr.toISOString().split("T")[0]; - } else { - throw new Error("Invalid start date format."); - } + if (context.bracketType === "single_elimination") { + return generateSingleEliminationBracket(tournamentId, startDateStr, context.teams.map((team) => team.teamid), context.venues.map((venue) => venue.venueid)); } - const rounds = generateRoundRobinPairings(context.teams.map((team) => team.teamid)); - const matches: ScheduledMatchInsert[] = []; + throw new Error(`Unsupported bracket type: ${context.bracketType}`); +}; + +export const getTournamentSchedule = async (tournamentId: number) => { + return getBracketByTournament(tournamentId); +}; - rounds.forEach((roundPairings, roundIndex) => { - const roundStart = buildRoundStartTime(startDateStr, roundIndex); +const buildMatchTimestamp = ( + startDate: string, + roundIndex: number, + slotOffset: number +): string => { + const roundStart = buildRoundStartTime(startDate, roundIndex); + const matchTimestamp = new Date( + Date.UTC( + roundStart.getUTCFullYear(), + roundStart.getUTCMonth(), + roundStart.getUTCDate(), + roundStart.getUTCHours() + slotOffset * 2, + roundStart.getUTCMinutes(), + roundStart.getUTCSeconds() + ) + ); + + return matchTimestamp.toISOString(); +}; + +const nextPowerOfTwo = (value: number): number => { + let power = 1; + + while (power < value) { + power *= 2; + } + + return power; +}; - roundPairings.forEach((pairing, pairingIndex) => { - const venue = context.venues[pairingIndex % context.venues.length]; +const buildSeedOrder = (size: number): number[] => { + let seeds = [1]; - if (!venue) { - throw new Error("Tournament requires at least 1 venue."); + while (seeds.length < size) { + const nextSize = seeds.length * 2; + const mirror = nextSize + 1; + seeds = seeds.flatMap((seed) => [seed, mirror - seed]); + } + + return seeds; +}; + +const getEliminationRoundLabel = (totalRounds: number, roundNumber: number): string => { + const matchesInRound = 2 ** (totalRounds - roundNumber); + + if (matchesInRound === 1) return "Final"; + if (matchesInRound === 2) return "Semifinal"; + if (matchesInRound === 4) return "Quarterfinal"; + + return `Round ${roundNumber}`; +}; + +const clearDependentBranch = async ( + tournamentId: number, + sourceMatchId: number, + client: PoolClient +): Promise => { + const dependentMatch = await findDependentMatch(tournamentId, sourceMatchId, client); + + if (!dependentMatch) { + return; + } + + const side = dependentMatch.home_source_match_id === sourceMatchId ? "home" : "away"; + await setMatchParticipant(dependentMatch.matchid, side, null, client); + await resetMatchOutcome(dependentMatch.matchid, client); + await clearDependentBranch(tournamentId, dependentMatch.matchid, client); +}; + +const propagateWinnerToNextMatch = async ( + tournamentId: number, + sourceMatchId: number, + winnerTeamId: number, + client: PoolClient +): Promise => { + const dependentMatch = await findDependentMatch(tournamentId, sourceMatchId, client); + + if (!dependentMatch) { + return; + } + + const side = dependentMatch.home_source_match_id === sourceMatchId ? "home" : "away"; + const existingTeamId = side === "home" ? dependentMatch.home_team_id : dependentMatch.away_team_id; + + if (existingTeamId === winnerTeamId) { + return; + } + + await setMatchParticipant(dependentMatch.matchid, side, winnerTeamId, client); + await resetMatchOutcome(dependentMatch.matchid, client); + await clearDependentBranch(tournamentId, dependentMatch.matchid, client); +}; + +const generateRoundRobinSchedule = async ( + tournamentId: number, + startDate: string, + teamIds: number[], + venueIds: number[] +): Promise<{ generatedMatches: number }> => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + await clearTournamentMatchesWithExecutor(tournamentId, client); + + const rounds = generateRoundRobinPairings(teamIds); + let generatedMatches = 0; + + for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) { + const roundPairings = rounds[roundIndex] ?? []; + + for (let pairingIndex = 0; pairingIndex < roundPairings.length; pairingIndex++) { + const pairing = roundPairings[pairingIndex]; + const venueId = venueIds[pairingIndex % venueIds.length]; + + if (!pairing || venueId === undefined) { + continue; + } + + const slotOffset = Math.floor(pairingIndex / venueIds.length); + + await insertTournamentMatch( + { + tournamentId, + roundNumber: roundIndex + 1, + slotNumber: pairingIndex + 1, + matchLabel: `Round ${roundIndex + 1} Match ${pairingIndex + 1}`, + homeTeamId: pairing.homeTeamId, + awayTeamId: pairing.awayTeamId, + homeSourceMatchId: null, + awaySourceMatchId: null, + winnerTeamId: null, + venueId, + matchTime: buildMatchTimestamp(startDate, roundIndex, slotOffset), + homeScore: null, + awayScore: null, + status: "pending", + }, + client + ); + + generatedMatches += 1; } + } - const slotOffset = Math.floor(pairingIndex / context.venues.length); - const matchTimestamp = new Date( - Date.UTC( - roundStart.getUTCFullYear(), - roundStart.getUTCMonth(), - roundStart.getUTCDate(), - roundStart.getUTCHours() + slotOffset * 2, - roundStart.getUTCMinutes(), - roundStart.getUTCSeconds() - ) - ); + await client.query("COMMIT"); + return { generatedMatches }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; - const isoString = matchTimestamp.toISOString(); - if (!isoString || isoString.includes("NaN")) { - throw new Error(`Failed to build valid timestamp for match in round ${roundIndex + 1}`); +const generateSingleEliminationBracket = async ( + tournamentId: number, + startDate: string, + teamIds: number[], + venueIds: number[] +): Promise<{ generatedMatches: number }> => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + await clearTournamentMatchesWithExecutor(tournamentId, client); + + const bracketSize = nextPowerOfTwo(teamIds.length); + const totalRounds = Math.log2(bracketSize); + const seededOrder = buildSeedOrder(bracketSize); + const seededTeams: TeamSlot[] = seededOrder.map((seed) => ({ + teamId: teamIds[seed - 1] ?? null, + })); + + let previousRoundMatchIds: number[] = []; + const autoWinners: Array<{ matchId: number; winnerTeamId: number }> = []; + let generatedMatches = 0; + + for (let roundNumber = 1; roundNumber <= totalRounds; roundNumber++) { + const matchesInRound = 2 ** (totalRounds - roundNumber); + const currentRoundMatchIds: number[] = []; + const roundLabel = getEliminationRoundLabel(totalRounds, roundNumber); + + for (let slotIndex = 0; slotIndex < matchesInRound; slotIndex++) { + const venueId = venueIds[slotIndex % venueIds.length]; + + if (venueId === undefined) { + throw new Error("Tournament requires at least 1 venue."); + } + + const slotOffset = Math.floor(slotIndex / venueIds.length); + let homeTeamId: number | null = null; + let awayTeamId: number | null = null; + let homeSourceMatchId: number | null = null; + let awaySourceMatchId: number | null = null; + let winnerTeamId: number | null = null; + let status: "pending" | "completed" = "pending"; + + if (roundNumber === 1) { + homeTeamId = seededTeams[slotIndex * 2]?.teamId ?? null; + awayTeamId = seededTeams[slotIndex * 2 + 1]?.teamId ?? null; + + if (homeTeamId !== null && awayTeamId === null) { + winnerTeamId = homeTeamId; + status = "completed"; + } else if (homeTeamId === null && awayTeamId !== null) { + winnerTeamId = awayTeamId; + status = "completed"; + } + } else { + homeSourceMatchId = previousRoundMatchIds[slotIndex * 2] ?? null; + awaySourceMatchId = previousRoundMatchIds[slotIndex * 2 + 1] ?? null; + } + + const matchId = await insertTournamentMatch( + { + tournamentId, + roundNumber, + slotNumber: slotIndex + 1, + matchLabel: `${roundLabel} ${slotIndex + 1}`, + homeTeamId, + awayTeamId, + homeSourceMatchId, + awaySourceMatchId, + winnerTeamId, + venueId, + matchTime: buildMatchTimestamp(startDate, roundNumber - 1, slotOffset), + homeScore: null, + awayScore: null, + status, + }, + client + ); + + if (winnerTeamId !== null) { + autoWinners.push({ matchId, winnerTeamId }); + } + + currentRoundMatchIds.push(matchId); + generatedMatches += 1; } - matches.push({ + previousRoundMatchIds = currentRoundMatchIds; + } + + for (const autoWinner of autoWinners) { + await propagateWinnerToNextMatch( tournamentId, - roundNumber: roundIndex + 1, - homeTeamId: pairing.homeTeamId, - awayTeamId: pairing.awayTeamId, - venueId: venue.venueid, - matchTime: isoString, - }); - }); - }); - - await replaceTournamentSchedule(tournamentId, matches); - - return { generatedMatches: matches.length }; + autoWinner.matchId, + autoWinner.winnerTeamId, + client + ); + } + + await client.query("COMMIT"); + return { generatedMatches }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } }; -export const getTournamentSchedule = async (tournamentId: number) => { - return getScheduleByTournament(tournamentId); +const buildRoundName = ( + bracketType: string, + roundNumber: number, + totalRounds: number +): string => { + if (bracketType === "single_elimination") { + return getEliminationRoundLabel(totalRounds, roundNumber); + } + + return `Round ${roundNumber}`; +}; + +export const getTournamentBracket = async (tournamentId: number) => { + const context = await getTournamentScheduleContext(tournamentId); + + if (!context) { + throw new Error("Tournament not found."); + } + + const matches = await getBracketByTournament(tournamentId); + const totalRounds = matches.reduce((max, match) => Math.max(max, match.round_number), 0); + const roundsMap = new Map(); + + for (const match of matches) { + const roundMatches = roundsMap.get(match.round_number) ?? []; + roundMatches.push(match); + roundsMap.set(match.round_number, roundMatches); + } + + const rounds = Array.from(roundsMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([roundNumber, roundMatches]) => ({ + roundNumber, + name: buildRoundName(context.bracketType, roundNumber, totalRounds), + matches: roundMatches + .sort((a, b) => a.slot_number - b.slot_number) + .map((match) => ({ + matchId: match.matchid, + label: match.match_label, + slotNumber: match.slot_number, + status: match.status, + venue: match.venue, + matchTime: match.match_time, + winnerTeamId: match.winner_team_id, + homeSourceMatchId: match.home_source_match_id, + awaySourceMatchId: match.away_source_match_id, + homeTeam: { + id: match.home_team_id, + name: match.home_team, + score: match.home_score, + }, + awayTeam: { + id: match.away_team_id, + name: match.away_team, + score: match.away_score, + }, + })), + })); + + return { + tournament: { + tournamentId: context.tournamentId, + name: context.name, + sport: context.sport, + bracketType: context.bracketType, + startDate: + normalizeDateString(context.startDate), + endDate: + normalizeDateString(context.endDate), + }, + rounds, + }; +}; + +export const updateTournamentMatchResult = async ( + tournamentId: number, + matchId: number, + homeScore: number, + awayScore: number +): Promise<{ winnerTeamId: number }> => { + if (!Number.isInteger(homeScore) || !Number.isInteger(awayScore) || homeScore < 0 || awayScore < 0) { + throw new Error("Scores must be whole numbers greater than or equal to 0."); + } + + if (homeScore === awayScore) { + throw new Error("Matches cannot end in a tie."); + } + + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const match = await getMatchById(tournamentId, matchId, client); + + if (!match) { + throw new Error("Match not found."); + } + + if (match.home_team_id === null || match.away_team_id === null) { + throw new Error("Both teams must be known before submitting a result."); + } + + const winnerTeamId = homeScore > awayScore ? match.home_team_id : match.away_team_id; + + await saveMatchResult(tournamentId, matchId, homeScore, awayScore, winnerTeamId, client); + + if (match.bracket_type === "single_elimination") { + await propagateWinnerToNextMatch(tournamentId, matchId, winnerTeamId, client); + } + + await client.query("COMMIT"); + + return { winnerTeamId }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index acdc90b..94d5f23 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import LandingPage from "./pages/landing/LandingPage"; import LoginPage from "./pages/login/LoginPage"; +import TournamentPage from "./pages/tournaments/TournamentPage"; import "./global-styles/App.css"; function App() { @@ -9,6 +10,7 @@ function App() { } /> } /> + } /> ); diff --git a/frontend/src/api/tournamentApi.ts b/frontend/src/api/tournamentApi.ts new file mode 100644 index 0000000..5a9848a --- /dev/null +++ b/frontend/src/api/tournamentApi.ts @@ -0,0 +1,102 @@ +export type CreateTournamentRequest = { + name: string; + sport: string; + bracketType: "single_elimination" | "round_robin"; + startDate: string; + endDate: string; + createdBy: number; + teams: string[]; + venues: string[]; +}; + +export type BracketResponse = { + success: boolean; + bracket?: { + tournament: { + tournamentId: number; + name: string; + sport: string; + bracketType: string; + startDate: string; + endDate: string; + }; + rounds: Array<{ + roundNumber: number; + name: string; + matches: Array<{ + matchId: number; + label: string; + slotNumber: number; + status: "pending" | "completed"; + venue: string; + matchTime: string; + winnerTeamId: number | null; + homeSourceMatchId: number | null; + awaySourceMatchId: number | null; + homeTeam: { + id: number | null; + name: string; + score: number | null; + }; + awayTeam: { + id: number | null; + name: string; + score: number | null; + }; + }>; + }>; + }; + message?: string; +}; + +const handleJson = async (response: Response): Promise => { + const data = (await response.json()) as T & { message?: string; success?: boolean }; + + if (!response.ok) { + throw new Error(data.message ?? "Request failed."); + } + + return data; +}; + +export const createTournamentApi = async (payload: CreateTournamentRequest) => { + const response = await fetch("/api/tournaments", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + return handleJson<{ success: boolean; tournamentId: number }>(response); +}; + +export const generateTournamentApi = async (tournamentId: number) => { + const response = await fetch(`/api/tournaments/${tournamentId}/schedule/generate`, { + method: "POST", + }); + + return handleJson<{ success: boolean; generatedMatches: number }>(response); +}; + +export const getBracketApi = async (tournamentId: number) => { + const response = await fetch(`/api/tournaments/${tournamentId}/bracket`); + return handleJson(response); +}; + +export const updateMatchResultApi = async ( + tournamentId: number, + matchId: number, + homeScore: number, + awayScore: number +) => { + const response = await fetch(`/api/tournaments/${tournamentId}/matches/${matchId}/result`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ homeScore, awayScore }), + }); + + return handleJson<{ success: boolean; winnerTeamId: number }>(response); +}; diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx index 54b8e56..4c29bcc 100644 --- a/frontend/src/pages/login/LoginPage.tsx +++ b/frontend/src/pages/login/LoginPage.tsx @@ -1,17 +1,21 @@ import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { callLoginAPI } from "../../api/loginApi.ts"; function LoginPage() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState(""); + const navigate = useNavigate(); const handleLogin = async () => { try { const res = await callLoginAPI(username, password); if (res.success) { + localStorage.setItem("bb-user", JSON.stringify(res.user)); setMessage("Login successful"); + navigate("/tournaments"); } else { setMessage("Invalid credentials"); } diff --git a/frontend/src/pages/tournaments/TournamentPage.tsx b/frontend/src/pages/tournaments/TournamentPage.tsx new file mode 100644 index 0000000..bdac4c4 --- /dev/null +++ b/frontend/src/pages/tournaments/TournamentPage.tsx @@ -0,0 +1,399 @@ +import { useMemo, useState } from "react"; +import { + createTournamentApi, + generateTournamentApi, + getBracketApi, + updateMatchResultApi, + type BracketResponse, +} from "../../api/tournamentApi"; +import "./styles/tournamentStyles.css"; + +type StoredUser = { + userid?: number; + username?: string; + role?: string; +}; + +type ScoreDrafts = Record; + +const defaultForm = { + name: "Spring Championship", + sport: "Basketball", + bracketType: "single_elimination" as "single_elimination" | "round_robin", + startDate: "2026-03-21", + endDate: "2026-03-28", + teamsText: "Team A\nTeam B\nTeam C\nTeam D\nTeam E\nTeam F", + venuesText: "Court 1\nCourt 2", +}; + +function TournamentPage() { + const [form, setForm] = useState(defaultForm); + const [loadTournamentId, setLoadTournamentId] = useState(""); + const [currentTournamentId, setCurrentTournamentId] = useState(null); + const [bracket, setBracket] = useState(); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isBusy, setIsBusy] = useState(false); + const [scoreDrafts, setScoreDrafts] = useState({}); + + const storedUser = useMemo(() => { + const raw = localStorage.getItem("bb-user"); + if (!raw) return null; + + try { + return JSON.parse(raw) as StoredUser; + } catch { + return null; + } + }, []); + + const creatorId = storedUser?.userid ?? 1; + const teamList = form.teamsText.split("\n").map((value) => value.trim()).filter(Boolean); + const venueList = form.venuesText.split("\n").map((value) => value.trim()).filter(Boolean); + + const refreshBracket = async (tournamentId: number) => { + const response = await getBracketApi(tournamentId); + setBracket(response.bracket); + setCurrentTournamentId(tournamentId); + setScoreDrafts({}); + }; + + const handleCreateTournament = async () => { + setIsBusy(true); + setError(""); + setMessage(""); + + try { + const createResponse = await createTournamentApi({ + name: form.name, + sport: form.sport, + bracketType: form.bracketType, + startDate: form.startDate, + endDate: form.endDate, + createdBy: creatorId, + teams: teamList, + venues: venueList, + }); + + await generateTournamentApi(createResponse.tournamentId); + await refreshBracket(createResponse.tournamentId); + setMessage(`Tournament ${createResponse.tournamentId} created and generated.`); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Failed to create tournament."); + } finally { + setIsBusy(false); + } + }; + + const handleLoadBracket = async () => { + const tournamentId = Number(loadTournamentId); + if (!Number.isInteger(tournamentId) || tournamentId <= 0) { + setError("Enter a valid tournament id to load."); + return; + } + + setIsBusy(true); + setError(""); + setMessage(""); + + try { + await refreshBracket(tournamentId); + setMessage(`Loaded tournament ${tournamentId}.`); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Failed to load bracket."); + } finally { + setIsBusy(false); + } + }; + + const handleScoreChange = (matchId: number, side: "homeScore" | "awayScore", value: string) => { + setScoreDrafts((current) => ({ + ...current, + [matchId]: { + homeScore: current[matchId]?.homeScore ?? "", + awayScore: current[matchId]?.awayScore ?? "", + [side]: value, + }, + })); + }; + + const handleSubmitResult = async (matchId: number) => { + if (!currentTournamentId) { + setError("No tournament is loaded."); + return; + } + + const draft = scoreDrafts[matchId]; + const homeScore = Number(draft?.homeScore); + const awayScore = Number(draft?.awayScore); + + if (!Number.isInteger(homeScore) || !Number.isInteger(awayScore)) { + setError("Enter whole-number scores before submitting."); + return; + } + + setIsBusy(true); + setError(""); + setMessage(""); + + try { + await updateMatchResultApi(currentTournamentId, matchId, homeScore, awayScore); + await refreshBracket(currentTournamentId); + setMessage(`Updated result for match ${matchId}.`); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Failed to update result."); + } finally { + setIsBusy(false); + } + }; + + const handleQuickAdvance = async ( + matchId: number, + winner: "home" | "away" + ) => { + if (!currentTournamentId) { + setError("No tournament is loaded."); + return; + } + + const homeScore = winner === "home" ? 1 : 0; + const awayScore = winner === "away" ? 1 : 0; + + setIsBusy(true); + setError(""); + setMessage(""); + + try { + await updateMatchResultApi(currentTournamentId, matchId, homeScore, awayScore); + await refreshBracket(currentTournamentId); + setMessage(`Advanced winner for match ${matchId}.`); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Failed to advance winner."); + } finally { + setIsBusy(false); + } + }; + + return ( +
+
+
+

Bracket Builder

+

Single-Elimination and Round-Robin Tournaments

+

+ Create a tournament, generate its bracket, and submit results to advance winners. +

+

+ Active user: {storedUser?.username ?? "Demo organizer"} (creator id {creatorId}) +

+
+ +
+ + + + + +
+ +
+