diff --git a/src/features/bracket/bracket.ts b/src/features/bracket/bracket.ts deleted file mode 100644 index 536a5ed..0000000 --- a/src/features/bracket/bracket.ts +++ /dev/null @@ -1,412 +0,0 @@ -// Type definitions -interface TMatchSlot {} - -interface Seed extends TMatchSlot { - seed: number; -} - -interface TBD extends TMatchSlot { - parent: TMatchBase; - loser: boolean; - ifNecessary?: boolean; -} - -interface TMatchBase { - lid: number; // local id - round: number; - order?: number | null; -} - -interface TMatch extends TMatchBase { - home: Seed | TBD; - away: Seed | TBD; - reset?: boolean; -} - -interface TBye extends TMatchBase { - home: Seed | TBD; -} - -type MatchType = TMatch | TBye; - -// Utility functions -function innerOuter(ls: T[]): T[] { - if (ls.length === 2) return ls; - - const size = Math.floor(ls.length / 4); - - const innerPart = [ls.slice(size, 2 * size), ls.slice(2 * size, 3 * size)]; - const outerPart = [ls.slice(0, size), ls.slice(3 * size)]; - - const inner = (part: T[][]): T[] => [part[0].pop()!, part[1].shift()!]; - const outer = (part: T[][]): T[] => [part[0].shift()!, part[1].pop()!]; - - const quads: T[][] = Array(Math.floor(size / 2)).fill(null).map(() => []); - - const push = (part: T[][], method: (p: T[][]) => T[], arr: T[]) => { - if (part[0].length && part[1].length) { - arr.push(...method(part)); - } - }; - - for (let i = 0; i < Math.floor(size / 2); i++) { - push(outerPart, outer, quads[i]); - push(innerPart, inner, quads[i]); - push(outerPart, inner, quads[i]); - push(innerPart, outer, quads[i]); - } - - const result: T[] = []; - for (let i = 0; i < quads.length; i++) { - const curr = i % 2 === 0 ? quads.shift()! : quads.pop()!; - result.push(...curr); - } - - return result; -} - -function reverseHalfShift(ls: T[]): T[] { - const halfLength = Math.floor(ls.length / 2); - return [...ls.slice(-halfLength), ...ls.slice(0, -halfLength)]; -} - -export class BracketGenerator { - private _bracket: MatchType[][] = []; - private _losersBracket: MatchType[][] = []; - private _order: number = 0; - private _floatingLosers: TBD[] = []; - private _lid: number = 0; - private _matches: Map = new Map(); - - public n: number; - public doubleElim: boolean; - private _nearestPowerOf2: number; - private _m: number; - private _byes: number; - - constructor(n: number, doubleElim: boolean = false) { - if (n < 8 || n > 64) { - throw new Error("The number of teams must be greater than or equal to 8 and less than or equal to 64"); - } - - this.n = n; - this.doubleElim = doubleElim; - this._nearestPowerOf2 = Math.pow(2, Math.ceil(Math.log2(n))); - this._m = this._nearestPowerOf2; - this._byes = this._m - n; - - this._generateSingleElim(); - } - - private _makeMatch(round: number, home: Seed | TBD, away: Seed | TBD, order: number): TMatch { - const match: TMatch = { - lid: this._lid, - round: round, - home: home, - away: away, - order: order - }; - this._matches.set(this._lid, match); - this._lid += 1; - return match; - } - - private _makeBye(round: number, home: Seed | TBD): TBye { - const bye: TBye = { - lid: this._lid, - round: round, - home: home - }; - this._matches.set(this._lid, bye); - this._lid += 1; - return bye; - } - - private _makeTBD(parent: TMatchBase, loser: boolean = false): TBD { - return { - parent: parent, - loser: loser - }; - } - - private _makeSeed(seed: number): Seed { - return { seed: seed }; - } - - private _parseQuad(quad: MatchType[]): MatchType[] { - // Used to generate losers bracket by iterating over the first round of the bracket, 4 matches/byes at a time - - const pop = (): TBye => this._makeBye(0, this._floatingLosers.pop()!); - const popAt = (i: number) => (): TBye => this._makeBye(0, this._floatingLosers.splice(i, 1)[0]); - const shift = (): TBye => this._makeBye(0, this._floatingLosers.shift()!); - const popShift = (): TMatch => this._makeMatch(0, this._floatingLosers.pop()!, this._floatingLosers.shift()!, this._orderIncrement()); - const pairShift = (): TMatch => this._makeMatch(0, this._floatingLosers.shift()!, this._floatingLosers.shift()!, this._orderIncrement()); - - // Actions to perform based on number of byes in the winners bracket quad - const actions: { [key: number]: (() => MatchType)[] } = { - 0: [pop, pairShift, pop, pairShift], - 1: [pop, shift, pop, pairShift], - 2: [pop, shift, pop, shift], - 3: [popAt(-2), popShift], - 4: [pop, pop] - }; - - // Count the number of byes in the quad - const b = quad.filter(m => 'home' in m && !('away' in m)).length; - - const result = actions[b].map(action => action()); - return result; - } - - private _flattenRound(round: MatchType[], roundNumber: number = 0): MatchType[] { - // Check if all matches are byes - if (round.every(m => 'home' in m && !('away' in m))) { - const result: MatchType[] = []; - for (let i = 0; i < round.length; i += 2) { - result.push(this._makeMatch( - roundNumber, - (round[i] as TBye).home, - (round[i + 1] as TBye).home, - this._orderIncrement() - )); - } - return result; - } - - return round; - } - - private _startsWithBringInRound(): boolean { - // Start at 8, first block of size 4 returns 0 - let start = 8; - const blockSizes = [4, 5, 7, 9, 15, 17]; // Sizes of blocks - let result = 0; // First block returns 0 - - // Loop through predefined block sizes - for (const blockSize of blockSizes) { - const end = start + blockSize - 1; - if (start <= this.n && this.n <= end) { - return result === 0; - } - start = end + 1; - result = 1 - result; // Alternate between 0 and 1 - } - - return false; - } - - private _generateStartingRounds(): void { - this._floatingLosers = []; - - // Generate Pairings based on seeding - const seeds: (Seed | null)[] = []; - for (let i = 1; i <= this.n; i++) { - seeds.push(this._makeSeed(i)); - } - for (let i = 0; i < this._byes; i++) { - seeds.push(null); - } - - const pairings: [Seed | null, Seed | null][] = []; - const innerOuterResult = innerOuter(seeds); - for (let i = 0; i < innerOuterResult.length; i += 2) { - pairings.push([innerOuterResult[i], innerOuterResult[i + 1]]); - } - - // First Round - let round: MatchType[] = []; - for (const [home, away] of pairings) { - if (away === null) { - round.push(this._makeBye(0, home!)); - } else { - const match = this._makeMatch(0, home!, away, this._orderIncrement()); - round.push(match); - this._floatingLosers.push(this._makeTBD(match, true)); - } - } - - this._bracket = [round]; - - // Second Round - const prev = round; - round = []; - - const getSlot = (m: MatchType): Seed | TBD => { - return 'away' in m ? this._makeTBD(m) : (m as TBye).home; - }; - - const startOrder = this._orderIncrement(); - const orderDelta = Math.abs(this._byes - (this._m / 4)); - const orderSplit = [startOrder + orderDelta, startOrder]; - - for (let i = 0; i < prev.length; i += 2) { - const home = getSlot(prev[i]); - const away = getSlot(prev[i + 1]); - - let order: number; - if ('parent' in away) { - order = orderSplit[0]; - orderSplit[0] += 1; - } else { - order = orderSplit[1]; - orderSplit[1] += 1; - } - - const match = this._makeMatch(1, home, away, order); - round.push(match); - this._floatingLosers.push(this._makeTBD(match, true)); - } - - this._bracket.push(round); - this._order = orderSplit[0] - 1; - - // Generate losers bracket if double elim - if (this.doubleElim) { - // Round one - this._floatingLosers = innerOuter(this._floatingLosers); - this._losersBracket = []; - let roundOne: MatchType[] = []; - for (let i = 0; i < prev.length; i += 4) { - roundOne.push(...this._parseQuad(prev.slice(i, i + 4))); - } - this._losersBracket.push(this._flattenRound(roundOne)); - - // Round two - const roundTwo: MatchType[] = []; - for (let i = 0; i < roundOne.length; i += 2) { - roundTwo.push(this._makeMatch( - 1, - getSlot(roundOne[i]), - getSlot(roundOne[i + 1]), - this._orderIncrement() - )); - } - - this._losersBracket.push(roundTwo); - } - } - - private _orderIncrement(): number { - this._order += 1; - return this._order; - } - - private _generateBringInRound(roundNumber: number): void { - console.log('generating bring in round', roundNumber); - const bringIns = reverseHalfShift(this._floatingLosers); - this._floatingLosers = []; - const round: MatchType[] = []; - - const prev = this._losersBracket[this._losersBracket.length - 1]; - for (const match of prev) { - const bringIn = bringIns.pop()!; - const newMatch = this._makeMatch( - roundNumber, - bringIn, - this._makeTBD(match), - this._orderIncrement() - ); - round.push(newMatch); - } - - this._losersBracket.push(round); - } - - private _generateLosersRound(roundNumber: number): void { - console.log('generating losers round', roundNumber); - const round: MatchType[] = []; - const prev = this._losersBracket[this._losersBracket.length - 1]; - - if (prev.length < 2) return; - - for (let i = 0; i < prev.length; i += 2) { - const newMatch = this._makeMatch( - roundNumber, - this._makeTBD(prev[i]), - this._makeTBD(prev[i + 1]), - this._orderIncrement() - ); - round.push(newMatch); - } - - this._losersBracket.push(round); - } - - private _generateSingleElim(): void { - this._generateStartingRounds(); - let prev = this._bracket[this._bracket.length - 1]; - - const add = ( - round: MatchType[], - prevSlot: TBD | null, - match: MatchType - ): [MatchType[], TBD | null] => { - if (prevSlot === null) return [round, this._makeTBD(match)]; - const newMatch = this._makeMatch( - this._bracket.length, - prevSlot, - this._makeTBD(match), - this._orderIncrement() - ); - this._floatingLosers.push(this._makeTBD(newMatch, true)); - return [[...round, newMatch], null]; - }; - - while (prev.length > 1) { - let round: MatchType[] = []; - let prevSlot: TBD | null = null; - for (const match of prev) { - [round, prevSlot] = add(round, prevSlot, match); - } - this._bracket.push(round); - prev = round; - - if (this.doubleElim) { - const r = this._losersBracket.length; - if (this._startsWithBringInRound()) { - this._generateBringInRound(r); - this._generateLosersRound(r + 1); - } else { - this._generateLosersRound(r); - this._generateBringInRound(r + 1); - } - } - } - - // Grand Finals and bracket reset - if (this.doubleElim) { - const winnersFinal = this._bracket[this._bracket.length - 1][this._bracket[this._bracket.length - 1].length - 1]; - const losersFinal = this._losersBracket[this._losersBracket.length - 1][this._losersBracket[this._losersBracket.length - 1].length - 1]; - - const grandFinal = this._makeMatch( - this._bracket.length, - this._makeTBD(winnersFinal), - this._makeTBD(losersFinal), - this._orderIncrement() - ); - - const resetMatch = this._makeMatch( - this._bracket.length + 1, - this._makeTBD(grandFinal), - this._makeTBD(grandFinal, true), - this._orderIncrement() - ); - resetMatch.reset = true; - - this._bracket.push([grandFinal], [resetMatch]); - } - } - - // Public getters for accessing the brackets - get bracket(): MatchType[][] { - return this._bracket; - } - - get losersBracket(): MatchType[][] { - return this._losersBracket; - } - - get matches(): Map { - return this._matches; - } -} diff --git a/src/features/bracket/components/bracket-page.tsx b/src/features/bracket/components/bracket-page.tsx index 8fc3181..578ee4d 100644 --- a/src/features/bracket/components/bracket-page.tsx +++ b/src/features/bracket/components/bracket-page.tsx @@ -1,83 +1,99 @@ -import { Text, Container, Flex, ScrollArea } from "@mantine/core"; +import { Text, Container, Flex, ScrollArea, NumberInput, Group } from "@mantine/core"; import { SeedList } from "./seed-list"; import BracketView from "./bracket-view"; -import { MutableRefObject, RefObject, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { bracketQueries } from "../queries"; import { useQuery } from "@tanstack/react-query"; -import { useDraggable } from "react-use-draggable-scroll"; -import { ref } from "process"; +// import { useDraggable } from "react-use-draggable-scroll"; import './styles.module.css'; -import { useIsMobile } from "@/hooks/use-is-mobile"; +// import { useIsMobile } from "@/hooks/use-is-mobile"; import useAppShellHeight from "@/hooks/use-appshell-height"; +import { createBracketMaps, BracketMaps } from "../utils/bracket-maps"; interface Team { id: string; name: string; } -interface BracketData { - n: number; - doubleElim: boolean; - matches: { [key: string]: any }; - winnersBracket: number[][]; - losersBracket: number[][]; +interface Match { + lid: number; + round: number; + order: number | null; + type: string; + home: any; + away?: any; + reset?: boolean; } -export const PreviewBracketPage: React.FC = () => { - const isMobile = useIsMobile(); - const height = useAppShellHeight(); - const refDraggable = useRef(null); - const { events } = useDraggable(refDraggable as RefObject, { isMounted: !!refDraggable.current }); +interface BracketData { + winners: Match[][]; + losers: Match[][]; +} - const teamCount = 20; + +export const PreviewBracketPage: React.FC = () => { + const height = useAppShellHeight(); + + const [teamCount, setTeamCount] = useState(20); const { data, isLoading, error } = useQuery(bracketQueries.preview(teamCount)); // Create teams with proper structure - const [teams, setTeams] = useState( - Array.from({ length: teamCount }, (_, i) => ({ + const [teams, setTeams] = useState([]); + + // Update teams when teamCount changes + useEffect(() => { + setTeams(Array.from({ length: teamCount }, (_, i) => ({ id: `team-${i + 1}`, name: `Team ${i + 1}` - })) - ); + }))); + }, [teamCount]); - const [seededWinnersBracket, setSeededWinnersBracket] = useState([]); - const [seededLosersBracket, setSeededLosersBracket] = useState([]); + const [seededWinnersBracket, setSeededWinnersBracket] = useState([]); + const [seededLosersBracket, setSeededLosersBracket] = useState([]); + const [bracketMaps, setBracketMaps] = useState(null); useEffect(() => { - if (!data) return; + if (!data || teams.length === 0) return; - // Map match IDs to actual match objects with team names - const mapBracket = (bracketIds: number[][]) => { - return bracketIds.map(roundIds => - roundIds.map(lid => { - const match = data.matches[lid]; - if (!match) return null; + // Create bracket maps for easy lookups + const maps = createBracketMaps(data); + setBracketMaps(maps); + // Map brackets with team names + const mapBracket = (bracket: Match[][]) => { + return bracket.map(round => + round.map(match => { const mappedMatch = { ...match }; - // Map home slot - handle both uppercase and lowercase type names - if (match.home?.type?.toLowerCase() === 'seed') { - mappedMatch.home = { - ...match.home, - team: teams[match.home.seed - 1] - }; + // Map home slot if it has a seed + if (match.home?.seed && match.home.seed > 0) { + const teamIndex = match.home.seed - 1; + if (teams[teamIndex]) { + mappedMatch.home = { + ...match.home, + team: teams[teamIndex] + }; + } } - // Map away slot if it exists - handle both uppercase and lowercase type names - if (match.away?.type?.toLowerCase() === 'seed') { - mappedMatch.away = { - ...match.away, - team: teams[match.away.seed - 1] - }; + // Map away slot if it has a seed + if (match.away?.seed && match.away.seed > 0) { + const teamIndex = match.away.seed - 1; + if (teams[teamIndex]) { + mappedMatch.away = { + ...match.away, + team: teams[teamIndex] + }; + } } return mappedMatch; - }).filter(m => m !== null) + }) ); }; - setSeededWinnersBracket(mapBracket(data.winnersBracket)); - setSeededLosersBracket(mapBracket(data.losersBracket)); + setSeededWinnersBracket(mapBracket(data.winners)); + setSeededLosersBracket(mapBracket(data.losers)); }, [teams, data]); const handleSeedChange = (teamIndex: number, newSeedIndex: number) => { @@ -95,14 +111,27 @@ export const PreviewBracketPage: React.FC = () => { if (isLoading) return

