Compare commits
+16
-17
@@ -1,7 +1,7 @@
|
|||||||
# backend/app/crud.py
|
# backend/app/crud.py
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from . import logic, models, schemas
|
from . import logic, models, schemas
|
||||||
|
|
||||||
@@ -45,17 +45,14 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
|||||||
def get_tournaments(db: Session):
|
def get_tournaments(db: Session):
|
||||||
return db.query(models.Tournament).all()
|
return db.query(models.Tournament).all()
|
||||||
|
|
||||||
|
def get_tournament(db: Session, tournament_id: str, lock: bool = False):
|
||||||
def get_tournament(db: Session, tournament_id: str):
|
query = db.query(models.Tournament).filter(models.Tournament.id == tournament_id)
|
||||||
return (
|
if lock:
|
||||||
db.query(models.Tournament)
|
query = query.with_for_update()
|
||||||
.filter(models.Tournament.id == tournament_id)
|
return query.first()
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_tournament(db: Session, tournament_id: str) -> bool:
|
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:
|
if not t:
|
||||||
return False
|
return False
|
||||||
db.delete(t)
|
db.delete(t)
|
||||||
@@ -66,7 +63,7 @@ def delete_tournament(db: Session, tournament_id: str) -> bool:
|
|||||||
def update_tournament_details(
|
def update_tournament_details(
|
||||||
db: Session, tournament_id: str, data: schemas.TournamentUpdate
|
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:
|
if not t:
|
||||||
return None
|
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):
|
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:
|
if not t:
|
||||||
return None
|
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]):
|
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:
|
if not t:
|
||||||
return None
|
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):
|
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:
|
if not t:
|
||||||
return None
|
return None
|
||||||
team = db.get(models.Team, team_id)
|
team = db.get(models.Team, team_id)
|
||||||
@@ -182,7 +179,7 @@ def delete_court(db: Session, court_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
|
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -201,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 (
|
return (
|
||||||
db.query(models.Match)
|
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)
|
.filter(models.Match.id == match_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|||||||
+87
-82
@@ -1,4 +1,5 @@
|
|||||||
# backend/app/logic.py
|
# backend/app/logic.py
|
||||||
|
import heapq
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -9,6 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
from . import models
|
from . import models
|
||||||
from .constants import BracketTypes, MatchStatus, TournamentTypes
|
from .constants import BracketTypes, MatchStatus, TournamentTypes
|
||||||
from .core.brackets import BracketGenerator
|
from .core.brackets import BracketGenerator
|
||||||
|
from .core.structures import Match
|
||||||
|
|
||||||
|
|
||||||
def generate_bracket(db: Session, t: models.Tournament):
|
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)
|
abstract_matches = gen.generate(len(teams), double_elimination=is_double)
|
||||||
id_map = {m.id: str(uuid4()) for m in abstract_matches}
|
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:
|
if is_winner_path:
|
||||||
curr = match_node.next_win
|
curr = match_node.next_win
|
||||||
slot = getattr(match_node, "next_win_slot", None)
|
slot = getattr(match_node, "next_win_slot", None)
|
||||||
@@ -93,6 +95,7 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
models.Tournament.timestamp >= start_of_day,
|
models.Tournament.timestamp >= start_of_day,
|
||||||
models.Tournament.timestamp < end_of_day,
|
models.Tournament.timestamp < end_of_day,
|
||||||
)
|
)
|
||||||
|
.with_for_update()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,16 +136,14 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
for m in all_matches:
|
for m in all_matches:
|
||||||
get_depth(m.id)
|
get_depth(m.id)
|
||||||
|
|
||||||
# 4. Initialize timers & lock finished matches
|
# 4. Initialize timers
|
||||||
all_courts: set[models.Court] = set()
|
all_courts: set[models.Court] = set()
|
||||||
court_sharing_count = defaultdict(
|
court_sharing_count = defaultdict(int)
|
||||||
int
|
|
||||||
) # NEW: Track how highly contested each court is
|
|
||||||
|
|
||||||
for t_item in tournaments:
|
for t_item in tournaments:
|
||||||
for c in t_item.courts:
|
for c in t_item.courts:
|
||||||
all_courts.add(c)
|
all_courts.add(c)
|
||||||
court_sharing_count[c.id] += 1 # NEW
|
court_sharing_count[c.id] += 1
|
||||||
|
|
||||||
court_timers = {c.id: start_of_day for c in all_courts}
|
court_timers = {c.id: start_of_day for c in all_courts}
|
||||||
planned_finish_times = {}
|
planned_finish_times = {}
|
||||||
@@ -159,8 +160,9 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
|
|
||||||
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
||||||
|
|
||||||
# 5. Global Interleaving Schedule Loop
|
# 5. Critical Path Time-Stepping Scheduler
|
||||||
loop_limit = len(unscheduled) * 3
|
loop_limit = max(5000, len(unscheduled) * 50)
|
||||||
|
|
||||||
while unscheduled and loop_limit > 0:
|
while unscheduled and loop_limit > 0:
|
||||||
loop_limit -= 1
|
loop_limit -= 1
|
||||||
|
|
||||||
@@ -173,7 +175,6 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
for m in unscheduled:
|
for m in unscheduled:
|
||||||
if best_court_id not in [c.id for c in m.tournament.courts]:
|
if best_court_id not in [c.id for c in m.tournament.courts]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_ready = True
|
is_ready = True
|
||||||
max_prereq_time = (
|
max_prereq_time = (
|
||||||
m.tournament.timestamp.replace(tzinfo=None)
|
m.tournament.timestamp.replace(tzinfo=None)
|
||||||
@@ -236,36 +237,40 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
else:
|
else:
|
||||||
court_timers[best_court_id] += timedelta(minutes=5)
|
court_timers[best_court_id] += timedelta(minutes=5)
|
||||||
|
|
||||||
# 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!)
|
# 6. Ultra-Fast Smart Referee Assignment
|
||||||
all_matches = sorted(
|
all_matches.sort(key=lambda x: (x.start_time or start_of_day, x.court_id or 0))
|
||||||
all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0)
|
|
||||||
)
|
|
||||||
duty_counts = defaultdict(int)
|
duty_counts = defaultdict(int)
|
||||||
active_refs = []
|
|
||||||
|
|
||||||
def is_busy(outcome_tuple, source_m: models.Match, start, end):
|
team_first_match = {}
|
||||||
next_m_id = (
|
for m in all_matches:
|
||||||
source_m.winner_next_match_id
|
if m.start_time:
|
||||||
if outcome_tuple[0] == "W"
|
if m.p1_team_id and m.p1_team_id not in team_first_match:
|
||||||
else source_m.loser_next_match_id
|
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:
|
||||||
if next_m_id:
|
team_first_match[m.p2_team_id] = m.start_time
|
||||||
next_m = match_map[next_m_id]
|
|
||||||
next_start = next_m.start_time
|
active_refs = []
|
||||||
if next_start:
|
match_outcome_next_start = {}
|
||||||
next_end = next_start + timedelta(minutes=next_m.tournament.duration)
|
|
||||||
if not (next_end <= start or next_start >= end):
|
for m in all_matches:
|
||||||
return True
|
if m.winner_next_match_id:
|
||||||
for r_outcome, r_start, r_end in active_refs:
|
nm = match_map.get(m.winner_next_match_id)
|
||||||
if r_outcome == outcome_tuple:
|
if nm and nm.start_time:
|
||||||
if not (r_end <= start or r_start >= end):
|
match_outcome_next_start[("W", m.id)] = nm.start_time
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
for m in all_matches:
|
for m in all_matches:
|
||||||
if not m.start_time:
|
if not m.start_time:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m_start = m.start_time
|
m_start = m.start_time
|
||||||
m_end = m_start + timedelta(minutes=m.tournament.duration)
|
m_end = m_start + timedelta(minutes=m.tournament.duration)
|
||||||
|
|
||||||
@@ -292,59 +297,62 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
best_score = float("inf")
|
best_score = float("inf")
|
||||||
|
|
||||||
for prev_m in all_matches:
|
for prev_m in all_matches:
|
||||||
if not prev_m.start_time:
|
if prev_m.tournament_id != m.tournament_id:
|
||||||
|
continue
|
||||||
|
if not prev_m.start_time or prev_m.start_time >= m_start:
|
||||||
continue
|
continue
|
||||||
prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
|
prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
|
||||||
|
if prev_end > m_start:
|
||||||
|
continue
|
||||||
|
|
||||||
if prev_end <= m_start:
|
for outcome in ["W", "L"]:
|
||||||
l_outcome = ("L", prev_m.id)
|
out_key = (outcome, prev_m.id)
|
||||||
if not is_busy(l_outcome, prev_m, m_start, m_end):
|
next_start = match_outcome_next_start.get(out_key)
|
||||||
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
if next_start and next_start < m_end:
|
||||||
score = (duty_counts[l_outcome] * 120) + wait_mins
|
continue
|
||||||
if prev_m.court_id == m.court_id:
|
identifier = f"{outcome}:{prev_m.id}"
|
||||||
score -= 30
|
if is_ref_busy(identifier, m_start, m_end):
|
||||||
if score < best_score:
|
continue
|
||||||
best_score = score
|
|
||||||
best_outcome = (l_outcome, prev_m)
|
|
||||||
|
|
||||||
w_outcome = ("W", prev_m.id)
|
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
||||||
if not is_busy(w_outcome, prev_m, m_start, m_end):
|
score = (duty_counts[identifier] * 120) + wait_mins
|
||||||
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
if outcome == "W":
|
||||||
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
|
score += 60
|
||||||
if prev_m.court_id == m.court_id:
|
if prev_m.court_id == m.court_id:
|
||||||
score -= 30
|
score -= 30
|
||||||
if score < best_score:
|
|
||||||
best_score = score
|
if score < best_score:
|
||||||
best_outcome = (w_outcome, prev_m)
|
best_score = score
|
||||||
|
best_outcome = identifier
|
||||||
|
|
||||||
if best_outcome:
|
if best_outcome:
|
||||||
outcome_tuple, prev_m = best_outcome
|
m.ref_label = best_outcome
|
||||||
duty_counts[outcome_tuple] += 1
|
m.ref_team_id = None
|
||||||
active_refs.append((outcome_tuple, m_start, m_end))
|
duty_counts[best_outcome] += 1
|
||||||
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
|
active_refs.append((best_outcome, m_start, m_end))
|
||||||
m.ref_label = f"{role} of #{prev_m.match_number}"
|
|
||||||
else:
|
else:
|
||||||
m.ref_label = "Staff / Volunteers"
|
assigned_bye_team = False
|
||||||
for future_m in all_matches:
|
sorted_teams = sorted(
|
||||||
if future_m.start_time and future_m.start_time >= m_end:
|
m.tournament.teams, key=lambda t: duty_counts[f"TEAM_{t.id}"]
|
||||||
assigned = False
|
)
|
||||||
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
|
|
||||||
if t_id:
|
for team in sorted_teams:
|
||||||
team_identifier = f"TEAM_{t_id}"
|
t_id = team.id
|
||||||
is_team_busy = any(
|
t_first_start = team_first_match.get(t_id)
|
||||||
r_outcome == team_identifier
|
identifier = f"TEAM_{t_id}"
|
||||||
and not (r_end <= m_start or r_start >= m_end)
|
if t_first_start and t_first_start >= m_end:
|
||||||
for r_outcome, r_start, r_end in active_refs
|
if not is_ref_busy(identifier, m_start, m_end):
|
||||||
)
|
m.ref_team_id = t_id
|
||||||
if not is_team_busy:
|
m.ref_label = None
|
||||||
m.ref_team_id = t_id
|
duty_counts[identifier] += 1
|
||||||
m.ref_label = None
|
active_refs.append((identifier, m_start, m_end))
|
||||||
active_refs.append((team_identifier, m_start, m_end))
|
assigned_bye_team = True
|
||||||
assigned = True
|
|
||||||
break
|
|
||||||
if assigned:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not assigned_bye_team:
|
||||||
|
m.ref_label = "Staff / Volunteers"
|
||||||
|
m.ref_team_id = None
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -355,10 +363,10 @@ 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
|
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||||
|
|
||||||
for m in match.tournament.matches:
|
for m in match.tournament.matches:
|
||||||
if m.ref_label == f"Loser of #{match.match_number}":
|
if m.ref_label == f"L:{match.id}":
|
||||||
m.ref_team_id = loser_id
|
m.ref_team_id = loser_id
|
||||||
db.add(m)
|
db.add(m)
|
||||||
elif m.ref_label == f"Winner of #{match.match_number}":
|
elif m.ref_label == f"W:{match.id}":
|
||||||
m.ref_team_id = winner_id
|
m.ref_team_id = winner_id
|
||||||
db.add(m)
|
db.add(m)
|
||||||
|
|
||||||
@@ -427,10 +435,7 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for m in match.tournament.matches:
|
for m in match.tournament.matches:
|
||||||
if (
|
if m.ref_label in [f"L:{match.id}", f"W:{match.id}"]:
|
||||||
m.ref_label == f"Loser of #{match.match_number}"
|
|
||||||
or m.ref_label == f"Winner of #{match.match_number}"
|
|
||||||
):
|
|
||||||
m.ref_team_id = None
|
m.ref_team_id = None
|
||||||
db.add(m)
|
db.add(m)
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ from ..database import get_db
|
|||||||
|
|
||||||
router = APIRouter(prefix="/courts", tags=["Courts"])
|
router = APIRouter(prefix="/courts", tags=["Courts"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[schemas.CourtSchema])
|
@router.get("", response_model=list[schemas.CourtSchema])
|
||||||
def get_all_courts(db: Session = Depends(get_db)):
|
def get_all_courts(db: Session = Depends(get_db)):
|
||||||
"""Public route to list all global courts"""
|
"""Public route to list all global courts"""
|
||||||
return db.query(models.Court).all()
|
return db.query(models.Court).all()
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=schemas.CourtSchema)
|
@router.post("", response_model=schemas.CourtSchema)
|
||||||
def create_global_court(
|
def create_global_court(
|
||||||
data: schemas.CourtCreate,
|
data: schemas.CourtCreate,
|
||||||
@@ -26,7 +24,6 @@ def create_global_court(
|
|||||||
"""Admin route to register a new physical court"""
|
"""Admin route to register a new physical court"""
|
||||||
return crud.create_court(db, data)
|
return crud.create_court(db, data)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{court_id}")
|
@router.delete("/{court_id}")
|
||||||
def delete_global_court(
|
def delete_global_court(
|
||||||
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
|
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
|
||||||
@@ -36,7 +33,6 @@ def delete_global_court(
|
|||||||
raise HTTPException(404, "Court not found")
|
raise HTTPException(404, "Court not found")
|
||||||
return SUCCESS
|
return SUCCESS
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{court_id}/schedule")
|
@router.get("/{court_id}/schedule")
|
||||||
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
|
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
|
||||||
court = db.query(models.Court).filter(models.Court.id == court_id).first()
|
court = db.query(models.Court).filter(models.Court.id == court_id).first()
|
||||||
@@ -59,22 +55,59 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
schedule = []
|
t_ids = {m.tournament_id for m in matches}
|
||||||
for m in matches:
|
if t_ids:
|
||||||
schedule.append(
|
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,
|
"id": m.id,
|
||||||
"tournament_id": m.tournament_id,
|
"tournament_id": m.tournament.id,
|
||||||
"tournament_name": m.tournament.name,
|
"tournament_name": m.tournament.name,
|
||||||
"time": m.start_time.strftime("%H:%M") if m.start_time else "TBD",
|
"duration": m.tournament.duration,
|
||||||
"status": m.status,
|
"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,
|
"match_number": m.match_number,
|
||||||
"p1": m.p1_team.name if m.p1_team else "TBD",
|
"p1": get_team_label(m, 0, m.p1_team),
|
||||||
"p2": m.p2_team.name if m.p2_team else "TBD",
|
"p2": get_team_label(m, 1, m.p2_team),
|
||||||
"p1_sets": sum(1 for s in m.sets if s["p1"] > s["p2"]) if m.sets else 0,
|
"p1_is_real": bool(m.p1_team),
|
||||||
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0,
|
"p2_is_real": bool(m.p2_team),
|
||||||
"ref_name": m.ref_team.name if m.ref_team else m.ref_label,
|
"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
|
||||||
return {"court": court.name, "matches": schedule}
|
],
|
||||||
|
}
|
||||||
@@ -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)
|
@router.get("/{id}/matches/{match_id}", response_model=schemas.MatchOut)
|
||||||
def get_match_details(id: str, match_id: str, db: Session = Depends(get_db)):
|
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:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]):
|
|||||||
|
|
||||||
if not is_admin and not code_matches:
|
if not is_admin and not code_matches:
|
||||||
raise HTTPException(403, "Invalid tournament code or admin privileges required")
|
raise HTTPException(403, "Invalid tournament code or admin privileges required")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/matches/{match_id}/score")
|
@router.post("/{id}/matches/{match_id}/score")
|
||||||
async def report_score(
|
async def report_score(
|
||||||
id: str,
|
id: str,
|
||||||
@@ -29,13 +27,13 @@ async def report_score(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: Optional[str] = Depends(get_optional_user),
|
user: Optional[str] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
t = crud.get_tournament(db, id)
|
t = crud.get_tournament(db, id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "Tournament not found")
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
|
||||||
_check_auth(t, user, report.code)
|
_check_auth(t, user, report.code)
|
||||||
|
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|
||||||
@@ -65,13 +63,13 @@ async def edit_score(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: Optional[str] = Depends(get_optional_user),
|
user: Optional[str] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
t = crud.get_tournament(db, id)
|
t = crud.get_tournament(db, id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "Tournament not found")
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
|
||||||
_check_auth(t, user, report.code)
|
_check_auth(t, user, report.code)
|
||||||
|
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|
||||||
@@ -99,13 +97,13 @@ async def clear_score(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: Optional[str] = Depends(get_optional_user),
|
user: Optional[str] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
t = crud.get_tournament(db, id)
|
t = crud.get_tournament(db, id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "Tournament not found")
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
|
||||||
_check_auth(t, user, code)
|
_check_auth(t, user, code)
|
||||||
|
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// frontend/src/components/Tournament/ScoreModal.tsx
|
// frontend/src/components/Tournament/ScoreModal.tsx
|
||||||
|
|
||||||
import { type SetData } from '../../types';
|
import { type SetData } from '../../types';
|
||||||
import { Clock, Eraser, Trophy } from 'lucide-react';
|
import { Clock, Eraser, Trophy, Loader2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Modal from '../UI/Modal';
|
import Modal from '../UI/Modal';
|
||||||
import WhistleIcon from "../../assets/whistle.svg?react"
|
import WhistleIcon from "../../assets/whistle.svg?react"
|
||||||
@@ -33,14 +33,35 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
|||||||
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
|
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 refName = match.ref_team?.name || match.ref_label;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (isSubmitting) return; // Prevent double-execution
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(match.id, sets, code);
|
await onSubmit(match.id, sets, code);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { detail?: string };
|
const error = err as { detail?: string };
|
||||||
setError(typeof error?.detail === 'string' ? error.detail : "Check code or scores");
|
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,9 +93,15 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
|||||||
|
|
||||||
{/* Dedicated Ref Row (Always visible) */}
|
{/* Dedicated Ref Row (Always visible) */}
|
||||||
{refName && (
|
{refName && (
|
||||||
<div className="flex justify-center items-center gap-2 bg-orange-50 dark:bg-orange-900/10 p-2.5 rounded-lg border border-orange-100 dark:border-orange-900/30">
|
<div className={`flex justify-center items-center gap-2 p-2.5 rounded-lg border transition-colors ${match.ref_team
|
||||||
<WhistleIcon className="text-orange-500 shrink-0" width={14} height={14} />
|
? 'bg-orange-50 dark:bg-orange-900/10 border-orange-100 dark:border-orange-900/30'
|
||||||
<span className="text-xs font-bold text-orange-700 dark:text-orange-500 uppercase tracking-wide truncate">
|
: '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}
|
{refName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,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"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -158,17 +184,24 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{match.isFinished && (
|
{match.isFinished && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onClear(match.id, code)}
|
onClick={handleClear}
|
||||||
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"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React, { useEffect, useState, useRef } from 'react';
|
|||||||
import { Loader2, Trash, Plus } from 'lucide-react';
|
import { Loader2, Trash, Plus } from 'lucide-react';
|
||||||
import { useOutletContext, Link } from 'react-router-dom';
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { printName } from '../utils/helpers';
|
|
||||||
import WhistleIcon from '../assets/whistle.svg?react';
|
import WhistleIcon from '../assets/whistle.svg?react';
|
||||||
|
|
||||||
import CourtIcon from '../assets/court.svg?react';
|
import CourtIcon from '../assets/court.svg?react';
|
||||||
@@ -14,10 +13,13 @@ interface CourtMatch {
|
|||||||
tournament_id: string;
|
tournament_id: string;
|
||||||
tournament_name: string;
|
tournament_name: string;
|
||||||
time: string;
|
time: string;
|
||||||
|
duration: number;
|
||||||
status: string;
|
status: string;
|
||||||
match_number: number;
|
match_number: number;
|
||||||
p1: string;
|
p1: string;
|
||||||
p2: string;
|
p2: string;
|
||||||
|
p1_is_real: boolean;
|
||||||
|
p2_is_real: boolean;
|
||||||
p1_sets: number;
|
p1_sets: number;
|
||||||
p2_sets: number;
|
p2_sets: number;
|
||||||
ref_name?: string;
|
ref_name?: string;
|
||||||
@@ -72,32 +74,65 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
|
|||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
}, [courtId]);
|
}, [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
|
// AUTO-SCROLL LOGIC
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
|
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const line = scrollContainerRef.current?.querySelector('.now-line');
|
const container = scrollContainerRef.current;
|
||||||
if (line) {
|
if (!container) return;
|
||||||
line.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
setHasScrolled(true);
|
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);
|
}, 500);
|
||||||
}
|
}
|
||||||
}, [data, currentTime, hasScrolled]);
|
}, [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>;
|
if (!data) return <div className="w-80 shrink-0 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl flex items-center justify-center border border-zinc-200 dark:border-zinc-800"><Loader2 className="animate-spin text-orange-500" /></div>;
|
||||||
|
|
||||||
// --- ACTIVE INDEX LOGIC ---
|
|
||||||
let activeIndex = -1;
|
|
||||||
if (currentTime) {
|
|
||||||
// Find the index of the most recently started match
|
|
||||||
for (let i = 0; i < data.matches.length; i++) {
|
|
||||||
if (data.matches[i].time <= currentTime) {
|
|
||||||
activeIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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="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="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">
|
||||||
@@ -112,28 +147,22 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32">
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32 relative">
|
||||||
{data.matches.length === 0 && (
|
{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>
|
<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) => {
|
{data.matches.map((m, index) => {
|
||||||
const isFinished = m.status === 'Finished';
|
const isFinished = m.status === 'Finished';
|
||||||
const isPastSlot = index < activeIndex; // Strictly before the active match
|
const isPastSlot = isDayFinished || (activeIndex !== -1 && index < activeIndex);
|
||||||
const isLive = index === activeIndex && !isFinished; // Currently active and incomplete
|
const isLive = !isDayFinished && index === activeIndex;
|
||||||
|
const showNowLine = !isDayFinished && ((activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0));
|
||||||
|
|
||||||
// The line is drawn right above the active index (or index 0 if the day hasn't started)
|
|
||||||
const showNowLine = (activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0);
|
|
||||||
|
|
||||||
// Styling configurations based on match state
|
|
||||||
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
|
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
|
||||||
let textOpacity = 'text-zinc-900 dark:text-white';
|
let textOpacity = 'text-zinc-900 dark:text-white';
|
||||||
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
|
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
|
||||||
|
|
||||||
if (isFinished) {
|
if (isFinished || isPastSlot || isDayFinished) {
|
||||||
borderStyle = 'border-orange-500/30 opacity-60 grayscale hover:opacity-100 hover:grayscale-0';
|
|
||||||
textOpacity = 'text-zinc-500';
|
|
||||||
} else if (isPastSlot) {
|
|
||||||
borderStyle = 'border-zinc-300 dark:border-zinc-700 opacity-50 grayscale hover:opacity-100 hover:grayscale-0';
|
borderStyle = 'border-zinc-300 dark:border-zinc-700 opacity-50 grayscale hover:opacity-100 hover:grayscale-0';
|
||||||
textOpacity = 'text-zinc-500';
|
textOpacity = 'text-zinc-500';
|
||||||
pOpacity = 'text-zinc-500';
|
pOpacity = 'text-zinc-500';
|
||||||
@@ -147,7 +176,7 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
|
|||||||
<React.Fragment key={m.id}>
|
<React.Fragment key={m.id}>
|
||||||
{/* --- THE --NOW-- LINE --- */}
|
{/* --- THE --NOW-- LINE --- */}
|
||||||
{showNowLine && (
|
{showNowLine && (
|
||||||
<div className="now-line relative flex items-center py-3 animate-in fade-in">
|
<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="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="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 className="flex-1 border-t-2 border-red-500 rounded-full"></div>
|
||||||
@@ -175,11 +204,23 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
|
|||||||
|
|
||||||
<div className="space-y-0.5 mb-1.5">
|
<div className="space-y-0.5 mb-1.5">
|
||||||
<div className="flex justify-between items-center text-xs leading-tight">
|
<div className="flex justify-between items-center text-xs leading-tight">
|
||||||
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
|
<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>}
|
{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>
|
||||||
<div className="flex justify-between items-center text-xs leading-tight">
|
<div className="flex justify-between items-center text-xs leading-tight">
|
||||||
<span className={`font-bold truncate pr-2 ${isFinished && m.p2_sets > m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)}</span>
|
<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>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,6 +235,17 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
|
|||||||
</React.Fragment>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// frontend/src/pages/Dashboard.tsx
|
// 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 { useEffect, useState, useCallback } from 'react';
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import DashCard from '../components/Dashboard/DashCard';
|
import DashCard from '../components/Dashboard/DashCard';
|
||||||
@@ -32,6 +32,7 @@ export default function Dashboard() {
|
|||||||
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
|
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
|
||||||
const [showPast, setShowPast] = useState<boolean>(false);
|
const [showPast, setShowPast] = useState<boolean>(false);
|
||||||
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
|
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
@@ -41,6 +42,8 @@ export default function Dashboard() {
|
|||||||
setTournaments(list as TournamentType[]);
|
setTournaments(list as TournamentType[]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -54,6 +57,8 @@ export default function Dashboard() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(WS_URL);
|
ws = new WebSocket(WS_URL);
|
||||||
@@ -61,13 +66,36 @@ export default function Dashboard() {
|
|||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'dashboard_update') loadDashboard();
|
if (msg.type === 'dashboard_update') loadDashboard();
|
||||||
};
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
reconnectTimeout = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("WebSocket connection failed", err);
|
console.error("WebSocket connection failed", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
connect();
|
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]);
|
}, [setNavTitle, setNavSubtitle, loadDashboard]);
|
||||||
|
|
||||||
const handleEdit = (t: TournamentType) => {
|
const handleEdit = (t: TournamentType) => {
|
||||||
@@ -88,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 now = new Date();
|
||||||
const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
|
const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
|
||||||
live: [],
|
live: [],
|
||||||
|
|||||||
@@ -21,17 +21,22 @@ interface RawMatch {
|
|||||||
round_number: number;
|
round_number: number;
|
||||||
match_number: number;
|
match_number: number;
|
||||||
winner_next_match_id?: string | number | null;
|
winner_next_match_id?: string | number | null;
|
||||||
|
winner_next_match_slot?: number | null;
|
||||||
loser_next_match_id?: string | number | null;
|
loser_next_match_id?: string | number | null;
|
||||||
|
loser_next_match_slot?: number | null;
|
||||||
p1_team_id?: string | number | null;
|
p1_team_id?: string | number | null;
|
||||||
p2_team_id?: string | number | null;
|
p2_team_id?: string | number | null;
|
||||||
winner_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;
|
status: string;
|
||||||
court_id: string | number;
|
court_id: string | number;
|
||||||
start_time?: string;
|
start_time?: string;
|
||||||
sets?: { p1: number; p2: number }[];
|
sets?: { p1: number; p2: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessedMatch extends RawMatch {
|
export interface ProcessedMatch extends Omit<RawMatch, 'ref_label' | 'ref_team'> {
|
||||||
bracket: string;
|
bracket: string;
|
||||||
round: number;
|
round: number;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -47,6 +52,8 @@ export interface ProcessedMatch extends RawMatch {
|
|||||||
p2_sets: number;
|
p2_sets: number;
|
||||||
hasTeams: boolean;
|
hasTeams: boolean;
|
||||||
isFinished: boolean;
|
isFinished: boolean;
|
||||||
|
ref_label?: string;
|
||||||
|
ref_team?: { id: string | number; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OutletContextType {
|
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 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 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
|
return rawMatches
|
||||||
.filter(m => m.id !== skippedResetMatchId)
|
.filter(m => m.id !== skippedResetMatchId)
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const sources = incoming[m.id] || [];
|
|
||||||
|
|
||||||
let sourceIndex = 0;
|
// BUG 1 FIX: Explicitly check the SLOT (0 for top, 1 for bottom)
|
||||||
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
|
// to prevent duplicated labels.
|
||||||
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
|
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 hasTeams = !!(m.p1_team_id && m.p2_team_id);
|
||||||
const isFinished = m.status === "Finished";
|
const isFinished = m.status === "Finished";
|
||||||
@@ -116,6 +131,8 @@ export default function Tournament() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
|
ref_label: resolvedRefLabel || undefined,
|
||||||
|
ref_team: m.ref_team || undefined,
|
||||||
bracket: m.bracket_type,
|
bracket: m.bracket_type,
|
||||||
round: m.round_number,
|
round: m.round_number,
|
||||||
number: m.match_number,
|
number: m.match_number,
|
||||||
@@ -153,23 +170,47 @@ export default function Tournament() {
|
|||||||
await fetchData();
|
await fetchData();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (wsRef.current) return;
|
let reconnectTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
const ws = new WebSocket(WS_URL);
|
const ws = new WebSocket(WS_URL);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
ws.onmessage = (e: MessageEvent) => {
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
const msg = JSON.parse(e.data);
|
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();
|
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 () => {
|
return () => {
|
||||||
if (wsRef.current?.readyState === 1) wsRef.current.close();
|
clearTimeout(reconnectTimeout);
|
||||||
wsRef.current = null;
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.onclose = null;
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [id, fetchData]);
|
}, [id, fetchData]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user