From 7ce44979b9a5b6c45e1c9b26b19cb63bf992886d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Sat, 21 Feb 2026 18:29:22 +0100 Subject: [PATCH] Complete main functionallity of backend and frontend. --- backend/app/core/config.py | 3 +- backend/app/logic.py | 56 ++++---- backend/app/models.py | 12 ++ backend/app/routes/tournaments/__init__.py | 2 +- backend/app/routes/tournaments/settings.py | 20 +++ backend/app/routes/tournaments/tournaments.py | 4 +- backend/app/schemas.py | 7 +- .../src/components/Bracket/BracketView.jsx | 69 +++------- frontend/src/components/Bracket/MatchCard.jsx | 66 ++++++++++ frontend/src/components/Bracket/Podium.jsx | 91 +++++++++++++ .../src/components/Forms/TournamentForm.jsx | 123 +++++++++++++----- frontend/src/components/Layout/Layout.jsx | 25 +--- frontend/src/components/Layout/Navbar.jsx | 14 +- .../src/components/Schedule/ScheduleView.jsx | 26 +++- frontend/src/pages/Dashboard.jsx | 3 +- frontend/src/pages/Tournament.jsx | 30 +++-- 16 files changed, 383 insertions(+), 168 deletions(-) create mode 100644 backend/app/routes/tournaments/settings.py create mode 100644 frontend/src/components/Bracket/MatchCard.jsx create mode 100644 frontend/src/components/Bracket/Podium.jsx diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0314ed3..2ad2f07 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,12 +1,11 @@ # backend/app/core/config.py import os -import secrets from pwdlib import PasswordHash DB_PATH = os.getenv("DB_PATH", "./tournaments.db") # Security Config -SECRET_KEY = os.getenv("SECRET_KEY", secrets.token_hex(32)) +SECRET_KEY = os.getenv("SECRET_KEY", "SUPER-SECRET-TOKEN") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours diff --git a/backend/app/logic.py b/backend/app/logic.py index 2444e64..c7aede2 100644 --- a/backend/app/logic.py +++ b/backend/app/logic.py @@ -8,7 +8,6 @@ from sqlalchemy.orm import Session from . import models from .constants import MatchStatus, TournamentTypes from .core.brackets import BracketGenerator -from .core import structures def generate_bracket(db: Session, t: models.Tournament): @@ -21,18 +20,26 @@ def generate_bracket(db: Session, t: models.Tournament): abstract_matches = gen.generate(len(teams), double_elimination=is_double) id_map = {m.id: str(uuid4()) for m in abstract_matches} - def resolve_target(match_node: structures.Match | None): - curr = match_node - while curr and curr.is_bye: + def resolve_target(match_node, is_winner_path): + if is_winner_path: + curr = match_node.next_win + slot = getattr(match_node, "next_win_slot", None) + else: + curr = match_node.next_loss + slot = getattr(match_node, "next_loss_slot", None) + + while curr and getattr(curr, "is_bye", False): + slot = getattr(curr, "next_win_slot", None) curr = curr.next_win - return curr + + return curr, slot db_matches = [] friendly_counter = 1 for m in abstract_matches: - real_win = resolve_target(m.next_win) - real_loss = resolve_target(m.next_loss) + real_win, win_slot = resolve_target(m, True) + real_loss, loss_slot = resolve_target(m, False) initial_status = MatchStatus.SCHEDULED p1_id = None @@ -59,7 +66,9 @@ def generate_bracket(db: Session, t: models.Tournament): p1_team_id=p1_id, p2_team_id=p2_id, winner_next_match_id=id_map[real_win.id] if real_win else None, + winner_next_match_slot=win_slot if real_win else None, loser_next_match_id=id_map[real_loss.id] if real_loss else None, + loser_next_match_slot=loss_slot if real_loss else None, ) db_matches.append(new_match) friendly_counter += 1 @@ -172,12 +181,14 @@ def advance_winner(db: Session, match: models.Match, winner_id: int): loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id - def update_next_match(next_match: models.Match, team_id: int): - if not next_match: + def update_next_match( + next_match: models.Match, team_id: int, target_slot: int | None + ): + if not next_match or target_slot is None: return - if not next_match.p1_team_id: + if target_slot == 0: next_match.p1_team_id = team_id - elif not next_match.p2_team_id: + elif target_slot == 1: next_match.p2_team_id = team_id if ( @@ -189,9 +200,9 @@ def advance_winner(db: Session, match: models.Match, winner_id: int): db.add(next_match) - update_next_match(match.winner_next_match, winner_id) + update_next_match(match.winner_next_match, winner_id, match.winner_next_match_slot) if match.loser_next_match and loser_id: - update_next_match(match.loser_next_match, loser_id) + update_next_match(match.loser_next_match, loser_id, match.loser_next_match_slot) db.commit() @@ -203,8 +214,10 @@ def undo_advancement(db: Session, match: models.Match): 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: models.Match, team_id: int): - if not next_match: + def clear_from_next( + next_match: models.Match, team_id: int, target_slot: int | None + ): + if not next_match or target_slot is None: return if next_match.winner_team_id: @@ -212,17 +225,16 @@ def undo_advancement(db: Session, match: models.Match): next_match.winner_team_id = None next_match.sets = [] - if next_match.p1_team_id == team_id: + if target_slot == 0: next_match.p1_team_id = None - elif next_match.p2_team_id == team_id: + elif target_slot == 1: next_match.p2_team_id = None - if next_match.p1_team_id and next_match.p2_team_id: - next_match.status = MatchStatus.PENDING - else: + + if next_match.status == MatchStatus.PENDING: next_match.status = MatchStatus.SCHEDULED db.add(next_match) - clear_from_next(match.winner_next_match, winner_id) + clear_from_next(match.winner_next_match, winner_id, match.winner_next_match_slot) if match.loser_next_match and loser_id: - clear_from_next(match.loser_next_match, loser_id) + clear_from_next(match.loser_next_match, loser_id, match.loser_next_match_slot) diff --git a/backend/app/models.py b/backend/app/models.py index 7538c8d..8d99942 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -96,6 +96,18 @@ class Match(Base): ForeignKey("matches.id"), nullable=True ) + winner_next_match_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("matches.id"), nullable=True + ) + winner_next_match_slot: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True + ) + + loser_next_match_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("matches.id"), nullable=True + ) + loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + tournament: Mapped["Tournament"] = relationship(back_populates="matches") court: Mapped[Optional["Court"]] = relationship() p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id]) diff --git a/backend/app/routes/tournaments/__init__.py b/backend/app/routes/tournaments/__init__.py index e99c883..e344d86 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 +from . import tournaments, settings, teams, courts, matches, report diff --git a/backend/app/routes/tournaments/settings.py b/backend/app/routes/tournaments/settings.py new file mode 100644 index 0000000..184c2fc --- /dev/null +++ b/backend/app/routes/tournaments/settings.py @@ -0,0 +1,20 @@ +# backend/app/routes/tournaments/settings.py + + +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session + +from ... import crud, schemas +from ...database import get_db +from ...core.auth import get_current_user +from . import router + + +@router.get("/{id}/settings", response_model=schemas.TournamentSettingsResponse) +def get_tournament( + id: str, db: Session = Depends(get_db), user: str = Depends(get_current_user) +): + t = crud.get_tournament(db, id) + if not t: + raise HTTPException(404, "Tournament not found") + return t diff --git a/backend/app/routes/tournaments/tournaments.py b/backend/app/routes/tournaments/tournaments.py index fc55f9f..7c54274 100644 --- a/backend/app/routes/tournaments/tournaments.py +++ b/backend/app/routes/tournaments/tournaments.py @@ -10,7 +10,7 @@ from ...core.auth import get_current_user from . import router -@router.post("", response_model=schemas.TournamentUpdateResponse) +@router.post("", response_model=schemas.TournamentSettingsResponse) async def create_tournament( data: schemas.TournamentCreate, db: Session = Depends(get_db), @@ -34,7 +34,7 @@ def get_tournament(id: str, db: Session = Depends(get_db)): return t -@router.patch("/{id}", response_model=schemas.TournamentUpdateResponse) +@router.patch("/{id}", response_model=schemas.TournamentSettingsResponse) async def update_settings( id: str, data: schemas.TournamentUpdate, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a4d1de9..aef2cb4 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -51,7 +51,9 @@ class MatchOut(BaseModel): winner_team_id: int | None = None winner_next_match_id: str | None = None + winner_next_match_slot: int | None = None loser_next_match_id: str | None = None + loser_next_match_slot: int | None = None sets: list[SetScore] = [] @@ -80,6 +82,7 @@ class TournamentOut(BaseModel): id: str name: str timestamp: datetime + duration: int type: TournamentTypes team_count: int court_count: int @@ -98,8 +101,10 @@ class TournamentDetail(BaseModel): model_config = ConfigDict(from_attributes=True) -class TournamentUpdateResponse(TournamentDetail): +class TournamentSettingsResponse(TournamentOut): code: str + teams: list[TeamSchema] + courts: list[CourtSchema] class Token(BaseModel): diff --git a/frontend/src/components/Bracket/BracketView.jsx b/frontend/src/components/Bracket/BracketView.jsx index 9095633..b0de91a 100644 --- a/frontend/src/components/Bracket/BracketView.jsx +++ b/frontend/src/components/Bracket/BracketView.jsx @@ -1,47 +1,9 @@ // frontend/src/components/Bracket/BracketView.jsx -import React, { useEffect, useRef, useState } from 'react'; -import { Check } from 'lucide-react'; -import { stringToColor } from '../../utils/helpers'; +import { useEffect, useRef, useState } from 'react'; +import MatchCard from "./MatchCard"; +import Podium from './Podium'; -const MatchCard = ({ match, onClick }) => { - const isFinished = match.status === "Finished"; - const badgeColor = match.time ? stringToColor(match.court) : null; - - let borderClass = 'border-zinc-300 dark:border-zinc-700'; - if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10'; - - const canInteract = match.hasTeams; - - const cursorClass = canInteract - ? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5' - : 'cursor-default opacity-100'; - - return ( -
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}`} - > -
-
- # {match.number} - {match.time && {match.court}} -
- {isFinished ? : {match.time || 'TBD'}} -
-
- {[{ 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({ matches, onMatchClick }) { const containerRef = useRef(null); @@ -85,7 +47,7 @@ export default function BracketView({ matches, onMatchClick }) { } else { matchesInRound.sort((a, b) => { const getSourceAvg = (match) => { - const sources = list.filter(x => x.next_win === match.id); + const sources = list.filter(x => x.winner_next_match_id === 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; @@ -110,9 +72,6 @@ export default function BracketView({ matches, onMatchClick }) { return (
- {/* UPDATED: items-center ensures Finals (right col) are centered vertically - relative to the Winners/Losers block (left col). - */}
{lines} @@ -130,12 +89,20 @@ export default function BracketView({ matches, onMatchClick }) {
)}
- {matches.some(m => m.bracket === 'Finals') && ( -
-
Championship
- {finals.map(m => )} -
- )} + +
+ {matches.some(m => m.bracket === 'Finals') && ( + <> +
Championship
+ {finals.map(m => )} + + )} + +
+
+ +
+ ); diff --git a/frontend/src/components/Bracket/MatchCard.jsx b/frontend/src/components/Bracket/MatchCard.jsx new file mode 100644 index 0000000..48b965f --- /dev/null +++ b/frontend/src/components/Bracket/MatchCard.jsx @@ -0,0 +1,66 @@ +// frontend/src/components/Bracket/MatchCard.jsx + +import { Check } from 'lucide-react'; +import { stringToColor } from '../../utils/helpers'; + +export default function MatchCard({ match, onClick }) { + const isFinished = match.status === "Finished"; + const badgeColor = match.time ? stringToColor(match.court) : null; + + let borderClass = 'border-zinc-300 dark:border-zinc-700'; + if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10'; + + const canInteract = match.hasTeams; + + const cursorClass = canInteract + ? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5' + : 'cursor-default opacity-100'; + + return ( +
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}`} + > +
+
+ # {match.number} + {match.time && ( + + {match.court} + + )} +
+ {isFinished ? ( + + ) : ( + {match.time || 'TBD'} + )} +
+ +
+ {[ + { + n: match.p1, + s: match.p1_sets, + win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, + real: match.p1_team_id + }, + { + n: match.p2, + s: match.p2_sets, + win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, + real: match.p2_team_id + } + ].map((p, i) => ( +
+ {p.n} + + {p.s} + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Bracket/Podium.jsx b/frontend/src/components/Bracket/Podium.jsx new file mode 100644 index 0000000..5042576 --- /dev/null +++ b/frontend/src/components/Bracket/Podium.jsx @@ -0,0 +1,91 @@ +// frontend/src/components/Bracket/Podium.jsx + +import React from 'react'; +import { Trophy } from 'lucide-react'; + +export default function Podium({ matches }) { + const isDoubleElim = matches.some(m => m.bracket === 'Loser'); + const wb = matches.filter(m => m.bracket === 'Winner').sort((a, b) => a.round - b.round); + const lb = matches.filter(m => m.bracket === 'Loser').sort((a, b) => a.round - b.round); + const finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round); + + let gfMatch = null; + let resetMatch = null; + let tpMatch = null; + + if (isDoubleElim) { + tpMatch = lb[lb.length - 1]; + gfMatch = finals[0]; + resetMatch = finals[1]; + } else { + gfMatch = wb[wb.length - 1]; + tpMatch = finals[0]; + } + + const podium = [ + { rank: 1, label: "1st", team: "TBD", isReal: false, color: "bg-yellow-400 text-yellow-900 dark:text-yellow-950 shadow-yellow-400/50" }, + { rank: 2, label: "2nd", team: "TBD", isReal: false, color: "bg-zinc-300 dark:bg-zinc-400 text-zinc-800 dark:text-zinc-900 shadow-zinc-400/50" }, + { rank: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false } + ]; + + if (tpMatch) { + if (tpMatch.isFinished && tpMatch.winnerName) { + if (isDoubleElim) { + podium[2].team = tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1; + } else { + podium[2].team = tpMatch.winnerName; + } + podium[2].isReal = true; + } else { + podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`; + } + } else { + podium[2].hidden = true; + } + + if (resetMatch && resetMatch.isFinished && resetMatch.winnerName) { + podium[0].team = resetMatch.winnerName; + podium[0].isReal = true; + podium[1].team = resetMatch.winnerName === resetMatch.p1 ? resetMatch.p2 : resetMatch.p1; + podium[1].isReal = true; + } else if (gfMatch) { + if (gfMatch.isFinished && gfMatch.winnerName) { + const isResetForced = resetMatch && resetMatch.hasTeams; + + if (!isResetForced) { + podium[0].team = gfMatch.winnerName; + podium[0].isReal = true; + podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1; + podium[1].isReal = true; + } else { + podium[0].team = `Winner of #${resetMatch.number}`; + podium[1].team = `Loser of #${resetMatch.number}`; + } + } else { + podium[0].team = `Winner of #${gfMatch.number}`; + podium[1].team = `Loser of #${gfMatch.number}`; + } + } + + return ( +
+
+

+ Final Standings +

+
+
+ {podium.filter(p => !p.hidden).map(p => ( +
+
+ {p.rank} +
+
+ {p.team} +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Forms/TournamentForm.jsx b/frontend/src/components/Forms/TournamentForm.jsx index 8e95f8f..9872eb1 100644 --- a/frontend/src/components/Forms/TournamentForm.jsx +++ b/frontend/src/components/Forms/TournamentForm.jsx @@ -1,12 +1,37 @@ // frontend/src/components/Forms/TournamentForm.jsx -import React, { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; import api from '../../services/api'; -export default function TournamentForm({ tournament, onSuccess, onDelete }) { +export default function TournamentForm({ tournamentId, onSuccess, onDelete }) { + const [initialData, setInitialData] = useState(null); + const [isLoading, setIsLoading] = useState(!!tournamentId); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + useEffect(() => { + if (!tournamentId) { + setInitialData(null); + setIsLoading(false); + return; + } + + const fetchSettings = async () => { + try { + const data = await api.get(`/tournaments/${tournamentId}/settings`); + setInitialData(data); + } catch (err) { + console.error(err); + setError("Failed to load tournament settings."); + } finally { + setIsLoading(false); + } + }; + + fetchSettings(); + }, [tournamentId]); + const handleSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); @@ -14,7 +39,6 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) { const formData = new FormData(e.target); - // Extract raw values to transform const rawTeams = formData.get('teams'); const rawCourts = formData.get('courts'); const date = formData.get('date'); @@ -33,21 +57,39 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) { return; } - const timestamp = new Date(`${date}T${startTime}`).toISOString(); - - const payload = { - name, - code, - type: typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1), - timestamp, - duration: parseInt(duration), - teams, - courts - }; + const timestamp = `${date}T${startTime}:00`; + const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1); + const parsedDuration = parseInt(duration); try { - if (tournament) await api.patch(`/tournaments/${tournament.id}`, payload); - else await api.post('/tournaments', payload); + if (tournamentId) { + // UPDATE: Dispatch the 3 separated PATCH endpoints concurrently + const basePayload = { + name, + code, + type: formattedType, + timestamp, + duration: parsedDuration + }; + + await Promise.all([ + api.patch(`/tournaments/${tournamentId}`, basePayload), + api.patch(`/tournaments/${tournamentId}/teams`, teams), + api.patch(`/tournaments/${tournamentId}/courts`, courts) + ]); + } else { + // CREATE: Send the full monolithic payload to POST /tournaments + const fullPayload = { + name, + code, + type: formattedType, + timestamp, + duration: parsedDuration, + teams, + courts + }; + await api.post('/tournaments', fullPayload); + } onSuccess(); } catch (err) { console.error(err); @@ -61,17 +103,26 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) { } }; - // Calculate default date/time for form - const defaultDate = tournament?.timestamp - ? new Date(tournament.timestamp).toISOString().split('T')[0] - : new Date().toISOString().split('T')[0]; + if (isLoading) { + return ( +
+ +
+ ); + } - const defaultTime = tournament?.timestamp - ? new Date(tournament.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) - : "09:00"; + let defaultDate = new Date().toISOString().split('T')[0]; + let defaultTime = "09:00"; + + if (initialData?.timestamp) { + const [d, t] = initialData.timestamp.split('T'); + defaultDate = d; + defaultTime = t ? t.substring(0, 5) : "09:00"; + } return ( -
+ // Add a key mapped to the ID so React completely rebuilds the form when data loads + {error && (
{error} @@ -83,7 +134,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) { Code Type @@ -152,7 +203,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) { c.name || c).join(', ')} + defaultValue={initialData?.courts?.map(c => 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" /> @@ -163,7 +214,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {