8 Commits
31 changed files with 331 additions and 88 deletions
+80
View File
@@ -0,0 +1,80 @@
name: Test and Release
on:
push:
branches: ["**"]
pull_request:
branches: ["main", "master"]
release:
types: [published]
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: |
cd backend
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
run: |
cd backend
pip install pytest pytest-cov
pytest
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Test with NPM
run: |
cd frontend
npm test -- run --exclude "**/*.live.test.ts"
build-docker:
needs: [test-backend, test-frontend]
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
packages: write
contents: read
strategy:
matrix:
service: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Log in to Registry
uses: docker/login-action@v3
with:
registry: git.stws.cc
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.stws.cc/${{ github.repository }}/${{ matrix.service }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Build and Push
uses: docker/build-push-action@v6
with:
context: ./${{ matrix.service }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+29 -12
View File
@@ -9,8 +9,8 @@
<br> <br>
<img src="https://img.shields.io/badge/Python-3.14-blue?style=for-the-badge&logo=python" height="25"> <img src="https://img.shields.io/badge/Python-3.14-blue?style=for-the-badge&logo=python" height="25">
<img src="https://img.shields.io/badge/Flask-3.1.2-lightgrey?style=for-the-badge&logo=flask" height="25"> <img src="https://img.shields.io/badge/Flask-3.1.3-lightgrey?style=for-the-badge&logo=flask" height="25">
<img src="https://img.shields.io/badge/Tailwind_CSS-4.1.17-38B2AC?style=for-the-badge&logo=tailwind-css" height="25"> <img src="https://img.shields.io/badge/Tailwind_CSS-4.1.18-38B2AC?style=for-the-badge&logo=tailwind-css" height="25">
</div> </div>
@@ -20,15 +20,17 @@
The central hub for managing tournaments. The central hub for managing tournaments.
<img src="./docs/dashboard-admin.png" width="100%" alt="Dashboard Admin"> <img src="./docs/bracket-light.png" width="100%" alt="Brackets">
### Appearance Modes ---
Seamless support for both light and dark environments. ### Mobile Friendly
Seamless support for both PC and mobile users.
<div align="center"> <div align="center">
<img src="./docs/dashboard-dark.png" width="48%" alt="Dark Mode"> <img src="./docs/dashboard.png" width="70%" alt="Dashboard">
<img src="./docs/dashboard-light.png" width="48%" alt="Light Mode"> <img src="./docs/mobile-dashboard.png" width="16%" alt="Mobile Friendly">
</div> </div>
--- ---
@@ -38,12 +40,13 @@ Seamless support for both light and dark environments.
### Setup & Creation ### Setup & Creation
<div align="center"> <div align="center">
<img src="./docs/settings.png" height="400px" alt="Settings"> <img src="./docs/tournament-new.png" width="48%" alt="New Tournament">
<img src="./docs/tournament-edit.png" width="48%" alt="Edit Tournament">
</div> </div>
### Bracket Visualization ### Bracket Visualization
Dynamic bracket generation handling various tournament sizes. Dynamic bracket generation handling various tournament sizes and with seamless support for both light and dark environments.
<div align="center"> <div align="center">
<img src="./docs/bracket-dark.png" width="48%" alt="Bracket Dark"> <img src="./docs/bracket-dark.png" width="48%" alt="Bracket Dark">
@@ -55,8 +58,8 @@ Dynamic bracket generation handling various tournament sizes.
**Single Elimination & Live Scoring** **Single Elimination & Live Scoring**
<div align="center"> <div align="center">
<img src="./docs/bracket-single.png" width="48%" alt="Single Elim"> <img src="./docs/single-elimination.png" width="48%" alt="Single Elimination">
<img src="./docs/bracket-scoring.png" width="48%" alt="Scoring"> <img src="./docs/scoring.png" width="48%" alt="Scoring">
</div> </div>
--- ---
@@ -64,8 +67,17 @@ Dynamic bracket generation handling various tournament sizes.
## Scheduling & Results ## Scheduling & Results
Automated scheduling views with real-time score updates. Automated scheduling views with real-time score updates.
<div align="center">
<img src="./docs/schedule.png" width="70%" alt="Schedule">
<img src="./docs/mobile-schedule.png" width="16%" alt="Schedule - Mobile Friendly">
</div>
<img src="./docs/schedule.png" width="100%" alt="Schedule"> ---
## Court Overview
Keep up with all courts all at once in one place.
<img src="./docs/courts.png" width="100%" alt="Schedule">
--- ---
@@ -102,6 +114,11 @@ Secure scoring interfaces for Referees and Administrators.
</tr> </tr>
</table> </table>
## Admin login
Secure settings behind a admin login.
<img src="./docs/admin.png" width="100%" alt="Dashboard Admin">
## Deploy ## Deploy
Clone the repo and run the docker-compose or create your own. Create a .env file with your custom values there is an example.env available. Clone the repo and run the docker-compose or create your own. Create a .env file with your custom values there is an example.env available.
+11 -14
View File
@@ -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
+7 -10
View File
@@ -95,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()
) )
@@ -296,6 +297,8 @@ 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 prev_m.tournament_id != m.tournament_id:
continue
if not prev_m.start_time or prev_m.start_time >= m_start: 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)
@@ -307,10 +310,7 @@ def update_schedule_times(db: Session, t: models.Tournament):
next_start = match_outcome_next_start.get(out_key) next_start = match_outcome_next_start.get(out_key)
if next_start and next_start < m_end: if next_start and next_start < m_end:
continue continue
identifier = f"{outcome}:{prev_m.id}"
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): if is_ref_busy(identifier, m_start, m_end):
continue 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 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)
@@ -435,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)
+39 -8
View File
@@ -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,6 +55,39 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
.all() .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 { return {
"court": court.name, "court": court.name,
"matches": [ "matches": [
@@ -68,13 +97,15 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
"tournament_name": m.tournament.name, "tournament_name": m.tournament.name,
"duration": m.tournament.duration, "duration": m.tournament.duration,
"time": m.start_time.strftime("%H:%M") if m.start_time else None, "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, "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_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)]), "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)]), "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 for m in matches
if m.start_time if m.start_time
+3 -5
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: 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,7 +27,7 @@ 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")
@@ -65,7 +63,7 @@ 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")
@@ -99,7 +97,7 @@ 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")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -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>
+16 -3
View File
@@ -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';
@@ -19,6 +18,8 @@ interface CourtMatch {
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;
@@ -203,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>
+38 -2
View File
@@ -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: [],
+64 -23
View File
@@ -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]);