20 Commits
59 changed files with 3679 additions and 2211 deletions

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
name: Test and Release
on:
push:
branches: ["**"]
pull_request:
branches: ["main", "master"]
release:
types: [published]
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: |
cd backend
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
run: |
cd backend
pip install pytest pytest-cov
pytest
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Test with NPM
run: |
cd frontend
npm test -- run --exclude "**/*.live.test.ts"
build-docker:
needs: [test-backend, test-frontend]
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
packages: write
contents: read
strategy:
matrix:
service: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Log in to Registry
uses: docker/login-action@v3
with:
registry: git.stws.cc
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.stws.cc/${{ github.repository }}/${{ matrix.service }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Build and Push
uses: docker/build-push-action@v6
with:
context: ./${{ matrix.service }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-69
View File
@@ -1,69 +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 -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 -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
+29 -12
View File
@@ -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.
+149
View File
@@ -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
+4 -1
View File
@@ -9,9 +9,12 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini .
COPY ./alembic ./alembic
COPY ./app ./app
RUN mkdir -p /app/data
EXPOSE $PORT
CMD ["sh", "-c", "gunicorn -w 1 -k uvicorn.workers.UvicornWorker app.main:app --bind 0.0.0.0:$PORT"]
CMD ["sh", "-c", "alembic upgrade head && gunicorn -w 1 -k uvicorn.workers.UvicornWorker app.main:app --bind 0.0.0.0:$PORT"]
+149
View File
@@ -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 = sqlite:///./app.db
[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
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+21
View File
@@ -0,0 +1,21 @@
# backend/alembic/env.py
import sys
from os.path import dirname, abspath
from logging.config import fileConfig
from sqlalchemy import pool
from alembic import context
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from app.database import Base, SQLALCHEMY_DATABASE_URL
import app.models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
target_metadata = Base.metadata
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,27 @@
# backend/alembic/versions/60af14f4cc88_force_add_dropped_out.py
"""force add dropped out
Revision ID: 60af14f4cc88
Revises: eb5be3f1f723
Create Date: 2026-08-29 21:22:48.979751
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '60af14f4cc88'
down_revision: Union[str, Sequence[str], None] = 'eb5be3f1f723'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('teams', sa.Column('dropped_out', sa.Boolean(), server_default='0', nullable=False))
def downgrade() -> None:
op.drop_column('teams', 'dropped_out')
@@ -0,0 +1,30 @@
# backend/alembic/versions/eb5be3f1f723_initial_baseline.py
"""initial baseline
Revision ID: eb5be3f1f723
Revises:
Create Date: 2026-08-29 19:44:15.681944
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eb5be3f1f723'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
+105 -49
View File
@@ -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()
)
+223 -109
View File
@@ -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
+2 -1
View File
@@ -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(
+76 -29
View File
@@ -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]
)
+113
View File
@@ -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
],
}
+1 -1
View File
@@ -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
-60
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ def get_tournament_matches(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/matches/{match_id}", response_model=schemas.MatchOut)
def get_match_details(id: str, match_id: str, db: Session = Depends(get_db)):
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
+6 -8
View File
@@ -19,8 +19,6 @@ def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]):
if not is_admin and not code_matches:
raise HTTPException(403, "Invalid tournament code or admin privileges required")
@router.post("/{id}/matches/{match_id}/score")
async def report_score(
id: str,
@@ -29,13 +27,13 @@ async def report_score(
db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user),
):
t = crud.get_tournament(db, id)
t = crud.get_tournament(db, id, lock=True)
if not t:
raise HTTPException(404, "Tournament not found")
_check_auth(t, user, report.code)
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
@@ -65,13 +63,13 @@ async def edit_score(
db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user),
):
t = crud.get_tournament(db, id)
t = crud.get_tournament(db, id, lock=True)
if not t:
raise HTTPException(404, "Tournament not found")
_check_auth(t, user, report.code)
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
@@ -99,13 +97,13 @@ async def clear_score(
db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user),
):
t = crud.get_tournament(db, id)
t = crud.get_tournament(db, id, lock=True)
if not t:
raise HTTPException(404, "Tournament not found")
_check_auth(t, user, code)
match = crud.get_match(db, id, match_id)
match = crud.get_match(db, match_id)
if not match:
raise HTTPException(404, "Match not found")
+2 -2
View File
@@ -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
+10 -3
View File
@@ -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):
Binary file not shown.
+27 -14
View File
@@ -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],
}
+23 -22
View File
@@ -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"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+1721 -1637
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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
}
}
+2
View File
@@ -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>
+5
View File
@@ -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>
+249 -115
View File
@@ -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>
+41 -23
View File
@@ -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>
+339
View File
@@ -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>
);
}
+42 -4
View File
@@ -1,6 +1,6 @@
// frontend/src/pages/Dashboard.tsx
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal } from 'lucide-react';
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal, Loader2 } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import DashCard from '../components/Dashboard/DashCard';
@@ -32,6 +32,7 @@ export default function Dashboard() {
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
const [showPast, setShowPast] = useState<boolean>(false);
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const loadDashboard = useCallback(async () => {
@@ -41,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: [],
+1 -1
View File
@@ -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);
+68 -24
View File
@@ -21,17 +21,22 @@ interface RawMatch {
round_number: number;
match_number: number;
winner_next_match_id?: string | number | null;
winner_next_match_slot?: number | null;
loser_next_match_id?: string | number | null;
loser_next_match_slot?: number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_team_id?: string | number | null;
ref_team_id?: string | number | null;
ref_label?: string | null;
ref_team?: { id: string | number; name: string } | null;
status: string;
court_id: string | number;
start_time?: string;
sets?: { p1: number; p2: number }[];
}
export interface ProcessedMatch extends RawMatch {
export interface ProcessedMatch extends Omit<RawMatch, 'ref_label' | 'ref_team'> {
bracket: string;
round: number;
number: number;
@@ -47,6 +52,8 @@ export interface ProcessedMatch extends RawMatch {
p2_sets: number;
hasTeams: boolean;
isFinished: boolean;
ref_label?: string;
ref_team?: { id: string | number; name: string };
}
interface OutletContextType {
@@ -88,26 +95,34 @@ export default function Tournament() {
const courtMap: Record<string | number, string> = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap: Record<string | number, string> = Object.fromEntries(teams.map(t => [t.id, t.name]));
const incoming: Record<string | number, { label: string; id: string | number }[]> = {};
rawMatches.forEach(m => {
const num = m.match_number;
if (m.winner_next_match_id) {
(incoming[m.winner_next_match_id] = incoming[m.winner_next_match_id] || []).push({ label: `Winner of #${num}`, id: m.id });
}
if (m.loser_next_match_id) {
(incoming[m.loser_next_match_id] = incoming[m.loser_next_match_id] || []).push({ label: `Loser of #${num}`, id: m.id });
}
});
return rawMatches
.filter(m => m.id !== skippedResetMatchId)
.map(m => {
const sources = incoming[m.id] || [];
let sourceIndex = 0;
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
// BUG 1 FIX: Explicitly check the SLOT (0 for top, 1 for bottom)
// to prevent duplicated labels.
const getPlaceholder = (slot: number) => {
const winParent = rawMatches.find(rm => rm.winner_next_match_id === m.id && rm.winner_next_match_slot === slot);
if (winParent) return `Winner of #${winParent.match_number}`;
const loseParent = rawMatches.find(rm => rm.loser_next_match_id === m.id && rm.loser_next_match_slot === slot);
if (loseParent) return `Loser of #${loseParent.match_number}`;
return 'TBD';
};
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : getPlaceholder(0);
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : getPlaceholder(1);
// BUG 2 FIX: Resolve Backend Graph Links ("W:uuid" / "L:uuid") for Referees
let resolvedRefLabel = m.ref_label;
if (resolvedRefLabel && resolvedRefLabel.includes(':')) {
const [outcome, refId] = resolvedRefLabel.split(':');
const refMatch = rawMatches.find(rm => String(rm.id) === String(refId));
if (refMatch) {
resolvedRefLabel = `${outcome === 'W' ? 'Winner' : 'Loser'} of #${refMatch.match_number}`;
}
}
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
const isFinished = m.status === "Finished";
@@ -116,6 +131,8 @@ export default function Tournament() {
return {
...m,
ref_label: resolvedRefLabel || undefined,
ref_team: m.ref_team || undefined,
bracket: m.bracket_type,
round: m.round_number,
number: m.match_number,
@@ -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]);