3 Commits
6 changed files with 173 additions and 122 deletions
+5 -3
View File
@@ -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
@@ -201,10 +201,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()
) )
+75 -67
View File
@@ -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)
@@ -133,16 +135,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 +159,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 +174,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 +236,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
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 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 +296,63 @@ 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 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
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
role_str = "Winner" if outcome == "W" else "Loser"
identifier = f"{role_str} of #{prev_m.match_number}"
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 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: if prev_m.court_id == m.court_id:
score -= 30 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: if score < best_score:
best_score = score best_score = score
best_outcome = (w_outcome, prev_m) 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:
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
) )
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_team_id = t_id
m.ref_label = None m.ref_label = None
active_refs.append((team_identifier, m_start, m_end)) duty_counts[identifier] += 1
assigned = True active_refs.append((identifier, m_start, m_end))
break assigned_bye_team = True
if assigned:
break break
if not assigned_bye_team:
m.ref_label = "Staff / Volunteers"
m.ref_team_id = None
db.commit() db.commit()
+13 -11
View File
@@ -59,22 +59,24 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
.all() .all()
) )
schedule = [] return {
for m in matches: "court": court.name,
schedule.append( "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,
"match_number": m.match_number, "match_number": m.match_number,
"p1": m.p1_team.name if m.p1_team else "TBD", "p1": m.p1_team.name if m.p1_team else "TBD",
"p2": m.p2_team.name if m.p2_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, "p1_sets": len([s for s in m.sets if s.get("p1", 0) > s.get("p2", 0)]),
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0, "p2_sets": len([s for s in m.sets if s.get("p2", 0) > s.get("p1", 0)]),
"ref_name": m.ref_team.name if m.ref_team else m.ref_label, "ref_name": m.ref_team.name if m.ref_team else m.ref_label,
} }
) 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) @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")
+3 -3
View File
@@ -35,7 +35,7 @@ async def report_score(
_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")
@@ -71,7 +71,7 @@ async def edit_score(
_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")
@@ -105,7 +105,7 @@ async def clear_score(
_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")
+66 -27
View File
@@ -14,6 +14,7 @@ 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;
@@ -72,32 +73,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 (!container) return;
if (isDayFinished) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
} else {
const line = container.querySelector('.now-line') as HTMLElement;
if (line) { if (line) {
line.scrollIntoView({ behavior: 'smooth', block: 'start' }); container.scrollTo({
setHasScrolled(true); 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 +146,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 +175,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>
@@ -194,6 +222,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>
); );