15 Commits
49 changed files with 2473 additions and 1571 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 }}
-79
View File
@@ -1,79 +0,0 @@
stages:
- Test Stage
- Build Stage
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH
"Python Tests":
stage: Test Stage
image: python:3.14-alpine
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
key:
files:
- backend/requirements.txt
paths:
- .cache/pip
script:
- echo "Running tests on the Python Backend..."
- cd backend
- pip install -r requirements.txt
- pytest
"React Tests":
stage: Test Stage
image: node:alpine
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm-cache"
cache:
key:
files:
- frontend/package-lock.json
paths:
- .npm-cache
- frontend/node_modules
script:
- cd frontend
- npm ci --prefer-offline
- npm test -- run --exclude "**/*.live.test.ts"
"Build Backend Image":
stage: Build Stage
image: docker:24.0.5
rules:
- if: $CI_COMMIT_TAG
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- cd backend
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE/backend:latest .
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE/backend:latest
"Build Frontend Image":
stage: Build Stage
image: docker:24.0.5
rules:
- if: $CI_COMMIT_TAG
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- cd frontend
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE/frontend:latest .
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE/frontend:latest
+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.
+27
View File
@@ -0,0 +1,27 @@
# backend/alembic/env.py
import sys
from os.path import dirname, abspath
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 1. Add your backend folder to the Python path so it can find 'app'
sys.path.insert(0, dirname(dirname(abspath(__file__))))
# 2. Import your Base, Models, and dynamic Database URL
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)
# 3. OVERRIDE Alembic's config with your app's actual database URL!
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
# 4. Tell Alembic to look at your models for changes
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,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."""
op.add_column('teams', sa.Column('dropped_out', sa.Boolean(), server_default='0', nullable=False))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('teams', 'dropped_out')
+77 -20
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,7 +28,12 @@ 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.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)
@@ -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)
@@ -182,7 +237,7 @@ def delete_court(db: Session, court_id: int):
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
t = get_tournament(db, tournament_id)
t = get_tournament(db, tournament_id, lock=True)
if not t:
return None
@@ -201,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()
)
+121 -82
View File
@@ -1,4 +1,5 @@
# backend/app/logic.py
import heapq
from collections import defaultdict
from datetime import datetime, timedelta
from uuid import uuid4
@@ -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)
@@ -93,6 +95,7 @@ def update_schedule_times(db: Session, t: models.Tournament):
models.Tournament.timestamp >= start_of_day,
models.Tournament.timestamp < end_of_day,
)
.with_for_update()
.all()
)
@@ -133,34 +136,53 @@ def update_schedule_times(db: Session, t: models.Tournament):
for m in all_matches:
get_depth(m.id)
# 4. Initialize timers & lock finished matches
# 4. Initialize timers
all_courts: set[models.Court] = set()
court_sharing_count = defaultdict(
int
) # NEW: Track how highly contested each court is
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 # NEW
court_sharing_count[c.id] += 1
court_timers = {c.id: start_of_day for c in all_courts}
planned_finish_times = {}
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. Global Interleaving Schedule Loop
loop_limit = len(unscheduled) * 3
# 5. Critical Path Time-Stepping Scheduler
loop_limit = max(5000, len(unscheduled) * 50)
while unscheduled and loop_limit > 0:
loop_limit -= 1
@@ -173,7 +195,6 @@ def update_schedule_times(db: Session, t: models.Tournament):
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 = (
m.tournament.timestamp.replace(tzinfo=None)
@@ -236,36 +257,32 @@ def update_schedule_times(db: Session, t: models.Tournament):
else:
court_timers[best_court_id] += timedelta(minutes=5)
# 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!)
all_matches = sorted(
all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0)
)
# 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: models.Match, 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=next_m.tournament.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=m.tournament.duration)
@@ -292,58 +309,42 @@ def update_schedule_times(db: Session, t: models.Tournament):
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
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
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)
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
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)
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()
@@ -355,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)
@@ -421,16 +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)
@@ -462,3 +466,38 @@ def undo_advancement(db: Session, match: models.Match):
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
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
+8 -3
View File
@@ -4,7 +4,7 @@ from typing import Optional
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
@@ -67,16 +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 Match(Base):
__tablename__ = "matches"
+51 -18
View File
@@ -10,13 +10,11 @@ from ..database import get_db
router = APIRouter(prefix="/courts", tags=["Courts"])
@router.get("", response_model=list[schemas.CourtSchema])
def get_all_courts(db: Session = Depends(get_db)):
"""Public route to list all global courts"""
return db.query(models.Court).all()
@router.post("", response_model=schemas.CourtSchema)
def create_global_court(
data: schemas.CourtCreate,
@@ -26,7 +24,6 @@ def create_global_court(
"""Admin route to register a new physical court"""
return crud.create_court(db, data)
@router.delete("/{court_id}")
def delete_global_court(
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
@@ -36,7 +33,6 @@ def delete_global_court(
raise HTTPException(404, "Court not found")
return SUCCESS
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
court = db.query(models.Court).filter(models.Court.id == court_id).first()
@@ -59,22 +55,59 @@ def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
.all()
)
schedule = []
for m in matches:
schedule.append(
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_id": m.tournament.id,
"tournament_name": m.tournament.name,
"time": m.start_time.strftime("%H:%M") if m.start_time else "TBD",
"status": m.status,
"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": m.p1_team.name if m.p1_team else "TBD",
"p2": m.p2_team.name if m.p2_team else "TBD",
"p1_sets": sum(1 for s in m.sets if s["p1"] > s["p2"]) if m.sets else 0,
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0,
"ref_name": m.ref_team.name if m.ref_team else m.ref_label,
"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),
}
)
return {"court": court.name, "matches": schedule}
for m in matches
if m.start_time
],
}
+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
+8 -1
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
@@ -70,7 +76,7 @@ class TournamentCreate(BaseModel):
type: TournamentTypes
timestamp: datetime
duration: int
teams: list[str]
teams: list[TeamUpdate]
courts: list[int]
@@ -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.
+8 -12
View File
@@ -5,7 +5,6 @@ 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
@@ -15,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,
@@ -61,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"
@@ -86,9 +80,6 @@ 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"}
@@ -102,13 +93,18 @@ async def valid_tournament_payload(db):
db.add_all([c1, c2])
db.commit()
# 2. Return payload with IDs
# 2. Return payload with the NEW object format for teams!
return {
"name": "Test Tournament",
"code": "1234",
"type": "Double",
"timestamp": "2026-05-24T11:00:00",
"duration": 15,
"teams": ["Team A", "Team B", "Team C", "Team D"],
"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],
}
}
+14 -15
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,21 +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]
# 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
p1_name = first_match["p1_team_id"]
p2_name = first_match["p2_team_id"]
assert 6 == p1_name
assert 7 == p2_name
assert isinstance(first_match["p2_team_id"], int)
async def test_manage_courts(
@@ -76,7 +75,7 @@ async def test_manage_courts(
# CREATE: Change this from a tournament-specific route to the global one
create_res = await client.post(
"/courts", json={"name": "New Court"}, headers=auth_headers # GLOBAL ROUTE
"/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

+1245 -1209
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
}
}
+165 -29
View File
@@ -1,6 +1,6 @@
// frontend/src/components/Forms/TournamentForm.tsx
import { Loader2, Plus } from 'lucide-react';
import { Loader2, Plus, Trash2, UserMinus } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import api from '../../services/api';
@@ -9,6 +9,12 @@ interface Court {
name: string;
}
interface LocalTeam {
id: string | number;
name: string;
dropped_out: boolean;
}
interface TournamentSettings {
id: string | number;
name: string;
@@ -17,7 +23,8 @@ interface TournamentSettings {
timestamp: string;
duration: number;
courts: Court[];
teams: { name?: string }[];
teams: { id?: string | number; name?: string; dropped_out?: boolean }[];
has_started?: boolean;
}
interface TournamentFormProps {
@@ -32,6 +39,9 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
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);
@@ -46,6 +56,14 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
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);
@@ -58,6 +76,29 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
void fetchDependencies();
}, [tournamentId]);
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 {
@@ -82,8 +123,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
setError(null);
const formData = new FormData(e.currentTarget);
const rawTeams = formData.get('teams') as string;
const date = formData.get('date') as string;
const startTime = formData.get('start_time') as string;
const typeRaw = formData.get('type') as string;
@@ -91,9 +130,11 @@ 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 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;
@@ -115,22 +156,34 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
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`, selectedCourts)
]);
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: selectedCourts
teams: finalTeams, courts: selectedCourts
};
await api.post('/tournaments', fullPayload);
}
onSuccess();
} catch (err: unknown) {
console.error(err);
setError("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);
}
@@ -153,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>
)}
@@ -196,7 +251,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
</div>
</div>
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
<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">
@@ -231,27 +285,109 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
</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="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 className="bg-zinc-50 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-lg p-1.5 space-y-1.5">
<div className="space-y-1 max-h-56 overflow-y-auto pr-1">
{teams.map((t, i) => (
<div key={t.id} className="flex items-center gap-1 bg-white dark:bg-zinc-950 p-0.5 border border-zinc-200 dark:border-zinc-800 rounded shadow-sm group transition-colors focus-within:border-orange-500 focus-within:ring-1 focus-within:ring-orange-500/20">
<div className="px-1 text-[9px] font-black text-zinc-400 w-6 text-right select-none">{i + 1}.</div>
<input
value={t.name}
onChange={(e) => updateTeamName(t.id, e.target.value)}
disabled={t.dropped_out}
className={`flex-1 bg-transparent border-none outline-none text-xs font-bold px-1 py-0.5 focus:text-orange-600 transition-all ${t.dropped_out ? 'text-red-500 line-through opacity-60' : 'text-zinc-700 dark:text-zinc-200'
}`}
/>
<div className="flex items-center gap-1">
{/* 1. If dropped out, ALWAYS show Restore */}
{t.dropped_out && (
<button
type="button"
onClick={() => toggleDropOut(t.id)}
className="p-1 rounded text-red-500 bg-red-50 dark:bg-red-900/30 opacity-100 transition focus:opacity-100"
title="Restore Team"
>
<UserMinus size={14} />
</button>
)}
{/* 2. If started and active, show Drop Out */}
{hasStarted && !t.dropped_out && (
<button
type="button"
onClick={() => toggleDropOut(t.id)}
className="p-1 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Mark as Dropped Out"
>
<UserMinus size={14} />
</button>
)}
{/* 3. If NOT started, ALWAYS allow Deletion */}
{!hasStarted && (
<button
type="button"
onClick={() => removeTeam(t.id)}
className="p-1 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove Team entirely"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
{teams.length === 0 && (
<div className="text-center p-4 text-[9px] font-black text-zinc-400 uppercase tracking-widest border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded">
No teams added
</div>
)}
</div>
{!hasStarted && (
<div className="pt-1.5 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex gap-1.5 items-start">
<textarea
value={newTeamInput}
onChange={(e) => setNewTeamInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddTeams();
}
}}
placeholder="Type name (Enter to add) or paste list..."
rows={newTeamInput.includes('\n') ? 3 : 1}
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-2 py-1.5 text-xs focus:border-orange-500 outline-none text-zinc-900 dark:text-white resize-none transition-all shadow-sm"
/>
{/* FIX 5: Replaced min-h-[32px] with the canonical min-h-8 */}
<button type="button" onClick={handleAddTeams} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1.5 rounded font-bold transition flex items-center justify-center shrink-0 shadow-sm self-stretch min-h-8">
<Plus size={16} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
<div className="flex justify-between mt-6 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4 items-center">
{tournamentId && onDelete && (
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline font-bold">
Delete Tournament
</button>
)}
{!tournamentId && <div className="hidden"></div>}
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50">
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-8 py-2.5 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition active:scale-95 disabled:opacity-50 flex items-center gap-2 ml-auto">
{isSubmitting && <Loader2 size={16} className="animate-spin" />}
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
</button>
</div>
@@ -1,7 +1,7 @@
// frontend/src/components/Tournament/ScoreModal.tsx
import { type SetData } from '../../types';
import { Clock, Eraser, Trophy } from 'lucide-react';
import { Clock, Eraser, Trophy, Loader2 } from 'lucide-react';
import { useState } from 'react';
import Modal from '../UI/Modal';
import WhistleIcon from "../../assets/whistle.svg?react"
@@ -16,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;
@@ -33,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);
}
};
@@ -57,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} />
@@ -70,11 +110,16 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
</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>
@@ -101,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">
@@ -127,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
@@ -147,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>
))}
@@ -158,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>
+83 -31
View File
@@ -4,7 +4,6 @@ import React, { useEffect, useState, useRef } from 'react';
import { Loader2, Trash, Plus } from 'lucide-react';
import { useOutletContext, Link } from 'react-router-dom';
import api from '../services/api';
import { printName } from '../utils/helpers';
import WhistleIcon from '../assets/whistle.svg?react';
import CourtIcon from '../assets/court.svg?react';
@@ -14,10 +13,13 @@ interface CourtMatch {
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;
@@ -72,32 +74,65 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
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 line = scrollContainerRef.current?.querySelector('.now-line');
if (line) {
line.scrollIntoView({ behavior: 'smooth', block: 'start' });
setHasScrolled(true);
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]);
}, [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>;
// --- ACTIVE INDEX LOGIC ---
let activeIndex = -1;
if (currentTime) {
// Find the index of the most recently started match
for (let i = 0; i < data.matches.length; i++) {
if (data.matches[i].time <= currentTime) {
activeIndex = i;
}
}
}
return (
<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">
@@ -112,28 +147,22 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
)}
</div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32">
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32 relative">
{data.matches.length === 0 && (
<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 = index < activeIndex; // Strictly before the active match
const isLive = index === activeIndex && !isFinished; // Currently active and incomplete
const isPastSlot = isDayFinished || (activeIndex !== -1 && index < activeIndex);
const isLive = !isDayFinished && index === activeIndex;
const showNowLine = !isDayFinished && ((activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0));
// The line is drawn right above the active index (or index 0 if the day hasn't started)
const showNowLine = (activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0);
// Styling configurations based on match state
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
let textOpacity = 'text-zinc-900 dark:text-white';
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
if (isFinished) {
borderStyle = 'border-orange-500/30 opacity-60 grayscale hover:opacity-100 hover:grayscale-0';
textOpacity = 'text-zinc-500';
} else if (isPastSlot) {
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';
@@ -147,7 +176,7 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
<React.Fragment key={m.id}>
{/* --- THE --NOW-- LINE --- */}
{showNowLine && (
<div className="now-line relative flex items-center py-3 animate-in fade-in">
<div className="now-line relative flex items-center py-3 animate-in fade-in" style={{ scrollMarginTop: '20px' }}>
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
<div className="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>
@@ -175,11 +204,23 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
<div className="space-y-0.5 mb-1.5">
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
<span className={`truncate pr-2
${isFinished && m.p1_sets > m.p2_sets ? 'font-bold text-orange-500' :
m.p1_is_real ? `font-bold ${pOpacity}` :
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
>
{m.p1}
</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p1_sets}</span>}
</div>
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p2_sets > m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)}</span>
<span className={`truncate pr-2
${isFinished && m.p2_sets > m.p1_sets ? 'font-bold text-orange-500' :
m.p2_is_real ? `font-bold ${pOpacity}` :
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
>
{m.p2}
</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p2_sets}</span>}
</div>
</div>
@@ -194,6 +235,17 @@ const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (
</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>
);
+38 -2
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,6 +42,8 @@ export default function Dashboard() {
setTournaments(list as TournamentType[]);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
@@ -54,6 +57,8 @@ export default function Dashboard() {
})();
let ws: WebSocket;
let reconnectTimeout: ReturnType<typeof setTimeout>;
const connect = () => {
try {
ws = new WebSocket(WS_URL);
@@ -61,13 +66,36 @@ export default function Dashboard() {
const msg = JSON.parse(e.data);
if (msg.type === 'dashboard_update') loadDashboard();
};
ws.onclose = () => {
reconnectTimeout = setTimeout(connect, 3000);
};
} catch (err) {
console.error("WebSocket connection failed", err);
}
};
connect();
return () => { if (ws) ws.close(); };
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
(async () => {
await loadDashboard();
})();
if (!ws || ws.readyState === WebSocket.CLOSED) {
clearTimeout(reconnectTimeout);
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearTimeout(reconnectTimeout);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (ws) {
ws.onclose = null;
ws.close();
}
};
}, [setNavTitle, setNavSubtitle, loadDashboard]);
const handleEdit = (t: TournamentType) => {
@@ -88,6 +116,14 @@ export default function Dashboard() {
}
};
if (loading) {
return (
<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: [],
+64 -23
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,
@@ -153,23 +170,47 @@ export default function Tournament() {
await fetchData();
})();
if (wsRef.current) return;
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]);