From 55a13934ef61022d66b3f255d9632b5c678eb241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Sun, 14 Jun 2026 20:57:19 +0200 Subject: [PATCH] Label fixes, Websocket reconnects and more detailed court view cards. --- backend/app/crud.py | 25 +++--- backend/app/logic.py | 17 ++-- backend/app/routes/courts.py | 49 +++++++++-- backend/app/routes/tournaments/report.py | 8 +- .../src/components/Tournament/ScoreModal.tsx | 53 ++++++++--- frontend/src/pages/Courts.tsx | 19 +++- frontend/src/pages/Dashboard.tsx | 40 ++++++++- frontend/src/pages/Tournament.tsx | 87 ++++++++++++++----- 8 files changed, 222 insertions(+), 76 deletions(-) diff --git a/backend/app/crud.py b/backend/app/crud.py index 7a1ad6c..369d0a5 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -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 diff --git a/backend/app/logic.py b/backend/app/logic.py index 6156392..b55ce81 100644 --- a/backend/app/logic.py +++ b/backend/app/logic.py @@ -95,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() ) @@ -296,6 +297,8 @@ def update_schedule_times(db: Session, t: models.Tournament): best_score = float("inf") for prev_m in all_matches: + if prev_m.tournament_id != m.tournament_id: + continue if not prev_m.start_time or prev_m.start_time >= m_start: continue prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration) @@ -307,10 +310,7 @@ def update_schedule_times(db: Session, t: models.Tournament): 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}" - + identifier = f"{outcome}:{prev_m.id}" if is_ref_busy(identifier, m_start, m_end): continue @@ -363,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) @@ -435,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) diff --git a/backend/app/routes/courts.py b/backend/app/routes/courts.py index b491759..55cdd34 100644 --- a/backend/app/routes/courts.py +++ b/backend/app/routes/courts.py @@ -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,6 +55,39 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)): .all() ) + t_ids = {m.tournament_id for m in matches} + if t_ids: + all_t_matches = db.query(models.Match).filter(models.Match.tournament_id.in_(t_ids)).all() + else: + all_t_matches = [] + + match_by_id = {str(tm.id).lower(): tm for tm in all_t_matches} + parent_map = {} + + for tm in all_t_matches: + if tm.winner_next_match_id: + parent_map[(str(tm.winner_next_match_id).lower(), tm.winner_next_match_slot)] = f"Winner of #{tm.match_number}" + if tm.loser_next_match_id: + parent_map[(str(tm.loser_next_match_id).lower(), tm.loser_next_match_slot)] = f"Loser of #{tm.match_number}" + + def resolve_ref(m: models.Match): + if m.ref_team: + return m.ref_team.name + + if m.ref_label and ":" in m.ref_label: + outcome, ref_id = m.ref_label.split(":") + ref_match = match_by_id.get(ref_id.strip().lower()) + if ref_match: + role = "Winner" if outcome.upper() == "W" else "Loser" + return f"{role} of #{ref_match.match_number}" + + return m.ref_label or "TBD" + + def get_team_label(m: models.Match, slot: int, team): + if team: + return team.name + return parent_map.get((str(m.id).lower(), slot), "TBD") + return { "court": court.name, "matches": [ @@ -68,15 +97,17 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)): "tournament_name": m.tournament.name, "duration": m.tournament.duration, "time": m.start_time.strftime("%H:%M") if m.start_time else None, - "status": m.status.value, + "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": 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": m.ref_team.name if m.ref_team else m.ref_label, + "ref_name": resolve_ref(m), } for m in matches if m.start_time ], - } + } \ No newline at end of file diff --git a/backend/app/routes/tournaments/report.py b/backend/app/routes/tournaments/report.py index 6a6e5be..986d294 100644 --- a/backend/app/routes/tournaments/report.py +++ b/backend/app/routes/tournaments/report.py @@ -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,7 +27,7 @@ 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") @@ -65,7 +63,7 @@ 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") @@ -99,7 +97,7 @@ 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") diff --git a/frontend/src/components/Tournament/ScoreModal.tsx b/frontend/src/components/Tournament/ScoreModal.tsx index b333615..fd91359 100644 --- a/frontend/src/components/Tournament/ScoreModal.tsx +++ b/frontend/src/components/Tournament/ScoreModal.tsx @@ -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(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]); const [code, setCode] = useState(''); const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(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 && ( -
- - +
+ + {refName}
@@ -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" />
- ))} @@ -158,17 +184,24 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
{match.isFinished && ( )}
diff --git a/frontend/src/pages/Courts.tsx b/frontend/src/pages/Courts.tsx index 1aa939f..c47650b 100644 --- a/frontend/src/pages/Courts.tsx +++ b/frontend/src/pages/Courts.tsx @@ -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'; @@ -19,6 +18,8 @@ interface CourtMatch { match_number: number; p1: string; p2: string; + p1_is_real: boolean; + p2_is_real: boolean; p1_sets: number; p2_sets: number; ref_name?: string; @@ -203,11 +204,23 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
- m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)} + 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} + {isFinished && {m.p1_sets}}
- m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)} + 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} + {isFinished && {m.p2_sets}}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1d350a6..42e80d4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null); const [showPast, setShowPast] = useState(false); const [showAllFuture, setShowAllFuture] = useState(false); + const [loading, setLoading] = useState(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; + 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 ( +
+ +
+ ); + } + const now = new Date(); const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = { live: [], diff --git a/frontend/src/pages/Tournament.tsx b/frontend/src/pages/Tournament.tsx index 6e9452c..d082347 100644 --- a/frontend/src/pages/Tournament.tsx +++ b/frontend/src/pages/Tournament.tsx @@ -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 { 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 = Object.fromEntries(courts.map(c => [c.id, c.name])); const teamMap: Record = Object.fromEntries(teams.map(t => [t.id, t.name])); - const incoming: Record = {}; - - 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; const connect = () => { const ws = new WebSocket(WS_URL); wsRef.current = ws; ws.onmessage = (e: MessageEvent) => { const msg = JSON.parse(e.data); - if (msg.type === 'tournament_update' && msg.id === id) void fetchData(); + if (msg.type === 'tournament_update' && msg.id === id) { + (async () => { + await fetchData(); + })(); + } + }; + ws.onclose = () => { + wsRef.current = null; + reconnectTimeout = setTimeout(connect, 3000); }; - ws.onclose = () => { wsRef.current = null; }; }; - connect(); + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + (async () => { + await fetchData(); + })(); + if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) { + clearTimeout(reconnectTimeout); + connect(); + } + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { - if (wsRef.current?.readyState === 1) wsRef.current.close(); - wsRef.current = null; + clearTimeout(reconnectTimeout); + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + wsRef.current = null; + } }; }, [id, fetchData]);