4 Commits
9 changed files with 389 additions and 192 deletions
+16 -17
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
@@ -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)
@@ -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]):
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
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 (
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()
)
+78 -73
View File
@@ -1,4 +1,5 @@
# backend/app/logic.py
import heapq
from collections import defaultdict
from datetime import datetime, timedelta
from uuid import uuid4
@@ -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)
@@ -93,6 +95,7 @@ def update_schedule_times(db: Session, t: models.Tournament):
models.Tournament.timestamp >= start_of_day,
models.Tournament.timestamp < end_of_day,
)
.with_for_update()
.all()
)
@@ -133,16 +136,14 @@ def update_schedule_times(db: Session, t: models.Tournament):
for m in all_matches:
get_depth(m.id)
# 4. Initialize timers & lock finished matches
# 4. Initialize timers
all_courts: set[models.Court] = set()
court_sharing_count = defaultdict(
int
) # NEW: Track how highly contested each court is
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 # NEW
court_sharing_count[c.id] += 1
court_timers = {c.id: start_of_day for c in all_courts}
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}
# 5. Global Interleaving Schedule Loop
loop_limit = len(unscheduled) * 3
# 5. Critical Path Time-Stepping Scheduler
loop_limit = max(5000, len(unscheduled) * 50)
while unscheduled and loop_limit > 0:
loop_limit -= 1
@@ -173,7 +175,6 @@ def update_schedule_times(db: Session, t: models.Tournament):
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 = (
m.tournament.timestamp.replace(tzinfo=None)
@@ -236,36 +237,40 @@ def update_schedule_times(db: Session, t: models.Tournament):
else:
court_timers[best_court_id] += timedelta(minutes=5)
# 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!)
all_matches = sorted(
all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0)
)
# 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)
active_refs = []
def is_busy(outcome_tuple, source_m: models.Match, start, end):
next_m_id = (
source_m.winner_next_match_id
if outcome_tuple[0] == "W"
else source_m.loser_next_match_id
)
if next_m_id:
next_m = match_map[next_m_id]
next_start = next_m.start_time
if next_start:
next_end = next_start + timedelta(minutes=next_m.tournament.duration)
if not (next_end <= start or next_start >= end):
return True
for r_outcome, r_start, r_end in active_refs:
if r_outcome == outcome_tuple:
if not (r_end <= start or r_start >= end):
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)
@@ -292,59 +297,62 @@ def update_schedule_times(db: Session, t: models.Tournament):
best_score = float("inf")
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
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
if prev_end <= m_start:
l_outcome = ("L", prev_m.id)
if not is_busy(l_outcome, prev_m, m_start, m_end):
wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[l_outcome] * 120) + wait_mins
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 = (l_outcome, prev_m)
w_outcome = ("W", prev_m.id)
if not is_busy(w_outcome, prev_m, m_start, m_end):
wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
if prev_m.court_id == m.court_id:
score -= 30
if score < best_score:
best_score = score
best_outcome = (w_outcome, prev_m)
best_outcome = identifier
if best_outcome:
outcome_tuple, prev_m = best_outcome
duty_counts[outcome_tuple] += 1
active_refs.append((outcome_tuple, m_start, m_end))
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
m.ref_label = f"{role} of #{prev_m.match_number}"
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:
m.ref_label = "Staff / Volunteers"
for future_m in all_matches:
if future_m.start_time and future_m.start_time >= m_end:
assigned = False
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
if t_id:
team_identifier = f"TEAM_{t_id}"
is_team_busy = any(
r_outcome == team_identifier
and not (r_end <= m_start or r_start >= m_end)
for r_outcome, r_start, r_end in active_refs
assigned_bye_team = False
sorted_teams = sorted(
m.tournament.teams, key=lambda t: duty_counts[f"TEAM_{t.id}"]
)
if not is_team_busy:
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
active_refs.append((team_identifier, m_start, m_end))
assigned = True
break
if assigned:
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()
@@ -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
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
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
db.add(m)
@@ -427,10 +435,7 @@ def undo_advancement(db: Session, match: models.Match):
return
for m in match.tournament.matches:
if (
m.ref_label == f"Loser of #{match.match_number}"
or m.ref_label == f"Winner of #{match.match_number}"
):
if m.ref_label in [f"L:{match.id}", f"W:{match.id}"]:
m.ref_team_id = None
db.add(m)
+51 -18
View File
@@ -10,13 +10,11 @@ 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,
@@ -26,7 +24,6 @@ def create_global_court(
"""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)
@@ -36,7 +33,6 @@ def delete_global_court(
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()
@@ -59,22 +55,59 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
.all()
)
schedule = []
for m in matches:
schedule.append(
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_id": m.tournament.id,
"tournament_name": m.tournament.name,
"time": m.start_time.strftime("%H:%M") if m.start_time else "TBD",
"status": m.status,
"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": m.p1_team.name if m.p1_team else "TBD",
"p2": m.p2_team.name if m.p2_team else "TBD",
"p1_sets": sum(1 for s in m.sets if s["p1"] > s["p2"]) if m.sets else 0,
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0,
"ref_name": m.ref_team.name if m.ref_team else m.ref_label,
"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
],
}
)
return {"court": court.name, "matches": schedule}
+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")
@@ -1,7 +1,7 @@
// frontend/src/components/Tournament/ScoreModal.tsx
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 Modal from '../UI/Modal';
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 [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
}
};
@@ -72,9 +93,15 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
{/* Dedicated Ref Row (Always visible) */}
{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">
<WhistleIcon className="text-orange-500 shrink-0" width={14} height={14} />
<span className="text-xs font-bold text-orange-700 dark:text-orange-500 uppercase tracking-wide truncate">
<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>
@@ -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"
/>
</div>
</div>
</div>
))}
@@ -158,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>
+82 -30
View File
@@ -4,7 +4,6 @@ import React, { useEffect, useState, useRef } from 'react';
import { Loader2, Trash, Plus } from 'lucide-react';
import { useOutletContext, Link } from 'react-router-dom';
import api from '../services/api';
import { printName } from '../utils/helpers';
import WhistleIcon from '../assets/whistle.svg?react';
import CourtIcon from '../assets/court.svg?react';
@@ -14,10 +13,13 @@ interface CourtMatch {
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;
@@ -72,32 +74,65 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
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 line = scrollContainerRef.current?.querySelector('.now-line');
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) {
line.scrollIntoView({ behavior: 'smooth', block: 'start' });
setHasScrolled(true);
container.scrollTo({
top: line.offsetTop - 20,
behavior: 'smooth'
});
}
}
setHasScrolled(true);
}, 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>;
// --- ACTIVE INDEX LOGIC ---
let activeIndex = -1;
if (currentTime) {
// Find the index of the most recently started match
for (let i = 0; i < data.matches.length; i++) {
if (data.matches[i].time <= currentTime) {
activeIndex = i;
}
}
}
return (
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/50 dark:bg-zinc-900/20 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-3 md:p-4 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-3 shrink-0">
@@ -112,28 +147,22 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
)}
</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 && (
<div className="text-center text-sm font-bold uppercase text-zinc-400 dark:text-zinc-600 mt-10">No matches</div>
)}
{data.matches.map((m, index) => {
const isFinished = m.status === 'Finished';
const isPastSlot = index < activeIndex; // Strictly before the active match
const isLive = index === activeIndex && !isFinished; // Currently active and incomplete
const isPastSlot = isDayFinished || (activeIndex !== -1 && index < activeIndex);
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 textOpacity = 'text-zinc-900 dark:text-white';
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
if (isFinished) {
borderStyle = 'border-orange-500/30 opacity-60 grayscale hover:opacity-100 hover:grayscale-0';
textOpacity = 'text-zinc-500';
} else if (isPastSlot) {
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';
@@ -147,7 +176,7 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
<React.Fragment key={m.id}>
{/* --- THE --NOW-- LINE --- */}
{showNowLine && (
<div className="now-line relative flex items-center py-3 animate-in fade-in">
<div className="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>
@@ -175,11 +204,23 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
<div className="space-y-0.5 mb-1.5">
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
<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={`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>}
</div>
</div>
@@ -194,6 +235,17 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
</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>
);
+38 -2
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,6 +42,8 @@ export default function Dashboard() {
setTournaments(list as TournamentType[]);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
@@ -54,6 +57,8 @@ export default function Dashboard() {
})();
let ws: WebSocket;
let reconnectTimeout: ReturnType<typeof setTimeout>;
const connect = () => {
try {
ws = new WebSocket(WS_URL);
@@ -61,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) => {
@@ -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 groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
live: [],
+63 -22
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,
@@ -153,23 +170,47 @@ export default function Tournament() {
await fetchData();
})();
if (wsRef.current) return;
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();
clearTimeout(reconnectTimeout);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
};
}, [id, fetchData]);