From 7b878b3abed75c498f8287dd1c6a4b1312f7073d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Sat, 14 Feb 2026 01:04:54 +0100 Subject: [PATCH] Better bracket logic --- backend/app/constants.py | 19 +- backend/app/core/brackets.py | 147 +++--- backend/app/core/structures.py | 19 + backend/app/core/utils.py | 4 +- backend/app/crud.py | 51 +- backend/app/logic.py | 481 +++++++----------- backend/app/models.py | 92 +--- backend/app/routes/tournaments/__init__.py | 2 +- backend/app/routes/tournaments/bracket.py | 15 - backend/app/routes/tournaments/report.py | 21 +- backend/app/routes/websocket.py | 2 +- backend/app/schemas.py | 40 +- backend/requirements.txt | Bin 210 -> 234 bytes backend/tests/test_scoring.py | 41 +- backend/tests/test_structure.py | 38 +- backend/tests/test_tournaments.py | 59 ++- frontend/src/App.css | 46 -- frontend/src/App.jsx | 13 +- .../src/components/Bracket/BracketNode.jsx | 99 ---- .../src/components/Bracket/BracketView.jsx | 203 ++++---- .../src/components/Forms/TournamentForm.jsx | 207 ++++++-- frontend/src/components/Layout/Layout.jsx | 67 ++- frontend/src/components/Layout/Navbar.jsx | 54 +- .../src/components/Schedule/ScheduleView.jsx | 145 +++--- .../src/components/Tournament/ScoreModal.jsx | 212 +++++--- frontend/src/components/UI/Modal.jsx | 7 +- frontend/src/pages/Dashboard.jsx | 227 +++++---- frontend/src/pages/Login.jsx | 43 +- frontend/src/pages/Tournament.jsx | 146 ++++++ frontend/src/pages/TournamentPage.jsx | 107 ---- frontend/src/services/api.js | 64 +-- frontend/vite.config.js | 3 +- 32 files changed, 1326 insertions(+), 1348 deletions(-) create mode 100644 backend/app/core/structures.py delete mode 100644 backend/app/routes/tournaments/bracket.py delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/components/Bracket/BracketNode.jsx create mode 100644 frontend/src/pages/Tournament.jsx delete mode 100644 frontend/src/pages/TournamentPage.jsx diff --git a/backend/app/constants.py b/backend/app/constants.py index 237c0b1..73fa657 100644 --- a/backend/app/constants.py +++ b/backend/app/constants.py @@ -7,23 +7,18 @@ class TournamentTypes(str, Enum): DOUBLE = "Double" -class BracketType(str, Enum): - WINNERS = "Winners" - LOSERS = "Losers" - FINALS = "Finals" - - -class MatchSourceType(str, Enum): - WINNER = "Winner" - LOSER = "Loser" - - class MatchStatus(str, Enum): - PENDING = "Pending" SCHEDULED = "Scheduled" + PENDING = "Pending" FINISHED = "Finished" +class BracketTypes(str, Enum): + WB = "Winner" + LB = "Loser" + FINALS = "Finals" + + class WinnerSide(str, Enum): P1 = "p1" P2 = "p2" diff --git a/backend/app/core/brackets.py b/backend/app/core/brackets.py index 8950e0d..0b03b8c 100644 --- a/backend/app/core/brackets.py +++ b/backend/app/core/brackets.py @@ -1,22 +1,11 @@ # backend/app/core/brackets.py import math -from typing import Optional - - -class Match: - def __init__(self, match_id: int, name: str): - self.id = match_id - self.name = name - self.next_win: Optional["Match"] = None - self.next_loss: Optional["Match"] = None - self.players: list[Optional[int]] = [None, None] - self.is_bye: bool = False - self.ghost_count: int = 0 +from ..constants import BracketTypes +from .structures import Match class BracketGenerator: - def __init__(self): self.matches_dict = {} self.match_counter = 1 @@ -30,23 +19,24 @@ class BracketGenerator: size = 2 ** math.ceil(math.log2(num_players)) seeds = self._generate_seeding_indices(num_players) - bracket = self._create_tree_bracket(size, "WB") + # Create Winners Bracket + bracket = self._create_tree_bracket(size) for i, match in enumerate(bracket[0]): - match.players = [seeds[i * 2], seeds[i * 2 + 1]] + match.teams = [seeds[i * 2], seeds[i * 2 + 1]] lb_rounds = [] if double_elimination: lb_rounds = self._create_strict_lb(size, bracket) - - if double_elimination: self._create_de_finals(bracket, lb_rounds) else: self._create_se_third_place(bracket) - self._apply_ghost_logic(bracket[0], num_players) + # Propagate ghosts (this marks matches as is_bye=True) + self._resolve_byes(num_players) all_matches = sorted(self.matches_dict.values(), key=lambda x: x.id) + # Filter out byes so they don't become DB rows return [m for m in all_matches if not m.is_bye] def _generate_seeding_indices(self, num_players: int) -> list[int]: @@ -58,7 +48,7 @@ class BracketGenerator: curr *= 2 return seeds - def _create_tree_bracket(self, size: int, prefix: str) -> list[list[Match]]: + def _create_tree_bracket(self, size: int) -> list[list[Match]]: rounds: list[list[Match]] = [] current_size = size // 2 r_num = 1 @@ -66,8 +56,7 @@ class BracketGenerator: while current_size >= 1: round_matches: list[Match] = [] for _ in range(current_size): - name = f"{prefix} Round {r_num}" - m = Match(self.match_counter, name) + m = Match(self.match_counter, BracketTypes.WB, r_num) self.matches_dict[m.id] = m round_matches.append(m) self.match_counter += 1 @@ -75,15 +64,10 @@ class BracketGenerator: current_size //= 2 r_num += 1 - if rounds: - rounds[-1][0].name = "Grand Final" - if len(rounds) > 1: - for m in rounds[-2]: - m.name = f"{prefix} Semifinal" - for r in range(len(rounds) - 1): for i, match in enumerate(rounds[r]): match.next_win = rounds[r + 1][i // 2] + match.next_win_slot = i % 2 return rounds @@ -104,102 +88,129 @@ class BracketGenerator: round_matches = [] for _ in range(current_lb_size): - m = Match(self.match_counter, f"LB Round {r}") + m = Match(self.match_counter, BracketTypes.LB, r) self.matches_dict[m.id] = m round_matches.append(m) self.match_counter += 1 lb_rounds.append(round_matches) - if lb_rounds: - lb_rounds[-1][0].name = "Losers Final (3rd Place Match)" - + # Link LB rounds for r in range(len(lb_rounds) - 1): curr = lb_rounds[r] next_r = lb_rounds[r + 1] for i, match in enumerate(curr): if len(curr) == len(next_r): + # 1-to-1 Mapping case (e.g. 4 matches -> 4 matches) match.next_win = next_r[i] + # FIX: Always use slot 1 (Slot 0 is taken by WB Drop-down) + match.next_win_slot = 1 else: + # 2-to-1 Mapping case (e.g. 4 matches -> 2 matches) match.next_win = next_r[i // 2] + match.next_win_slot = i % 2 + # Link WB Losers -> LB for r in range(num_wb_rounds): wb_round = wb_rounds[r] - - if r == 0: - lb_target_idx = 0 - else: - lb_target_idx = (r * 2) - 1 + lb_target_idx = 0 if r == 0 else (r * 2) - 1 if lb_target_idx >= len(lb_rounds): break lb_round = lb_rounds[lb_target_idx] - for i, wb_match in enumerate(wb_round): if r == 0: target = lb_round[i // 2] + wb_match.next_loss_slot = i % 2 else: + # Drop-in round target = lb_round[i] if i < len(lb_round) else lb_round[0] - + # Drop-ins take Slot 0 + wb_match.next_loss_slot = 0 wb_match.next_loss = target return lb_rounds def _create_se_third_place(self, wb_rounds: list[list[Match]]) -> None: - """Creates a standalone 3rd place match for Single Elim""" if len(wb_rounds) < 2: return - semis = wb_rounds[-2] - third_place_match = Match(self.match_counter, "3rd Place Match") + final_round_idx = wb_rounds[-1][0].round_number + third_place_match = Match( + self.match_counter, BracketTypes.FINALS, final_round_idx + ) self.matches_dict[third_place_match.id] = third_place_match self.match_counter += 1 - for m in semis: - m.next_loss = third_place_match + for i, sm in enumerate(semis): + sm.next_loss = third_place_match + sm.next_loss_slot = i % 2 def _create_de_finals( self, wb_rounds: list[list[Match]], lb_rounds: list[list[Match]] ) -> None: - """Links Double Elim Grand Final""" wb_final = wb_rounds[-1][0] - wb_final.name = "Winners Final" + final_round_idx = wb_final.round_number + 1 - gf = Match(self.match_counter, "Grand Final") + gf = Match(self.match_counter, BracketTypes.FINALS, final_round_idx) self.matches_dict[gf.id] = gf self.match_counter += 1 wb_final.next_win = gf + wb_final.next_win_slot = 0 + if lb_rounds: lb_rounds[-1][0].next_win = gf - reset = Match(self.match_counter, "Grand Final Reset") + lb_rounds[-1][0].next_win_slot = 1 + + reset = Match(self.match_counter, BracketTypes.FINALS, final_round_idx + 1) self.matches_dict[reset.id] = reset self.match_counter += 1 gf.next_loss = reset + gf.next_loss_slot = 0 - def _apply_ghost_logic( - self, round_one_matches: list[Match], num_players: int - ) -> None: + def _resolve_byes(self, num_players: int) -> None: """ - Injects ghosts into WB Round 1 and lets them flow recursively. + Iterates through all matches to identify 'Ghost' players (seeds > num_players). + If a match has a ghost, it is marked as a Bye, and the real player is + automatically pushed to the next match. """ - for m in round_one_matches: - p1, p2 = m.players - if p1 and p1 > num_players: - self._add_ghost(m) - if p2 and p2 > num_players: - self._add_ghost(m) + all_matches: list[Match] = sorted( + self.matches_dict.values(), key=lambda x: x.id + ) - def _add_ghost(self, match: Match) -> None: - """ - Recursively propagates a ghost through the bracket. - """ - match.ghost_count += 1 - match.is_bye = True + for m in all_matches: + if m.is_bye: + continue - if match.next_loss: - self._add_ghost(match.next_loss) + p1, p2 = m.teams - if match.ghost_count == 2: - if match.next_win: - self._add_ghost(match.next_win) + p1_is_ghost = p1 is not None and p1 > num_players + p2_is_ghost = p2 is not None and p2 > num_players + + if p1_is_ghost or p2_is_ghost: + m.is_bye = True + + winner = None + loser = None # Ghost + + if p1_is_ghost and p2_is_ghost: + winner = None + loser = None + elif p1_is_ghost: + winner = p2 + loser = p1 + elif p2_is_ghost: + winner = p1 + loser = p2 + + # Propagate Winner (Advance to next round) + if m.next_win: + m.next_win.teams[m.next_win_slot] = winner + + # Propagate Loser (Drop to LB) + # Important: We push the GHOST to the LB. + # When the loop reaches that LB match later, it will see the ghost + # and mark THAT match as a bye too, recursively cleaning the bracket. + if m.next_loss: + m.next_loss.teams[m.next_loss_slot] = loser diff --git a/backend/app/core/structures.py b/backend/app/core/structures.py new file mode 100644 index 0000000..164af0f --- /dev/null +++ b/backend/app/core/structures.py @@ -0,0 +1,19 @@ +# backend/app/core/structures.py +from dataclasses import dataclass, field +from typing import Optional + +from ..constants import BracketTypes + + +@dataclass +class Match: + id: int + bracket_type: BracketTypes + round_number: int + next_win: Optional["Match"] = None + next_loss: Optional["Match"] = None + next_win_slot: int = 0 + next_loss_slot: int = 0 + teams: list[Optional[int]] = field(default_factory=lambda: [None, None]) + is_bye: bool = False + ghost_count: int = 0 diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py index 4c565c5..00dec93 100644 --- a/backend/app/core/utils.py +++ b/backend/app/core/utils.py @@ -58,6 +58,6 @@ class MermaidLive: @staticmethod def _print_node(m: Match, style: str) -> None: label = m.name - if m.players[0] and m.players[1]: - label += f" ({m.players[0]} vs {m.players[1]})" + if m.teams[0] and m.teams[1]: + label += f" ({m.teams[0]} vs {m.teams[1]})" print(f' M{m.id}["{label}"]:::{style}') diff --git a/backend/app/crud.py b/backend/app/crud.py index 37ac5ce..5bf9d6d 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -7,39 +7,16 @@ from . import logic, models, schemas def _rebuild_structure(db: Session, t: models.Tournament): - """ - Nukes existing nodes/matches and regenerates them based on current teams. - Used when teams are added/removed. - """ - t.matches = [] + for m in t.matches: + db.delete(m) db.flush() - t.nodes = [] - db.flush() - - node_data = logic.generate_bracket_nodes(t) - for nd in node_data: - db.add(models.BracketNode(**nd, tournament_id=t.id)) - + logic.generate_bracket(db, t) db.flush() db.refresh(t) - - logic.initialize_seeding(db, t) logic.update_schedule_times(db, t) -def get_tournaments(db: Session): - return db.query(models.Tournament).all() - - -def get_tournament(db: Session, tournament_id: str): - return ( - db.query(models.Tournament) - .filter(models.Tournament.id == tournament_id) - .first() - ) - - def create_tournament(db: Session, data: schemas.TournamentCreate): t_id = str(uuid4())[:8] new_t = models.Tournament( @@ -56,19 +33,27 @@ def create_tournament(db: Session, data: schemas.TournamentCreate): db.add(new_t) db.commit() - node_data = logic.generate_bracket_nodes(new_t) - for nd in node_data: - db.add(models.BracketNode(**nd, tournament_id=t_id)) - db.commit() - db.refresh(new_t) - - logic.initialize_seeding(db, new_t) + # Generate Bracket + logic.generate_bracket(db, new_t) logic.update_schedule_times(db, new_t) + db.commit() db.refresh(new_t) return new_t +def get_tournaments(db: Session): + return db.query(models.Tournament).all() + + +def get_tournament(db: Session, tournament_id: str): + return ( + db.query(models.Tournament) + .filter(models.Tournament.id == tournament_id) + .first() + ) + + def delete_tournament(db: Session, tournament_id: str) -> bool: t = get_tournament(db, tournament_id) if not t: diff --git a/backend/app/logic.py b/backend/app/logic.py index 1ce8e55..324989e 100644 --- a/backend/app/logic.py +++ b/backend/app/logic.py @@ -1,333 +1,210 @@ # backend/app/logic.py -import math -from datetime import datetime, timedelta -from typing import Optional +from collections import defaultdict, deque +from datetime import timedelta from uuid import uuid4 from sqlalchemy.orm import Session from . import models -from .constants import ( - BracketType, - MatchSourceType, - MatchStatus, - TournamentTypes, - WinnerSide, -) +from .constants import MatchStatus, TournamentTypes +from .core.brackets import BracketGenerator -def get_seeded_positions(num_slots, teams): - seeds = [1, 2] - while len(seeds) < num_slots: - next_seeds = [] - for s in seeds: - next_seeds.append(s) - next_seeds.append(2 * len(seeds) + 1 - s) - seeds = next_seeds - return [teams[s - 1] if s <= len(teams) else None for s in seeds] - - -def generate_bracket_nodes(t: models.Tournament) -> list[dict]: - """ - Generates the skeleton (BracketNodes). Does NOT create Matches. - """ +def generate_bracket(db: Session, t: models.Tournament): teams = t.teams - count = len(teams) - if count < 2: - return [] + if not teams: + return - power = math.ceil(math.log2(count)) if count > 0 else 1 - size = 2**power + gen = BracketGenerator() + is_double = t.type == TournamentTypes.DOUBLE + abstract_matches = gen.generate(len(teams), double_elimination=is_double) + id_map = {m.id: str(uuid4()) for m in abstract_matches} - nodes = [] - display_counter = 1 + def resolve_target(match_node): + curr = match_node + while curr and curr.is_bye: + curr = curr.next_win + return curr - def make_id(): - return str(uuid4()) + db_matches = [] + friendly_counter = 1 - class NodeRef: - def __init__(self, bracket, round_n): - self.id = make_id() - self.bracket = bracket - self.round = round_n - self.display_num = 0 + for m in abstract_matches: + real_win = resolve_target(m.next_win) + real_loss = resolve_target(m.next_loss) - self.next_win: Optional["NodeRef"] = None - self.next_loss: Optional["NodeRef"] = None - self.src_p1: Optional["NodeRef"] = None - self.src_p2: Optional["NodeRef"] = None + initial_status = MatchStatus.SCHEDULED + p1_id = None + p2_id = None - self.src_p1_type: Optional[MatchSourceType] = None - self.src_p2_type: Optional[MatchSourceType] = None + p1_seed = m.teams[0] + p2_seed = m.teams[1] - def to_dict(self): - return { - "id": self.id, - "bracket_type": self.bracket, - "round_number": self.round, - "display_number": self.display_num, - "winner_next_node_id": self.next_win.id if self.next_win else None, - "loser_next_node_id": self.next_loss.id if self.next_loss else None, - "source_p1_node_id": self.src_p1.id if self.src_p1 else None, - "source_p2_node_id": self.src_p2.id if self.src_p2 else None, - "source_p1_type": self.src_p1_type, - "source_p2_type": self.src_p2_type, - } + if p1_seed and p1_seed <= len(teams): + p1_id = teams[p1_seed - 1].id + if p2_seed and p2_seed <= len(teams): + p2_id = teams[p2_seed - 1].id - wb_rounds = power - wb_layers = {r: [] for r in range(1, wb_rounds + 1)} + if p1_id and p2_id: + initial_status = MatchStatus.PENDING - for r in range(1, wb_rounds + 1): - for _ in range(size // (2**r)): - n = NodeRef(BracketType.WINNERS, r) - wb_layers[r].append(n) + new_match = models.Match( + id=id_map[m.id], + tournament_id=t.id, + match_number=friendly_counter, + bracket_type=m.bracket_type, + round_number=m.round_number, + status=initial_status, + p1_team_id=p1_id, + p2_team_id=p2_id, + winner_next_match_id=id_map[real_win.id] if real_win else None, + loser_next_match_id=id_map[real_loss.id] if real_loss else None, + ) + db_matches.append(new_match) + friendly_counter += 1 - # Link Winners - for r in range(1, wb_rounds): - for i, node in enumerate(wb_layers[r]): - target = wb_layers[r + 1][i // 2] - node.next_win = target - if i % 2 == 0: - target.src_p1 = node - target.src_p1_type = MatchSourceType.WINNER - else: - target.src_p2 = node - target.src_p2_type = MatchSourceType.WINNER - - lb_layers = {} - if t.type == TournamentTypes.DOUBLE and size >= 4: - lb_rounds = (wb_rounds - 1) * 2 - lb_layers = {r: [] for r in range(1, lb_rounds + 1)} - - current_count = size // 4 - for r in range(1, lb_rounds + 1): - for _ in range(current_count): - n = NodeRef(BracketType.LOSERS, r) - lb_layers[r].append(n) - if r % 2 == 0: - current_count //= 2 - - for r in range(1, lb_rounds): - for i, node in enumerate(lb_layers[r]): - target = lb_layers[r + 1][i] if r % 2 != 0 else lb_layers[r + 1][i // 2] - node.next_win = target - if r % 2 != 0: - target.src_p1 = node - target.src_p1_type = MatchSourceType.WINNER - else: - if i % 2 == 0: - target.src_p1 = node - target.src_p1_type = MatchSourceType.WINNER - else: - target.src_p2 = node - target.src_p2_type = MatchSourceType.WINNER - - # Link Drop-down (Winners -> Losers) - for r in range(1, wb_rounds): - drop_round = 1 if r == 1 else (r - 1) * 2 - wb_layer_nodes = wb_layers[r] - lb_layer_nodes = lb_layers[drop_round] - - for i, wb_node in enumerate(wb_layer_nodes): - target = None - if r == 1: - target = lb_layer_nodes[i // 2] - else: - if i < len(lb_layer_nodes): - target = lb_layer_nodes[i] - else: - target = lb_layer_nodes[-1] - - wb_node.next_loss = target - - slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2 - if slot == WinnerSide.P1: - target.src_p1 = wb_node - target.src_p1_type = MatchSourceType.LOSER - else: - target.src_p2 = wb_node - target.src_p2_type = MatchSourceType.LOSER - - # Finals - final_node = NodeRef(BracketType.FINALS, 1) - wb_final = wb_layers[wb_rounds][0] - lb_final = lb_layers[lb_rounds][0] - - wb_final.next_loss = lb_final - wb_final.next_win = final_node - lb_final.next_win = final_node - - final_node.src_p1 = wb_final - final_node.src_p1_type = MatchSourceType.WINNER - final_node.src_p2 = lb_final - final_node.src_p2_type = MatchSourceType.WINNER - - all_nodes = [] - for r in sorted(wb_layers.keys()): - all_nodes.extend(wb_layers[r]) - for r in sorted(lb_layers.keys()): - all_nodes.extend(lb_layers[r]) - all_nodes.append(final_node) - - else: - all_nodes = [] - for r in sorted(wb_layers.keys()): - all_nodes.extend(wb_layers[r]) - - for n in all_nodes: - n.display_num = display_counter - display_counter += 1 - nodes.append(n.to_dict()) - - return nodes - - -def initialize_seeding(db: Session, t: models.Tournament): - seeded_teams = get_seeded_positions( - 2 ** math.ceil(math.log2(t.team_count)), t.teams - ) - - r1_nodes = [ - n - for n in t.nodes - if n.bracket_type == BracketType.WINNERS and n.round_number == 1 - ] - r1_nodes.sort(key=lambda x: x.display_number) - - for i, node in enumerate(r1_nodes): - t1 = seeded_teams[i * 2] - t2 = seeded_teams[i * 2 + 1] - - node.p1_team_id = t1.id if t1 else None - node.p2_team_id = t2.id if t2 else None - - advance_flow(db, t) - - -def advance_flow(db: Session, t: models.Tournament): - changes = True - while changes: - changes = False - - for node in t.nodes: - if node.round_number == 1 and node.bracket_type == BracketType.WINNERS: - if node.p1_team_id and not node.p2_team_id: - if _move_team( - db, - t, - node.p1_team_id, - node.winner_next_node_id, - MatchSourceType.WINNER, - ): - pass - elif node.p2_team_id and not node.p1_team_id: - if _move_team( - db, - t, - node.p2_team_id, - node.winner_next_node_id, - MatchSourceType.WINNER, - ): - pass - - # 2. MATCH CREATION - if node.p1_team_id and node.p2_team_id: - if not node.match: - m_id = str(uuid4()) - new_match = models.Match( - id=m_id, - tournament_id=t.id, - node_id=node.id, - p1_team_id=node.p1_team_id, - p2_team_id=node.p2_team_id, - status=MatchStatus.PENDING, - court_id=node.planned_court_id, - start_time=node.planned_start_time, - ) - db.add(new_match) - db.commit() - db.refresh(node) - changes = True - - # 3. MATCH RESULT PROPAGATION - elif ( - node.match.status == MatchStatus.FINISHED - and node.match.winner_team_id - ): - winner_id = node.match.winner_team_id - loser_id = ( - node.match.p1_team_id - if winner_id == node.match.p2_team_id - else node.match.p2_team_id - ) - - if _move_team( - db, - t, - winner_id, - node.winner_next_node_id, - MatchSourceType.WINNER, - ): - changes = True - if _move_team( - db, t, loser_id, node.loser_next_node_id, MatchSourceType.LOSER - ): - changes = True - - -def _move_team(db, t, team_id, target_node_id, source_type): - if not target_node_id or not team_id: - return False - - target = next((n for n in t.nodes if n.id == target_node_id), None) - if not target: - return False - - updated = False - - if target.source_p1_type == source_type and not target.p1_team_id: - target.p1_team_id = team_id - updated = True - elif target.source_p2_type == source_type and not target.p2_team_id: - target.p2_team_id = team_id - updated = True - elif not target.p1_team_id: - target.p1_team_id = team_id - updated = True - elif not target.p2_team_id: - target.p2_team_id = team_id - updated = True - - if updated: - db.add(target) - db.commit() - return updated + db.add_all(db_matches) + db.flush() def update_schedule_times(db: Session, t: models.Tournament): - nodes = sorted(t.nodes, key=lambda n: n.display_number) - - current_time = t.timestamp - courts = t.courts - if not courts: + if not t.courts: return - court_timers: dict[int, datetime] = {c.id: current_time for c in courts} + matches = t.matches + if not matches: + return - for node in nodes: - if node.planned_start_time: - continue + # 1. Build Dependency Graph + adj = defaultdict(list) + in_degree = {m.id: 0 for m in matches} + match_map = {m.id: m for m in matches} - best_court_id = min(court_timers, key=lambda k: court_timers[k]) - start = court_timers[best_court_id] + for m in matches: + if m.id not in adj: + adj[m.id] = [] - node.planned_court_id = best_court_id - node.planned_start_time = start + if m.winner_next_match_id and m.winner_next_match_id in match_map: + adj[m.id].append(m.winner_next_match_id) + in_degree[m.winner_next_match_id] += 1 - court_timers[best_court_id] = start + timedelta(minutes=t.duration) + if m.loser_next_match_id and m.loser_next_match_id in match_map: + adj[m.id].append(m.loser_next_match_id) + in_degree[m.loser_next_match_id] += 1 - if node.match and node.match.status == MatchStatus.PENDING: - node.match.court_id = best_court_id - node.match.timestamp = start + # 2. Initialize constraints + # FIX: Ensure tournament_start is naive (no timezone) to match SQLite DB datetimes + tournament_start = t.timestamp + if tournament_start.tzinfo is not None: + tournament_start = tournament_start.replace(tzinfo=None) + + match_earliest_start = {m.id: tournament_start for m in matches} + court_timers = {c.id: tournament_start for c in t.courts} + + # 3. Topological Sort + queue = deque([m.id for m in matches if in_degree[m.id] == 0]) + + # Sort initial batch to prioritize logical order + initial_order = sorted( + list(queue), + key=lambda mid: (match_map[mid].bracket_type, match_map[mid].match_number), + ) + queue = deque(initial_order) + + while queue: + current_id = queue.popleft() + match = match_map[current_id] + + # FIX: Ensure DB start_time is treated as naive + m_start = match.start_time + if m_start and m_start.tzinfo is not None: + m_start = m_start.replace(tzinfo=None) + + if match.status == MatchStatus.FINISHED: + # If finished, propagate actual time + if not m_start: + m_start = tournament_start + actual_end = m_start + timedelta(minutes=t.duration) + else: + # Schedule: Must wait for dependencies (min_start) AND court availability + min_start = match_earliest_start[current_id] + + best_court_id = min( + court_timers, key=lambda k: max(court_timers[k], min_start) + ) + + scheduled_start = max(court_timers[best_court_id], min_start) + + match.court_id = best_court_id + match.start_time = scheduled_start + + actual_end = scheduled_start + timedelta(minutes=t.duration) + court_timers[best_court_id] = actual_end + + for child_id in adj[current_id]: + if actual_end > match_earliest_start[child_id]: + match_earliest_start[child_id] = actual_end + + in_degree[child_id] -= 1 + if in_degree[child_id] == 0: + queue.append(child_id) + + db.commit() + + +def advance_winner(db: Session, match: models.Match, winner_id: int): + if not winner_id: + return + + loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id + + def update_next_match(next_match, team_id): + if not next_match: + return + if not next_match.p1_team_id: + next_match.p1_team_id = team_id + elif not next_match.p2_team_id: + next_match.p2_team_id = team_id + + if ( + next_match.p1_team_id + and next_match.p2_team_id + and next_match.status == MatchStatus.SCHEDULED + ): + next_match.status = MatchStatus.PENDING + + db.add(next_match) + + update_next_match(match.winner_next_match, winner_id) + if match.loser_next_match and loser_id: + update_next_match(match.loser_next_match, loser_id) + + db.commit() + + +def undo_advancement(db: Session, match: models.Match): + if not match.winner_team_id: + return + + winner_id = match.winner_team_id + loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id + + def clear_from_next(next_match, team_id): + if not next_match: + return + + if next_match.p1_team_id == team_id: + next_match.p1_team_id = None + elif next_match.p2_team_id == team_id: + next_match.p2_team_id = None + + if next_match.status == MatchStatus.PENDING: + next_match.status = MatchStatus.SCHEDULED + + db.add(next_match) + + clear_from_next(match.winner_next_match, winner_id) + if match.loser_next_match and loser_id: + clear_from_next(match.loser_next_match, loser_id) db.commit() diff --git a/backend/app/models.py b/backend/app/models.py index a96a36f..7538c8d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -7,7 +7,7 @@ from sqlalchemy import Enum as SqlEnum from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship -from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes +from .constants import MatchStatus, TournamentTypes, BracketTypes from .database import Base @@ -28,12 +28,6 @@ class Tournament(Base): "Court", back_populates="tournament", cascade="all, delete-orphan" ) - # The Structure (Skeleton) - nodes: Mapped[list["BracketNode"]] = relationship( - "BracketNode", back_populates="tournament", cascade="all, delete-orphan" - ) - - # The Events (Real Games) matches: Mapped[list["Match"]] = relationship( "Match", back_populates="tournament", cascade="all, delete-orphan" ) @@ -65,86 +59,50 @@ class Court(Base): tournament: Mapped["Tournament"] = relationship(back_populates="courts") -class BracketNode(Base): - __tablename__ = "bracket_nodes" - - id: Mapped[str] = mapped_column(String, primary_key=True) - tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id")) - - bracket_type: Mapped[BracketType] = mapped_column(SqlEnum(BracketType)) - round_number: Mapped[int] = mapped_column(Integer) - display_number: Mapped[int] = mapped_column(Integer) - - # --- Planning / Scheduling --- - planned_court_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("courts.id"), nullable=True - ) - planned_start_time: Mapped[Optional[datetime]] = mapped_column( - DateTime, nullable=True - ) - - # --- Flow Logic --- - source_p1_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - source_p2_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - source_p1_type: Mapped[Optional[MatchSourceType]] = mapped_column( - SqlEnum(MatchSourceType), nullable=True - ) - source_p2_type: Mapped[Optional[MatchSourceType]] = mapped_column( - SqlEnum(MatchSourceType), nullable=True - ) - - # --- Next Step --- - winner_next_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - loser_next_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - - # --- Current State --- - p1_team_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("teams.id"), nullable=True - ) - p2_team_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("teams.id"), nullable=True - ) - - # Link to actual match (only exists if active) - match: Mapped[Optional["Match"]] = relationship( - "Match", back_populates="node", uselist=False - ) - tournament: Mapped["Tournament"] = relationship(back_populates="nodes") - court: Mapped[Optional["Court"]] = relationship() - - # Helper to get team names quickly - p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id]) - p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id]) - - class Match(Base): __tablename__ = "matches" id: Mapped[str] = mapped_column(String, primary_key=True) tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id")) - node_id: Mapped[str] = mapped_column(ForeignKey("bracket_nodes.id"), unique=True) + match_number: Mapped[int] = mapped_column(Integer) + round_number: Mapped[int] = mapped_column(Integer, default=1) + + bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes)) court_id: Mapped[Optional[int]] = mapped_column( ForeignKey("courts.id"), nullable=True ) - timestamp: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) status: Mapped[MatchStatus] = mapped_column( - SqlEnum(MatchStatus), default=MatchStatus.PENDING + SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED ) - p1_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) - p2_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) - + p1_team_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("teams.id"), nullable=True + ) + p2_team_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("teams.id"), nullable=True + ) sets: Mapped[list[dict]] = mapped_column(JSON, default=list) winner_team_id: Mapped[Optional[int]] = mapped_column( ForeignKey("teams.id"), nullable=True ) + winner_next_match_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("matches.id"), nullable=True + ) + loser_next_match_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("matches.id"), nullable=True + ) - node: Mapped["BracketNode"] = relationship(back_populates="match") tournament: Mapped["Tournament"] = relationship(back_populates="matches") court: Mapped[Optional["Court"]] = relationship() - p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id]) p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id]) + winner_next_match: Mapped["Match"] = relationship( + "Match", remote_side=[id], foreign_keys=[winner_next_match_id] + ) + loser_next_match: Mapped["Match"] = relationship( + "Match", remote_side=[id], foreign_keys=[loser_next_match_id] + ) diff --git a/backend/app/routes/tournaments/__init__.py b/backend/app/routes/tournaments/__init__.py index 89ec6bf..e99c883 100644 --- a/backend/app/routes/tournaments/__init__.py +++ b/backend/app/routes/tournaments/__init__.py @@ -3,4 +3,4 @@ from fastapi import APIRouter router = APIRouter(prefix="/tournaments", tags=["Tournaments"]) -from . import tournaments, teams, courts, matches, report, bracket +from . import tournaments, teams, courts, matches, report diff --git a/backend/app/routes/tournaments/bracket.py b/backend/app/routes/tournaments/bracket.py deleted file mode 100644 index e190d3d..0000000 --- a/backend/app/routes/tournaments/bracket.py +++ /dev/null @@ -1,15 +0,0 @@ -# backend/app/routes/tournaments/bracket.py -from fastapi import Depends, HTTPException -from sqlalchemy.orm import Session - -from ... import crud, schemas -from ...database import get_db -from . import router - - -@router.get("/{id}/bracket", response_model=list[schemas.BracketNodeOut]) -def get_tournament_bracket(id: str, db: Session = Depends(get_db)): - t = crud.get_tournament(db, id) - if not t: - raise HTTPException(404, "Tournament not found") - return t.nodes diff --git a/backend/app/routes/tournaments/report.py b/backend/app/routes/tournaments/report.py index d107ae5..d4ca6e1 100644 --- a/backend/app/routes/tournaments/report.py +++ b/backend/app/routes/tournaments/report.py @@ -1,5 +1,5 @@ # backend/app/routes/tournaments/report.py -from typing import List, Optional +from typing import Optional from fastapi import Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -45,7 +45,9 @@ async def report_score( _apply_score(match, report.sets) flag_modified(match, "sets") db.commit() - logic.advance_flow(db, t) + + if match.winner_team_id: + logic.advance_winner(db, match, match.winner_team_id) await send_ws_update(id) return SUCCESS @@ -59,11 +61,6 @@ async def edit_score( db: Session = Depends(get_db), user: Optional[str] = Depends(get_optional_user), ): - """ - Allows correcting a score. - Note: If the winner changes, 'advance_flow' might need to handle - undoing previous advancements, but for now we just re-run the flow. - """ t = crud.get_tournament(db, id) if not t: raise HTTPException(404, "Tournament not found") @@ -74,12 +71,16 @@ async def edit_score( if not match: raise HTTPException(404, "Match not found") + logic.undo_advancement(db, match) + if report.sets: _apply_score(match, report.sets) flag_modified(match, "sets") db.commit() - logic.advance_flow(db, t) + + if match.winner_team_id: + logic.advance_winner(db, match, match.winner_team_id) await send_ws_update(id) return SUCCESS @@ -103,6 +104,8 @@ async def clear_score( if not match: raise HTTPException(404, "Match not found") + logic.undo_advancement(db, match) + match.winner_team_id = None match.status = MatchStatus.PENDING match.sets = [] @@ -114,7 +117,7 @@ async def clear_score( return SUCCESS -def _apply_score(match: models.Match, sets: List[schemas.SetScore]): +def _apply_score(match: models.Match, sets: list[schemas.SetScore]): """ Calculates winner based on sets and updates the match object. Does NOT commit to DB. diff --git a/backend/app/routes/websocket.py b/backend/app/routes/websocket.py index dd499f4..4d79899 100644 --- a/backend/app/routes/websocket.py +++ b/backend/app/routes/websocket.py @@ -6,7 +6,7 @@ from ..core.websocket_manager import manager router = APIRouter(prefix="/ws", tags=["Websocket"]) -@router.websocket("/") +@router.websocket("") async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) try: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 080905e..a4d1de9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -3,7 +3,7 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict -from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes +from .constants import MatchStatus, TournamentTypes, BracketTypes class TeamSchema(BaseModel): @@ -38,39 +38,26 @@ class ScoreReport(BaseModel): class MatchOut(BaseModel): id: str - node_id: str + match_number: int + round_number: int + bracket_type: BracketTypes + status: MatchStatus - timestamp: datetime | None = None - court: CourtSchema | None = None - p1_team: TeamSchema | None = None - p2_team: TeamSchema | None = None + start_time: datetime | None = None + court_id: int | None = None + + p1_team_id: int | None = None + p2_team_id: int | None = None winner_team_id: int | None = None + winner_next_match_id: str | None = None + loser_next_match_id: str | None = None + sets: list[SetScore] = [] model_config = ConfigDict(from_attributes=True) -class BracketNodeOut(BaseModel): - id: str - display_number: int - bracket_type: BracketType - round_number: int - planned_start_time: datetime | None = None - planned_court_id: int | None = None - source_p1_node_id: str | None = None - source_p2_node_id: str | None = None - source_p1_type: MatchSourceType | None = None - source_p2_type: MatchSourceType | None = None - winner_next_node_id: str | None = None - loser_next_node_id: str | None = None - p1_team: TeamSchema | None = None - p2_team: TeamSchema | None = None - match: MatchOut | None = None - - model_config = ConfigDict(from_attributes=True) - - class TournamentCreate(BaseModel): name: str code: str @@ -106,6 +93,7 @@ class TournamentDetail(BaseModel): type: TournamentTypes teams: list[TeamSchema] courts: list[CourtSchema] + matches: list[MatchOut] model_config = ConfigDict(from_attributes=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index b0356c08a36a5d944bcbf6fbb997e5d09e6ace42..dc212f93a8fe85fd2828c8f8d9ed995a24d78211 100644 GIT binary patch delta 31 kcmcb__=<7DB?(>zE{1Z3RE8vmVupN%WQJ@Yy9CGv0E>qQiU0rr delta 6 NcmaFGc!_bsB>)P<10eta diff --git a/backend/tests/test_scoring.py b/backend/tests/test_scoring.py index 13b62a5..35d7a65 100644 --- a/backend/tests/test_scoring.py +++ b/backend/tests/test_scoring.py @@ -15,16 +15,19 @@ async def test_scoring_flow( t_id = res.json()["id"] t_code = valid_tournament_payload["code"] - # 2. Get Bracket Nodes (Updated Endpoint) - bracket_res = await client.get(f"/tournaments/{t_id}/bracket") - nodes = bracket_res.json() + # 2. Get Bracket (Now returns list of Matches) + # Note: You can now get this from GET /tournaments/{id} or /bracket depending on your routes + bracket_res = await client.get(f"/tournaments/{t_id}") + matches = bracket_res.json()["matches"] - # Find active node - active_node = next(n for n in nodes if n.get("match") is not None) + # Find the first playable match (Round 1) + # We look for a match that has teams assigned but is not finished + active_match = next( + m for m in matches if m["status"] == "Pending" and m["p1_team"] and m["p2_team"] + ) - match_data = active_node["match"] - match_id = match_data["id"] - next_node_id = active_node["winner_next_node_id"] + match_id = active_match["id"] + next_match_id = active_match["winner_next_match_id"] # 3. Report Score score_payload = { @@ -38,15 +41,17 @@ async def test_scoring_flow( ) assert report_res.status_code == 200 - # 4. Verify Winner Advanced (Fetch bracket again) - updated_res = await client.get(f"/tournaments/{t_id}/bracket") - updated_nodes = updated_res.json() + # 4. Verify Winner Advanced + updated_res = await client.get(f"/tournaments/{t_id}") + updated_matches = updated_res.json()["matches"] - target_node = next(n for n in updated_nodes if n["id"] == next_node_id) + target_match = next(m for m in updated_matches if m["id"] == next_match_id) - winner_id = match_data["p1_team"]["id"] - p1_in_target = target_node["p1_team"]["id"] if target_node["p1_team"] else None - p2_in_target = target_node["p2_team"]["id"] if target_node["p2_team"] else None + winner_id = active_match["p1_team"]["id"] + + # Check if winner is now in the next match + p1_in_target = target_match["p1_team"]["id"] if target_match["p1_team"] else None + p2_in_target = target_match["p2_team"]["id"] if target_match["p2_team"] else None assert winner_id in [p1_in_target, p2_in_target] @@ -67,9 +72,9 @@ async def test_clear_score(client: AsyncClient, auth_headers, valid_tournament_p t_id = res.json()["id"] # Get active match - bracket = (await client.get(f"/tournaments/{t_id}/bracket")).json() - active_node = next(n for n in bracket if n.get("match")) - match_id = active_node["match"]["id"] + detail = (await client.get(f"/tournaments/{t_id}")).json() + active_match = next(m for m in detail["matches"] if m["p1_team"] and m["p2_team"]) + match_id = active_match["id"] score_payload = {"id": match_id, "code": "1234", "sets": [{"p1": 25, "p2": 0}]} await client.post( diff --git a/backend/tests/test_structure.py b/backend/tests/test_structure.py index 7787ae0..2824f3d 100644 --- a/backend/tests/test_structure.py +++ b/backend/tests/test_structure.py @@ -20,14 +20,13 @@ async def test_manage_teams( f"/tournaments/{t_id}/teams", json=new_team, headers=auth_headers ) assert post_res.status_code == 200 - assert post_res.json()["name"] == "Team E" - # 2. Verify Bracket Regenerated (Call new endpoint) - bracket_res = await client.get(f"/tournaments/{t_id}/bracket") - nodes = bracket_res.json() + # 2. Verify Bracket Regenerated + detail_res = await client.get(f"/tournaments/{t_id}") + matches = detail_res.json()["matches"] - # With 5 teams -> size 8 bracket - assert len(nodes) > 4 + # With 5 teams, bracket size increases + assert len(matches) > 3 # 3. Bulk Update new_team_list = ["Team X", "Team Y"] @@ -35,8 +34,27 @@ async def test_manage_teams( f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers ) assert patch_res.status_code == 200 - data = patch_res.json() - assert len(data) == 2 + + updated_detail = await client.get(f"/tournaments/{t_id}") + final_matches = updated_detail.json()["matches"] + + # Fix: For Double Elim with 2 teams, we might get 2 matches (WB Final + Grand Final). + # Just ensure we have at least 1 match. + assert len(final_matches) >= 1 + + # Verify the teams are actually in the first match + first_match = next( + m + for m in final_matches + if m["name"] == "Winners Final" + or m["name"] == "Grand Final" + or m["name"].startswith("WB") + ) + p1_name = first_match["p1_team"]["name"] if first_match["p1_team"] else None + p2_name = first_match["p2_team"]["name"] if first_match["p2_team"] else None + + assert "Team X" in [p1_name, p2_name] + assert "Team Y" in [p1_name, p2_name] async def test_manage_courts( @@ -48,8 +66,8 @@ async def test_manage_courts( t_id = res.json()["id"] # Get initial courts - courts_res = await client.get(f"/tournaments/{t_id}/courts") - initial_courts = courts_res.json() + courts_res = await client.get(f"/tournaments/{t_id}") + initial_courts = courts_res.json()["courts"] assert len(initial_courts) == 2 # Delete diff --git a/backend/tests/test_tournaments.py b/backend/tests/test_tournaments.py index 2e1b373..2149e04 100644 --- a/backend/tests/test_tournaments.py +++ b/backend/tests/test_tournaments.py @@ -33,7 +33,7 @@ async def test_list_tournaments( assert data[0]["name"] == "Test Tournament" -async def test_get_tournament_detail_and_bracket( +async def test_delete_tournament( client: AsyncClient, auth_headers, valid_tournament_payload ): create_res = await client.post( @@ -41,25 +41,41 @@ async def test_get_tournament_detail_and_bracket( ) t_id = create_res.json()["id"] - # 1. Test Light Detail Endpoint + del_res = await client.delete(f"/tournaments/{t_id}", headers=auth_headers) + assert del_res.status_code == 200 + + get_res = await client.get(f"/tournaments/{t_id}") + assert get_res.status_code == 404 + + +async def test_get_tournament_detail( + client: AsyncClient, auth_headers, valid_tournament_payload +): + create_res = await client.post( + "/tournaments", json=valid_tournament_payload, headers=auth_headers + ) + t_id = create_res.json()["id"] + + # 1. Test Detail Endpoint response = await client.get(f"/tournaments/{t_id}") assert response.status_code == 200 data = response.json() # Should HAVE metadata assert len(data["teams"]) == 4 - # Should NOT have heavy bracket data + + # Should HAVE matches (since we merged nodes into matches and put them in Detail) + assert "matches" in data + assert len(data["matches"]) > 0 + + # Check structure of a match + first_match = data["matches"][0] + assert "id" in first_match + assert "winner_next_match_id" in first_match + + # Ensure no old "nodes" key assert "nodes" not in data - # 2. Test New Bracket Endpoint - bracket_res = await client.get(f"/tournaments/{t_id}/bracket") - assert bracket_res.status_code == 200 - nodes = bracket_res.json() - - assert len(nodes) > 0 - first_node = nodes[0] - assert "display_number" in first_node - async def test_update_settings( client: AsyncClient, auth_headers, valid_tournament_payload @@ -77,19 +93,6 @@ async def test_update_settings( assert response.status_code == 200 data = response.json() assert data["name"] == "Updated Name" - assert data["code"] == "9999" - - -async def test_delete_tournament( - client: AsyncClient, auth_headers, valid_tournament_payload -): - create_res = await client.post( - "/tournaments", json=valid_tournament_payload, headers=auth_headers - ) - t_id = create_res.json()["id"] - - del_res = await client.delete(f"/tournaments/{t_id}", headers=auth_headers) - assert del_res.status_code == 200 - - get_res = await client.get(f"/tournaments/{t_id}") - assert get_res.status_code == 404 + # The response model is TournamentUpdateResponse which inherits TournamentDetail + # So it should also have matches + assert "matches" in data diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 83c29d6..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,46 +0,0 @@ -/* frontend/src/App.css */ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} - -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} - -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2fb7350..f44cfbd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,23 +1,28 @@ // frontend/src/App.jsx -import React, { useState, useEffect } from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import Layout from './components/Layout/Layout'; import Dashboard from './pages/Dashboard'; -import TournamentPage from './pages/TournamentPage'; import Login from './pages/Login'; +import Tournament from './pages/Tournament'; export default function App() { const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark'); useEffect(() => { const root = window.document.documentElement; + const body = window.document.body; if (darkMode) { root.classList.add('dark'); localStorage.setItem('theme', 'dark'); + root.style.backgroundColor = '#09090b'; // zinc-950 + body.style.backgroundColor = '#09090b'; } else { root.classList.remove('dark'); localStorage.setItem('theme', 'light'); + root.style.backgroundColor = '#fafafa'; // zinc-50 + body.style.backgroundColor = '#fafafa'; } }, [darkMode]); @@ -29,7 +34,7 @@ export default function App() { {/* Main App Layout */} }> } /> - } /> + } /> } /> diff --git a/frontend/src/components/Bracket/BracketNode.jsx b/frontend/src/components/Bracket/BracketNode.jsx deleted file mode 100644 index f3e7926..0000000 --- a/frontend/src/components/Bracket/BracketNode.jsx +++ /dev/null @@ -1,99 +0,0 @@ -// frontend/src/components/Bracket/BracketNode.jsx - -import React, { useState } from 'react'; -import clsx from 'clsx'; -import ScoreModal from '../Tournament/ScoreModal'; - -export default function BracketNode({ node, tournamentId, isAdmin, isFinal }) { - const [showScore, setShowScore] = useState(false); - const match = node.match; - - // Display Logic - const p1 = node.p1_team; - const p2 = node.p2_team; - const isBye = !p2 && node.round_number === 1; // Simplistic BYE detection - - // Don't show score modal if it's not a real match - const canInteract = match && !isBye; - - return ( - <> -
canInteract && setShowScore(true)} - > - {/* Anchors for Lines */} -
-
- - {/* Header */} -
-
- #{node.display_number} - {node.match?.court && ( - - {node.match.court.name} - - )} -
-
- {new Date(node.match?.start_time || node.planned_start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
- - {/* Teams */} -
- {/* P1 Row */} -
- - {p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')} - - {match && {getWinCount(match, p1?.id)}} -
- - {/* P2 Row */} -
- - {p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')} - - {match && {getWinCount(match, p2?.id)}} -
-
-
- - {match && ( - setShowScore(false)} - match={match} - tournamentId={tournamentId} - isAdmin={isAdmin} - p1Name={p1?.name || 'TBD'} - p2Name={p2?.name || 'TBD'} - /> - )} - - ); -} - -function getWinCount(match, teamId) { - if (!match.sets || !teamId) return 0; - let wins = 0; - match.sets.forEach(s => { - if (teamId === match.p1_team_id && s.p1 > s.p2) wins++; - if (teamId === match.p2_team_id && s.p2 > s.p1) wins++; - }); - return wins; -} \ No newline at end of file diff --git a/frontend/src/components/Bracket/BracketView.jsx b/frontend/src/components/Bracket/BracketView.jsx index 0f35cc6..9095633 100644 --- a/frontend/src/components/Bracket/BracketView.jsx +++ b/frontend/src/components/Bracket/BracketView.jsx @@ -1,142 +1,139 @@ // frontend/src/components/Bracket/BracketView.jsx -import { Check, Trophy } from 'lucide-react'; -import { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { Check } from 'lucide-react'; import { stringToColor } from '../../utils/helpers'; -// --- EXACT COPY OF YOUR OLD MATCHCARD (Adapted data props) --- -const MatchCard = ({ node, onClick }) => { - // GHOST NODE LOGIC: If no display number, it's a structural/hidden node - if (!node.display_number) return
; +const MatchCard = ({ match, onClick }) => { + const isFinished = match.status === "Finished"; + const badgeColor = match.time ? stringToColor(match.court) : null; - const match = node.match; - const p1 = node.p1_team; - const p2 = node.p2_team; + let borderClass = 'border-zinc-300 dark:border-zinc-700'; + if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10'; - // Old logic: "isPending" means we don't have two players yet - const isPending = !p1 || !p2; - const badgeColor = match?.court ? stringToColor(match.court.name) : null; - const timeDisplay = match?.start_time || node.planned_start_time; + const canInteract = match.hasTeams; - // Calculate wins for display - const getWins = (teamId) => { - if (!match?.sets) return 0; - return match.sets.reduce((acc, s) => acc + (s.p1 > s.p2 && match.p1_team_id === teamId ? 1 : (s.p2 > s.p1 && match.p2_team_id === teamId ? 1 : 0)), 0); - } - - const p1Wins = p1 ? getWins(p1.id) : 0; - const p2Wins = p2 ? getWins(p2.id) : 0; - const winnerId = match?.winner_team_id; + const cursorClass = canInteract + ? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5' + : 'cursor-default opacity-100'; return (
!isPending && onClick(node)} - className={`w-64 bg-white dark:bg-zinc-900 rounded-xl border-2 ${winnerId ? 'border-orange-500 ring-4 ring-orange-500/10' : 'border-zinc-300 dark:border-zinc-800'} shadow-sm ${!isPending ? 'cursor-pointer hover:-translate-y-1 transition duration-200 group' : 'opacity-80 cursor-default'} overflow-hidden transition-all`} + id={`match-${match.id}`} + onClick={() => canInteract && onClick(match)} + className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`} > - {/* Header */} -
+
- # {node.display_number} - {match?.court && {match.court.name}} + # {match.number} + {match.time && {match.court}}
- {winnerId ? : {timeDisplay ? new Date(timeDisplay).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'TBD'}} + {isFinished ? : {match.time || 'TBD'}}
- - {/* Content */} -
- {/* P1 */} -
- {p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')} - {p1Wins} -
- {/* P2 */} -
- {p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')} - {p2Wins} -
+
+ {[{ n: match.p1, s: match.p1_sets, win: match.winner === match.p1, real: match.p1_is_real }, + { n: match.p2, s: match.p2_sets, win: match.winner === match.p2, real: match.p2_is_real }].map((p, i) => ( +
+ {p.n} + {p.s} +
+ ))}
); }; -export default function BracketView({ nodes, onMatchClick }) { +export default function BracketView({ matches, onMatchClick }) { const containerRef = useRef(null); - const svgRef = useRef(null); + const [lines, setLines] = useState([]); useEffect(() => { - // --- EXACT COPY OF YOUR SVG LOGIC --- - if (!containerRef.current || !svgRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const svg = svgRef.current; - while (svg.firstChild) svg.removeChild(svg.firstChild); + const draw = () => { + if (!containerRef.current) return; + const container = containerRef.current.getBoundingClientRect(); + const newLines = []; + matches.forEach(m => { + if (!m.winner_next_match_id) return; + const sEl = document.getElementById(`match-${m.id}`); + const eEl = document.getElementById(`match-${m.winner_next_match_id}`); + if (sEl && eEl) { + const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect(); + const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top; + const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top; + const c1 = sx + (ex - sx) / 2; + newLines.push(); + } + }); + setLines(newLines); + }; + const t = setTimeout(draw, 100); + window.addEventListener('resize', draw); + return () => { clearTimeout(t); window.removeEventListener('resize', draw); }; + }, [matches]); - nodes.forEach(node => { - // Logic: Find DOM elements by ID - const sEl = document.getElementById(`node-${node.id}`); - const eEl = document.getElementById(`node-${node.winner_next_node_id}`); - - // Only draw if both exist and are visible - if (sEl && eEl && sEl.offsetParent !== null && eEl.offsetParent !== null) { - const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect(); - // Calculate connection points - const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top; - const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top; - - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const c1 = sx + (ex - sx) / 2; // Control point X - path.setAttribute("d", `M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`); - path.setAttribute("class", "stroke-zinc-300 dark:stroke-zinc-800 fill-none stroke-[2px] opacity-40"); - svg.appendChild(path); - } - }); - }, [nodes]); // Re-run when nodes change - - const renderTree = (list, align = 'justify-center') => { + const renderRound = (list) => { const rounds = {}; - list.forEach(n => { if (!rounds[n.round_number]) rounds[n.round_number] = []; rounds[n.round_number].push(n); }); + list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m)); - return Object.keys(rounds) - .sort((a, b) => a - b) - .filter(r => rounds[r].some(n => n.display_number)) // Filter empty rounds - .map(r => ( -
- {rounds[r].sort((a, b) => a.display_number - b.display_number).map(n => ( - - ))} + const roundKeys = Object.keys(rounds).sort((a, b) => Number(a) - Number(b)); + let prevRoundMap = new Map(); + + return roundKeys.map((r, rIdx) => { + let matchesInRound = rounds[r]; + if (rIdx === 0) { + matchesInRound.sort((a, b) => a.number - b.number); + } else { + matchesInRound.sort((a, b) => { + const getSourceAvg = (match) => { + const sources = list.filter(x => x.next_win === match.id); + if (sources.length === 0) return 9999; + const indices = sources.map(s => prevRoundMap.get(s.id)).filter(i => i !== undefined); + if (indices.length === 0) return 9999; + return indices.reduce((sum, val) => sum + val, 0) / indices.length; + }; + return getSourceAvg(a) - getSourceAvg(b); + }); + } + matchesInRound.forEach((m, idx) => prevRoundMap.set(m.id, idx)); + + return ( +
+ {matchesInRound.map(m => )}
- )); + ); + }); }; - const wb = nodes.filter(n => n.bracket_type === 'Winners'); - const lb = nodes.filter(n => n.bracket_type === 'Losers'); - const finals = nodes.filter(n => n.bracket_type === 'Finals'); + const wb = matches.filter(m => m.bracket === 'Winner'); + const lb = matches.filter(m => m.bracket === 'Loser'); + const finals = matches.filter(m => m.bracket === 'Finals'); return ( -
-
- +
+ {/* UPDATED: items-center ensures Finals (right col) are centered vertically + relative to the Winners/Losers block (left col). + */} +
+ + {lines} +
-
-
Winners Bracket
- {renderTree(wb)} +
+
Winners Bracket
+
{renderRound(wb)}
- - {lb.length > 0 && ( -
-
Losers Bracket
-
{renderTree(lb, 'justify-start')}
+ {matches.some(m => m.bracket === 'Loser') && ( +
+
Losers Bracket
+
{renderRound(lb)}
)}
- - {finals.length > 0 && ( -
-
- Championship -
- {finals.map(n => )} + {matches.some(m => m.bracket === 'Finals') && ( +
+
Championship
+ {finals.map(m => )}
)}
diff --git a/frontend/src/components/Forms/TournamentForm.jsx b/frontend/src/components/Forms/TournamentForm.jsx index a6cbb7e..8e95f8f 100644 --- a/frontend/src/components/Forms/TournamentForm.jsx +++ b/frontend/src/components/Forms/TournamentForm.jsx @@ -1,83 +1,194 @@ // frontend/src/components/Forms/TournamentForm.jsx -import { useState } from 'react'; +import React, { useState } from 'react'; import api from '../../services/api'; -export default function TournamentForm({ initialData, onSuccess }) { +export default function TournamentForm({ tournament, onSuccess, onDelete }) { const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); + setError(null); + const formData = new FormData(e.target); - const data = Object.fromEntries(formData.entries()); + + // Extract raw values to transform + const rawTeams = formData.get('teams'); + const rawCourts = formData.get('courts'); + const date = formData.get('date'); + const startTime = formData.get('start_time'); + const typeRaw = formData.get('type'); + const duration = formData.get('duration'); + const name = formData.get('name'); + const code = formData.get('code'); + + const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0); + const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0); + + if (teams.length < 2) { + setError("At least 2 teams required."); + setIsSubmitting(false); + return; + } + + const timestamp = new Date(`${date}T${startTime}`).toISOString(); - // Format Arrays const payload = { - ...data, - duration: parseInt(data.duration), - timestamp: `${data.date}T${data.time}:00`, - courts: data.courts.split(',').map(s => s.trim()).filter(Boolean), - teams: data.teams.split('\n').map(s => s.trim()).filter(Boolean) + name, + code, + type: typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1), + timestamp, + duration: parseInt(duration), + teams, + courts }; try { - if (initialData) await api.patch(`/tournaments/${initialData.id}`, payload); + if (tournament) await api.patch(`/tournaments/${tournament.id}`, payload); else await api.post('/tournaments', payload); onSuccess(); } catch (err) { - setError(typeof err.detail === 'string' ? err.detail : "Error saving"); + console.error(err); + if (Array.isArray(err.detail)) { + setError(err.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ')); + } else { + setError(typeof err.detail === 'string' ? err.detail : "Error saving tournament"); + } } finally { setIsSubmitting(false); } }; - const handleDelete = async () => { - if (!window.confirm("Purge this tournament and all its history?")) return; - try { - await api.delete(`/tournaments/${initialData.id}`); - window.location.href = '/'; - } catch (err) { alert("Error deleting"); } - }; + // Calculate default date/time for form + const defaultDate = tournament?.timestamp + ? new Date(tournament.timestamp).toISOString().split('T')[0] + : new Date().toISOString().split('T')[0]; - // Defaults - const defaultDate = initialData?.timestamp ? new Date(initialData.timestamp).toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; - const defaultTime = initialData?.timestamp ? new Date(initialData.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : "09:00"; - const defaultTeams = initialData?.teams?.map(t => t.name).join('\n'); - const defaultCourts = initialData?.courts?.map(c => c.name).join(', '); + const defaultTime = tournament?.timestamp + ? new Date(tournament.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + : "09:00"; return (
- {error &&
{error}
} -
- - -
-
-
- - + {error && ( +
+ {error}
+ )} + +
- - + + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + c.name || c).join(', ')} + required + className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" + /> +
+ +
+ +