Loading...

; if (error) return

Error loading bracket

; - if (!data) return

No data available

; + if (!data || !bracketMaps || teams.length === 0) return

No data available

; return ( - - - Preview Bracket ({data.n} teams, {data.doubleElim ? 'Double' : 'Single'} Elimination) + + + Preview Bracket (Double Elimination) + + Teams: + setTeamCount(Number(value) || 12)} + min={12} + max={20} + size="sm" + w={80} + allowDecimal={false} + clampBehavior="strict" + /> +
@@ -112,14 +141,8 @@ export const PreviewBracketPage: React.FC = () => {
@@ -127,7 +150,7 @@ export const PreviewBracketPage: React.FC = () => {
@@ -136,7 +159,7 @@ export const PreviewBracketPage: React.FC = () => {
diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx index 5c79afb..debf630 100644 --- a/src/features/bracket/components/bracket-view.tsx +++ b/src/features/bracket/components/bracket-view.tsx @@ -1,32 +1,38 @@ import { ActionIcon, Card, Container, Flex, Text } from '@mantine/core'; import { PlayIcon } from '@phosphor-icons/react'; import React from 'react'; +import { BracketMaps } from '../utils/bracket-maps'; + +interface Match { + lid: number; + round: number; + order: number | null; + type: string; + home: any; + away?: any; + reset?: boolean; +} interface BracketViewProps { - bracket: any[][]; - matches: { [key: string]: any }; + bracket: Match[][]; + bracketMaps: BracketMaps; onAnnounce?: (teamOne: any, teamTwo: any) => void; } -const BracketView: React.FC = ({ bracket, matches, onAnnounce }) => { - // Helper to check match type (handle both uppercase and lowercase) +const BracketView: React.FC = ({ bracket, bracketMaps, onAnnounce }) => { + // Helper to check match type const isMatchType = (type: string, expected: string) => { return type?.toLowerCase() === expected.toLowerCase(); }; - // Helper to check slot type (handle both uppercase and lowercase) - const isSlotType = (type: string, expected: string) => { - return type?.toLowerCase() === expected.toLowerCase(); - }; - - // Helper to get parent match order number - const getParentMatchOrder = (parentId: number): number | string => { - const parentMatch = matches[parentId]; + // Helper to get parent match order number using the new bracket maps + const getParentMatchOrder = (parentLid: number): number | string => { + const parentMatch = bracketMaps.matchByLid.get(parentLid); if (parentMatch && parentMatch.order !== null && parentMatch.order !== undefined) { return parentMatch.order; } - // If no order (like for byes), return the parentId with a different prefix - return `Match ${parentId}`; + // If no order (like for byes), return the parentLid with a different prefix + return `Match ${parentLid}`; }; return ( @@ -49,33 +55,83 @@ const BracketView: React.FC = ({ bracket, matches, onAnnounce {match.order} - - {isSlotType(match.home?.type, 'seed') && ( - <> - Seed {match.home.seed} - {match.home.team && {match.home.team.name}} - - )} - {isSlotType(match.home?.type, 'tbd') && ( - - {match.home.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.home.parentId || match.home.parent)} - - )} - {!match.home && TBD} + + + {match.home?.seed && ( + + {match.home.seed} + + )} +
+ {match.home?.seed ? ( + match.home.team ? ( + {match.home.team.name} + ) : ( + Team {match.home.seed} + ) + ) : (match.home?.parent_lid !== null && match.home?.parent_lid !== undefined) ? ( + + {match.home.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.home.parent_lid)} + + ) : ( + TBD + )} +
+
- - {isSlotType(match.away?.type, 'seed') && ( - <> - Seed {match.away.seed} - {match.away.team && {match.away.team.name}} - - )} - {isSlotType(match.away?.type, 'tbd') && ( - - {match.away.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.away.parentId || match.away.parent)} - - )} - {!match.away && TBD} + + + {match.away?.seed && ( + + {match.away.seed} + + )} +
+ {match.away?.seed ? ( + match.away.team ? ( + {match.away.team.name} + ) : ( + Team {match.away.seed} + ) + ) : (match.away?.parent_lid !== null && match.away?.parent_lid !== undefined) ? ( + + {match.away.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.away.parent_lid)} + + ) : match.away ? ( + TBD + ) : null} +
+
{match.reset && ( ; + matchByOrder: Map; + allMatches: Match[]; +} + +export function createBracketMaps(bracketData: BracketData): BracketMaps { + const matchByLid = new Map(); + const matchByOrder = new Map(); + const allMatches: Match[] = []; + + [...bracketData.winners, ...bracketData.losers].forEach(round => { + round.forEach(match => { + matchByLid.set(match.lid, match); + + if (match.order !== null && match.order !== undefined) { + matchByOrder.set(match.order, match); + } + + allMatches.push(match); + }); + }); + + return { + matchByLid, + matchByOrder, + allMatches + }; +} + +export function getMatchByLid(maps: BracketMaps, lid: number): Match | undefined { + return maps.matchByLid.get(lid); +} + +export function getMatchByOrder(maps: BracketMaps, order: number): Match | undefined { + return maps.matchByOrder.get(order); +} \ No newline at end of file diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts index 671f6a1..17d8863 100644 --- a/src/lib/pocketbase/services/tournaments.ts +++ b/src/lib/pocketbase/services/tournaments.ts @@ -2,31 +2,11 @@ import { logger } from "@/lib/logger"; import type { Tournament, TournamentInput, TournamentUpdateInput } from "@/features/tournaments/types"; import PocketBase from "pocketbase"; import { transformTournament } from "@/lib/pocketbase/util/transform-types"; -import { BracketGenerator } from "@/features/bracket/bracket"; export function createTournamentsService(pb: PocketBase) { return { async getTournament(id: string): Promise { try { - const generator = new BracketGenerator(12, true); - - console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=--=-=-=-=-=-=-=-=-=-") - console.log('Winners Bracket:'); - generator.bracket.forEach((round, i) => { - console.log(`Round ${i}:`); - round.forEach(match => { - console.log('-', match); - }); - }); - - console.log('\nLosers Bracket:'); - generator.losersBracket.forEach((round, i) => { - console.log(`Round ${i}:`); - round.forEach(match => { - console.log('-', match); - }); - }); - logger.info('PocketBase | Getting tournament', id); const result = await pb.collection('tournaments').getOne(id, { expand: 'teams, teams.players'