Added seperate courts

This commit is contained in:
2026-05-24 15:53:54 +02:00 Verified
parent b2d341642e
commit 33e623b337
18 changed files with 1787 additions and 1282 deletions
+28 -29
View File
@@ -28,7 +28,7 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
type=data.type, type=data.type,
) )
new_t.teams = [models.Team(name=n) for n in data.teams] new_t.teams = [models.Team(name=n) for n in data.teams]
new_t.courts = [models.Court(name=n) for n in data.courts] new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
db.add(new_t) db.add(new_t)
db.commit() db.commit()
@@ -141,53 +141,52 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
return True return True
def get_courts(db: Session, tournament_id: str): def get_courts(db: Session):
return ( return db.query(models.Court).all()
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
)
def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreate): def create_court(db: Session, court_data: schemas.CourtCreate):
t = get_tournament(db, tournament_id) new_court = models.Court(name=court_data.name)
if not t:
return None
new_court = models.Court(name=court_data.name, tournament_id=tournament_id)
db.add(new_court) db.add(new_court)
db.flush()
logic.update_schedule_times(db, t)
db.commit() db.commit()
db.refresh(new_court) db.refresh(new_court)
return new_court return new_court
def delete_court(db: Session, tournament_id: str, court_id: int): def delete_court(db: Session, court_id: int):
t = get_tournament(db, tournament_id)
if not t:
return None
court = db.get(models.Court, court_id) court = db.get(models.Court, court_id)
if not court or court.tournament_id != tournament_id: if not court:
return None return False
affected_tournaments = list(court.tournaments)
matches = db.query(models.Match).filter(models.Match.court_id == court_id).all()
for m in matches:
if m.status == models.MatchStatus.FINISHED:
m.court_id = None
else:
m.court_id = None
m.start_time = None
db.delete(court) db.delete(court)
db.flush()
logic.update_schedule_times(db, t)
db.commit() db.commit()
processed_days = set()
for t in affected_tournaments:
day = t.timestamp.date() if t.timestamp else None
if day and day not in processed_days:
logic.update_schedule_times(db, t)
processed_days.add(day)
return True return True
def update_tournament_courts( def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
db: Session, tournament_id: str, new_court_names: list[str]
):
t = get_tournament(db, tournament_id) t = get_tournament(db, tournament_id)
if not t: if not t:
return None return None
t.courts = [models.Court(name=c, tournament_id=t.id) for c in new_court_names] t.courts = db.query(models.Court).filter(models.Court.id.in_(new_court_ids)).all()
db.flush() db.flush()
logic.update_schedule_times(db, t) logic.update_schedule_times(db, t)
+130 -55
View File
@@ -1,6 +1,6 @@
# backend/app/logic.py # backend/app/logic.py
from collections import defaultdict from collections import defaultdict
from datetime import timedelta from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -79,59 +79,108 @@ def generate_bracket(db: Session, t: models.Tournament):
def update_schedule_times(db: Session, t: models.Tournament): def update_schedule_times(db: Session, t: models.Tournament):
if not t.courts or not t.matches: if not t.timestamp:
return return
matches = t.matches # 1. Grab the entire day of tournaments
match_map = {m.id: m for m in matches} target_date = t.timestamp.date()
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = start_of_day + timedelta(days=1)
tournaments = (
db.query(models.Tournament)
.filter(
models.Tournament.timestamp >= start_of_day,
models.Tournament.timestamp < end_of_day,
)
.all()
)
if not tournaments:
return
all_matches: list[models.Match] = []
match_map: dict[str, models.Match] = {}
prereqs = defaultdict(list) prereqs = defaultdict(list)
for m in matches:
if m.winner_next_match_id:
prereqs[m.winner_next_match_id].append(m.id)
if m.loser_next_match_id:
prereqs[m.loser_next_match_id].append(m.id)
depth_cache = {} depth_cache = {}
# 2. Gather all matches and prereqs globally
for t_item in tournaments:
for m in t_item.matches:
all_matches.append(m)
match_map[m.id] = m
if m.winner_next_match_id:
prereqs[m.winner_next_match_id].append(m.id)
if m.loser_next_match_id:
prereqs[m.loser_next_match_id].append(m.id)
# 3. Calculate depths
def get_depth(m_id): def get_depth(m_id):
if m_id not in match_map: if m_id not in match_map:
return 0 return 0
if m_id in depth_cache: if m_id in depth_cache:
return depth_cache[m_id] return depth_cache[m_id]
m = match_map[m_id] m = match_map[m_id]
child_depths = [0] child_depths = [0]
if m.winner_next_match_id: if m.winner_next_match_id:
child_depths.append(get_depth(m.winner_next_match_id)) child_depths.append(get_depth(m.winner_next_match_id))
if m.loser_next_match_id: if m.loser_next_match_id:
child_depths.append(get_depth(m.loser_next_match_id)) child_depths.append(get_depth(m.loser_next_match_id))
depth = 1 + max(child_depths) depth = 1 + max(child_depths)
depth_cache[m_id] = depth depth_cache[m_id] = depth
return depth return depth
for m in matches: for m in all_matches:
get_depth(m.id) get_depth(m.id)
tournament_start = ( # 4. Initialize timers & lock finished matches
t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp all_courts: set[models.Court] = set()
) court_sharing_count = defaultdict(
court_timers = {c.id: tournament_start for c in t.courts} int
) # NEW: Track how highly contested each court is
for t_item in tournaments:
for c in t_item.courts:
all_courts.add(c)
court_sharing_count[c.id] += 1 # NEW
court_timers = {c.id: start_of_day for c in all_courts}
planned_finish_times = {} planned_finish_times = {}
unscheduled = list(matches) unscheduled: list[models.Match] = []
loop_limit = len(matches) * 2 for m in all_matches:
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
fin = m.start_time + timedelta(minutes=m.tournament.duration)
planned_finish_times[m.id] = fin
if court_timers.get(m.court_id, start_of_day) < fin:
court_timers[m.court_id] = fin
else:
unscheduled.append(m)
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
# 5. Global Interleaving Schedule Loop
loop_limit = len(unscheduled) * 3
while unscheduled and loop_limit > 0: while unscheduled and loop_limit > 0:
loop_limit -= 1 loop_limit -= 1
best_court_id = min(court_timers, key=lambda k: court_timers[k])
best_court_id = min(
court_timers.keys(), key=lambda k: (court_timers[k], court_sharing_count[k])
)
current_time = court_timers[best_court_id] current_time = court_timers[best_court_id]
ready: list[models.Match] = [] ready: list[models.Match] = []
for m in unscheduled: for m in unscheduled:
if best_court_id not in [c.id for c in m.tournament.courts]:
continue
is_ready = True is_ready = True
max_prereq_time = tournament_start max_prereq_time = (
m.tournament.timestamp.replace(tzinfo=None)
if m.tournament.timestamp.tzinfo
else m.tournament.timestamp
)
for p_id in prereqs[m.id]: for p_id in prereqs[m.id]:
if p_id not in planned_finish_times: if p_id not in planned_finish_times:
is_ready = False is_ready = False
@@ -143,27 +192,41 @@ def update_schedule_times(db: Session, t: models.Tournament):
if ready: if ready:
ready.sort( ready.sort(
key=lambda x: (-depth_cache[x.id], x.round_number, x.match_number) key=lambda x: (
len(x.tournament.courts),
tournament_match_counts[x.tournament_id],
-depth_cache[x.id],
x.round_number,
x.match_number,
)
) )
cand = ready[0] cand = ready[0]
cand.court_id = best_court_id cand.court_id = best_court_id
cand.start_time = current_time cand.start_time = current_time
fin = current_time + timedelta(minutes=t.duration) fin = current_time + timedelta(minutes=cand.tournament.duration)
planned_finish_times[cand.id] = fin planned_finish_times[cand.id] = fin
court_timers[best_court_id] = fin court_timers[best_court_id] = fin
tournament_match_counts[cand.tournament_id] += 1
unscheduled.remove(cand) unscheduled.remove(cand)
else: else:
next_wake = None next_wake = None
for m in unscheduled: for m in unscheduled:
if best_court_id not in [c.id for c in m.tournament.courts]:
continue
is_ready = True is_ready = True
max_prereq_time = tournament_start max_prereq_time = (
m.tournament.timestamp.replace(tzinfo=None)
if m.tournament.timestamp.tzinfo
else m.tournament.timestamp
)
for p_id in prereqs[m.id]: for p_id in prereqs[m.id]:
if p_id not in planned_finish_times: if p_id not in planned_finish_times:
is_ready = False is_ready = False
break break
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id]) max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
if is_ready and max_prereq_time > current_time: if is_ready and max_prereq_time > current_time:
if next_wake is None or max_prereq_time < next_wake: if next_wake is None or max_prereq_time < next_wake:
next_wake = max_prereq_time next_wake = max_prereq_time
@@ -171,22 +234,26 @@ def update_schedule_times(db: Session, t: models.Tournament):
if next_wake: if next_wake:
court_timers[best_court_id] = next_wake court_timers[best_court_id] = next_wake
else: else:
break court_timers[best_court_id] += timedelta(minutes=5)
# ========================================== # 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!)
# --- DYNAMIC REFEREE ASSIGNMENT --- all_matches = sorted(
# ========================================== all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0)
all_matches = sorted(list(matches), key=lambda x: (x.start_time, x.court_id)) )
duty_counts = defaultdict(int) duty_counts = defaultdict(int)
active_refs = [] active_refs = []
def is_busy(outcome_tuple, source_m, start, end): def is_busy(outcome_tuple, source_m: models.Match, start, end):
next_m_id = source_m.winner_next_match_id if outcome_tuple[0] == "W" else source_m.loser_next_match_id next_m_id = (
source_m.winner_next_match_id
if outcome_tuple[0] == "W"
else source_m.loser_next_match_id
)
if next_m_id: if next_m_id:
next_m = match_map[next_m_id] next_m = match_map[next_m_id]
next_start = next_m.start_time next_start = next_m.start_time
if next_start: if next_start:
next_end = next_start + timedelta(minutes=t.duration) next_end = next_start + timedelta(minutes=next_m.tournament.duration)
if not (next_end <= start or next_start >= end): if not (next_end <= start or next_start >= end):
return True return True
for r_outcome, r_start, r_end in active_refs: for r_outcome, r_start, r_end in active_refs:
@@ -198,29 +265,37 @@ def update_schedule_times(db: Session, t: models.Tournament):
for m in all_matches: for m in all_matches:
if not m.start_time: if not m.start_time:
continue continue
m_start = m.start_time m_start = m.start_time
m_end = m_start + timedelta(minutes=t.duration) m_end = m_start + timedelta(minutes=m.tournament.duration)
if m.bracket_type == BracketTypes.FINALS: if m.bracket_type == BracketTypes.FINALS:
prev_final = next((p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id), None) prev_final = next(
(
p
for p in all_matches
if p.bracket_type == BracketTypes.FINALS
and p.winner_next_match_id == m.id
),
None,
)
if prev_final: if prev_final:
m.ref_team_id = prev_final.ref_team_id m.ref_team_id = prev_final.ref_team_id
m.ref_label = prev_final.ref_label m.ref_label = prev_final.ref_label
identifier = (
identifier = f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
)
active_refs.append((identifier, m_start, m_end)) active_refs.append((identifier, m_start, m_end))
continue continue
best_outcome = None best_outcome = None
best_score = float('inf') best_score = float("inf")
for prev_m in all_matches: for prev_m in all_matches:
if not prev_m.start_time: if not prev_m.start_time:
continue continue
prev_end = prev_m.start_time + timedelta(minutes=t.duration) prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
if prev_end <= m_start: if prev_end <= m_start:
l_outcome = ("L", prev_m.id) l_outcome = ("L", prev_m.id)
if not is_busy(l_outcome, prev_m, m_start, m_end): if not is_busy(l_outcome, prev_m, m_start, m_end):
@@ -231,17 +306,17 @@ def update_schedule_times(db: Session, t: models.Tournament):
if score < best_score: if score < best_score:
best_score = score best_score = score
best_outcome = (l_outcome, prev_m) best_outcome = (l_outcome, prev_m)
w_outcome = ("W", prev_m.id) w_outcome = ("W", prev_m.id)
if not is_busy(w_outcome, prev_m, m_start, m_end): if not is_busy(w_outcome, prev_m, m_start, m_end):
wait_mins = (m_start - prev_end).total_seconds() / 60.0 wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[w_outcome] * 120) + wait_mins + 60 score = (duty_counts[w_outcome] * 120) + wait_mins + 60
if prev_m.court_id == m.court_id: if prev_m.court_id == m.court_id:
score -= 30 score -= 30
if score < best_score: if score < best_score:
best_score = score best_score = score
best_outcome = (w_outcome, prev_m) best_outcome = (w_outcome, prev_m)
if best_outcome: if best_outcome:
outcome_tuple, prev_m = best_outcome outcome_tuple, prev_m = best_outcome
duty_counts[outcome_tuple] += 1 duty_counts[outcome_tuple] += 1
@@ -249,20 +324,18 @@ def update_schedule_times(db: Session, t: models.Tournament):
role = "Winner" if outcome_tuple[0] == "W" else "Loser" role = "Winner" if outcome_tuple[0] == "W" else "Loser"
m.ref_label = f"{role} of #{prev_m.match_number}" m.ref_label = f"{role} of #{prev_m.match_number}"
else: else:
m.ref_label = "Staff / Volunteers" m.ref_label = "Staff / Volunteers"
for future_m in all_matches: for future_m in all_matches:
if future_m.start_time and future_m.start_time >= m_end: if future_m.start_time and future_m.start_time >= m_end:
assigned = False assigned = False
for t_id in [future_m.p1_team_id, future_m.p2_team_id]: for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
if t_id: if t_id:
team_identifier = f"TEAM_{t_id}" team_identifier = f"TEAM_{t_id}"
is_team_busy = any( is_team_busy = any(
r_outcome == team_identifier and not (r_end <= m_start or r_start >= m_end) r_outcome == team_identifier
and not (r_end <= m_start or r_start >= m_end)
for r_outcome, r_start, r_end in active_refs for r_outcome, r_start, r_end in active_refs
) )
if not is_team_busy: if not is_team_busy:
m.ref_team_id = t_id m.ref_team_id = t_id
m.ref_label = None m.ref_label = None
@@ -271,7 +344,6 @@ def update_schedule_times(db: Session, t: models.Tournament):
break break
if assigned: if assigned:
break break
# ----------------------------------
db.commit() db.commit()
@@ -355,7 +427,10 @@ def undo_advancement(db: Session, match: models.Match):
return return
for m in match.tournament.matches: for m in match.tournament.matches:
if m.ref_label == f"Loser of #{match.match_number}" or m.ref_label == f"Winner of #{match.match_number}": if (
m.ref_label == f"Loser of #{match.match_number}"
or m.ref_label == f"Winner of #{match.match_number}"
):
m.ref_team_id = None m.ref_team_id = None
db.add(m) db.add(m)
@@ -385,5 +460,5 @@ def undo_advancement(db: Session, match: models.Match):
clear_from_next(match.winner_next_match, match.winner_next_match_slot) clear_from_next(match.winner_next_match, match.winner_next_match_slot)
if match.loser_next_match and loser_id: if match.loser_next_match and loser_id:
clear_from_next(match.loser_next_match, match.loser_next_match_slot) clear_from_next(match.loser_next_match, match.loser_next_match_slot)
db.commit() db.commit()
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine from .database import Base, engine
from .routes import auth, tournaments, websocket from .routes import auth, tournaments, websocket, courts
@asynccontextmanager @asynccontextmanager
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api") app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
app.include_router(tournaments.router) app.include_router(tournaments.router)
app.include_router(courts.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.add_middleware( app.add_middleware(
+68 -26
View File
@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import JSON, DateTime from sqlalchemy import JSON, DateTime, Table, Column
from sqlalchemy import Enum as SqlEnum from sqlalchemy import Enum as SqlEnum
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -10,6 +10,33 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from .constants import MatchStatus, TournamentTypes, BracketTypes from .constants import MatchStatus, TournamentTypes, BracketTypes
from .database import Base from .database import Base
tournament_courts = Table(
"tournament_courts",
Base.metadata,
Column(
"tournament_id",
String(8),
ForeignKey("tournaments.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"court_id",
Integer,
ForeignKey("courts.id", ondelete="CASCADE"),
primary_key=True,
),
)
class Court(Base):
__tablename__ = "courts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
tournaments: Mapped[list["Tournament"]] = relationship(
"Tournament", secondary=tournament_courts, back_populates="courts"
)
class Tournament(Base): class Tournament(Base):
__tablename__ = "tournaments" __tablename__ = "tournaments"
@@ -25,7 +52,7 @@ class Tournament(Base):
"Team", back_populates="tournament", cascade="all, delete-orphan" "Team", back_populates="tournament", cascade="all, delete-orphan"
) )
courts: Mapped[list["Court"]] = relationship( courts: Mapped[list["Court"]] = relationship(
"Court", back_populates="tournament", cascade="all, delete-orphan" "Court", secondary=tournament_courts, back_populates="tournaments"
) )
matches: Mapped[list["Match"]] = relationship( matches: Mapped[list["Match"]] = relationship(
@@ -50,15 +77,6 @@ class Team(Base):
tournament: Mapped["Tournament"] = relationship(back_populates="teams") tournament: Mapped["Tournament"] = relationship(back_populates="teams")
class Court(Base):
__tablename__ = "courts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
tournament: Mapped["Tournament"] = relationship(back_populates="courts")
class Match(Base): class Match(Base):
__tablename__ = "matches" __tablename__ = "matches"
@@ -70,32 +88,56 @@ class Match(Base):
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes)) bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
court_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courts.id"), nullable=True) court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
)
start_time: 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.SCHEDULED) status: Mapped[MatchStatus] = mapped_column(
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
)
p1_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True) p1_team_id: Mapped[Optional[int]] = mapped_column(
p2_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True) ForeignKey("teams.id"), nullable=True
)
ref_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
)
ref_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True) ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
sets: Mapped[list[dict]] = mapped_column(JSON, default=list) sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
winner_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True) 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) )
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) 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) loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tournament: Mapped["Tournament"] = relationship(back_populates="matches") tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship() court: Mapped[Optional["Court"]] = relationship()
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id]) p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id]) p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
ref_team: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[ref_team_id])
winner_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[winner_next_match_id]) ref_team: Mapped[Optional["Team"]] = relationship(
loser_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[loser_next_match_id]) "Team", foreign_keys=[ref_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]
)
+80
View File
@@ -0,0 +1,80 @@
# backend/app/routes/courts.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, date
from .. import crud, schemas, models
from ..constants import SUCCESS
from ..core.auth import get_admin_user
from ..database import get_db
router = APIRouter(prefix="/courts", tags=["Courts"])
@router.get("", response_model=list[schemas.CourtSchema])
def get_all_courts(db: Session = Depends(get_db)):
"""Public route to list all global courts"""
return db.query(models.Court).all()
@router.post("", response_model=schemas.CourtSchema)
def create_global_court(
data: schemas.CourtCreate,
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
"""Admin route to register a new physical court"""
return crud.create_court(db, data)
@router.delete("/{court_id}")
def delete_global_court(
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
):
success = crud.delete_court(db, court_id)
if not success:
raise HTTPException(404, "Court not found")
return SUCCESS
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
court = db.query(models.Court).filter(models.Court.id == court_id).first()
if not court:
raise HTTPException(404, "Court not found")
today = date.today()
matches = (
db.query(models.Match)
.join(models.Tournament)
.filter(models.Match.court_id == court_id)
.filter(
models.Tournament.timestamp >= datetime.combine(today, datetime.min.time())
)
.filter(
models.Tournament.timestamp < datetime.combine(today, datetime.max.time())
)
.order_by(models.Match.start_time)
.all()
)
schedule = []
for m in matches:
schedule.append(
{
"id": m.id,
"tournament_id": m.tournament_id,
"tournament_name": m.tournament.name,
"time": m.start_time.strftime("%H:%M") if m.start_time else "TBD",
"status": m.status,
"match_number": m.match_number,
"p1": m.p1_team.name if m.p1_team else "TBD",
"p2": m.p2_team.name if m.p2_team else "TBD",
"p1_sets": sum(1 for s in m.sets if s["p1"] > s["p2"]) if m.sets else 0,
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0,
"ref_name": m.ref_team.name if m.ref_team else m.ref_label,
}
)
return {"court": court.name, "matches": schedule}
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
router = APIRouter(prefix="/tournaments", tags=["Tournaments"]) router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
from . import tournaments, settings, teams, courts, matches, report from . import tournaments, settings, teams, matches, report
-60
View File
@@ -1,60 +0,0 @@
# backend/app/routes/tournaments/courts.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import crud, schemas
from ...constants import SUCCESS
from ...core.auth import get_admin_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from . import router
@router.get("/{id}/courts", response_model=list[schemas.CourtSchema])
def get_courts(id: str, db: Session = Depends(get_db)):
return crud.get_courts(db, id)
@router.post("/{id}/courts", response_model=schemas.CourtSchema)
async def create_court(
id: str,
court: schemas.CourtCreate,
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
new_court = crud.create_court(db, id, court)
if not new_court:
raise HTTPException(404, "Tournament not found")
await send_ws_update(id)
return new_court
@router.patch("/{id}/courts", response_model=schemas.TournamentDetail)
async def update_courts(
id: str,
courts: list[str],
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
t = crud.update_tournament_courts(db, id, courts)
if not t:
raise HTTPException(404, "Not found")
await send_ws_update(id)
return t
@router.delete("/{id}/courts/{court_id}")
async def delete_court(
id: str,
court_id: int,
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
success = crud.delete_court(db, id, court_id)
if not success:
raise HTTPException(404, "Court or Tournament not found")
await send_ws_update(id)
return SUCCESS
@@ -60,3 +60,17 @@ async def delete_tournament(
await send_ws_update(id) await send_ws_update(id)
return SUCCESS return SUCCESS
@router.patch("/{id}/courts", response_model=schemas.TournamentOut)
def update_tournament_courts(
id: str,
court_ids: list[int],
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
"""Updates the global courts assigned to a specific tournament"""
t = crud.update_tournament_courts(db, id, court_ids)
if not t:
raise HTTPException(404, "Tournament not found")
return t
+2 -2
View File
@@ -49,7 +49,7 @@ class MatchOut(BaseModel):
p1_team_id: int | None = None p1_team_id: int | None = None
p2_team_id: int | None = None p2_team_id: int | None = None
winner_team_id: int | None = None winner_team_id: int | None = None
ref_team_id: int | None = None ref_team_id: int | None = None
ref_label: str | None = None ref_label: str | None = None
ref_team: TeamSchema | None = None ref_team: TeamSchema | None = None
@@ -71,7 +71,7 @@ class TournamentCreate(BaseModel):
timestamp: datetime timestamp: datetime
duration: int duration: int
teams: list[str] teams: list[str]
courts: list[str] courts: list[int]
class TournamentUpdate(BaseModel): class TournamentUpdate(BaseModel):
+1033 -985
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -5,6 +5,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Login from './pages/Login'; import Login from './pages/Login';
import Courts from './pages/Courts';
import Tournament from './pages/Tournament'; import Tournament from './pages/Tournament';
export default function App() { export default function App() {
@@ -34,6 +35,7 @@ export default function App() {
{/* Main App Layout */} {/* Main App Layout */}
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}> <Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/courts" element={<Courts />} />
<Route path="/tournaments/:id" element={<Tournament />} /> <Route path="/tournaments/:id" element={<Tournament />} />
</Route> </Route>
+5
View File
@@ -0,0 +1,5 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M431.789,47.747H272.387V32.384c0-9.043-7.331-16.383-16.383-16.383s-16.383,7.34-16.383,16.383v15.362H80.211 C35.988,47.747,0,83.734,0,127.967v256.067c0,44.232,35.988,80.22,80.211,80.22h159.41v15.354 c0,9.052,7.331,16.392,16.383,16.392c9.043,0,16.374-7.339,16.383-16.392v-15.354h159.401c44.232,0,80.211-35.988,80.211-80.22 V127.967C512,83.734,476.012,47.747,431.789,47.747z M80.211,431.496c-26.172,0-47.454-21.291-47.454-47.454V127.976 c0-26.172,21.291-47.454,47.454-47.454v-0.009h159.41v350.983H80.211z M479.243,384.042c0,26.172-21.291,47.454-47.454,47.454 H272.387V80.513h159.401c26.172,0,47.454,21.291,47.454,47.454V384.042z"/>
<path d="M351.738,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.044,0,16.383-7.34,16.383-16.392V110.377C368.121,101.333,360.79,93.994,351.738,93.994z"/>
<path d="M160.271,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.043,0,16.374-7.34,16.383-16.392V110.377C176.654,101.333,169.324,93.994,160.271,93.994z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,6 +1,7 @@
// frontend/src/components/Dashboard/DashCard.tsx // frontend/src/components/Dashboard/DashCard.tsx
import { MapPin, SlidersHorizontal, Users } from 'lucide-react'; import { SlidersHorizontal, Users } from 'lucide-react';
import CourtIcon from '../../assets/court.svg?react';
interface TournamentSummary { interface TournamentSummary {
id: string | number; id: string | number;
@@ -45,7 +46,7 @@ export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps
{t.team_count} Teams {t.team_count} Teams
</div> </div>
<div className="flex items-center gap-1.5 font-medium"> <div className="flex items-center gap-1.5 font-medium">
<MapPin size={16} className="text-orange-500" /> <CourtIcon width={16} height={16} className="text-orange-500" />
{t.court_count} Courts {t.court_count} Courts
</div> </div>
@@ -1,9 +1,14 @@
// frontend/src/components/Forms/TournamentForm.tsx // frontend/src/components/Forms/TournamentForm.tsx
import { Loader2 } from 'lucide-react'; import { Loader2, Plus } from 'lucide-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import api from '../../services/api'; import api from '../../services/api';
interface Court {
id: number;
name: string;
}
interface TournamentSettings { interface TournamentSettings {
id: string | number; id: string | number;
name: string; name: string;
@@ -11,7 +16,7 @@ interface TournamentSettings {
type: string; type: string;
timestamp: string; timestamp: string;
duration: number; duration: number;
courts: { name?: string }[]; courts: Court[];
teams: { name?: string }[]; teams: { name?: string }[];
} }
@@ -23,33 +28,55 @@ interface TournamentFormProps {
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) { export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
const [initialData, setInitialData] = useState<TournamentSettings | null>(null); const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(!!tournamentId); const [globalCourts, setGlobalCourts] = useState<Court[]>([]);
const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
const [newCourtName, setNewCourtName] = useState('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (!tournamentId) { const fetchDependencies = async () => {
setInitialData(null);
setIsLoading(false);
return;
}
const fetchSettings = async () => {
try { try {
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`); const courtsRes = await api.get<Court[]>('/courts');
setInitialData(data); setGlobalCourts(courtsRes);
if (tournamentId) {
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
setSelectedCourts(data.courts.map(c => c.id));
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError("Failed to load tournament settings."); setError("Failed to load tournament data.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchSettings(); void fetchDependencies();
}, [tournamentId]); }, [tournamentId]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleAddCourt = async () => {
if (!newCourtName.trim()) return;
try {
const added = await api.post<Court>('/courts', { name: newCourtName.trim() });
setGlobalCourts([...globalCourts, added]);
setSelectedCourts([...selectedCourts, added.id]);
setNewCourtName('');
} catch {
setError("Failed to create new court.");
}
};
const toggleCourt = (id: number) => {
setSelectedCourts(prev =>
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
);
};
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
@@ -57,7 +84,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const rawTeams = formData.get('teams') as string; const rawTeams = formData.get('teams') as string;
const rawCourts = formData.get('courts') as string;
const date = formData.get('date') as string; const date = formData.get('date') as string;
const startTime = formData.get('start_time') as string; const startTime = formData.get('start_time') as string;
const typeRaw = formData.get('type') as string; const typeRaw = formData.get('type') as string;
@@ -66,7 +92,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const code = formData.get('code') as string; const code = formData.get('code') as string;
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0); 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) { if (teams.length < 2) {
setError("At least 2 teams required."); setError("At least 2 teams required.");
@@ -74,6 +99,12 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
return; return;
} }
if (selectedCourts.length === 0) {
setError("Please select at least one court.");
setIsSubmitting(false);
return;
}
const timestamp = `${date}T${startTime}:00`; const timestamp = `${date}T${startTime}:00`;
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1); const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
const parsedDuration = parseInt(duration, 10); const parsedDuration = parseInt(duration, 10);
@@ -81,39 +112,25 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
try { try {
if (tournamentId) { if (tournamentId) {
const basePayload = { const basePayload = {
name, name, code, type: formattedType, timestamp, duration: parsedDuration
code,
type: formattedType,
timestamp,
duration: parsedDuration
}; };
await Promise.all([ await Promise.all([
api.patch(`/tournaments/${tournamentId}`, basePayload), api.patch(`/tournaments/${tournamentId}`, basePayload),
api.patch(`/tournaments/${tournamentId}/teams`, teams), api.patch(`/tournaments/${tournamentId}/teams`, teams),
api.patch(`/tournaments/${tournamentId}/courts`, courts) api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
]); ]);
} else { } else {
const fullPayload = { const fullPayload = {
name, name, code, type: formattedType, timestamp, duration: parsedDuration,
code, teams, courts: selectedCourts
type: formattedType,
timestamp,
duration: parsedDuration,
teams,
courts
}; };
await api.post('/tournaments', fullPayload); await api.post('/tournaments', fullPayload);
} }
onSuccess(); onSuccess();
} catch (err: unknown) { } catch (err: unknown) {
console.error(err); console.error(err);
const error = err as { detail?: string | Array<{ loc: string[]; msg: string }> }; setError("Error saving tournament");
if (Array.isArray(error.detail)) {
setError(error.detail.map((e) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
} else {
setError(typeof error.detail === 'string' ? error.detail : "Error saving tournament");
}
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -147,35 +164,17 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label> <label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
<input <input name="name" defaultValue={initialData?.name} required placeholder="My Awesome Tournament" autoFocus 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" />
name="name"
defaultValue={initialData?.name}
required
placeholder="My Awesome Tournament"
autoFocus
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"
/>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label> <label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
<input <input name="code" defaultValue={initialData?.code} required placeholder="••••" autoComplete="off" className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
name="code"
defaultValue={initialData?.code}
required
placeholder="••••"
autoComplete="off"
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div> </div>
<div> <div>
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label> <label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
<select <select name="type" defaultValue={initialData?.type?.toLowerCase() || "double"} className="w-full h-10 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">
name="type"
defaultValue={initialData?.type?.toLowerCase() || "double"}
className="w-full h-10 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"
>
<option value="double">Double Elimination</option> <option value="double">Double Elimination</option>
<option value="single">Single Elimination</option> <option value="single">Single Elimination</option>
</select> </select>
@@ -185,43 +184,50 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="grid grid-cols-7 gap-4"> <div className="grid grid-cols-7 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label> <label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
<input <input type="number" name="duration" defaultValue={initialData?.duration || 30} min="0" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
type="number"
name="duration"
defaultValue={initialData?.duration || 30}
min="0"
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label> <label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
<input <input type="time" name="start_time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
type="time"
name="start_time"
defaultValue={defaultTime}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label> <label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
<input <input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
type="date"
name="date"
defaultValue={defaultDate}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div> </div>
</div> </div>
<div> {/* --- NEW: GLOBAL COURT SELECTOR --- */}
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label> <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
<input <label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
name="courts" <div className="flex flex-wrap gap-2 mb-3">
placeholder="Center Court, Court 1" {globalCourts.map(c => (
defaultValue={initialData?.courts?.map(c => c.name || c).join(', ')} <button
required key={c.id}
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" type="button"
/> onClick={() => toggleCourt(c.id)}
className={`px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition ${selectedCourts.includes(c.id)
? 'bg-orange-500 text-white shadow-md'
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700'
}`}
>
{c.name}
</button>
))}
{globalCourts.length === 0 && <span className="text-xs italic text-zinc-400">No courts registered yet.</span>}
</div>
<div className="flex gap-2">
<input
type="text"
value={newCourtName}
onChange={(e) => setNewCourtName(e.target.value)}
placeholder="Add new court..."
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-1 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
/>
<button type="button" onClick={handleAddCourt} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1 rounded transition flex items-center">
<Plus size={16} />
</button>
</div>
</div> </div>
<div> <div>
@@ -239,21 +245,13 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4"> <div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
{tournamentId && onDelete && ( {tournamentId && onDelete && (
<button <button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
type="button"
onClick={() => onDelete(tournamentId)}
className="text-red-500 text-sm hover:underline h-5 self-end"
>
Delete Tournament Delete Tournament
</button> </button>
)} )}
{!tournamentId && <div className="hidden"></div>} {!tournamentId && <div className="hidden"></div>}
<button <button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50">
disabled={isSubmitting}
type="submit"
className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')} {isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
</button> </button>
</div> </div>
+41 -23
View File
@@ -1,7 +1,8 @@
// frontend/src/components/Layout/Navbar.tsx // frontend/src/components/Layout/Navbar.tsx
import { Lock, LogOut, Volleyball } from 'lucide-react'; import { Lock, LogOut, Volleyball } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import CourtIcon from '../../assets/court.svg?react';
interface NavbarProps { interface NavbarProps {
title: string; title: string;
@@ -11,39 +12,56 @@ interface NavbarProps {
} }
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) { export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
return ( const location = useLocation();
<nav className="transition-colors bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-100 px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
<div className="p-1.5 sm:p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30 active:scale-90">
<Volleyball className="text-white" size={20} />
</div>
<div className="hidden sm:block">
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
<p className="text-[9px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
</div>
</Link>
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-35 xs:max-w-[180px] sm:max-w-100"> return (
<div className="font-black uppercase text-[10px] sm:text-sm tracking-widest sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1"> <nav className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 px-4 py-3 flex items-center justify-between shadow-sm sticky top-0 z-50">
{/* Left: Logo & Main Navigation */}
<div className="flex items-center gap-6">
<Link to="/" className="flex items-center gap-2 shrink-0">
<div className="p-2 bg-orange-600 rounded-xl shadow-lg shadow-orange-600/20">
<Volleyball className="text-white" size={20} />
</div>
<div className="hidden lg:block">
<h1 className="text-lg font-black text-zinc-900 dark:text-white leading-none">VolleyManager</h1>
</div>
</Link>
<Link to="/courts" className="md:hidden p-2 text-zinc-600 dark:text-zinc-400 bg-zinc-100 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-lg">
<CourtIcon width={20} height={20} />
</Link>
<div className="hidden md:flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
<Link to="/" className={`px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/' ? 'bg-white dark:bg-zinc-700 shadow-sm text-orange-600' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
Tournaments
</Link>
<Link to="/courts" className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/courts' ? 'bg-orange-600 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
Courts
</Link>
</div>
</div>
{/* Center: Title (Responsive truncation) */}
<div className="absolute left-1/2 -translate-x-1/2 text-center hidden sm:block pointer-events-none">
<div className="font-black uppercase text-xs tracking-[0.2em] text-zinc-900 dark:text-white truncate max-w-37.5 md:max-w-62.5">
{title || 'Dashboard'} {title || 'Dashboard'}
</div> </div>
{subtitle && ( {subtitle && (
<div className="text-[8px] sm:text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none"> <div className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest mt-0.5">
{subtitle} {subtitle}
</div> </div>
)} )}
</div> </div>
<div className="flex gap-2 sm:gap-4 items-center shrink-0"> {/* Right: Actions */}
<div className="flex items-center gap-3">
{isAuthenticated ? ( {isAuthenticated ? (
<> <button onClick={onLogout} className="p-2 text-zinc-400 hover:text-red-500 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0"> <LogOut size={20} />
<LogOut size={18} className="sm:size-5.5" /> </button>
</button>
</>
) : ( ) : (
<Link to="/login" className="text-orange-600 font-black flex items-center gap-1.5 text-[9px] sm:text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-1.5 sm:p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20"> <Link to="/login" className="flex items-center gap-2 px-4 py-2 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs font-black uppercase tracking-wider hover:bg-orange-100 transition">
<Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span> <Lock size={14} /> Login
</Link> </Link>
)} )}
</div> </div>
@@ -1,10 +1,11 @@
// frontend/src/components/Tournament/ScoreModal.tsx // frontend/src/components/Tournament/ScoreModal.tsx
import { type SetData } from '../../types'; import { type SetData } from '../../types';
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react'; import { Clock, Eraser, Trophy } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Modal from '../UI/Modal'; import Modal from '../UI/Modal';
import WhistleIcon from "../../assets/whistle.svg?react" import WhistleIcon from "../../assets/whistle.svg?react"
import CourtIcon from '../../assets/court.svg?react';
interface MatchData { interface MatchData {
id: string | number; id: string | number;
@@ -64,7 +65,7 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
</div> </div>
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" /> <div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white"> <div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
<MapPin className="text-orange-500" size={16} /> <CourtIcon className="text-orange-500" width={16} height={16} />
<span>{match.court || "TBD"}</span> <span>{match.court || "TBD"}</span>
</div> </div>
</div> </div>
+281
View File
@@ -0,0 +1,281 @@
// frontend/src/pages/Courts.tsx
import React, { useEffect, useState, useRef } from 'react';
import { Loader2, Trash, Plus } from 'lucide-react';
import { useOutletContext, Link } from 'react-router-dom';
import api from '../services/api';
import { printName } from '../utils/helpers';
import WhistleIcon from '../assets/whistle.svg?react';
import CourtIcon from '../assets/court.svg?react';
interface CourtMatch {
id: string;
tournament_id: string;
tournament_name: string;
time: string;
status: string;
match_number: number;
p1: string;
p2: string;
p1_sets: number;
p2_sets: number;
ref_name?: string;
}
interface CourtSchedule {
court: string;
matches: CourtMatch[];
}
interface GlobalCourt {
id: number;
name: string;
}
const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (id: number) => void, role: string | null }) => {
const [data, setData] = useState<CourtSchedule | null>(null);
const [currentTime, setCurrentTime] = useState<string>('');
// Auto-scroll refs
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [hasScrolled, setHasScrolled] = useState(false);
// Keep the current time perfectly updated
useEffect(() => {
const updateTime = () => {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
setCurrentTime(`${hours}:${minutes}`);
};
updateTime();
const interval = setInterval(updateTime, 60000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const fetchSchedule = async () => {
try {
const res = await api.get<CourtSchedule>(`/courts/${courtId}/schedule`);
setData(res);
} catch (err) {
console.error(err);
}
};
void fetchSchedule();
}, [courtId]);
// AUTO-SCROLL LOGIC
useEffect(() => {
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
setTimeout(() => {
const line = scrollContainerRef.current?.querySelector('.now-line');
if (line) {
line.scrollIntoView({ behavior: 'smooth', block: 'start' });
setHasScrolled(true);
}
}, 500);
}
}, [data, currentTime, hasScrolled]);
if (!data) return <div className="w-80 shrink-0 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl flex items-center justify-center border border-zinc-200 dark:border-zinc-800"><Loader2 className="animate-spin text-orange-500" /></div>;
// --- ACTIVE INDEX LOGIC ---
let activeIndex = -1;
if (currentTime) {
// Find the index of the most recently started match
for (let i = 0; i < data.matches.length; i++) {
if (data.matches[i].time <= currentTime) {
activeIndex = i;
}
}
}
return (
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/50 dark:bg-zinc-900/20 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-3 md:p-4 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<CourtIcon className="text-orange-500 shrink-0" width={20} height={20} />
<h3 className="font-black text-base md:text-lg text-zinc-900 dark:text-white uppercase tracking-wider truncate">{data.court}</h3>
</div>
{role === 'admin' && (
<button onClick={() => onDelete(courtId)} className="text-zinc-400 hover:text-red-500 transition p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 active:scale-90" title="Delete Court">
<Trash size={16} />
</button>
)}
</div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32">
{data.matches.length === 0 && (
<div className="text-center text-sm font-bold uppercase text-zinc-400 dark:text-zinc-600 mt-10">No matches</div>
)}
{data.matches.map((m, index) => {
const isFinished = m.status === 'Finished';
const isPastSlot = index < activeIndex; // Strictly before the active match
const isLive = index === activeIndex && !isFinished; // Currently active and incomplete
// The line is drawn right above the active index (or index 0 if the day hasn't started)
const showNowLine = (activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0);
// Styling configurations based on match state
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
let textOpacity = 'text-zinc-900 dark:text-white';
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
if (isFinished) {
borderStyle = 'border-orange-500/30 opacity-60 grayscale hover:opacity-100 hover:grayscale-0';
textOpacity = 'text-zinc-500';
} else if (isPastSlot) {
borderStyle = 'border-zinc-300 dark:border-zinc-700 opacity-50 grayscale hover:opacity-100 hover:grayscale-0';
textOpacity = 'text-zinc-500';
pOpacity = 'text-zinc-500';
}
if (isLive) {
borderStyle = 'border-orange-500 ring-1 ring-orange-500/20 shadow-sm hover:-translate-y-1 hover:shadow-md';
}
return (
<React.Fragment key={m.id}>
{/* --- THE --NOW-- LINE --- */}
{showNowLine && (
<div className="now-line relative flex items-center py-3 animate-in fade-in">
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
<div className="mx-2 text-[10px] font-black text-red-600 uppercase tracking-widest bg-red-100 dark:bg-red-900/30 px-3 py-1 rounded-full border border-red-200 dark:border-red-900/50 shadow-sm">Now</div>
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
</div>
)}
<Link
to={`/tournaments/${m.tournament_id}`}
className={`match-card block bg-white dark:bg-zinc-950 p-2.5 rounded-lg border transition-all ${borderStyle}`}
>
<div className="flex justify-between items-center mb-1.5">
<div className="flex items-center gap-2">
<div className={`text-base font-black font-mono leading-none ${textOpacity}`}>{m.time}</div>
{isLive && (
<div className="flex items-center gap-1 bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-500 px-1.5 py-0.5 rounded-full border border-orange-200 dark:border-orange-800/50">
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></span>
<span className="text-[7px] font-black uppercase tracking-widest">Live</span>
</div>
)}
</div>
<div className="text-[7px] font-black uppercase text-zinc-500 bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded truncate max-w-22.5">
{m.tournament_name}
</div>
</div>
<div className="space-y-0.5 mb-1.5">
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p1_sets}</span>}
</div>
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p2_sets > m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)}</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p2_sets}</span>}
</div>
</div>
{m.ref_name && !isFinished && (
<div className="mt-1.5 pt-1.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-1 text-[8px] font-bold text-zinc-500 dark:text-zinc-400">
<WhistleIcon className="text-orange-500" width={10} height={10} />
<span className="uppercase tracking-widest truncate">{m.ref_name}</span>
</div>
)}
</Link>
</React.Fragment>
)
})}
</div>
</div>
);
};
export default function Courts() {
const { setNavTitle, setNavSubtitle, role } = useOutletContext<any>();
const [courts, setCourts] = useState<GlobalCourt[]>([]);
const [loading, setLoading] = useState(true);
const [newCourtName, setNewCourtName] = useState('');
useEffect(() => {
setNavTitle('Courts');
setNavSubtitle('');
const fetchCourts = async () => {
try {
const res = await api.get<GlobalCourt[]>('/courts');
setCourts(res);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
void fetchCourts();
}, [setNavTitle, setNavSubtitle]);
const handleDelete = async (id: number) => {
if (!window.confirm("Are you sure you want to delete this court? It will remove it from all tournaments and auto-reschedule them.")) return;
try {
await api.delete(`/courts/${id}`);
setCourts(courts.filter(c => c.id !== id));
} catch (err) {
console.error("Failed to delete court", err);
}
};
const handleAddCourt = async () => {
if (!newCourtName.trim()) return;
try {
const added = await api.post<GlobalCourt>('/courts', { name: newCourtName.trim() });
setCourts([...courts, added]);
setNewCourtName('');
} catch (err) {
console.error("Failed to create new court", err);
}
};
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
return (
<div className="h-full w-full overflow-hidden flex flex-col bg-white dark:bg-zinc-950 pt-8 pb-8">
<div className="flex-1 overflow-x-auto overflow-y-hidden px-6 md:px-12">
<div className="flex h-full gap-6 pb-4 min-w-max">
{courts.map(c => (
<CourtColumn key={c.id} courtId={c.id} onDelete={handleDelete} role={role} />
))}
{courts.length === 0 && role !== 'admin' && (
<div className="text-center text-zinc-500 font-bold uppercase mt-20 w-full">
No physical courts registered yet.
</div>
)}
{/* --- ADMIN PANEL: Add New Court Inline --- */}
{role === 'admin' && (
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/30 dark:bg-zinc-900/10 rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 p-6 items-center justify-center gap-4">
<div className="w-14 h-14 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-400">
<CourtIcon width={32} height={32} />
</div>
<div className="text-sm font-bold text-zinc-500 uppercase text-center">Add New Court</div>
<div className="flex flex-col w-full gap-3 mt-2">
<input
value={newCourtName}
onChange={(e) => setNewCourtName(e.target.value)}
placeholder="Court name..."
className="w-full bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded-xl px-4 py-3 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white shadow-sm"
/>
<button onClick={handleAddCourt} className="w-full bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 py-3 rounded-xl font-bold uppercase text-xs tracking-wider transition flex items-center justify-center gap-2 active:scale-95">
<Plus size={16} /> Add Court
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Login() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);