12 Commits
40 changed files with 6568 additions and 1107 deletions
+12 -2
View File
@@ -51,7 +51,12 @@ workflow:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- cd backend
- docker build -t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG -t $CI_REGISTRY_IMAGE/backend:latest .
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE/backend:latest .
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE/backend:latest
@@ -64,6 +69,11 @@ workflow:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- cd frontend
- docker build -t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG -t $CI_REGISTRY_IMAGE/frontend:latest .
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE/frontend:latest .
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE/frontend:latest
+2 -1
View File
@@ -3,5 +3,6 @@
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"python-envs.defaultEnvManager": "ms-python.python:venv"
}
+44 -46
View File
@@ -1,7 +1,7 @@
# backend/app/crud.py
from uuid import uuid4
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from . import logic, models, schemas
@@ -28,7 +28,7 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
type=data.type,
)
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.commit()
@@ -45,17 +45,14 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
def get_tournaments(db: Session):
return db.query(models.Tournament).all()
def get_tournament(db: Session, tournament_id: str):
return (
db.query(models.Tournament)
.filter(models.Tournament.id == tournament_id)
.first()
)
def get_tournament(db: Session, tournament_id: str, lock: bool = False):
query = db.query(models.Tournament).filter(models.Tournament.id == tournament_id)
if lock:
query = query.with_for_update()
return query.first()
def delete_tournament(db: Session, tournament_id: str) -> bool:
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
return False
db.delete(t)
@@ -66,7 +63,7 @@ def delete_tournament(db: Session, tournament_id: str) -> bool:
def update_tournament_details(
db: Session, tournament_id: str, data: schemas.TournamentUpdate
):
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
return None
@@ -94,7 +91,7 @@ def get_teams(db: Session, tournament_id: str):
def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
return None
@@ -110,7 +107,7 @@ def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]):
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
return None
@@ -125,7 +122,7 @@ def update_tournament_teams(db: Session, tournament_id: str, new_team_names: lis
def delete_team(db: Session, tournament_id: str, team_id: int):
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
return None
team = db.get(models.Team, team_id)
@@ -141,53 +138,52 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
return True
def get_courts(db: Session, tournament_id: str):
return (
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
)
def get_courts(db: Session):
return db.query(models.Court).all()
def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreate):
t = get_tournament(db, tournament_id)
if not t:
return None
new_court = models.Court(name=court_data.name, tournament_id=tournament_id)
def create_court(db: Session, court_data: schemas.CourtCreate):
new_court = models.Court(name=court_data.name)
db.add(new_court)
db.flush()
logic.update_schedule_times(db, t)
db.commit()
db.refresh(new_court)
return new_court
def delete_court(db: Session, tournament_id: str, court_id: int):
t = get_tournament(db, tournament_id)
if not t:
return None
def delete_court(db: Session, court_id: int):
court = db.get(models.Court, court_id)
if not court or court.tournament_id != tournament_id:
return None
if not court:
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.flush()
logic.update_schedule_times(db, t)
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
def update_tournament_courts(
db: Session, tournament_id: str, new_court_names: list[str]
):
t = get_tournament(db, tournament_id)
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
t = get_tournament(db, tournament_id, lock=True)
if not t:
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()
logic.update_schedule_times(db, t)
@@ -202,10 +198,12 @@ def get_tournament_matches(db: Session, tournament_id: str):
)
def get_match(db: Session, tournament_id: str, match_id: str):
def get_match(db: Session, match_id: str):
return (
db.query(models.Match)
.filter(models.Match.tournament_id == tournament_id)
.options(
joinedload(models.Match.tournament).joinedload(models.Tournament.matches)
)
.filter(models.Match.id == match_id)
.first()
)
+221 -26
View File
@@ -1,6 +1,7 @@
# backend/app/logic.py
import heapq
from collections import defaultdict
from datetime import timedelta
from datetime import datetime, timedelta
from uuid import uuid4
from sqlalchemy.orm import Session
@@ -9,6 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
from . import models
from .constants import BracketTypes, MatchStatus, TournamentTypes
from .core.brackets import BracketGenerator
from .core.structures import Match
def generate_bracket(db: Session, t: models.Tournament):
@@ -21,7 +23,7 @@ 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, is_winner_path):
def resolve_target(match_node: Match, is_winner_path):
if is_winner_path:
curr = match_node.next_win
slot = getattr(match_node, "next_win_slot", None)
@@ -79,59 +81,107 @@ def generate_bracket(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
matches = t.matches
match_map = {m.id: m for m in matches}
# 1. Grab the entire day of tournaments
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,
)
.with_for_update()
.all()
)
if not tournaments:
return
all_matches: list[models.Match] = []
match_map: dict[str, models.Match] = {}
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 = {}
# 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):
if m_id not in match_map:
return 0
if m_id in depth_cache:
return depth_cache[m_id]
m = match_map[m_id]
child_depths = [0]
if m.winner_next_match_id:
child_depths.append(get_depth(m.winner_next_match_id))
if m.loser_next_match_id:
child_depths.append(get_depth(m.loser_next_match_id))
depth = 1 + max(child_depths)
depth_cache[m_id] = depth
return depth
for m in matches:
for m in all_matches:
get_depth(m.id)
tournament_start = (
t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp
)
court_timers = {c.id: tournament_start for c in t.courts}
# 4. Initialize timers
all_courts: set[models.Court] = set()
court_sharing_count = defaultdict(int)
for t_item in tournaments:
for c in t_item.courts:
all_courts.add(c)
court_sharing_count[c.id] += 1
court_timers = {c.id: start_of_day for c in all_courts}
planned_finish_times = {}
unscheduled = list(matches)
unscheduled: list[models.Match] = []
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. Critical Path Time-Stepping Scheduler
loop_limit = max(5000, len(unscheduled) * 50)
loop_limit = len(matches) * 2
while unscheduled and loop_limit > 0:
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]
ready: list[models.Match] = []
for m in unscheduled:
if best_court_id not in [c.id for c in m.tournament.courts]:
continue
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]:
if p_id not in planned_finish_times:
is_ready = False
@@ -143,27 +193,41 @@ def update_schedule_times(db: Session, t: models.Tournament):
if ready:
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.court_id = best_court_id
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
court_timers[best_court_id] = fin
tournament_match_counts[cand.tournament_id] += 1
unscheduled.remove(cand)
else:
next_wake = None
for m in unscheduled:
if best_court_id not in [c.id for c in m.tournament.courts]:
continue
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]:
if p_id not in planned_finish_times:
is_ready = False
break
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
if is_ready and max_prereq_time > current_time:
if next_wake is None or max_prereq_time < next_wake:
next_wake = max_prereq_time
@@ -171,7 +235,123 @@ def update_schedule_times(db: Session, t: models.Tournament):
if next_wake:
court_timers[best_court_id] = next_wake
else:
break
court_timers[best_court_id] += timedelta(minutes=5)
# 6. Ultra-Fast Smart Referee Assignment
all_matches.sort(key=lambda x: (x.start_time or start_of_day, x.court_id or 0))
duty_counts = defaultdict(int)
team_first_match = {}
for m in all_matches:
if m.start_time:
if m.p1_team_id and m.p1_team_id not in team_first_match:
team_first_match[m.p1_team_id] = m.start_time
if m.p2_team_id and m.p2_team_id not in team_first_match:
team_first_match[m.p2_team_id] = m.start_time
active_refs = []
match_outcome_next_start = {}
for m in all_matches:
if m.winner_next_match_id:
nm = match_map.get(m.winner_next_match_id)
if nm and nm.start_time:
match_outcome_next_start[("W", m.id)] = nm.start_time
if m.loser_next_match_id:
nm = match_map.get(m.loser_next_match_id)
if nm and nm.start_time:
match_outcome_next_start[("L", m.id)] = nm.start_time
def is_ref_busy(identifier, start, end):
for ref_id, r_start, r_end in active_refs:
if ref_id == identifier and start < r_end and end > r_start:
return True
return False
for m in all_matches:
if not m.start_time:
continue
m_start = m.start_time
m_end = m_start + timedelta(minutes=m.tournament.duration)
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,
)
if prev_final:
m.ref_team_id = prev_final.ref_team_id
m.ref_label = prev_final.ref_label
identifier = (
f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
)
active_refs.append((identifier, m_start, m_end))
continue
best_outcome = None
best_score = float("inf")
for prev_m in all_matches:
if prev_m.tournament_id != m.tournament_id:
continue
if not prev_m.start_time or prev_m.start_time >= m_start:
continue
prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
if prev_end > m_start:
continue
for outcome in ["W", "L"]:
out_key = (outcome, prev_m.id)
next_start = match_outcome_next_start.get(out_key)
if next_start and next_start < m_end:
continue
identifier = f"{outcome}:{prev_m.id}"
if is_ref_busy(identifier, m_start, m_end):
continue
wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[identifier] * 120) + wait_mins
if outcome == "W":
score += 60
if prev_m.court_id == m.court_id:
score -= 30
if score < best_score:
best_score = score
best_outcome = identifier
if best_outcome:
m.ref_label = best_outcome
m.ref_team_id = None
duty_counts[best_outcome] += 1
active_refs.append((best_outcome, m_start, m_end))
else:
assigned_bye_team = False
sorted_teams = sorted(
m.tournament.teams, key=lambda t: duty_counts[f"TEAM_{t.id}"]
)
for team in sorted_teams:
t_id = team.id
t_first_start = team_first_match.get(t_id)
identifier = f"TEAM_{t_id}"
if t_first_start and t_first_start >= m_end:
if not is_ref_busy(identifier, m_start, m_end):
m.ref_team_id = t_id
m.ref_label = None
duty_counts[identifier] += 1
active_refs.append((identifier, m_start, m_end))
assigned_bye_team = True
break
if not assigned_bye_team:
m.ref_label = "Staff / Volunteers"
m.ref_team_id = None
db.commit()
@@ -182,6 +362,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
for m in match.tournament.matches:
if m.ref_label == f"L:{match.id}":
m.ref_team_id = loser_id
db.add(m)
elif m.ref_label == f"W:{match.id}":
m.ref_team_id = winner_id
db.add(m)
if (
match.bracket_type == BracketTypes.FINALS
and match.winner_next_match
@@ -246,6 +434,11 @@ def undo_advancement(db: Session, match: models.Match):
if not match.winner_team_id:
return
for m in match.tournament.matches:
if m.ref_label in [f"L:{match.id}", f"W:{match.id}"]:
m.ref_team_id = None
db.add(m)
winner_id = match.winner_team_id
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
@@ -272,3 +465,5 @@ def undo_advancement(db: Session, match: models.Match):
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
if match.loser_next_match and loser_id:
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
db.commit()
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
from .routes import auth, tournaments, websocket
from .routes import auth, tournaments, websocket, courts
@asynccontextmanager
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
app.include_router(tournaments.router)
app.include_router(courts.router)
app.include_router(auth.router)
app.include_router(websocket.router)
app.add_middleware(
+40 -17
View File
@@ -2,7 +2,7 @@
from datetime import datetime
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 ForeignKey, Integer, String
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 .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):
__tablename__ = "tournaments"
@@ -25,7 +52,7 @@ class Tournament(Base):
"Team", back_populates="tournament", cascade="all, delete-orphan"
)
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(
@@ -50,15 +77,6 @@ class Team(Base):
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):
__tablename__ = "matches"
@@ -85,16 +103,16 @@ class Match(Base):
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)
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
winner_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
@@ -112,6 +130,11 @@ class Match(Base):
court: Mapped[Optional["Court"]] = relationship()
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
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]
)
+113
View File
@@ -0,0 +1,113 @@
# 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()
)
t_ids = {m.tournament_id for m in matches}
if t_ids:
all_t_matches = db.query(models.Match).filter(models.Match.tournament_id.in_(t_ids)).all()
else:
all_t_matches = []
match_by_id = {str(tm.id).lower(): tm for tm in all_t_matches}
parent_map = {}
for tm in all_t_matches:
if tm.winner_next_match_id:
parent_map[(str(tm.winner_next_match_id).lower(), tm.winner_next_match_slot)] = f"Winner of #{tm.match_number}"
if tm.loser_next_match_id:
parent_map[(str(tm.loser_next_match_id).lower(), tm.loser_next_match_slot)] = f"Loser of #{tm.match_number}"
def resolve_ref(m: models.Match):
if m.ref_team:
return m.ref_team.name
if m.ref_label and ":" in m.ref_label:
outcome, ref_id = m.ref_label.split(":")
ref_match = match_by_id.get(ref_id.strip().lower())
if ref_match:
role = "Winner" if outcome.upper() == "W" else "Loser"
return f"{role} of #{ref_match.match_number}"
return m.ref_label or "TBD"
def get_team_label(m: models.Match, slot: int, team):
if team:
return team.name
return parent_map.get((str(m.id).lower(), slot), "TBD")
return {
"court": court.name,
"matches": [
{
"id": m.id,
"tournament_id": m.tournament.id,
"tournament_name": m.tournament.name,
"duration": m.tournament.duration,
"time": m.start_time.strftime("%H:%M") if m.start_time else None,
"status": m.status.value if hasattr(m.status, 'value') else m.status,
"match_number": m.match_number,
"p1": get_team_label(m, 0, m.p1_team),
"p2": get_team_label(m, 1, m.p2_team),
"p1_is_real": bool(m.p1_team),
"p2_is_real": bool(m.p2_team),
"p1_sets": len([s for s in m.sets if s.get("p1", 0) > s.get("p2", 0)]),
"p2_sets": len([s for s in m.sets if s.get("p2", 0) > s.get("p1", 0)]),
"ref_name": resolve_ref(m),
}
for m in matches
if m.start_time
],
}
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
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
+1 -1
View File
@@ -16,7 +16,7 @@ def get_tournament_matches(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/matches/{match_id}", response_model=schemas.MatchOut)
def get_match_details(id: str, match_id: str, db: Session = Depends(get_db)):
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
+6 -8
View File
@@ -19,8 +19,6 @@ def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]):
if not is_admin and not code_matches:
raise HTTPException(403, "Invalid tournament code or admin privileges required")
@router.post("/{id}/matches/{match_id}/score")
async def report_score(
id: str,
@@ -29,13 +27,13 @@ async def report_score(
db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user),
):
t = crud.get_tournament(db, id)
t = crud.get_tournament(db, id, lock=True)
if not t:
raise HTTPException(404, "Tournament not found")
_check_auth(t, user, report.code)
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
@@ -65,13 +63,13 @@ async def edit_score(
db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user),
):
t = crud.get_tournament(db, id)
t = crud.get_tournament(db, id, lock=True)
if not t:
raise HTTPException(404, "Tournament not found")
_check_auth(t, user, report.code)
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
@@ -99,13 +97,13 @@ async def clear_score(
db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user),
):
t = crud.get_tournament(db, id)
t = crud.get_tournament(db, id, lock=True)
if not t:
raise HTTPException(404, "Tournament not found")
_check_auth(t, user, code)
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
@@ -60,3 +60,17 @@ async def delete_tournament(
await send_ws_update(id)
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
+5 -1
View File
@@ -50,6 +50,10 @@ class MatchOut(BaseModel):
p2_team_id: int | None = None
winner_team_id: int | None = None
ref_team_id: int | None = None
ref_label: str | None = None
ref_team: TeamSchema | None = None
winner_next_match_id: str | None = None
winner_next_match_slot: int | None = None
loser_next_match_id: str | None = None
@@ -67,7 +71,7 @@ class TournamentCreate(BaseModel):
timestamp: datetime
duration: int
teams: list[str]
courts: list[str]
courts: list[int]
class TournamentUpdate(BaseModel):
+20 -3
View File
@@ -7,6 +7,7 @@ from app.database import Base, get_db
# Import your app and models
from app.main import app
from app import models
from fastapi import Request
from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine
@@ -43,6 +44,15 @@ def db(prepare_db) -> Generator[Session, None, None]:
connection.close()
@pytest.fixture(autouse=True)
def clean_db(db):
db.query(models.Match).delete()
db.query(models.Team).delete()
db.query(models.Court).delete()
db.commit()
yield
@pytest.fixture(scope="function")
async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
def override_get_db():
@@ -85,13 +95,20 @@ def auth_headers(client):
@pytest.fixture
def valid_tournament_payload():
async def valid_tournament_payload(db):
# 1. Create global courts first
c1 = models.Court(name="Plan 1")
c2 = models.Court(name="Plan 2")
db.add_all([c1, c2])
db.commit()
# 2. Return payload with IDs
return {
"name": "Test Tournament",
"code": "1234",
"type": "Double",
"timestamp": "2024-01-01T10:00:00",
"timestamp": "2026-05-24T11:00:00",
"duration": 15,
"teams": ["Team A", "Team B", "Team C", "Team D"],
"courts": ["Court 1", "Court 2"],
"courts": [c1.id, c2.id],
}
+11 -9
View File
@@ -44,7 +44,9 @@ async def test_manage_teams(
# Verify the teams are actually in the first match
first_match = final_matches[0]
print(first_match)
assert first_match["p1_team_id"] is not None
assert first_match["p2_team_id"] is not None
p1_name = first_match["p1_team_id"]
p2_name = first_match["p2_team_id"]
@@ -60,21 +62,21 @@ async def test_manage_courts(
)
t_id = res.json()["id"]
# Get initial courts
# 1. Get initial courts from the tournament detail
courts_res = await client.get(f"/tournaments/{t_id}")
initial_courts = courts_res.json()["courts"]
assert len(initial_courts) == 2
# Delete
court_id = initial_courts[0]["id"]
del_res = await client.delete(
f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers
)
# 2. Extract the ID from the list so the variable is defined!
court_to_delete_id = initial_courts[0]["id"]
# 3. Now use that variable in your delete call
del_res = await client.delete(f"/courts/{court_to_delete_id}", headers=auth_headers)
assert del_res.status_code == 200
# Create
# CREATE: Change this from a tournament-specific route to the global one
create_res = await client.post(
f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers
"/courts", json={"name": "New Court"}, headers=auth_headers # GLOBAL ROUTE
)
assert create_res.status_code == 200
assert create_res.json()["name"] == "New Court"
+4
View File
@@ -1,5 +1,9 @@
ADMIN_USER=admin
ADMIN_PASSWORD=admin
REF_USER=ref
REF_PASSWORD=ref
SECRET_KEY=PLEASE_REPLACE_ME_WITH_A_SECRET_KEY
# Optional
+2 -2
View File
@@ -5,7 +5,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="src/assets/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VolleyManager</title>
@@ -13,7 +13,7 @@
<body class="transition bg-zinc-50 dark:bg-zinc-950">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5242 -710
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -7,7 +7,7 @@
"dev": "vite --host",
"build": "tsc -b && vite build",
"lint": "tsc -b && eslint .",
"preview": "vite preview",
"preview": "vite preview --host",
"test": "vitest"
},
"dependencies": {
@@ -38,6 +38,8 @@
"tailwindcss": "^4.1.18",
"typescript-eslint": "^8.57.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-svgr": "^5.0.0",
"vitest": "^4.0.18"
}
}
}

