Compare commits
No files matched your search
@@ -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 }}
|
||||
@@ -1,79 +0,0 @@
|
||||
stages:
|
||||
- Test Stage
|
||||
- Build Stage
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_TAG
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
"Python Tests":
|
||||
stage: Test Stage
|
||||
image: python:3.14-alpine
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- backend/requirements.txt
|
||||
paths:
|
||||
- .cache/pip
|
||||
script:
|
||||
- echo "Running tests on the Python Backend..."
|
||||
- cd backend
|
||||
- pip install -r requirements.txt
|
||||
- pytest
|
||||
|
||||
"React Tests":
|
||||
stage: Test Stage
|
||||
image: node:alpine
|
||||
variables:
|
||||
npm_config_cache: "$CI_PROJECT_DIR/.npm-cache"
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/package-lock.json
|
||||
paths:
|
||||
- .npm-cache
|
||||
- frontend/node_modules
|
||||
script:
|
||||
- cd frontend
|
||||
- npm ci --prefer-offline
|
||||
- npm test -- run --exclude "**/*.live.test.ts"
|
||||
|
||||
"Build Backend Image":
|
||||
stage: Build Stage
|
||||
image: docker:24.0.5
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
before_script:
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
||||
script:
|
||||
- cd backend
|
||||
- >
|
||||
docker build
|
||||
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
|
||||
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
|
||||
-t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
|
||||
-t $CI_REGISTRY_IMAGE/backend:latest .
|
||||
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
|
||||
- docker push $CI_REGISTRY_IMAGE/backend:latest
|
||||
|
||||
"Build Frontend Image":
|
||||
stage: Build Stage
|
||||
image: docker:24.0.5
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
before_script:
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
||||
script:
|
||||
- cd frontend
|
||||
- >
|
||||
docker build
|
||||
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
|
||||
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
|
||||
-t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
|
||||
-t $CI_REGISTRY_IMAGE/frontend:latest .
|
||||
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
|
||||
- docker push $CI_REGISTRY_IMAGE/frontend:latest
|
||||
@@ -9,8 +9,8 @@
|
||||
<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/Flask-3.1.2-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/Flask-3.1.3-lightgrey?style=for-the-badge&logo=flask" 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>
|
||||
|
||||
@@ -20,15 +20,17 @@
|
||||
|
||||
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">
|
||||
<img src="./docs/dashboard-dark.png" width="48%" alt="Dark Mode">
|
||||
<img src="./docs/dashboard-light.png" width="48%" alt="Light Mode">
|
||||
<img src="./docs/dashboard.png" width="70%" alt="Dashboard">
|
||||
<img src="./docs/mobile-dashboard.png" width="16%" alt="Mobile Friendly">
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -38,12 +40,13 @@ Seamless support for both light and dark environments.
|
||||
### Setup & Creation
|
||||
|
||||
<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>
|
||||
|
||||
### 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">
|
||||
<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**
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/bracket-single.png" width="48%" alt="Single Elim">
|
||||
<img src="./docs/bracket-scoring.png" width="48%" alt="Scoring">
|
||||
<img src="./docs/single-elimination.png" width="48%" alt="Single Elimination">
|
||||
<img src="./docs/scoring.png" width="48%" alt="Scoring">
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -64,8 +67,17 @@ Dynamic bracket generation handling various tournament sizes.
|
||||
## Scheduling & Results
|
||||
|
||||
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>
|
||||
</table>
|
||||
|
||||
## Admin login
|
||||
|
||||
Secure settings behind a admin login.
|
||||
<img src="./docs/admin.png" width="100%" alt="Dashboard Admin">
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,7 +1,8 @@
|
||||
# backend/app/crud.py
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from fastapi import HTTPException
|
||||
|
||||
from . import logic, models, schemas
|
||||
|
||||
@@ -27,8 +28,13 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
||||
duration=data.duration,
|
||||
type=data.type,
|
||||
)
|
||||
new_t.teams = [models.Team(name=n) for n in data.teams]
|
||||
new_t.courts = [models.Court(name=n) for n in data.courts]
|
||||
|
||||
new_t.teams = [
|
||||
models.Team(name=nt.name, dropped_out=nt.dropped_out)
|
||||
for nt in data.teams
|
||||
]
|
||||
|
||||
new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
|
||||
|
||||
db.add(new_t)
|
||||
db.commit()
|
||||
@@ -45,17 +51,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 +69,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
|
||||
|
||||
@@ -74,6 +77,12 @@ def update_tournament_details(
|
||||
incoming_type = update_data.get("type")
|
||||
type_changed = incoming_type and incoming_type != t.type
|
||||
|
||||
# SAFETY CHECK: Prevent changing Double/Single Elim mid-tournament
|
||||
if type_changed:
|
||||
has_started = any(m.status == models.MatchStatus.FINISHED for m in t.matches)
|
||||
if has_started:
|
||||
raise HTTPException(400, "Cannot change tournament format after matches have been played.")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(t, key, value)
|
||||
|
||||
@@ -94,7 +103,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
|
||||
|
||||
@@ -109,15 +118,61 @@ def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
|
||||
return new_team
|
||||
|
||||
|
||||
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]):
|
||||
t = get_tournament(db, tournament_id)
|
||||
def update_tournament_teams(db: Session, tournament_id: str, new_teams: list[schemas.TeamUpdate]):
|
||||
t = get_tournament(db, tournament_id, lock=True)
|
||||
if not t:
|
||||
return None
|
||||
|
||||
t.teams = [models.Team(name=n, tournament_id=t.id) for n in new_team_names]
|
||||
has_started = any(m.status == models.MatchStatus.FINISHED for m in t.matches)
|
||||
|
||||
if has_started:
|
||||
if len(new_teams) != len(t.teams):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(400, "Cannot add or remove teams after start.")
|
||||
|
||||
team_map = {team.id: team for team in t.teams}
|
||||
|
||||
for nt in new_teams:
|
||||
if isinstance(nt.id, int) and nt.id in team_map:
|
||||
team = team_map[nt.id]
|
||||
team.name = nt.name
|
||||
|
||||
# Did they just drop out?
|
||||
if nt.dropped_out and not team.dropped_out:
|
||||
team.dropped_out = True
|
||||
db.flush()
|
||||
|
||||
pending_matches = [
|
||||
m for m in t.matches
|
||||
if m.status != models.MatchStatus.FINISHED
|
||||
and (m.p1_team_id == team.id or m.p2_team_id == team.id)
|
||||
]
|
||||
for pm in pending_matches:
|
||||
logic.check_and_apply_walkover(db, pm)
|
||||
|
||||
elif not nt.dropped_out and team.dropped_out:
|
||||
team.dropped_out = False
|
||||
|
||||
|
||||
db.commit()
|
||||
logic.update_schedule_times(db, t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
# If hasn't started, rebuild safely
|
||||
for team in t.teams:
|
||||
db.delete(team)
|
||||
db.flush()
|
||||
|
||||
t.teams = [
|
||||
models.Team(name=nt.name, tournament_id=t.id, dropped_out=nt.dropped_out)
|
||||
for nt in new_teams
|
||||
]
|
||||
db.flush()
|
||||
_rebuild_structure(db, t)
|
||||
for m in t.matches:
|
||||
logic.check_and_apply_walkover(db, m)
|
||||
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
@@ -125,7 +180,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)
|
||||
@@ -141,53 +196,52 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
|
||||
return True
|
||||
|
||||
|
||||
def get_courts(db: Session, tournament_id: str):
|
||||
return (
|
||||
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
|
||||
)
|
||||
def get_courts(db: Session):
|
||||
return db.query(models.Court).all()
|
||||
|
||||
|
||||
def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreate):
|
||||
t = get_tournament(db, tournament_id)
|
||||
if not t:
|
||||
return None
|
||||
|
||||
new_court = models.Court(name=court_data.name, tournament_id=tournament_id)
|
||||
def create_court(db: Session, court_data: schemas.CourtCreate):
|
||||
new_court = models.Court(name=court_data.name)
|
||||
db.add(new_court)
|
||||
db.flush()
|
||||
|
||||
logic.update_schedule_times(db, t)
|
||||
|
||||
db.commit()
|
||||
db.refresh(new_court)
|
||||
return new_court
|
||||
|
||||
|
||||
def delete_court(db: Session, tournament_id: str, court_id: int):
|
||||
t = get_tournament(db, tournament_id)
|
||||
if not t:
|
||||
return None
|
||||
def delete_court(db: Session, court_id: int):
|
||||
court = db.get(models.Court, court_id)
|
||||
if not court or court.tournament_id != tournament_id:
|
||||
return None
|
||||
if not court:
|
||||
return False
|
||||
|
||||
affected_tournaments = list(court.tournaments)
|
||||
|
||||
matches = db.query(models.Match).filter(models.Match.court_id == court_id).all()
|
||||
for m in matches:
|
||||
if m.status == models.MatchStatus.FINISHED:
|
||||
m.court_id = None
|
||||
else:
|
||||
m.court_id = None
|
||||
m.start_time = None
|
||||
|
||||
db.delete(court)
|
||||
db.flush()
|
||||
|
||||
logic.update_schedule_times(db, t)
|
||||
|
||||
db.commit()
|
||||
|
||||
processed_days = set()
|
||||
for t in affected_tournaments:
|
||||
day = t.timestamp.date() if t.timestamp else None
|
||||
if day and day not in processed_days:
|
||||
logic.update_schedule_times(db, t)
|
||||
processed_days.add(day)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def update_tournament_courts(
|
||||
db: Session, tournament_id: str, new_court_names: list[str]
|
||||
):
|
||||
t = get_tournament(db, tournament_id)
|
||||
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
|
||||
t = get_tournament(db, tournament_id, lock=True)
|
||||
if not t:
|
||||
return None
|
||||
|
||||
t.courts = [models.Court(name=c, tournament_id=t.id) for c in new_court_names]
|
||||
t.courts = db.query(models.Court).filter(models.Court.id.in_(new_court_ids)).all()
|
||||
db.flush()
|
||||
logic.update_schedule_times(db, t)
|
||||
|
||||
@@ -202,10 +256,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()
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
# backend/app/logic.py
|
||||
import heapq
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -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)
|
||||
@@ -79,59 +81,127 @@ def generate_bracket(db: Session, t: models.Tournament):
|
||||
|
||||
|
||||
def update_schedule_times(db: Session, t: models.Tournament):
|
||||
if not t.courts or not t.matches:
|
||||
if not t.timestamp:
|
||||
return
|
||||
|
||||
matches = t.matches
|
||||
match_map = {m.id: m for m in matches}
|
||||
# 1. Grab the entire day of tournaments
|
||||
target_date = t.timestamp.date()
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||
end_of_day = start_of_day + timedelta(days=1)
|
||||
|
||||
tournaments = (
|
||||
db.query(models.Tournament)
|
||||
.filter(
|
||||
models.Tournament.timestamp >= start_of_day,
|
||||
models.Tournament.timestamp < end_of_day,
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not tournaments:
|
||||
return
|
||||
|
||||
all_matches: list[models.Match] = []
|
||||
match_map: dict[str, models.Match] = {}
|
||||
prereqs = defaultdict(list)
|
||||
for m in matches:
|
||||
if m.winner_next_match_id:
|
||||
prereqs[m.winner_next_match_id].append(m.id)
|
||||
if m.loser_next_match_id:
|
||||
prereqs[m.loser_next_match_id].append(m.id)
|
||||
|
||||
depth_cache = {}
|
||||
|
||||
# 2. Gather all matches and prereqs globally
|
||||
for t_item in tournaments:
|
||||
for m in t_item.matches:
|
||||
all_matches.append(m)
|
||||
match_map[m.id] = m
|
||||
if m.winner_next_match_id:
|
||||
prereqs[m.winner_next_match_id].append(m.id)
|
||||
if m.loser_next_match_id:
|
||||
prereqs[m.loser_next_match_id].append(m.id)
|
||||
|
||||
# 3. Calculate depths
|
||||
def get_depth(m_id):
|
||||
if m_id not in match_map:
|
||||
return 0
|
||||
if m_id in depth_cache:
|
||||
return depth_cache[m_id]
|
||||
|
||||
m = match_map[m_id]
|
||||
child_depths = [0]
|
||||
if m.winner_next_match_id:
|
||||
child_depths.append(get_depth(m.winner_next_match_id))
|
||||
if m.loser_next_match_id:
|
||||
child_depths.append(get_depth(m.loser_next_match_id))
|
||||
|
||||
depth = 1 + max(child_depths)
|
||||
depth_cache[m_id] = depth
|
||||
return depth
|
||||
|
||||
for m in matches:
|
||||
for m in all_matches:
|
||||
get_depth(m.id)
|
||||
|
||||
tournament_start = (
|
||||
t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp
|
||||
)
|
||||
court_timers = {c.id: tournament_start for c in t.courts}
|
||||
# 4. Initialize timers
|
||||
all_courts: set[models.Court] = set()
|
||||
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
|
||||
|
||||
court_timers = {c.id: start_of_day for c in all_courts}
|
||||
planned_finish_times = {}
|
||||
unscheduled = list(matches)
|
||||
unscheduled: list[models.Match] = []
|
||||
tournament_has_started = {
|
||||
t_item.id: any(m.status == MatchStatus.FINISHED for m in t_item.matches)
|
||||
for t_item in tournaments
|
||||
}
|
||||
|
||||
for m in all_matches:
|
||||
is_locked = False
|
||||
t_started = tournament_has_started[m.tournament_id]
|
||||
|
||||
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
|
||||
is_locked = True
|
||||
|
||||
elif m.status == MatchStatus.PENDING and m.start_time and m.court_id:
|
||||
if t_started:
|
||||
is_locked = True
|
||||
|
||||
elif m.tournament_id != t.id and m.start_time and m.court_id:
|
||||
if t_started:
|
||||
is_locked = True
|
||||
|
||||
if is_locked and m.start_time and m.court_id:
|
||||
fin = m.start_time + timedelta(minutes=m.tournament.duration)
|
||||
planned_finish_times[m.id] = fin
|
||||
if court_timers.get(m.court_id, start_of_day) < fin:
|
||||
court_timers[m.court_id] = fin
|
||||
else:
|
||||
m.start_time = None
|
||||
m.court_id = None
|
||||
unscheduled.append(m)
|
||||
|
||||
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
||||
|
||||
# 5. Critical Path Time-Stepping Scheduler
|
||||
loop_limit = max(5000, len(unscheduled) * 50)
|
||||
|
||||
loop_limit = len(matches) * 2
|
||||
while unscheduled and loop_limit > 0:
|
||||
loop_limit -= 1
|
||||
best_court_id = min(court_timers, key=lambda k: court_timers[k])
|
||||
|
||||
best_court_id = min(
|
||||
court_timers.keys(), key=lambda k: (court_timers[k], court_sharing_count[k])
|
||||
)
|
||||
current_time = court_timers[best_court_id]
|
||||
|
||||
ready: list[models.Match] = []
|
||||
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 = tournament_start
|
||||
max_prereq_time = (
|
||||
m.tournament.timestamp.replace(tzinfo=None)
|
||||
if m.tournament.timestamp.tzinfo
|
||||
else m.tournament.timestamp
|
||||
)
|
||||
|
||||
for p_id in prereqs[m.id]:
|
||||
if p_id not in planned_finish_times:
|
||||
is_ready = False
|
||||
@@ -143,27 +213,41 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
||||
|
||||
if ready:
|
||||
ready.sort(
|
||||
key=lambda x: (-depth_cache[x.id], x.round_number, x.match_number)
|
||||
key=lambda x: (
|
||||
len(x.tournament.courts),
|
||||
tournament_match_counts[x.tournament_id],
|
||||
-depth_cache[x.id],
|
||||
x.round_number,
|
||||
x.match_number,
|
||||
)
|
||||
)
|
||||
cand = ready[0]
|
||||
|
||||
cand.court_id = best_court_id
|
||||
cand.start_time = current_time
|
||||
|
||||
fin = current_time + timedelta(minutes=t.duration)
|
||||
fin = current_time + timedelta(minutes=cand.tournament.duration)
|
||||
planned_finish_times[cand.id] = fin
|
||||
court_timers[best_court_id] = fin
|
||||
tournament_match_counts[cand.tournament_id] += 1
|
||||
unscheduled.remove(cand)
|
||||
else:
|
||||
next_wake = None
|
||||
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 = tournament_start
|
||||
max_prereq_time = (
|
||||
m.tournament.timestamp.replace(tzinfo=None)
|
||||
if m.tournament.timestamp.tzinfo
|
||||
else m.tournament.timestamp
|
||||
)
|
||||
for p_id in prereqs[m.id]:
|
||||
if p_id not in planned_finish_times:
|
||||
is_ready = False
|
||||
break
|
||||
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
|
||||
|
||||
if is_ready and max_prereq_time > current_time:
|
||||
if next_wake is None or max_prereq_time < next_wake:
|
||||
next_wake = max_prereq_time
|
||||
@@ -171,107 +255,96 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
||||
if next_wake:
|
||||
court_timers[best_court_id] = next_wake
|
||||
else:
|
||||
break
|
||||
court_timers[best_court_id] += timedelta(minutes=5)
|
||||
|
||||
# ==========================================
|
||||
# --- DYNAMIC REFEREE ASSIGNMENT ---
|
||||
# ==========================================
|
||||
all_matches = sorted(list(matches), key=lambda x: (x.start_time, x.court_id))
|
||||
# 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 = []
|
||||
match_outcome_next_start = {}
|
||||
|
||||
def is_busy(outcome_tuple, source_m, 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=t.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):
|
||||
return True
|
||||
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=t.duration)
|
||||
m_end = m_start + timedelta(minutes=m.tournament.duration)
|
||||
|
||||
if m.bracket_type == BracketTypes.FINALS:
|
||||
prev_final = next((p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id), None)
|
||||
|
||||
prev_final = next(
|
||||
(
|
||||
p
|
||||
for p in all_matches
|
||||
if p.bracket_type == BracketTypes.FINALS
|
||||
and p.winner_next_match_id == m.id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if prev_final:
|
||||
m.ref_team_id = prev_final.ref_team_id
|
||||
m.ref_label = prev_final.ref_label
|
||||
|
||||
identifier = f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
|
||||
identifier = (
|
||||
f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
|
||||
)
|
||||
active_refs.append((identifier, m_start, m_end))
|
||||
continue
|
||||
|
||||
|
||||
best_outcome = None
|
||||
best_score = float('inf')
|
||||
|
||||
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
|
||||
prev_end = prev_m.start_time + timedelta(minutes=t.duration)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
||||
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 = 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
|
||||
)
|
||||
|
||||
if not is_team_busy:
|
||||
m.ref_team_id = t_id
|
||||
m.ref_label = None
|
||||
active_refs.append((team_identifier, m_start, m_end))
|
||||
assigned = True
|
||||
break
|
||||
if assigned:
|
||||
break
|
||||
# ----------------------------------
|
||||
m.ref_label = None
|
||||
m.ref_team_id = None
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -283,10 +356,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)
|
||||
|
||||
@@ -349,13 +422,19 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
|
||||
|
||||
db.commit()
|
||||
|
||||
if match.winner_next_match:
|
||||
check_and_apply_walkover(db, match.winner_next_match)
|
||||
|
||||
if match.loser_next_match:
|
||||
check_and_apply_walkover(db, match.loser_next_match)
|
||||
|
||||
|
||||
def undo_advancement(db: Session, match: models.Match):
|
||||
if not match.winner_team_id:
|
||||
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)
|
||||
|
||||
@@ -385,5 +464,40 @@ def undo_advancement(db: Session, match: models.Match):
|
||||
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
|
||||
if match.loser_next_match and loser_id:
|
||||
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
||||
|
||||
db.commit()
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def check_and_apply_walkover(db: Session, m: models.Match):
|
||||
"""Instantly resolves matches if a participating team has dropped out."""
|
||||
if m.status == MatchStatus.FINISHED:
|
||||
return False
|
||||
if not m.p1_team_id or not m.p2_team_id:
|
||||
return False
|
||||
|
||||
p1 = db.get(models.Team, m.p1_team_id)
|
||||
p2 = db.get(models.Team, m.p2_team_id)
|
||||
if p1 and p2:
|
||||
if not p1.dropped_out and not p2.dropped_out:
|
||||
return False
|
||||
|
||||
# Determine WO Winner
|
||||
if p1.dropped_out and p2.dropped_out:
|
||||
winner_id = p1.id
|
||||
m.sets = [{"p1": 0, "p2": 0}]
|
||||
elif p1.dropped_out:
|
||||
winner_id = p2.id
|
||||
m.sets = [{"p1": 0, "p2": 21},{"p1": 0, "p2": 21}]
|
||||
else:
|
||||
winner_id = p1.id
|
||||
m.sets = [{"p1": 21, "p2": 0},{"p1": 21, "p2": 0}]
|
||||
|
||||
m.status = MatchStatus.FINISHED
|
||||
m.winner_team_id = winner_id
|
||||
db.add(m)
|
||||
db.flush()
|
||||
|
||||
advance_winner(db, m, winner_id)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .database import Base, engine
|
||||
from .routes import auth, tournaments, websocket
|
||||
from .routes import auth, tournaments, websocket, courts
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
|
||||
app.include_router(tournaments.router)
|
||||
app.include_router(courts.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(websocket.router)
|
||||
app.add_middleware(
|
||||
|
||||
@@ -2,14 +2,41 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, DateTime
|
||||
from sqlalchemy import JSON, DateTime, Table, Column
|
||||
from sqlalchemy import Enum as SqlEnum
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy import ForeignKey, Integer, String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .constants import MatchStatus, TournamentTypes, BracketTypes
|
||||
from .database import Base
|
||||
|
||||
tournament_courts = Table(
|
||||
"tournament_courts",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"tournament_id",
|
||||
String(8),
|
||||
ForeignKey("tournaments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"court_id",
|
||||
Integer,
|
||||
ForeignKey("courts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Court(Base):
|
||||
__tablename__ = "courts"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||
|
||||
tournaments: Mapped[list["Tournament"]] = relationship(
|
||||
"Tournament", secondary=tournament_courts, back_populates="courts"
|
||||
)
|
||||
|
||||
|
||||
class Tournament(Base):
|
||||
__tablename__ = "tournaments"
|
||||
@@ -25,7 +52,7 @@ class Tournament(Base):
|
||||
"Team", back_populates="tournament", cascade="all, delete-orphan"
|
||||
)
|
||||
courts: Mapped[list["Court"]] = relationship(
|
||||
"Court", back_populates="tournament", cascade="all, delete-orphan"
|
||||
"Court", secondary=tournament_courts, back_populates="tournaments"
|
||||
)
|
||||
|
||||
matches: Mapped[list["Match"]] = relationship(
|
||||
@@ -40,25 +67,21 @@ class Tournament(Base):
|
||||
def court_count(self) -> int:
|
||||
return len(self.courts)
|
||||
|
||||
# --- NEW: Dynamically check if the tournament has started ---
|
||||
@property
|
||||
def has_started(self) -> bool:
|
||||
return any(m.status == MatchStatus.FINISHED for m in self.matches)
|
||||
|
||||
|
||||
class Team(Base):
|
||||
__tablename__ = "teams"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
dropped_out: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
|
||||
|
||||
tournament: Mapped["Tournament"] = relationship(back_populates="teams")
|
||||
|
||||
|
||||
class Court(Base):
|
||||
__tablename__ = "courts"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
|
||||
|
||||
tournament: Mapped["Tournament"] = relationship(back_populates="courts")
|
||||
|
||||
|
||||
class Match(Base):
|
||||
__tablename__ = "matches"
|
||||
|
||||
@@ -70,32 +93,56 @@ class Match(Base):
|
||||
|
||||
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
|
||||
|
||||
court_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courts.id"), nullable=True)
|
||||
court_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("courts.id"), nullable=True
|
||||
)
|
||||
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
status: Mapped[MatchStatus] = mapped_column(SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED)
|
||||
status: Mapped[MatchStatus] = mapped_column(
|
||||
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
|
||||
)
|
||||
|
||||
p1_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||
p2_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||
|
||||
ref_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||
p1_team_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("teams.id"), nullable=True
|
||||
)
|
||||
p2_team_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("teams.id"), nullable=True
|
||||
)
|
||||
|
||||
ref_team_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("teams.id"), nullable=True
|
||||
)
|
||||
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
|
||||
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
winner_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
|
||||
|
||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
|
||||
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
winner_team_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("teams.id"), nullable=True
|
||||
)
|
||||
|
||||
loser_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
|
||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("matches.id"), nullable=True
|
||||
)
|
||||
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
|
||||
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("matches.id"), nullable=True
|
||||
)
|
||||
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
|
||||
court: Mapped[Optional["Court"]] = relationship()
|
||||
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
||||
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
||||
|
||||
ref_team: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[ref_team_id])
|
||||
|
||||
winner_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[winner_next_match_id])
|
||||
loser_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[loser_next_match_id])
|
||||
ref_team: Mapped[Optional["Team"]] = relationship(
|
||||
"Team", foreign_keys=[ref_team_id]
|
||||
)
|
||||
|
||||
winner_next_match: Mapped["Match"] = relationship(
|
||||
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
|
||||
)
|
||||
loser_next_match: Mapped["Match"] = relationship(
|
||||
"Match", remote_side=[id], foreign_keys=[loser_next_match_id]
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
# backend/app/routes/courts.py
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
|
||||
from .. import crud, schemas, models
|
||||
from ..constants import SUCCESS
|
||||
from ..core.auth import get_admin_user
|
||||
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,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(get_admin_user),
|
||||
):
|
||||
"""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)
|
||||
):
|
||||
success = crud.delete_court(db, court_id)
|
||||
if not success:
|
||||
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()
|
||||
if not court:
|
||||
raise HTTPException(404, "Court not found")
|
||||
|
||||
today = date.today()
|
||||
|
||||
matches = (
|
||||
db.query(models.Match)
|
||||
.join(models.Tournament)
|
||||
.filter(models.Match.court_id == court_id)
|
||||
.filter(
|
||||
models.Tournament.timestamp >= datetime.combine(today, datetime.min.time())
|
||||
)
|
||||
.filter(
|
||||
models.Tournament.timestamp < datetime.combine(today, datetime.max.time())
|
||||
)
|
||||
.order_by(models.Match.start_time)
|
||||
.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": [
|
||||
{
|
||||
"id": m.id,
|
||||
"tournament_id": m.tournament.id,
|
||||
"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 if hasattr(m.status, 'value') else m.status,
|
||||
"match_number": m.match_number,
|
||||
"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
|
||||
],
|
||||
}
|
||||
@@ -3,4 +3,4 @@ from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
|
||||
|
||||
from . import tournaments, settings, teams, courts, matches, report
|
||||
from . import tournaments, settings, teams, matches, report
|
||||
@@ -1,60 +0,0 @@
|
||||
# backend/app/routes/tournaments/courts.py
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import crud, schemas
|
||||
from ...constants import SUCCESS
|
||||
from ...core.auth import get_admin_user
|
||||
from ...core.websocket_manager import send_ws_update
|
||||
from ...database import get_db
|
||||
from . import router
|
||||
|
||||
|
||||
@router.get("/{id}/courts", response_model=list[schemas.CourtSchema])
|
||||
def get_courts(id: str, db: Session = Depends(get_db)):
|
||||
return crud.get_courts(db, id)
|
||||
|
||||
|
||||
@router.post("/{id}/courts", response_model=schemas.CourtSchema)
|
||||
async def create_court(
|
||||
id: str,
|
||||
court: schemas.CourtCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(get_admin_user),
|
||||
):
|
||||
new_court = crud.create_court(db, id, court)
|
||||
if not new_court:
|
||||
raise HTTPException(404, "Tournament not found")
|
||||
|
||||
await send_ws_update(id)
|
||||
return new_court
|
||||
|
||||
|
||||
@router.patch("/{id}/courts", response_model=schemas.TournamentDetail)
|
||||
async def update_courts(
|
||||
id: str,
|
||||
courts: list[str],
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(get_admin_user),
|
||||
):
|
||||
t = crud.update_tournament_courts(db, id, courts)
|
||||
if not t:
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
await send_ws_update(id)
|
||||
return t
|
||||
|
||||
|
||||
@router.delete("/{id}/courts/{court_id}")
|
||||
async def delete_court(
|
||||
id: str,
|
||||
court_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(get_admin_user),
|
||||
):
|
||||
success = crud.delete_court(db, id, court_id)
|
||||
if not success:
|
||||
raise HTTPException(404, "Court or Tournament not found")
|
||||
|
||||
await send_ws_update(id)
|
||||
return SUCCESS
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async def create_team(
|
||||
@router.patch("/{id}/teams", response_model=list[schemas.TeamSchema])
|
||||
async def update_teams(
|
||||
id: str,
|
||||
teams: list[str],
|
||||
teams: list[schemas.TeamUpdate], # <-- CHANGED: Now accepts the TeamUpdate schema!
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(get_admin_user),
|
||||
):
|
||||
@@ -57,4 +57,4 @@ async def delete_team(
|
||||
raise HTTPException(404, "Team or Tournament not found")
|
||||
|
||||
await send_ws_update(id)
|
||||
return SUCCESS
|
||||
return SUCCESS
|
||||
@@ -60,3 +60,17 @@ async def delete_tournament(
|
||||
await send_ws_update(id)
|
||||
|
||||
return SUCCESS
|
||||
|
||||
|
||||
@router.patch("/{id}/courts", response_model=schemas.TournamentOut)
|
||||
def update_tournament_courts(
|
||||
id: str,
|
||||
court_ids: list[int],
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(get_admin_user),
|
||||
):
|
||||
"""Updates the global courts assigned to a specific tournament"""
|
||||
t = crud.update_tournament_courts(db, id, court_ids)
|
||||
if not t:
|
||||
raise HTTPException(404, "Tournament not found")
|
||||
return t
|
||||
@@ -9,8 +9,14 @@ from .constants import MatchStatus, TournamentTypes, BracketTypes
|
||||
class TeamSchema(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
dropped_out: bool = False
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class TeamUpdate(BaseModel):
|
||||
id: int | str
|
||||
name: str
|
||||
dropped_out: bool = False
|
||||
|
||||
|
||||
class CourtSchema(BaseModel):
|
||||
id: int
|
||||
@@ -49,7 +55,7 @@ class MatchOut(BaseModel):
|
||||
p1_team_id: int | None = None
|
||||
p2_team_id: int | None = None
|
||||
winner_team_id: int | None = None
|
||||
|
||||
|
||||
ref_team_id: int | None = None
|
||||
ref_label: str | None = None
|
||||
ref_team: TeamSchema | None = None
|
||||
@@ -70,8 +76,8 @@ class TournamentCreate(BaseModel):
|
||||
type: TournamentTypes
|
||||
timestamp: datetime
|
||||
duration: int
|
||||
teams: list[str]
|
||||
courts: list[str]
|
||||
teams: list[TeamUpdate]
|
||||
courts: list[int]
|
||||
|
||||
|
||||
class TournamentUpdate(BaseModel):
|
||||
@@ -109,6 +115,7 @@ class TournamentSettingsResponse(TournamentOut):
|
||||
code: str
|
||||
teams: list[TeamSchema]
|
||||
courts: list[CourtSchema]
|
||||
has_started: bool
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
|
||||
@@ -5,8 +5,8 @@ import pytest
|
||||
from app.core.auth import get_admin_user, get_optional_user
|
||||
from app.database import Base, get_db
|
||||
|
||||
# Import your app and models
|
||||
from app.main import app
|
||||
from app import models
|
||||
from fastapi import Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import create_engine
|
||||
@@ -14,8 +14,6 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# --- DATABASE SETUP ---
|
||||
# Use in-memory SQLite.
|
||||
# StaticPool is CRITICAL for in-memory SQLite with async tests to share connection.
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///"
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
@@ -43,6 +41,15 @@ def db(prepare_db) -> Generator[Session, None, None]:
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_db(db):
|
||||
db.query(models.Match).delete()
|
||||
db.query(models.Team).delete()
|
||||
db.query(models.Court).delete()
|
||||
db.commit()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
||||
def override_get_db():
|
||||
@@ -51,14 +58,11 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
||||
finally:
|
||||
pass
|
||||
|
||||
# Strict Auth: Always requires a token (simulated by header presence)
|
||||
def override_get_admin_user(request: Request):
|
||||
if "Authorization" not in request.headers:
|
||||
# Let FastAPI raise the 401 naturally if header is missing
|
||||
raise pytest.skip("Auth header missing in strict auth test")
|
||||
return "test_admin"
|
||||
|
||||
# Optional Auth: Returns Admin IF header exists, else None
|
||||
def override_get_optional_user(request: Request):
|
||||
if "Authorization" in request.headers:
|
||||
return "test_admin"
|
||||
@@ -76,22 +80,31 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# --- HELPER FIXTURES ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client):
|
||||
return {"Authorization": "Bearer test_token"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_tournament_payload():
|
||||
async def valid_tournament_payload(db):
|
||||
# 1. Create global courts first
|
||||
c1 = models.Court(name="Plan 1")
|
||||
c2 = models.Court(name="Plan 2")
|
||||
db.add_all([c1, c2])
|
||||
db.commit()
|
||||
|
||||
# 2. Return payload with the NEW object format for teams!
|
||||
return {
|
||||
"name": "Test Tournament",
|
||||
"code": "1234",
|
||||
"type": "Double",
|
||||
"timestamp": "2024-01-01T10:00:00",
|
||||
"timestamp": "2026-05-24T11:00:00",
|
||||
"duration": 15,
|
||||
"teams": ["Team A", "Team B", "Team C", "Team D"],
|
||||
"courts": ["Court 1", "Court 2"],
|
||||
}
|
||||
"teams": [
|
||||
{"id": "temp-1", "name": "Team A", "dropped_out": False},
|
||||
{"id": "temp-2", "name": "Team B", "dropped_out": False},
|
||||
{"id": "temp-3", "name": "Team C", "dropped_out": False},
|
||||
{"id": "temp-4", "name": "Team D", "dropped_out": False},
|
||||
],
|
||||
"courts": [c1.id, c2.id],
|
||||
}
|
||||
@@ -24,12 +24,14 @@ async def test_manage_teams(
|
||||
# 2. Verify Bracket Regenerated
|
||||
detail_res = await client.get(f"/tournaments/{t_id}")
|
||||
matches = detail_res.json()["matches"]
|
||||
|
||||
# With 5 teams, bracket size increases
|
||||
assert len(matches) > 3
|
||||
|
||||
# 3. Bulk Update
|
||||
new_team_list = ["Team X", "Team Y"]
|
||||
# 3. Bulk Update - FIX: Send the new object format!
|
||||
new_team_list = [
|
||||
{"id": "temp-x", "name": "Team X", "dropped_out": False},
|
||||
{"id": "temp-y", "name": "Team Y", "dropped_out": False},
|
||||
]
|
||||
|
||||
patch_res = await client.patch(
|
||||
f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers
|
||||
)
|
||||
@@ -37,19 +39,18 @@ async def test_manage_teams(
|
||||
|
||||
updated_detail = await client.get(f"/tournaments/{t_id}")
|
||||
final_matches = updated_detail.json()["matches"]
|
||||
|
||||
# Fix: For Double Elim with 2 teams, we might get 2 matches (WB Final + Grand Final).
|
||||
# Just ensure we have at least 1 match.
|
||||
assert len(final_matches) >= 1
|
||||
|
||||
# Verify the teams are actually in the first match
|
||||
first_match = final_matches[0]
|
||||
print(first_match)
|
||||
p1_name = first_match["p1_team_id"]
|
||||
p2_name = first_match["p2_team_id"]
|
||||
|
||||
assert 6 == p1_name
|
||||
assert 7 == p2_name
|
||||
|
||||
# FIX: Safely assert they have IDs assigned without hardcoding exact numbers
|
||||
# (since auto-increment IDs can fluctuate in test environments)
|
||||
assert first_match["p1_team_id"] is not None
|
||||
assert isinstance(first_match["p1_team_id"], int)
|
||||
|
||||
assert first_match["p2_team_id"] is not None
|
||||
assert isinstance(first_match["p2_team_id"], int)
|
||||
|
||||
|
||||
async def test_manage_courts(
|
||||
@@ -60,21 +61,21 @@ async def test_manage_courts(
|
||||
)
|
||||
t_id = res.json()["id"]
|
||||
|
||||
# Get initial courts
|
||||
# 1. Get initial courts from the tournament detail
|
||||
courts_res = await client.get(f"/tournaments/{t_id}")
|
||||
initial_courts = courts_res.json()["courts"]
|
||||
assert len(initial_courts) == 2
|
||||
|
||||
# Delete
|
||||
court_id = initial_courts[0]["id"]
|
||||
del_res = await client.delete(
|
||||
f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers
|
||||
)
|
||||
# 2. Extract the ID from the list so the variable is defined!
|
||||
court_to_delete_id = initial_courts[0]["id"]
|
||||
|
||||
# 3. Now use that variable in your delete call
|
||||
del_res = await client.delete(f"/courts/{court_to_delete_id}", headers=auth_headers)
|
||||
assert del_res.status_code == 200
|
||||
|
||||
# Create
|
||||
# CREATE: Change this from a tournament-specific route to the global one
|
||||
create_res = await client.post(
|
||||
f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers
|
||||
"/courts", json={"name": "New Court"}, headers=auth_headers
|
||||
)
|
||||
assert create_res.status_code == 200
|
||||
assert create_res.json()["name"] == "New Court"
|
||||
assert create_res.json()["name"] == "New Court"
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -41,5 +41,8 @@
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-svgr": "^5.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
import Courts from './pages/Courts';
|
||||
import Tournament from './pages/Tournament';
|
||||
|
||||
export default function App() {
|
||||
@@ -34,6 +35,7 @@ export default function App() {
|
||||
{/* Main App Layout */}
|
||||
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/courts" element={<Courts />} />
|
||||
<Route path="/tournaments/:id" element={<Tournament />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M431.789,47.747H272.387V32.384c0-9.043-7.331-16.383-16.383-16.383s-16.383,7.34-16.383,16.383v15.362H80.211 C35.988,47.747,0,83.734,0,127.967v256.067c0,44.232,35.988,80.22,80.211,80.22h159.41v15.354 c0,9.052,7.331,16.392,16.383,16.392c9.043,0,16.374-7.339,16.383-16.392v-15.354h159.401c44.232,0,80.211-35.988,80.211-80.22 V127.967C512,83.734,476.012,47.747,431.789,47.747z M80.211,431.496c-26.172,0-47.454-21.291-47.454-47.454V127.976 c0-26.172,21.291-47.454,47.454-47.454v-0.009h159.41v350.983H80.211z M479.243,384.042c0,26.172-21.291,47.454-47.454,47.454 H272.387V80.513h159.401c26.172,0,47.454,21.291,47.454,47.454V384.042z"/>
|
||||
<path d="M351.738,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.044,0,16.383-7.34,16.383-16.392V110.377C368.121,101.333,360.79,93.994,351.738,93.994z"/>
|
||||
<path d="M160.271,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.043,0,16.374-7.34,16.383-16.392V110.377C176.654,101.333,169.324,93.994,160.271,93.994z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,7 @@
|
||||
// frontend/src/components/Dashboard/DashCard.tsx
|
||||
|
||||
import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
|
||||
import { SlidersHorizontal, Users } from 'lucide-react';
|
||||
import CourtIcon from '../../assets/court.svg?react';
|
||||
|
||||
interface TournamentSummary {
|
||||
id: string | number;
|
||||
@@ -45,7 +46,7 @@ export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps
|
||||
{t.team_count} Teams
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<MapPin size={16} className="text-orange-500" />
|
||||
<CourtIcon width={16} height={16} className="text-orange-500" />
|
||||
{t.court_count} Courts
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
// frontend/src/components/Forms/TournamentForm.tsx
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Plus, Trash2, UserMinus } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface Court {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface LocalTeam {
|
||||
id: string | number;
|
||||
name: string;
|
||||
dropped_out: boolean;
|
||||
}
|
||||
|
||||
interface TournamentSettings {
|
||||
id: string | number;
|
||||
name: string;
|
||||
@@ -11,8 +22,9 @@ interface TournamentSettings {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
duration: number;
|
||||
courts: { name?: string }[];
|
||||
teams: { name?: string }[];
|
||||
courts: Court[];
|
||||
teams: { id?: string | number; name?: string; dropped_out?: boolean }[];
|
||||
has_started?: boolean;
|
||||
}
|
||||
|
||||
interface TournamentFormProps {
|
||||
@@ -23,41 +35,94 @@ interface TournamentFormProps {
|
||||
|
||||
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
|
||||
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!!tournamentId);
|
||||
const [globalCourts, setGlobalCourts] = useState<Court[]>([]);
|
||||
const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
|
||||
const [newCourtName, setNewCourtName] = useState('');
|
||||
|
||||
const [teams, setTeams] = useState<LocalTeam[]>([]);
|
||||
const [newTeamInput, setNewTeamInput] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tournamentId) {
|
||||
setInitialData(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
const fetchDependencies = async () => {
|
||||
try {
|
||||
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
|
||||
setInitialData(data);
|
||||
const courtsRes = await api.get<Court[]>('/courts');
|
||||
setGlobalCourts(courtsRes);
|
||||
|
||||
if (tournamentId) {
|
||||
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
|
||||
setInitialData(data);
|
||||
setSelectedCourts(data.courts.map(c => c.id));
|
||||
|
||||
if (data.teams) {
|
||||
setTeams(data.teams.map((t, i) => ({
|
||||
id: t.id || `db-${i}-${Math.random()}`,
|
||||
name: t.name || '',
|
||||
dropped_out: (t as any).dropped_out || false
|
||||
})));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Failed to load tournament settings.");
|
||||
setError("Failed to load tournament data.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
void fetchDependencies();
|
||||
}, [tournamentId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleAddTeams = () => {
|
||||
const names = newTeamInput.split('\n').map(n => n.trim()).filter(n => n.length > 0);
|
||||
if (names.length === 0) return;
|
||||
|
||||
// FIX 2: Explicitly provide dropped_out: false to satisfy the LocalTeam interface
|
||||
const newTeams = names.map(name => ({ id: crypto.randomUUID(), name, dropped_out: false }));
|
||||
setTeams([...teams, ...newTeams]);
|
||||
setNewTeamInput('');
|
||||
};
|
||||
|
||||
// FIX 3 & 4: Change type from 'string' to 'string | number'
|
||||
const removeTeam = (id: string | number) => {
|
||||
setTeams(teams.filter(t => t.id !== id));
|
||||
};
|
||||
|
||||
const updateTeamName = (id: string | number, newName: string) => {
|
||||
setTeams(teams.map(t => t.id === id ? { ...t, name: newName } : t));
|
||||
};
|
||||
|
||||
const toggleDropOut = (id: string | number) => {
|
||||
setTeams(teams.map(t => t.id === id ? { ...t, dropped_out: !t.dropped_out } : t));
|
||||
};
|
||||
|
||||
const handleAddCourt = async () => {
|
||||
if (!newCourtName.trim()) return;
|
||||
try {
|
||||
const added = await api.post<Court>('/courts', { name: newCourtName.trim() });
|
||||
setGlobalCourts([...globalCourts, added]);
|
||||
setSelectedCourts([...selectedCourts, added.id]);
|
||||
setNewCourtName('');
|
||||
} catch {
|
||||
setError("Failed to create new court.");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCourt = (id: number) => {
|
||||
setSelectedCourts(prev =>
|
||||
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const rawTeams = formData.get('teams') as string;
|
||||
const rawCourts = formData.get('courts') as string;
|
||||
const date = formData.get('date') as string;
|
||||
const startTime = formData.get('start_time') as string;
|
||||
const typeRaw = formData.get('type') as string;
|
||||
@@ -65,15 +130,22 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
||||
const name = formData.get('name') as string;
|
||||
const code = formData.get('code') as string;
|
||||
|
||||
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
||||
const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
|
||||
const finalTeams = teams
|
||||
.filter(t => t.name.trim().length > 0)
|
||||
.map(t => ({ id: t.id, name: t.name.trim(), dropped_out: t.dropped_out }));
|
||||
|
||||
if (teams.length < 2) {
|
||||
if (finalTeams.length < 2) {
|
||||
setError("At least 2 teams required.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCourts.length === 0) {
|
||||
setError("Please select at least one court.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = `${date}T${startTime}:00`;
|
||||
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
|
||||
const parsedDuration = parseInt(duration, 10);
|
||||
@@ -81,39 +153,37 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
||||
try {
|
||||
if (tournamentId) {
|
||||
const basePayload = {
|
||||
name,
|
||||
code,
|
||||
type: formattedType,
|
||||
timestamp,
|
||||
duration: parsedDuration
|
||||
name, code, type: formattedType, timestamp, duration: parsedDuration
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
api.patch(`/tournaments/${tournamentId}`, basePayload),
|
||||
api.patch(`/tournaments/${tournamentId}/teams`, teams),
|
||||
api.patch(`/tournaments/${tournamentId}/courts`, courts)
|
||||
]);
|
||||
await api.patch(`/tournaments/${tournamentId}`, basePayload);
|
||||
await api.patch(`/tournaments/${tournamentId}/teams`, finalTeams);
|
||||
await api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts);
|
||||
} else {
|
||||
const fullPayload = {
|
||||
name,
|
||||
code,
|
||||
type: formattedType,
|
||||
timestamp,
|
||||
duration: parsedDuration,
|
||||
teams,
|
||||
courts
|
||||
name, code, type: formattedType, timestamp, duration: parsedDuration,
|
||||
teams: finalTeams, courts: selectedCourts
|
||||
};
|
||||
await api.post('/tournaments', fullPayload);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
const error = err as { detail?: string | Array<{ loc: string[]; msg: string }> };
|
||||
if (Array.isArray(error.detail)) {
|
||||
setError(error.detail.map((e) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
|
||||
} else {
|
||||
setError(typeof error.detail === 'string' ? error.detail : "Error saving tournament");
|
||||
} catch (err: any) {
|
||||
console.error("Save error:", err);
|
||||
let msg = "An error occurred while saving.";
|
||||
if (err?.response?.data?.detail) {
|
||||
const detail = err.response.data.detail;
|
||||
if (Array.isArray(detail)) {
|
||||
msg = detail.map((d: any) => `${d.loc[d.loc.length - 1]}: ${d.msg}`).join(' | ');
|
||||
} else if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else {
|
||||
msg = JSON.stringify(detail);
|
||||
}
|
||||
} else if (err?.message) {
|
||||
msg = err.message;
|
||||
}
|
||||
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -136,10 +206,12 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
||||
defaultTime = t ? t.substring(0, 5) : "09:00";
|
||||
}
|
||||
|
||||
const hasStarted = initialData?.has_started;
|
||||
|
||||
return (
|
||||
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
|
||||
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-3 rounded-lg border border-red-200 dark:border-red-900/30 font-bold leading-relaxed">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -147,35 +219,17 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={initialData?.name}
|
||||
required
|
||||
placeholder="My Awesome Tournament"
|
||||
autoFocus
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
/>
|
||||
<input name="name" defaultValue={initialData?.name} required placeholder="My Awesome Tournament" autoFocus className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
||||
<input
|
||||
name="code"
|
||||
defaultValue={initialData?.code}
|
||||
required
|
||||
placeholder="••••"
|
||||
autoComplete="off"
|
||||
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
/>
|
||||
<input name="code" defaultValue={initialData?.code} required placeholder="••••" autoComplete="off" className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue={initialData?.type?.toLowerCase() || "double"}
|
||||
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
>
|
||||
<select name="type" defaultValue={initialData?.type?.toLowerCase() || "double"} className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white">
|
||||
<option value="double">Double Elimination</option>
|
||||
<option value="single">Single Elimination</option>
|
||||
</select>
|
||||
@@ -185,75 +239,155 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
||||
<input
|
||||
type="number"
|
||||
name="duration"
|
||||
defaultValue={initialData?.duration || 30}
|
||||
min="0"
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
/>
|
||||
<input type="number" name="duration" defaultValue={initialData?.duration || 30} min="0" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
name="start_time"
|
||||
defaultValue={defaultTime}
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
/>
|
||||
<input type="time" name="start_time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
|
||||
<input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{globalCourts.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => toggleCourt(c.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition ${selectedCourts.includes(c.id)
|
||||
? 'bg-orange-500 text-white shadow-md'
|
||||
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
{globalCourts.length === 0 && <span className="text-xs italic text-zinc-400">No courts registered yet.</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
name="date"
|
||||
defaultValue={defaultDate}
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
type="text"
|
||||
value={newCourtName}
|
||||
onChange={(e) => setNewCourtName(e.target.value)}
|
||||
placeholder="Add new court..."
|
||||
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-1 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCourt} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1 rounded transition flex items-center">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label>
|
||||
<input
|
||||
name="courts"
|
||||
placeholder="Center Court, Court 1"
|
||||
defaultValue={initialData?.courts?.map(c => c.name || c).join(', ')}
|
||||
required
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-end mb-1">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase">Teams ({teams.length})</label>
|
||||
{tournamentId && (
|
||||
<span className="text-[9px] font-bold text-orange-500 uppercase tracking-widest bg-orange-100 dark:bg-orange-900/30 px-2 py-0.5 rounded border border-orange-200 dark:border-orange-800">Inline Editable</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase">Teams</label>
|
||||
<textarea
|
||||
name="teams"
|
||||
placeholder="One team per line..."
|
||||
defaultValue={initialData?.teams?.map(t => t.name || t).join('\n')}
|
||||
rows={5}
|
||||
required
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 font-mono text-sm focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white resize-none"
|
||||
/>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-lg p-1.5 space-y-1.5">
|
||||
<div className="space-y-1 max-h-56 overflow-y-auto pr-1">
|
||||
{teams.map((t, i) => (
|
||||
<div key={t.id} className="flex items-center gap-1 bg-white dark:bg-zinc-950 p-0.5 border border-zinc-200 dark:border-zinc-800 rounded shadow-sm group transition-colors focus-within:border-orange-500 focus-within:ring-1 focus-within:ring-orange-500/20">
|
||||
<div className="px-1 text-[9px] font-black text-zinc-400 w-6 text-right select-none">{i + 1}.</div>
|
||||
<input
|
||||
value={t.name}
|
||||
onChange={(e) => updateTeamName(t.id, e.target.value)}
|
||||
disabled={t.dropped_out}
|
||||
className={`flex-1 bg-transparent border-none outline-none text-xs font-bold px-1 py-0.5 focus:text-orange-600 transition-all ${t.dropped_out ? 'text-red-500 line-through opacity-60' : 'text-zinc-700 dark:text-zinc-200'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 1. If dropped out, ALWAYS show Restore */}
|
||||
{t.dropped_out && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDropOut(t.id)}
|
||||
className="p-1 rounded text-red-500 bg-red-50 dark:bg-red-900/30 opacity-100 transition focus:opacity-100"
|
||||
title="Restore Team"
|
||||
>
|
||||
<UserMinus size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 2. If started and active, show Drop Out */}
|
||||
{hasStarted && !t.dropped_out && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDropOut(t.id)}
|
||||
className="p-1 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Mark as Dropped Out"
|
||||
>
|
||||
<UserMinus size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 3. If NOT started, ALWAYS allow Deletion */}
|
||||
{!hasStarted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTeam(t.id)}
|
||||
className="p-1 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Remove Team entirely"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{teams.length === 0 && (
|
||||
<div className="text-center p-4 text-[9px] font-black text-zinc-400 uppercase tracking-widest border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded">
|
||||
No teams added
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasStarted && (
|
||||
<div className="pt-1.5 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex gap-1.5 items-start">
|
||||
<textarea
|
||||
value={newTeamInput}
|
||||
onChange={(e) => setNewTeamInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAddTeams();
|
||||
}
|
||||
}}
|
||||
placeholder="Type name (Enter to add) or paste list..."
|
||||
rows={newTeamInput.includes('\n') ? 3 : 1}
|
||||
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-2 py-1.5 text-xs focus:border-orange-500 outline-none text-zinc-900 dark:text-white resize-none transition-all shadow-sm"
|
||||
/>
|
||||
{/* FIX 5: Replaced min-h-[32px] with the canonical min-h-8 */}
|
||||
<button type="button" onClick={handleAddTeams} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1.5 rounded font-bold transition flex items-center justify-center shrink-0 shadow-sm self-stretch min-h-8">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
|
||||
<div className="flex justify-between mt-6 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4 items-center">
|
||||
{tournamentId && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(tournamentId)}
|
||||
className="text-red-500 text-sm hover:underline h-5 self-end"
|
||||
>
|
||||
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline font-bold">
|
||||
Delete Tournament
|
||||
</button>
|
||||
)}
|
||||
{!tournamentId && <div className="hidden"></div>}
|
||||
|
||||
<button
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-8 py-2.5 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition active:scale-95 disabled:opacity-50 flex items-center gap-2 ml-auto">
|
||||
{isSubmitting && <Loader2 size={16} className="animate-spin" />}
|
||||
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// frontend/src/components/Layout/Navbar.tsx
|
||||
|
||||
import { Lock, LogOut, Volleyball } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import CourtIcon from '../../assets/court.svg?react';
|
||||
|
||||
interface NavbarProps {
|
||||
title: string;
|
||||
@@ -11,39 +12,56 @@ interface NavbarProps {
|
||||
}
|
||||
|
||||
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
|
||||
return (
|
||||
<nav className="transition-colors bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-100 px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
|
||||
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
|
||||
<div className="p-1.5 sm:p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30 active:scale-90">
|
||||
<Volleyball className="text-white" size={20} />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
|
||||
<p className="text-[9px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
|
||||
</div>
|
||||
</Link>
|
||||
const location = useLocation();
|
||||
|
||||
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-35 xs:max-w-[180px] sm:max-w-100">
|
||||
<div className="font-black uppercase text-[10px] sm:text-sm tracking-widest sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
|
||||
return (
|
||||
<nav className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 px-4 py-3 flex items-center justify-between shadow-sm sticky top-0 z-50">
|
||||
|
||||
{/* Left: Logo & Main Navigation */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/" className="flex items-center gap-2 shrink-0">
|
||||
<div className="p-2 bg-orange-600 rounded-xl shadow-lg shadow-orange-600/20">
|
||||
<Volleyball className="text-white" size={20} />
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<h1 className="text-lg font-black text-zinc-900 dark:text-white leading-none">VolleyManager</h1>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/courts" className="md:hidden p-2 text-zinc-600 dark:text-zinc-400 bg-zinc-100 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-lg">
|
||||
<CourtIcon width={20} height={20} />
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||
<Link to="/" className={`px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/' ? 'bg-white dark:bg-zinc-700 shadow-sm text-orange-600' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
|
||||
Tournaments
|
||||
</Link>
|
||||
<Link to="/courts" className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/courts' ? 'bg-orange-600 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
|
||||
Courts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Title (Responsive truncation) */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 text-center hidden sm:block pointer-events-none">
|
||||
<div className="font-black uppercase text-xs tracking-[0.2em] text-zinc-900 dark:text-white truncate max-w-37.5 md:max-w-62.5">
|
||||
{title || 'Dashboard'}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-[8px] sm:text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none">
|
||||
<div className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest mt-0.5">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
|
||||
<LogOut size={18} className="sm:size-5.5" />
|
||||
</button>
|
||||
</>
|
||||
<button onClick={onLogout} className="p-2 text-zinc-400 hover:text-red-500 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/login" className="text-orange-600 font-black flex items-center gap-1.5 text-[9px] sm:text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-1.5 sm:p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20">
|
||||
<Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
|
||||
<Link to="/login" className="flex items-center gap-2 px-4 py-2 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs font-black uppercase tracking-wider hover:bg-orange-100 transition">
|
||||
<Lock size={14} /> Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// frontend/src/components/Tournament/ScoreModal.tsx
|
||||
|
||||
import { type SetData } from '../../types';
|
||||
import { Clock, Eraser, MapPin, 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"
|
||||
import CourtIcon from '../../assets/court.svg?react';
|
||||
|
||||
interface MatchData {
|
||||
id: string | number;
|
||||
@@ -15,6 +16,8 @@ interface MatchData {
|
||||
p1_label?: string;
|
||||
p2?: string;
|
||||
p2_label?: string;
|
||||
p1_is_real?: boolean; // <-- NEW
|
||||
p2_is_real?: boolean; // <-- NEW
|
||||
ref_label?: string;
|
||||
ref_team?: { name: string };
|
||||
isFinished: boolean;
|
||||
@@ -32,14 +35,53 @@ 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);
|
||||
|
||||
const refName = match.ref_team?.name || match.ref_label;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
if (isSubmitting) return;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// --- NEW: Walkover Function ---
|
||||
const handleWalkover = async (winnerIndex: 1 | 2) => {
|
||||
if (isSubmitting) return;
|
||||
const winnerName = winnerIndex === 1 ? match.p1 : match.p2;
|
||||
if (!window.confirm(`Award Walkover to ${winnerName}?`)) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const woSets = winnerIndex === 1 ? [{ p1: 21, p2: 0 }, { p1: 21, p2: 0 }] : [{ p1: 0, p2: 21 }, { p1: 0, p2: 21 }];
|
||||
|
||||
try {
|
||||
await onSubmit(match.id, woSets, code);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { detail?: string };
|
||||
setError(typeof error?.detail === 'string' ? error.detail : "Failed to record walkover");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,7 +98,6 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Time & Court Row */}
|
||||
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
|
||||
<div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
|
||||
<Clock className="text-orange-500" size={16} />
|
||||
@@ -64,16 +105,21 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
||||
</div>
|
||||
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
|
||||
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
|
||||
<MapPin className="text-orange-500" size={16} />
|
||||
<CourtIcon className="text-orange-500" width={16} height={16} />
|
||||
<span>{match.court || "TBD"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
@@ -100,10 +146,23 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-center px-2">
|
||||
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div>
|
||||
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm">VS</div>
|
||||
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div>
|
||||
{/* --- NEW: Team Names with Walkover Buttons --- */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-start px-2">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div>
|
||||
{isAuthenticated && !match.isFinished && match.p1_is_real && (
|
||||
<button onClick={() => handleWalkover(1)} type="button" className="text-[9px] bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-1 rounded uppercase font-black hover:bg-orange-500 hover:text-white transition">Win by WO</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm mt-1">VS</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div>
|
||||
{isAuthenticated && !match.isFinished && match.p2_is_real && (
|
||||
<button onClick={() => handleWalkover(2)} type="button" className="text-[9px] bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-1 rounded uppercase font-black hover:bg-orange-500 hover:text-white transition">Win by WO</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
@@ -126,15 +185,10 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
|
||||
title="Remove Set"
|
||||
className="mx-auto p-2 text-zinc-300 hover:text-red-500 transition shrink-0 group"
|
||||
>
|
||||
<Eraser
|
||||
size={18}
|
||||
className="group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
<Eraser size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-zinc-400 dark:text-zinc-600 text-center font-bold">
|
||||
-
|
||||
</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-600 text-center font-bold">-</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
@@ -146,7 +200,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>
|
||||
))}
|
||||
@@ -157,17 +210,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>
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// frontend/src/pages/Courts.tsx
|
||||
|
||||
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 WhistleIcon from '../assets/whistle.svg?react';
|
||||
|
||||
import CourtIcon from '../assets/court.svg?react';
|
||||
|
||||
interface CourtMatch {
|
||||
id: string;
|
||||
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;
|
||||
}
|
||||
|
||||
interface CourtSchedule {
|
||||
court: string;
|
||||
matches: CourtMatch[];
|
||||
}
|
||||
|
||||
interface GlobalCourt {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface OutletContext {
|
||||
setNavTitle: (t: string) => void;
|
||||
setNavSubtitle: (s: string) => void;
|
||||
role: string | null;
|
||||
}
|
||||
|
||||
const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (id: number) => void, role: string | null }) => {
|
||||
const [data, setData] = useState<CourtSchedule | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState<string>('');
|
||||
|
||||
// Auto-scroll refs
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [hasScrolled, setHasScrolled] = useState(false);
|
||||
|
||||
// Keep the current time perfectly updated
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
setCurrentTime(`${hours}:${minutes}`);
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSchedule = async () => {
|
||||
try {
|
||||
const res = await api.get<CourtSchedule>(`/courts/${courtId}/schedule`);
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
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 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) {
|
||||
container.scrollTo({
|
||||
top: line.offsetTop - 20,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
setHasScrolled(true);
|
||||
}, 500);
|
||||
}
|
||||
}, [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>;
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<CourtIcon className="text-orange-500 shrink-0" width={20} height={20} />
|
||||
<h3 className="font-black text-base md:text-lg text-zinc-900 dark:text-white uppercase tracking-wider truncate">{data.court}</h3>
|
||||
</div>
|
||||
{role === 'admin' && (
|
||||
<button onClick={() => onDelete(courtId)} className="text-zinc-400 hover:text-red-500 transition p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 active:scale-90" title="Delete Court">
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 = isDayFinished || (activeIndex !== -1 && index < activeIndex);
|
||||
const isLive = !isDayFinished && index === activeIndex;
|
||||
const showNowLine = !isDayFinished && ((activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0));
|
||||
|
||||
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 || 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';
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
borderStyle = 'border-orange-500 ring-1 ring-orange-500/20 shadow-sm hover:-translate-y-1 hover:shadow-md';
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={m.id}>
|
||||
{/* --- THE --NOW-- LINE --- */}
|
||||
{showNowLine && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={`/tournaments/${m.tournament_id}`}
|
||||
className={`match-card block bg-white dark:bg-zinc-950 p-2.5 rounded-lg border transition-all ${borderStyle}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-base font-black font-mono leading-none ${textOpacity}`}>{m.time}</div>
|
||||
{isLive && (
|
||||
<div className="flex items-center gap-1 bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-500 px-1.5 py-0.5 rounded-full border border-orange-200 dark:border-orange-800/50">
|
||||
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></span>
|
||||
<span className="text-[7px] font-black uppercase tracking-widest">Live</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[7px] font-black uppercase text-zinc-500 bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded truncate max-w-22.5">
|
||||
{m.tournament_name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mb-1.5">
|
||||
<div className="flex justify-between items-center text-xs leading-tight">
|
||||
<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={`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>
|
||||
|
||||
{m.ref_name && !isFinished && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-1 text-[8px] font-bold text-zinc-500 dark:text-zinc-400">
|
||||
<WhistleIcon className="text-orange-500" width={10} height={10} />
|
||||
<span className="uppercase tracking-widest truncate">{m.ref_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Courts() {
|
||||
const { setNavTitle, setNavSubtitle, role } = useOutletContext<OutletContext>();
|
||||
const [courts, setCourts] = useState<GlobalCourt[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newCourtName, setNewCourtName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setNavTitle('Courts');
|
||||
setNavSubtitle('');
|
||||
|
||||
const fetchCourts = async () => {
|
||||
try {
|
||||
const res = await api.get<GlobalCourt[]>('/courts');
|
||||
setCourts(res);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchCourts();
|
||||
}, [setNavTitle, setNavSubtitle]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm("Are you sure you want to delete this court? It will remove it from all tournaments and auto-reschedule them.")) return;
|
||||
try {
|
||||
await api.delete(`/courts/${id}`);
|
||||
setCourts(courts.filter(c => c.id !== id));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete court", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCourt = async () => {
|
||||
if (!newCourtName.trim()) return;
|
||||
try {
|
||||
const added = await api.post<GlobalCourt>('/courts', { name: newCourtName.trim() });
|
||||
setCourts([...courts, added]);
|
||||
setNewCourtName('');
|
||||
} catch (err) {
|
||||
console.error("Failed to create new court", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden flex flex-col bg-white dark:bg-zinc-950 pt-8 pb-8">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden px-6 md:px-12">
|
||||
<div className="flex h-full gap-6 pb-4 min-w-max">
|
||||
{courts.map(c => (
|
||||
<CourtColumn key={c.id} courtId={c.id} onDelete={handleDelete} role={role} />
|
||||
))}
|
||||
|
||||
{courts.length === 0 && role !== 'admin' && (
|
||||
<div className="text-center text-zinc-500 font-bold uppercase mt-20 w-full">
|
||||
No physical courts registered yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- ADMIN PANEL: Add New Court Inline --- */}
|
||||
{role === 'admin' && (
|
||||
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/30 dark:bg-zinc-900/10 rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 p-6 items-center justify-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-400">
|
||||
<CourtIcon width={32} height={32} />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-zinc-500 uppercase text-center">Add New Court</div>
|
||||
<div className="flex flex-col w-full gap-3 mt-2">
|
||||
<input
|
||||
value={newCourtName}
|
||||
onChange={(e) => setNewCourtName(e.target.value)}
|
||||
placeholder="Court name..."
|
||||
className="w-full bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded-xl px-4 py-3 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white shadow-sm"
|
||||
/>
|
||||
<button onClick={handleAddCourt} className="w-full bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 py-3 rounded-xl font-bold uppercase text-xs tracking-wider transition flex items-center justify-center gap-2 active:scale-95">
|
||||
<Plus size={16} /> Add Court
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +42,23 @@ export default function Dashboard() {
|
||||
setTournaments(list as TournamentType[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNavTitle('Dashboard');
|
||||
setNavSubtitle('');
|
||||
void loadDashboard();
|
||||
|
||||
localStorage.removeItem('volley_view');
|
||||
|
||||
(async () => {
|
||||
await loadDashboard();
|
||||
})();
|
||||
|
||||
let ws: WebSocket;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
@@ -59,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) => {
|
||||
@@ -86,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: [],
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Login() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -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,
|
||||
@@ -149,24 +166,51 @@ export default function Tournament() {
|
||||
}, [id, setNavTitle, setNavSubtitle]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
if (wsRef.current) return;
|
||||
(async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
|
||||
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();
|
||||
wsRef.current = null;
|
||||
clearTimeout(reconnectTimeout);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [id, fetchData]);
|
||||
|
||||
|
||||