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).
- */}
)}
- {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 (
-