Before

Width:  |  Height:  |  Size: 966 B

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+2
View File
@@ -5,6 +5,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Courts from './pages/Courts';
import Tournament from './pages/Tournament';
export default function App() {
@@ -34,6 +35,7 @@ export default function App() {
{/* Main App Layout */}
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
<Route path="/" element={<Dashboard />} />
<Route path="/courts" element={<Courts />} />
<Route path="/tournaments/:id" element={<Tournament />} />
</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

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor">
<path d="M400 64c8.8 0 16 7.2 16 16 0 17.7 14.3 32 32 32s32-14.3 32-32c0-8.8 7.2-16 16-16l112 0c17.7 0 32 14.3 32 32l0 70.3c0 15-10.4 28-25.1 31.2L413.8 242.2c1.4 9.7 2.2 19.6 2.2 29.8 0 114.9-93.1 208-208 208S0 386.9 0 272 93.1 64 208 64l192 0zM208 192a80 80 0 1 0 0 160 80 80 0 1 0 0-160z"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

+94 -39
View File
@@ -1,6 +1,7 @@
// frontend/src/components/Bracket/BracketView.tsx
import React, { useEffect, useRef, useState } from 'react';
import { ZoomIn, ZoomOut } from 'lucide-react';
import { type MatchData } from '../../types';
import Podium from "../Tournament/Podium";
import MatchCard from "./MatchCard";
@@ -13,10 +14,19 @@ interface BracketViewProps {
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<React.ReactElement[]>([]);
const [zoom, setZoom] = useState<number>(1);
const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const draw = () => {
if (!containerRef.current) return;
// Measure the unscaled bracket size to fix scrollbars later
setContentSize({
width: containerRef.current.scrollWidth,
height: containerRef.current.scrollHeight
});
const container = containerRef.current.getBoundingClientRect();
const newLines: React.ReactElement[] = [];
@@ -26,9 +36,13 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const sx = (r1.right - container.left) / zoom;
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
const ex = (r2.left - container.left) / zoom;
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
const c1 = sx + (ex - sx) / 2;
newLines.push(
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
);
@@ -42,9 +56,13 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
const eEl = document.getElementById(`match-${targetMatch.id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const sx = (r1.right - container.left) / zoom;
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
const ex = (r2.left - container.left) / zoom;
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
const c1 = sx + (ex - sx) / 2;
newLines.push(
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:stroke-zinc-400! fill-none stroke-[1.5px]" />
);
@@ -55,7 +73,7 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
setLines(newLines);
};
const t = setTimeout(draw, 100);
const t = setTimeout(draw, 50);
window.addEventListener('resize', draw);
const handlePrint = () => { draw(); setTimeout(draw, 100); };
@@ -71,7 +89,7 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
window.removeEventListener('beforeprint', handlePrint);
window.removeEventListener('afterprint', draw);
};
}, [matches]);
}, [matches, zoom]);
const renderRound = (list: MatchData[]) => {
const rounds: Record<number, MatchData[]> = {};
@@ -109,44 +127,81 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
}
return (
<div className="transition-colors w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-zinc-50 dark:bg-zinc-950 bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-white! print:bg-none!">
<style>
{`@media print {
@page { size: landscape; margin: 0.5cm; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
}`}
</style>
<div className="relative w-full h-full flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-950 print:bg-white!">
<div ref={containerRef} className="relative min-w-max min-h-full p-12 flex gap-20 items-center">
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
{lines}
</svg>
{/* FLOATING ZOOM CONTROLS (Moved to top-6 to completely avoid theme button) */}
<div className="absolute top-6 right-6 flex flex-col gap-3 z-50 print:hidden">
<button
onClick={() => setZoom(z => Math.min(1, z + 0.1))}
disabled={zoom >= 1}
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
title="Zoom In"
>
<ZoomIn size={20} strokeWidth={2.5} />
</button>
<button
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
disabled={zoom <= 0.3}
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
title="Zoom Out"
>
<ZoomOut size={20} strokeWidth={2.5} />
</button>
</div>
<div className="flex flex-col gap-24">
<div className="relative">
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Winners Bracket</div>
<div className="flex gap-20">{renderRound(displayWb)}</div>
</div>
{isDoubleElim && (
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:border-zinc-400!">
<div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Losers Bracket</div>
<div className="flex gap-20 mt-4">{renderRound(lb)}</div>
</div>
)}
</div>
<div className="flex-1 overflow-auto bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-none!">
<style>
{`@media print {
@page { size: landscape; margin: 0.5cm; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
}`}
</style>
<div className="flex flex-col justify-center gap-6 z-10">
{displayFinals.length > 0 && (
<div className="relative flex flex-col gap-6">
<div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:bg-transparent! print:border-black! print:text-black! px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap">
Championship
{/* SIZER WRAPPER (Dynamically scales width/height to fix the scrollbar ghost space) */}
<div style={{
width: contentSize.width ? `${contentSize.width * zoom}px` : 'max-content',
height: contentSize.height ? `${contentSize.height * zoom}px` : 'max-content'
}}>
{/* SCALING WRAPPER */}
<div
className="origin-top-left print:transform-none! w-max h-max"
style={{ transform: `scale(${zoom})` }}
>
{/* Added pt-16 md:pt-20 to ensure absolute titles aren't clipped
*/}
<div ref={containerRef} className="relative min-w-max min-h-full pt-16 md:pt-20 px-6 md:px-12 pb-40 md:pb-32 flex gap-12 md:gap-20 items-center">
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
{lines}
</svg>
<div className="flex flex-col gap-24">
<div className="relative">
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Winners Bracket</div>
<div className="flex gap-20">{renderRound(displayWb)}</div>
</div>
{isDoubleElim && (
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:border-zinc-400!">
<div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Losers Bracket</div>
<div className="flex gap-20 mt-4">{renderRound(lb)}</div>
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
{displayFinals.length > 0 && (
<div className="relative flex flex-col gap-6">
<div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:bg-transparent! print:border-black! print:text-black! px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap">
Championship
</div>
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
<Podium matches={matches} />
</div>
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
<Podium matches={matches} />
</div>
</div>
</div>
</div>
+12 -1
View File
@@ -3,6 +3,7 @@
import { Check } from 'lucide-react';
import { type MatchData } from '../../types';
import { printName, stringToColor } from '../../utils/helpers';
import WhistleIcon from "../../assets/whistle.svg?react"
interface MatchCardProps {
match: MatchData;
@@ -21,6 +22,8 @@ export default function MatchCard({ match, onClick }: MatchCardProps) {
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
: 'cursor-default opacity-100';
const refName = match.ref_team?.name || match.ref_label;
return (
<div
id={`match-${match.id}`}
@@ -43,7 +46,7 @@ export default function MatchCard({ match, onClick }: MatchCardProps) {
)}
</div>
<div className="p-2 space-y-1.5">
<div className="p-2 space-y-1.5 flex-1 flex flex-col justify-center">
{[
{ n: match.p1, s: match.p1_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, real: match.p1_is_real },
{ n: match.p2, s: match.p2_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, real: match.p2_is_real }
@@ -66,6 +69,14 @@ export default function MatchCard({ match, onClick }: MatchCardProps) {
</div>
))}
</div>
{/* --- FLOATING REF BADGE --- */}
{refName && !isFinished && (
<div className="absolute -bottom-2.5 left-1/2 -translate-x-1/2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 text-[8px] font-black uppercase tracking-widest px-2.5 py-0.5 rounded-full shadow-sm whitespace-nowrap z-20 flex items-center gap-1.5 print:hidden max-w-[90%]">
<WhistleIcon className="text-orange-500 shrink-0" width={12} height={12} />
<span className="truncate">{refName}</span>
</div>
)}
</div>
);
}
@@ -1,6 +1,7 @@
// 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 {
id: string | number;
@@ -45,7 +46,7 @@ export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps
{t.team_count} Teams
</div>
<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
</div>
@@ -1,9 +1,14 @@
// frontend/src/components/Forms/TournamentForm.tsx
import { Loader2 } from 'lucide-react';
import { Loader2, Plus } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import api from '../../services/api';
interface Court {
id: number;
name: string;
}
interface TournamentSettings {
id: string | number;
name: string;
@@ -11,7 +16,7 @@ interface TournamentSettings {
type: string;
timestamp: string;
duration: number;
courts: { name?: string }[];
courts: Court[];
teams: { name?: string }[];
}
@@ -23,33 +28,55 @@ interface TournamentFormProps {
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
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 [isSubmitting, setIsSubmitting] = useState<boolean>(false);
useEffect(() => {
if (!tournamentId) {
setInitialData(null);
setIsLoading(false);
return;
}
const fetchSettings = async () => {
const fetchDependencies = async () => {
try {
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
const courtsRes = await api.get<Court[]>('/courts');
setGlobalCourts(courtsRes);
if (tournamentId) {
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
setSelectedCourts(data.courts.map(c => c.id));
}
} catch (err) {
console.error(err);
setError("Failed to load tournament settings.");
setError("Failed to load tournament data.");
} finally {
setIsLoading(false);
}
};
fetchSettings();
void fetchDependencies();
}, [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();
setIsSubmitting(true);
setError(null);
@@ -57,7 +84,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const formData = new FormData(e.currentTarget);
const rawTeams = formData.get('teams') as string;
const rawCourts = formData.get('courts') as string;
const date = formData.get('date') as string;
const startTime = formData.get('start_time') 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 teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
if (teams.length < 2) {
setError("At least 2 teams required.");
@@ -74,6 +99,12 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
return;
}
if (selectedCourts.length === 0) {
setError("Please select at least one court.");
setIsSubmitting(false);
return;
}
const timestamp = `${date}T${startTime}:00`;
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
const parsedDuration = parseInt(duration, 10);
@@ -81,39 +112,25 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
try {
if (tournamentId) {
const basePayload = {
name,
code,
type: formattedType,
timestamp,
duration: parsedDuration
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)
api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
]);
} else {
const fullPayload = {
name,
code,
type: formattedType,
timestamp,
duration: parsedDuration,
teams,
courts
name, code, type: formattedType, timestamp, duration: parsedDuration,
teams, courts: selectedCourts
};
await api.post('/tournaments', fullPayload);
}
onSuccess();
} catch (err: unknown) {
console.error(err);
const error = err as { detail?: string | Array<{ loc: string[]; msg: string }> };
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");
}
setError("Error saving tournament");
} finally {
setIsSubmitting(false);
}
@@ -147,35 +164,17 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
<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"
/>
<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" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
<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"
/>
<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" />
</div>
<div>
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
<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"
>
<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">
<option value="double">Double Elimination</option>
<option value="single">Single Elimination</option>
</select>
@@ -185,43 +184,50 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="grid grid-cols-7 gap-4">
<div className="col-span-2">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
<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"
/>
<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" />
</div>
<div className="col-span-2">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
<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"
/>
<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" />
</div>
<div className="col-span-3">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
<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"
/>
<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" />
</div>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label>
<input
name="courts"
placeholder="Center Court, Court 1"
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"
/>
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
<div className="flex flex-wrap gap-2 mb-3">
{globalCourts.map(c => (
<button
key={c.id}
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>
@@ -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">
{tournamentId && onDelete && (
<button
type="button"
onClick={() => onDelete(tournamentId)}
className="text-red-500 text-sm hover:underline h-5 self-end"
>
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
Delete Tournament
</button>
)}
{!tournamentId && <div className="hidden"></div>}
<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"
>
<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">
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
</button>
</div>
+41 -23
View File
@@ -1,7 +1,8 @@
// frontend/src/components/Layout/Navbar.tsx
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 {
title: string;
@@ -11,39 +12,56 @@ interface NavbarProps {
}
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
return (
<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>
const location = useLocation();
<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">
<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">
return (
<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'}
</div>
{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}
</div>
)}
</div>
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
{/* Right: Actions */}
<div className="flex items-center gap-3">
{isAuthenticated ? (
<>
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
<LogOut size={18} className="sm:size-5.5" />
</button>
</>
<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">
<LogOut size={20} />
</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">
<Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
<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={14} /> Login
</Link>
)}
</div>
@@ -4,6 +4,7 @@ import { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react';
import React from 'react';
import { type MatchData } from '../../types';
import { printName, stringToColor } from '../../utils/helpers';
import WhistleIcon from "../../assets/whistle.svg?react"
interface ScheduleRowProps {
match: MatchData;
@@ -14,9 +15,10 @@ interface ScheduleRowProps {
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: ScheduleRowProps) {
const courtColor = stringToColor(m.court);
const isFinished = m.isFinished;
const refName = m.ref_team?.name || m.ref_label;
return (
<div className="transition-colors bg-white dark:bg-zinc-900 print:bg-white! p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:border-b! print:border-x-0! print:border-t-0! print:border-zinc-300! shadow-sm print:shadow-none! flex items-center justify-between hover:border-orange-500/30">
<div className="relative mb-5 transition-colors bg-white dark:bg-zinc-900 print:bg-white! p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:border-b! print:border-x-0! print:border-t-0! print:border-zinc-300! shadow-sm print:shadow-none! flex items-center justify-between hover:border-orange-500/30">
<div className="flex gap-4 md:gap-6 print:gap-6 flex-1 min-w-0">
<div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}>
<div className="text-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:text-black!">{m.time}</div>
@@ -42,7 +44,10 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: Sche
</React.Fragment>
))}
</div>
<div className="transition-colors hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:bg-transparent! text-zinc-400 print:text-zinc-500! px-2 py-1 rounded w-fit">Match #{m.number}</div>
<div className="flex flex-col md:flex-row md:items-center gap-0.5 md:gap-2 mt-0.5 md:mt-1">
<div className="transition-colors hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:bg-transparent! text-zinc-400 print:text-zinc-500! px-2 py-1 rounded w-fit">Match #{m.number}</div>
</div>
</div>
</div>
@@ -54,19 +59,14 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: Sche
className="flex flex-col justify-between items-center md:justify-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-1.5 md:p-2 rounded-xl transition group/btn min-w-8 md:min-w-20 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
title="Edit Score"
>
{/* --- MOBILE VERTICAL STACK --- */}
<div className={`${m.winnerName === m.p1 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
{m.p1_sets}
</div>
{/* Clean minimal vertical line for mobile */}
<div className="w-0.5 h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
<div className={`${m.winnerName === m.p2 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
{m.p2_sets}
</div>
{/* --- DESKTOP VIEW (Default + Hover Edit) --- */}
<div className="hidden md:flex group-hover/btn:hidden flex-col items-end">
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1">
<CheckCircle size={12} strokeWidth={3} /> Finished
@@ -102,6 +102,13 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: Sche
{isFinished ? m.p2_sets : ''}
</div>
</div>
{refName && !isFinished && (
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-300 text-[9px] md:text-[10px] font-black uppercase tracking-widest px-3 md:px-4 py-1 rounded-full shadow-md whitespace-nowrap z-20 flex items-center gap-1.5 print:hidden max-w-[90%]">
<WhistleIcon className="text-orange-500 shrink-0" width={12} height={12} />
<span className="truncate">{refName}</span>
</div>
)}
</div>
);
}
@@ -21,7 +21,7 @@ export default function ScheduleView({ schedule, onMatchClick }: ScheduleViewPro
const badgeWidth = Math.max(80, longestCourt.length * 10);
const filteredAndSorted = schedule
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}`).toLowerCase().includes(filter.toLowerCase()))
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}` + m.ref_label).toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
const timeA = a.start_time || a.timestamp || a.time || "";
const timeB = b.start_time || b.timestamp || b.time || "";
@@ -1,10 +1,11 @@
// frontend/src/components/Tournament/ScoreModal.tsx
import { type SetData } from '../../types';
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
import { Clock, Eraser, Trophy, Loader2 } from 'lucide-react';
import { useState } from 'react';
import Modal from '../UI/Modal';
import WhistleIcon from "../../assets/whistle.svg?react"
import CourtIcon from '../../assets/court.svg?react';
interface MatchData {
id: string | number;
@@ -15,6 +16,8 @@ interface MatchData {
p1_label?: string;
p2?: string;
p2_label?: string;
ref_label?: string;
ref_team?: { name: string };
isFinished: boolean;
sets?: SetData[];
}
@@ -30,13 +33,35 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
const [code, setCode] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // <-- NEW: Lock state
const refName = match.ref_team?.name || match.ref_label;
const handleSubmit = async () => {
if (isSubmitting) return; // Prevent double-execution
setIsSubmitting(true);
setError(null);
try {
await onSubmit(match.id, sets, code);
} catch (err: unknown) {
const error = err as { detail?: string };
setError(typeof error?.detail === 'string' ? error.detail : "Check code or scores");
setIsSubmitting(false); // Only re-enable the button if it failed (if it succeeds, modal closes)
}
};
const handleClear = async () => {
if (isSubmitting) return; // Prevent double-execution
setIsSubmitting(true);
setError(null);
try {
await onClear(match.id, code);
} catch (err: unknown) {
const error = err as { detail?: string };
setError(typeof error?.detail === 'string' ? error.detail : "Failed to clear score");
setIsSubmitting(false); // Re-enable on error
}
};
@@ -52,17 +77,35 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
return (
<div className="space-y-6">
{/* Top Info Bar */}
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
<div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
<Clock className="text-orange-500" size={18} />
<span>{match.time || "10:00"}</span>
</div>
<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">
<MapPin className="text-orange-500" size={18} />
<span>{match.court || "TBD"}</span>
<div className="flex flex-col gap-2">
{/* Time & Court Row */}
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
<div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
<Clock className="text-orange-500" size={16} />
<span>{match.time || "10:00"}</span>
</div>
<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">
<CourtIcon className="text-orange-500" width={16} height={16} />
<span>{match.court || "TBD"}</span>
</div>
</div>
{/* Dedicated Ref Row (Always visible) */}
{refName && (
<div className={`flex justify-center items-center gap-2 p-2.5 rounded-lg border transition-colors ${match.ref_team
? 'bg-orange-50 dark:bg-orange-900/10 border-orange-100 dark:border-orange-900/30'
: 'bg-zinc-50 dark:bg-zinc-950 border-gray-200 dark:border-zinc-800'
}`}>
<WhistleIcon className={`${match.ref_team ? 'text-orange-500' : 'text-zinc-400 dark:text-zinc-600'} shrink-0`} width={14} height={14} />
<span className={`truncate ${match.ref_team
? 'text-xs font-bold text-orange-700 dark:text-orange-500 uppercase tracking-wide'
: 'text-sm italic text-zinc-500 dark:text-zinc-400 font-medium'
}`}>
{refName}
</span>
</div>
)}
</div>
{error && (
@@ -131,7 +174,6 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-800 p-2 rounded text-center focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
/>
</div>
</div>
</div>
))}
@@ -142,17 +184,24 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
<div className="flex gap-2">
{match.isFinished && (
<button
onClick={() => onClear(match.id, code)}
className="w-1/3 bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-900 text-red-600 dark:text-red-300 py-3 rounded-lg font-bold transition text-sm"
onClick={handleClear}
disabled={isSubmitting}
className={`w-1/3 bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-900 text-red-600 dark:text-red-300 py-3 rounded-lg font-bold transition text-sm flex justify-center items-center ${isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Clear
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : 'Clear'}
</button>
)}
<button
onClick={handleSubmit}
className={`${match.isFinished ? 'w-2/3' : 'w-full'} bg-orange-600 hover:bg-orange-500 py-3 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition text-white active:scale-95`}
disabled={isSubmitting}
className={`${match.isFinished ? 'w-2/3' : 'w-full'} bg-orange-600 hover:bg-orange-500 py-3 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition text-white active:scale-95 flex justify-center items-center gap-2 ${isSubmitting ? 'opacity-70 cursor-not-allowed' : ''}`}
>
Submit Result
{isSubmitting ? (
<>
<Loader2 size={18} className="animate-spin" />
<span>Saving...</span>
</>
) : 'Submit Result'}
</button>
</div>
</div>
+339
View File
@@ -0,0 +1,339 @@
// 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 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;
duration: number;
status: string;
match_number: number;
p1: string;
p2: string;
p1_is_real: boolean;
p2_is_real: boolean;
p1_sets: number;
p2_sets: number;
ref_name?: string;
}
interface CourtSchedule {
court: string;
matches: CourtMatch[];
}
interface GlobalCourt {
id: number;
name: string;
}
interface OutletContext {
setNavTitle: (t: string) => void;
setNavSubtitle: (s: string) => void;
role: string | null;
}
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]);
let isDayFinished = false;
let activeIndex = -1;
if (data && data.matches.length > 0 && currentTime) {
const [currH, currM] = currentTime.split(':').map(Number);
const currTotalMins = currH * 60 + currM;
for (let i = 0; i < data.matches.length; i++) {
const m = data.matches[i];
if (!m.time) continue;
const [mH, mM] = m.time.split(':').map(Number);
const mStartTotalMins = mH * 60 + mM;
const duration = m.duration || 30;
const mEndTotalMins = mStartTotalMins + duration;
if (currTotalMins >= mStartTotalMins && currTotalMins < mEndTotalMins) {
activeIndex = i;
}
}
const lastMatch = data.matches[data.matches.length - 1];
if (lastMatch.time) {
const [lastH, lastM] = lastMatch.time.split(':').map(Number);
const lastStartTotal = lastH * 60 + lastM;
const lastDuration = lastMatch.duration || 30;
const lastEndTotal = lastStartTotal + lastDuration;
if (currTotalMins >= lastEndTotal) {
isDayFinished = true;
activeIndex = -1;
}
}
}
// AUTO-SCROLL LOGIC
useEffect(() => {
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
setTimeout(() => {
const container = scrollContainerRef.current;
if (!container) return;
if (isDayFinished) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
} else {
const line = container.querySelector('.now-line') as HTMLElement;
if (line) {
container.scrollTo({
top: line.offsetTop - 20,
behavior: 'smooth'
});
}
}
setHasScrolled(true);
}, 500);
}
}, [data, currentTime, hasScrolled, isDayFinished]);
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>;
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 relative">
{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 = isDayFinished || (activeIndex !== -1 && index < activeIndex);
const isLive = !isDayFinished && index === activeIndex;
const showNowLine = !isDayFinished && ((activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0));
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 || isPastSlot || isDayFinished) {
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" style={{ scrollMarginTop: '20px' }}>
<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={`truncate pr-2
${isFinished && m.p1_sets > m.p2_sets ? 'font-bold text-orange-500' :
m.p1_is_real ? `font-bold ${pOpacity}` :
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
>
{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={`truncate pr-2
${isFinished && m.p2_sets > m.p1_sets ? 'font-bold text-orange-500' :
m.p2_is_real ? `font-bold ${pOpacity}` :
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
>
{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>
)
})}
{/* --- THE LINE AT THE VERY END --- */}
{isDayFinished && (
<div className="now-line relative flex items-center py-6 animate-in fade-in">
<div className="flex-1 border-t border-zinc-300 dark:border-zinc-700"></div>
<div className="mx-3 text-[10px] font-black text-zinc-500 uppercase tracking-widest bg-zinc-100 dark:bg-zinc-800 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-700 shadow-sm">
All Matched played for today
</div>
<div className="flex-1 border-t border-zinc-300 dark:border-zinc-700"></div>
</div>
)}
</div>
</div>
);
};
export default function Courts() {
const { setNavTitle, setNavSubtitle, role } = useOutletContext<OutletContext>();
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>
);
}
+42 -4
View File
@@ -1,6 +1,6 @@
// frontend/src/pages/Dashboard.tsx
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal } from 'lucide-react';
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal, Loader2 } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import DashCard from '../components/Dashboard/DashCard';
@@ -32,6 +32,7 @@ export default function Dashboard() {
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
const [showPast, setShowPast] = useState<boolean>(false);
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const loadDashboard = useCallback(async () => {
@@ -41,17 +42,23 @@ export default function Dashboard() {
setTournaments(list as TournamentType[]);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setNavTitle('Dashboard');
setNavSubtitle('');
void loadDashboard();
localStorage.removeItem('volley_view');
(async () => {
await loadDashboard();
})();
let ws: WebSocket;
let reconnectTimeout: ReturnType<typeof setTimeout>;
const connect = () => {
try {
ws = new WebSocket(WS_URL);
@@ -59,13 +66,36 @@ export default function Dashboard() {
const msg = JSON.parse(e.data);
if (msg.type === 'dashboard_update') loadDashboard();
};
ws.onclose = () => {
reconnectTimeout = setTimeout(connect, 3000);
};
} catch (err) {
console.error("WebSocket connection failed", err);
}
};
connect();
return () => { if (ws) ws.close(); };
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
(async () => {
await loadDashboard();
})();
if (!ws || ws.readyState === WebSocket.CLOSED) {
clearTimeout(reconnectTimeout);
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearTimeout(reconnectTimeout);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (ws) {
ws.onclose = null;
ws.close();
}
};
}, [setNavTitle, setNavSubtitle, loadDashboard]);
const handleEdit = (t: TournamentType) => {
@@ -86,6 +116,14 @@ export default function Dashboard() {
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="animate-spin text-orange-600" size={48} />
</div>
);
}
const now = new Date();
const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
live: [],
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Login() {
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
+68 -24
View File
@@ -21,17 +21,22 @@ interface RawMatch {
round_number: number;
match_number: number;
winner_next_match_id?: string | number | null;
winner_next_match_slot?: number | null;
loser_next_match_id?: string | number | null;
loser_next_match_slot?: number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_team_id?: string | number | null;
ref_team_id?: string | number | null;
ref_label?: string | null;
ref_team?: { id: string | number; name: string } | null;
status: string;
court_id: string | number;
start_time?: string;
sets?: { p1: number; p2: number }[];
}
export interface ProcessedMatch extends RawMatch {
export interface ProcessedMatch extends Omit<RawMatch, 'ref_label' | 'ref_team'> {
bracket: string;
round: number;
number: number;
@@ -47,6 +52,8 @@ export interface ProcessedMatch extends RawMatch {
p2_sets: number;
hasTeams: boolean;
isFinished: boolean;
ref_label?: string;
ref_team?: { id: string | number; name: string };
}
interface OutletContextType {
@@ -88,26 +95,34 @@ export default function Tournament() {
const courtMap: Record<string | number, string> = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap: Record<string | number, string> = Object.fromEntries(teams.map(t => [t.id, t.name]));
const incoming: Record<string | number, { label: string; id: string | number }[]> = {};
rawMatches.forEach(m => {
const num = m.match_number;
if (m.winner_next_match_id) {
(incoming[m.winner_next_match_id] = incoming[m.winner_next_match_id] || []).push({ label: `Winner of #${num}`, id: m.id });
}
if (m.loser_next_match_id) {
(incoming[m.loser_next_match_id] = incoming[m.loser_next_match_id] || []).push({ label: `Loser of #${num}`, id: m.id });
}
});
return rawMatches
.filter(m => m.id !== skippedResetMatchId)
.map(m => {
const sources = incoming[m.id] || [];
let sourceIndex = 0;
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
// BUG 1 FIX: Explicitly check the SLOT (0 for top, 1 for bottom)
// to prevent duplicated labels.
const getPlaceholder = (slot: number) => {
const winParent = rawMatches.find(rm => rm.winner_next_match_id === m.id && rm.winner_next_match_slot === slot);
if (winParent) return `Winner of #${winParent.match_number}`;
const loseParent = rawMatches.find(rm => rm.loser_next_match_id === m.id && rm.loser_next_match_slot === slot);
if (loseParent) return `Loser of #${loseParent.match_number}`;
return 'TBD';
};
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : getPlaceholder(0);
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : getPlaceholder(1);
// BUG 2 FIX: Resolve Backend Graph Links ("W:uuid" / "L:uuid") for Referees
let resolvedRefLabel = m.ref_label;
if (resolvedRefLabel && resolvedRefLabel.includes(':')) {
const [outcome, refId] = resolvedRefLabel.split(':');
const refMatch = rawMatches.find(rm => String(rm.id) === String(refId));
if (refMatch) {
resolvedRefLabel = `${outcome === 'W' ? 'Winner' : 'Loser'} of #${refMatch.match_number}`;
}
}
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
const isFinished = m.status === "Finished";
@@ -116,6 +131,8 @@ export default function Tournament() {
return {
...m,
ref_label: resolvedRefLabel || undefined,
ref_team: m.ref_team || undefined,
bracket: m.bracket_type,
round: m.round_number,
number: m.match_number,
@@ -149,24 +166,51 @@ export default function Tournament() {
}, [id, setNavTitle, setNavSubtitle]);
useEffect(() => {
void fetchData();
if (wsRef.current) return;
(async () => {
await fetchData();
})();
let reconnectTimeout: ReturnType<typeof setTimeout>;
const connect = () => {
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) void fetchData();
if (msg.type === 'tournament_update' && msg.id === id) {
(async () => {
await fetchData();
})();
}
};
ws.onclose = () => {
wsRef.current = null;
reconnectTimeout = setTimeout(connect, 3000);
};
ws.onclose = () => { wsRef.current = null; };
};
connect();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
(async () => {
await fetchData();
})();
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
clearTimeout(reconnectTimeout);
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
if (wsRef.current?.readyState === 1) wsRef.current.close();
wsRef.current = null;
clearTimeout(reconnectTimeout);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
};
}, [id, fetchData]);
+2
View File
@@ -31,6 +31,8 @@ export interface MatchData {
loser_next_match_id?: string | number | null;
timestamp?: string;
start_time?: string;
ref_label?: string;
ref_team?: { id: string | number; name: string };
}
export interface SetData {
+2 -1
View File
@@ -1,3 +1,4 @@
// frontend/src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
+35 -1
View File
@@ -3,11 +3,45 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
import { VitePWA } from 'vite-plugin-pwa';
import svgr from 'vite-plugin-svgr';
export default defineConfig({
plugins: [
react(),
tailwindcss()
tailwindcss(),
svgr(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg'],
manifest: {
name: 'VolleyManager',
short_name: 'VolleyManager',
description: 'Tournament Operations Manager',
theme_color: '#09090b',
background_color: '#fafafa',
display: 'standalone',
orientation: 'portrait-primary',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
}
})
],
test: {
globals: true,