🏓 Initial ServeSync demo build

- FastAPI backend with PostgreSQL + Redis
- 4 core features: Registration, Courts, Matchmaking, Tournament
- Double elimination tournament with bracket visualization
- ELO-based matchmaking (Stage 1 Open, Stage 2 Skill-Based)
- Real-time WebSocket updates
- TV Screen display for courts
- Vue 3 + Tailwind CSS frontend
- Seed data: 12 players, 4 courts, active matches, tournament in progress
- Docker compose stack with Nginx reverse proxy
This commit is contained in:
Forge
2026-03-19 00:58:17 +08:00
commit 46fd2953c3
45 changed files with 4383 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
.venv
node_modules/
dist/
.DS_Store
*.log

43
backend/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create startup script
RUN cat > /start.sh << 'EOF'
#!/bin/bash
set -e
echo "Waiting for database..."
until python -c "
import psycopg2, os
try:
conn = psycopg2.connect(os.environ.get('DATABASE_URL', 'postgresql://servesync:servesync@db:5432/servesync'))
conn.close()
print('DB ready')
except Exception as e:
print(f'DB not ready: {e}')
exit(1)
"; do
sleep 2
done
echo "Running seed..."
python seed.py || echo "Seed already ran or error (continuing)"
echo "Starting server..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
EOF
RUN chmod +x /start.sh
EXPOSE 8000
CMD ["/start.sh"]

View File

218
backend/app/api/courts.py Normal file
View File

@@ -0,0 +1,218 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.database import get_db
from app.models.court import Court
from app.models.booking import Booking, BookingStatus
from app.models.player import Player
from app.models.match import Match, MatchStatus
from pydantic import BaseModel
from datetime import datetime, timedelta
from typing import Optional, List
router = APIRouter(prefix="/courts", tags=["courts"])
class BookingCreate(BaseModel):
player_id: int
court_id: int
start_time: datetime
duration_hours: float = 1.0
class CourtResponse(BaseModel):
id: int
name: str
court_number: int
hourly_rate: float
is_active: bool
surface_type: str
features: str
current_status: str
current_match: Optional[dict] = None
class Config:
from_attributes = True
class BookingResponse(BaseModel):
id: int
player_id: int
player_name: str
court_id: int
court_name: str
start_time: datetime
end_time: datetime
duration_hours: float
total_cost: float
status: str
class Config:
from_attributes = True
def get_court_status(court: Court, db: Session) -> tuple:
"""Get current status of a court"""
# Check if match is in progress
match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
if match:
team1 = [mp.player.name for mp in match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in match.match_players if mp.team == 2]
return "occupied", {
"match_id": match.id,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"stage": match.stage.value,
"started_at": match.started_at.isoformat() if match.started_at else None,
}
# Check if there's a booking now
now = datetime.utcnow()
booking = db.query(Booking).filter(
Booking.court_id == court.id,
Booking.start_time <= now,
Booking.end_time >= now,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).first()
if booking:
return "booked", None
return "available", None
@router.get("/", response_model=List[CourtResponse])
def get_courts(db: Session = Depends(get_db)):
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
status, match_info = get_court_status(court, db)
result.append(CourtResponse(
id=court.id,
name=court.name,
court_number=court.court_number,
hourly_rate=court.hourly_rate,
is_active=court.is_active,
surface_type=court.surface_type,
features=court.features,
current_status=status,
current_match=match_info,
))
return result
@router.get("/{court_id}")
def get_court(court_id: int, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
status, match_info = get_court_status(court, db)
return {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"hourly_rate": court.hourly_rate,
"surface_type": court.surface_type,
"features": court.features,
"current_status": status,
"current_match": match_info,
}
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
"""Get bookings for a court for the next 7 days"""
now = datetime.utcnow()
end = now + timedelta(days=7)
bookings = db.query(Booking).filter(
Booking.court_id == court_id,
Booking.start_time >= now,
Booking.end_time <= end,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).order_by(Booking.start_time).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"duration_hours": b.duration_hours,
"is_match_booking": b.is_match_booking,
}
for b in bookings
]
@router.post("/book")
def book_court(booking_data: BookingCreate, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == booking_data.court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
player = db.query(Player).filter(Player.id == booking_data.player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
end_time = booking_data.start_time + timedelta(hours=booking_data.duration_hours)
# Check for conflicts
conflict = db.query(Booking).filter(
Booking.court_id == booking_data.court_id,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]),
and_(Booking.start_time < end_time, Booking.end_time > booking_data.start_time)
).first()
if conflict:
raise HTTPException(status_code=400, detail="Court is already booked for this time slot")
total_cost = court.hourly_rate * booking_data.duration_hours
booking = Booking(
player_id=booking_data.player_id,
court_id=booking_data.court_id,
start_time=booking_data.start_time,
end_time=end_time,
duration_hours=booking_data.duration_hours,
total_cost=total_cost,
status=BookingStatus.CONFIRMED,
)
db.add(booking)
db.commit()
db.refresh(booking)
return {
"id": booking.id,
"court_name": court.name,
"player_name": player.name,
"start_time": booking.start_time.isoformat(),
"end_time": booking.end_time.isoformat(),
"total_cost": booking.total_cost,
"status": booking.status.value,
}
@router.get("/bookings/upcoming")
def get_upcoming_bookings(db: Session = Depends(get_db)):
now = datetime.utcnow()
bookings = db.query(Booking).filter(
Booking.start_time >= now,
Booking.status == BookingStatus.CONFIRMED
).order_by(Booking.start_time).limit(20).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"court_name": b.court.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"total_cost": b.total_cost,
}
for b in bookings
]

160
backend/app/api/matches.py Normal file
View File

@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.player import Player
from app.services import matchmaking
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/matches", tags=["matches"])
class CreateMatchRequest(BaseModel):
title: str
stage: str = "open"
match_type: str = "doubles"
creator_player_id: int
elo_tolerance: float = 200
class JoinMatchRequest(BaseModel):
player_id: int
team: int
class ScoreUpdateRequest(BaseModel):
team1_score: int
team2_score: int
def match_to_dict(match: Match, db: Session) -> dict:
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match.id).all()
team1 = []
team2 = []
for mp in players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pdata = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"elo_change": mp.elo_change,
}
if mp.team == 1:
team1.append(pdata)
else:
team2.append(pdata)
return {
"id": match.id,
"title": match.title,
"stage": match.stage.value,
"match_type": match.match_type.value,
"status": match.status.value,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"max_players": match.max_players,
"current_players": len(players),
"min_elo": match.min_elo,
"max_elo": match.max_elo,
"court_id": match.court_id,
"court_name": match.court.name if match.court else None,
"started_at": match.started_at.isoformat() if match.started_at else None,
"ended_at": match.ended_at.isoformat() if match.ended_at else None,
"created_at": match.created_at.isoformat() if match.created_at else None,
}
@router.get("/")
def get_matches(status: Optional[str] = None, stage: Optional[str] = None, db: Session = Depends(get_db)):
query = db.query(Match)
if status:
query = query.filter(Match.status == status)
if stage:
query = query.filter(Match.stage == stage)
matches = query.order_by(Match.created_at.desc()).limit(50).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/lobby")
def get_lobby(db: Session = Depends(get_db)):
matches = matchmaking.get_lobby_matches(db)
return [match_to_dict(m, db) for m in matches]
@router.get("/active")
def get_active_matches(db: Session = Depends(get_db)):
matches = db.query(Match).filter(Match.status == MatchStatus.IN_PROGRESS).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/{match_id}")
def get_match(match_id: int, db: Session = Depends(get_db)):
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
return match_to_dict(match, db)
@router.post("/")
def create_match(req: CreateMatchRequest, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == req.creator_player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
match_type = MatchType.DOUBLES if req.match_type == "doubles" else MatchType.SINGLES
if req.stage == "open":
match = matchmaking.create_open_match(db, req.title, match_type)
elif req.stage == "skill_based":
match = matchmaking.create_skill_match(db, req.title, player.elo_rating, match_type, req.elo_tolerance)
else:
raise HTTPException(status_code=400, detail="Invalid stage. Use 'open' or 'skill_based'")
# Auto-join creator to team 1
matchmaking.join_match(db, match.id, player.id, 1)
return match_to_dict(match, db)
@router.post("/{match_id}/join")
def join_match(match_id: int, req: JoinMatchRequest, db: Session = Depends(get_db)):
result = matchmaking.join_match(db, match_id, req.player_id, req.team)
if not result:
raise HTTPException(status_code=400, detail="Cannot join match (full, ELO constraint, or already joined)")
match = db.query(Match).filter(Match.id == match_id).first()
return match_to_dict(match, db)
@router.post("/{match_id}/start")
def start_match(match_id: int, db: Session = Depends(get_db)):
match = matchmaking.start_match(db, match_id)
if not match:
raise HTTPException(status_code=400, detail="Cannot start match (not enough players or already started)")
return match_to_dict(match, db)
@router.post("/{match_id}/score")
def update_score(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
if match.status != MatchStatus.IN_PROGRESS:
raise HTTPException(status_code=400, detail="Match not in progress")
match.team1_score = req.team1_score
match.team2_score = req.team2_score
db.commit()
return match_to_dict(match, db)
@router.post("/{match_id}/complete")
def complete_match(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = matchmaking.complete_match(db, match_id, req.team1_score, req.team2_score)
if not match:
raise HTTPException(status_code=400, detail="Cannot complete match")
return match_to_dict(match, db)

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.player import Player, MembershipTier
from app.services.elo import get_tier_from_elo
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/players", tags=["players"])
class PlayerCreate(BaseModel):
name: str
email: str
phone: Optional[str] = None
class PlayerUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
class PlayerResponse(BaseModel):
id: int
name: str
email: str
phone: Optional[str]
elo_rating: float
membership_tier: str
wins: int
losses: int
total_matches: int
win_rate: float
avatar_color: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, player: Player):
return cls(
id=player.id,
name=player.name,
email=player.email,
phone=player.phone,
elo_rating=round(player.elo_rating, 1),
membership_tier=player.membership_tier.value,
wins=player.wins,
losses=player.losses,
total_matches=player.total_matches,
win_rate=player.win_rate,
avatar_color=player.avatar_color,
)
@router.get("/", response_model=List[PlayerResponse])
def get_players(db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/leaderboard", response_model=List[PlayerResponse])
def get_leaderboard(limit: int = 10, db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).limit(limit).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/{player_id}", response_model=PlayerResponse)
def get_player(player_id: int, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
return PlayerResponse.from_orm(player)
@router.post("/", response_model=PlayerResponse)
def create_player(player_data: PlayerCreate, db: Session = Depends(get_db)):
existing = db.query(Player).filter(Player.email == player_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
color = colors[db.query(Player).count() % len(colors)]
player = Player(
name=player_data.name,
email=player_data.email,
phone=player_data.phone,
avatar_color=color,
)
db.add(player)
db.commit()
db.refresh(player)
return PlayerResponse.from_orm(player)

156
backend/app/api/screen.py Normal file
View File

@@ -0,0 +1,156 @@
"""Screen Display API - for TV/display views"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import desc
from app.database import get_db
from app.models.court import Court
from app.models.match import Match, MatchPlayer, MatchStatus
from app.models.player import Player
from app.models.tournament import Tournament, TournamentStatus
from app.services.tournament import get_bracket_data
from datetime import datetime
router = APIRouter(prefix="/screen", tags=["screen"])
@router.get("/court/{court_id}")
def get_court_display(court_id: int, db: Session = Depends(get_db)):
"""Full court display data for TV screen"""
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
return {"error": "Court not found"}
# Current active match
active_match = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.IN_PROGRESS
).first()
match_data = None
if active_match:
team1_players = []
team2_players = []
for mp in active_match.match_players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pd = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"membership_tier": p.membership_tier.value,
}
if mp.team == 1:
team1_players.append(pd)
else:
team2_players.append(pd)
elapsed = None
if active_match.started_at:
elapsed = int((datetime.utcnow() - active_match.started_at).total_seconds())
match_data = {
"id": active_match.id,
"stage": active_match.stage.value,
"match_type": active_match.match_type.value,
"team1": team1_players,
"team2": team2_players,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"elapsed_seconds": elapsed,
}
# Recent completed matches on this court
recent_matches = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.COMPLETED
).order_by(desc(Match.ended_at)).limit(3).all()
recent = []
for m in recent_matches:
team1 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 1]
team2 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 2]
recent.append({
"team1": team1,
"team2": team2,
"team1_score": m.team1_score,
"team2_score": m.team2_score,
"winner": "team1" if m.team1_score > m.team2_score else "team2",
})
return {
"court": {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"surface_type": court.surface_type,
},
"active_match": match_data,
"recent_matches": recent,
"timestamp": datetime.utcnow().isoformat(),
}
@router.get("/overview")
def get_overview(db: Session = Depends(get_db)):
"""All courts overview for main display"""
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
active_match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
court_data = {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"status": "occupied" if active_match else "available",
}
if active_match:
team1 = [mp.player.name for mp in active_match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in active_match.match_players if mp.team == 2]
court_data["match"] = {
"team1_names": team1,
"team2_names": team2,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"stage": active_match.stage.value,
}
result.append(court_data)
# Active tournament
tournament_data = None
active_tournament = db.query(Tournament).filter(
Tournament.status == TournamentStatus.IN_PROGRESS
).first()
if active_tournament:
tournament_data = get_bracket_data(db, active_tournament.id)
# Leaderboard top 5
top_players = db.query(Player).filter(Player.is_active == True).order_by(
Player.elo_rating.desc()
).limit(5).all()
return {
"courts": result,
"tournament": tournament_data,
"leaderboard": [
{
"rank": i + 1,
"name": p.name,
"elo": round(p.elo_rating, 1),
"tier": p.membership_tier.value,
"wins": p.wins,
"losses": p.losses,
"avatar_color": p.avatar_color,
}
for i, p in enumerate(top_players)
],
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus
from app.services import tournament as tournament_service
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/tournaments", tags=["tournaments"])
class CreateTournamentRequest(BaseModel):
name: str
max_participants: int = 8
class RegisterPlayerRequest(BaseModel):
player_id: int
class MatchScoreRequest(BaseModel):
team1_score: int
team2_score: int
@router.get("/")
def get_tournaments(db: Session = Depends(get_db)):
tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all()
return [
{
"id": t.id,
"name": t.name,
"status": t.status.value,
"max_participants": t.max_participants,
"current_participants": len(t.entries),
"started_at": t.started_at.isoformat() if t.started_at else None,
}
for t in tournaments
]
@router.get("/{tournament_id}")
def get_tournament(tournament_id: int, db: Session = Depends(get_db)):
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/")
def create_tournament(req: CreateTournamentRequest, db: Session = Depends(get_db)):
t = tournament_service.create_tournament(db, req.name, req.max_participants)
return {"id": t.id, "name": t.name, "status": t.status.value}
@router.post("/{tournament_id}/register")
def register_player(tournament_id: int, req: RegisterPlayerRequest, db: Session = Depends(get_db)):
entry = tournament_service.register_player(db, tournament_id, req.player_id)
if not entry:
raise HTTPException(status_code=400, detail="Cannot register player (tournament full or already registered)")
return {"message": "Registered successfully", "player_id": req.player_id}
@router.post("/{tournament_id}/start")
def start_tournament(tournament_id: int, db: Session = Depends(get_db)):
t = tournament_service.start_tournament(db, tournament_id)
if not t:
raise HTTPException(status_code=400, detail="Cannot start tournament (need at least 4 players)")
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/{tournament_id}/matches/{match_id}/score")
def complete_tournament_match(
tournament_id: int, match_id: int, req: MatchScoreRequest, db: Session = Depends(get_db)
):
tm = tournament_service.complete_tournament_match(db, match_id, req.team1_score, req.team2_score)
if not tm:
raise HTTPException(status_code=400, detail="Cannot complete match")
return tournament_service.get_bracket_data(db, tournament_id)
@router.get("/{tournament_id}/leaderboard")
def get_leaderboard(tournament_id: int, db: Session = Depends(get_db)):
data = tournament_service.get_bracket_data(db, tournament_id)
return data.get("leaderboard", [])

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://servesync:servesync@db:5432/servesync"
REDIS_URL: str = "redis://redis:6379"
SECRET_KEY: str = "servesync-demo-secret-key-2024"
DEBUG: bool = True
class Config:
env_file = ".env"
settings = Settings()

16
backend/app/database.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

141
backend/app/main.py Normal file
View File

@@ -0,0 +1,141 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import asyncio
import json
from typing import List, Dict
from app.database import engine, SessionLocal
from app.models import player, court, booking, match, tournament
# Create all tables
player.Base.metadata.create_all(bind=engine)
court.Base.metadata.create_all(bind=engine)
booking.Base.metadata.create_all(bind=engine)
match.Base.metadata.create_all(bind=engine)
tournament.Base.metadata.create_all(bind=engine)
from app.api import players, courts, matches, tournaments, screen
app = FastAPI(
title="ServeSync API",
description="Pickleball Court Management System",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(players.router, prefix="/api")
app.include_router(courts.router, prefix="/api")
app.include_router(matches.router, prefix="/api")
app.include_router(tournaments.router, prefix="/api")
app.include_router(screen.router, prefix="/api")
# WebSocket connection manager
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, channel: str):
await websocket.accept()
if channel not in self.active_connections:
self.active_connections[channel] = []
self.active_connections[channel].append(websocket)
def disconnect(self, websocket: WebSocket, channel: str):
if channel in self.active_connections:
self.active_connections[channel].remove(websocket)
async def broadcast(self, channel: str, data: dict):
if channel in self.active_connections:
disconnected = []
for connection in self.active_connections[channel]:
try:
await connection.send_text(json.dumps(data))
except Exception:
disconnected.append(connection)
for conn in disconnected:
self.active_connections[channel].remove(conn)
manager = ConnectionManager()
@app.websocket("/ws/court/{court_id}")
async def websocket_court(websocket: WebSocket, court_id: int):
"""WebSocket for real-time court display updates"""
channel = f"court_{court_id}"
await manager.connect(websocket, channel)
try:
while True:
# Send court data every 3 seconds
db = SessionLocal()
try:
from app.api.screen import get_court_display
data = get_court_display(court_id, db)
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(3)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.websocket("/ws/overview")
async def websocket_overview(websocket: WebSocket):
"""WebSocket for real-time overview display"""
channel = "overview"
await manager.connect(websocket, channel)
try:
while True:
db = SessionLocal()
try:
from app.api.screen import get_overview
data = get_overview(db)
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(3)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.websocket("/ws/matches")
async def websocket_matches(websocket: WebSocket):
"""WebSocket for real-time match lobby updates"""
channel = "matches"
await manager.connect(websocket, channel)
try:
while True:
db = SessionLocal()
try:
from app.api.matches import get_lobby
from app.api.matches import get_active_matches
data = {
"lobby": get_lobby(db),
"active": get_active_matches(db),
}
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(2)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.get("/")
def root():
return {"message": "ServeSync API v1.0", "status": "running"}
@app.get("/health")
def health():
return {"status": "healthy"}

View File

@@ -0,0 +1,5 @@
from app.models.player import Player
from app.models.court import Court
from app.models.booking import Booking
from app.models.match import Match, MatchPlayer
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class BookingStatus(str, enum.Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=False)
duration_hours = Column(Float, default=1.0)
total_cost = Column(Float, default=0.0)
status = Column(Enum(BookingStatus), default=BookingStatus.CONFIRMED)
is_match_booking = Column(Boolean, default=False)
notes = Column(String(500))
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
player = relationship("Player", back_populates="bookings")
court = relationship("Court", back_populates="bookings")

View File

@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class Court(Base):
__tablename__ = "courts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False)
court_number = Column(Integer, unique=True, nullable=False)
hourly_rate = Column(Float, default=200.0)
is_active = Column(Boolean, default=True)
surface_type = Column(String(50), default="Sport Court")
features = Column(String(500), default="LED Lighting, Spectator Seating")
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
bookings = relationship("Booking", back_populates="court")
matches = relationship("Match", back_populates="court")

View File

@@ -0,0 +1,73 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MatchStage(str, enum.Enum):
OPEN = "open"
SKILL_BASED = "skill_based"
TOURNAMENT = "tournament"
class MatchStatus(str, enum.Enum):
LOBBY = "lobby"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class MatchType(str, enum.Enum):
SINGLES = "singles"
DOUBLES = "doubles"
class Match(Base):
__tablename__ = "matches"
id = Column(Integer, primary_key=True, index=True)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
stage = Column(Enum(MatchStage), default=MatchStage.OPEN)
match_type = Column(Enum(MatchType), default=MatchType.DOUBLES)
status = Column(Enum(MatchStatus), default=MatchStatus.LOBBY)
# Scores
team1_score = Column(Integer, default=0)
team2_score = Column(Integer, default=0)
team1_games = Column(Integer, default=0)
team2_games = Column(Integer, default=0)
# Game details
max_players = Column(Integer, default=4)
min_elo = Column(Float, nullable=True)
max_elo = Column(Float, nullable=True)
# Match metadata
title = Column(String(200))
started_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
court = relationship("Court", back_populates="matches")
match_players = relationship("MatchPlayer", back_populates="match", cascade="all, delete-orphan")
class MatchPlayer(Base):
__tablename__ = "match_players"
id = Column(Integer, primary_key=True, index=True)
match_id = Column(Integer, ForeignKey("matches.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
team = Column(Integer, nullable=False) # 1 or 2
elo_before = Column(Float, nullable=True)
elo_after = Column(Float, nullable=True)
elo_change = Column(Float, default=0.0)
is_winner = Column(Boolean, nullable=True)
joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
match = relationship("Match", back_populates="match_players")
player = relationship("Player", back_populates="match_players")

View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MembershipTier(str, enum.Enum):
BRONZE = "bronze"
SILVER = "silver"
GOLD = "gold"
PLATINUM = "platinum"
ELITE = "elite"
class Player(Base):
__tablename__ = "players"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=False)
phone = Column(String(20))
elo_rating = Column(Float, default=1000.0)
membership_tier = Column(Enum(MembershipTier), default=MembershipTier.BRONZE)
wins = Column(Integer, default=0)
losses = Column(Integer, default=0)
total_matches = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
avatar_color = Column(String(7), default="#3B82F6")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
bookings = relationship("Booking", back_populates="player")
match_players = relationship("MatchPlayer", back_populates="player")
tournament_entries = relationship("TournamentEntry", back_populates="player")
@property
def win_rate(self):
if self.total_matches == 0:
return 0
return round(self.wins / self.total_matches * 100, 1)
@property
def tier_label(self):
return self.membership_tier.value.capitalize()

View File

@@ -0,0 +1,93 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class TournamentStatus(str, enum.Enum):
REGISTRATION = "registration"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class BracketType(str, enum.Enum):
WINNERS = "winners"
LOSERS = "losers"
GRAND_FINAL = "grand_final"
class TournamentMatchStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class Tournament(Base):
__tablename__ = "tournaments"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
status = Column(Enum(TournamentStatus), default=TournamentStatus.REGISTRATION)
max_participants = Column(Integer, default=8)
current_round = Column(Integer, default=1)
bracket_data = Column(JSON, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
entries = relationship("TournamentEntry", back_populates="tournament")
tournament_matches = relationship("TournamentMatch", back_populates="tournament")
class TournamentEntry(Base):
__tablename__ = "tournament_entries"
id = Column(Integer, primary_key=True, index=True)
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
seed = Column(Integer, nullable=True)
final_rank = Column(Integer, nullable=True)
is_eliminated = Column(Boolean, default=False)
losses = Column(Integer, default=0)
wins = Column(Integer, default=0)
is_in_losers = Column(Boolean, default=False)
registered_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
tournament = relationship("Tournament", back_populates="entries")
player = relationship("Player", back_populates="tournament_entries")
class TournamentMatch(Base):
__tablename__ = "tournament_matches"
id = Column(Integer, primary_key=True, index=True)
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
match_id = Column(Integer, ForeignKey("matches.id"), nullable=True)
round_number = Column(Integer, nullable=False)
match_number = Column(Integer, nullable=False)
bracket_type = Column(Enum(BracketType), default=BracketType.WINNERS)
status = Column(Enum(TournamentMatchStatus), default=TournamentMatchStatus.PENDING)
# Players
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player3_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p1
player4_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p2
# Scores
team1_score = Column(Integer, default=0)
team2_score = Column(Integer, default=0)
winner_team = Column(Integer, nullable=True)
# Next match routing
winner_next_match = Column(Integer, nullable=True)
loser_next_match = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
tournament = relationship("Tournament", back_populates="tournament_matches")

View File

View File

@@ -0,0 +1,53 @@
"""ELO Rating System for ServeSync"""
K_FACTOR = 32 # Standard K-factor
def expected_score(rating_a: float, rating_b: float) -> float:
"""Calculate expected score for player A vs player B"""
return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))
def calculate_elo_change(winner_rating: float, loser_rating: float) -> tuple[float, float]:
"""
Calculate ELO changes after a match.
Returns (winner_change, loser_change)
"""
expected_winner = expected_score(winner_rating, loser_rating)
expected_loser = expected_score(loser_rating, winner_rating)
winner_change = K_FACTOR * (1 - expected_winner)
loser_change = K_FACTOR * (0 - expected_loser)
return round(winner_change, 2), round(loser_change, 2)
def calculate_team_elo(player1_rating: float, player2_rating: float = None) -> float:
"""Calculate team ELO (average for doubles)"""
if player2_rating is None:
return player1_rating
return (player1_rating + player2_rating) / 2
def get_tier_from_elo(elo: float) -> str:
"""Get membership tier based on ELO rating"""
if elo < 1000:
return "bronze"
elif elo < 1200:
return "silver"
elif elo < 1500:
return "gold"
elif elo < 1800:
return "platinum"
else:
return "elite"
def is_valid_match(player_elo: float, match_min_elo: float, match_max_elo: float) -> bool:
"""Check if player is within ELO range for skill-based match"""
return match_min_elo <= player_elo <= match_max_elo
def get_elo_range(player_elo: float, tolerance: float = 200) -> tuple[float, float]:
"""Get valid ELO range for matching"""
return (max(0, player_elo - tolerance), player_elo + tolerance)

View File

@@ -0,0 +1,214 @@
"""Matchmaking Engine for ServeSync"""
from sqlalchemy.orm import Session
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.court import Court
from app.models.player import Player
from app.models.booking import Booking, BookingStatus
from app.services.elo import calculate_elo_change, get_elo_range, is_valid_match
from datetime import datetime, timedelta
from typing import Optional, List
def create_open_match(db: Session, title: str, match_type: MatchType = MatchType.DOUBLES) -> Match:
"""Stage 1: Create an open match lobby - no restrictions"""
max_players = 4 if match_type == MatchType.DOUBLES else 2
match = Match(
stage=MatchStage.OPEN,
match_type=match_type,
status=MatchStatus.LOBBY,
title=title,
max_players=max_players,
)
db.add(match)
db.commit()
db.refresh(match)
return match
def create_skill_match(
db: Session,
title: str,
creator_elo: float,
match_type: MatchType = MatchType.DOUBLES,
tolerance: float = 200,
) -> Match:
"""Stage 2: Create a skill-based match with ELO constraints"""
min_elo, max_elo = get_elo_range(creator_elo, tolerance)
max_players = 4 if match_type == MatchType.DOUBLES else 2
match = Match(
stage=MatchStage.SKILL_BASED,
match_type=match_type,
status=MatchStatus.LOBBY,
title=title,
max_players=max_players,
min_elo=min_elo,
max_elo=max_elo,
)
db.add(match)
db.commit()
db.refresh(match)
return match
def join_match(db: Session, match_id: int, player_id: int, team: int) -> Optional[MatchPlayer]:
"""Add a player to a match lobby"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match or match.status != MatchStatus.LOBBY:
return None
player = db.query(Player).filter(Player.id == player_id).first()
if not player:
return None
# Check player count
current_players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
if current_players >= match.max_players:
return None
# Check if already joined
existing = db.query(MatchPlayer).filter(
MatchPlayer.match_id == match_id,
MatchPlayer.player_id == player_id
).first()
if existing:
return existing
# For skill-based matches, check ELO constraint
if match.stage == MatchStage.SKILL_BASED:
if match.min_elo and match.max_elo:
if not is_valid_match(player.elo_rating, match.min_elo, match.max_elo):
return None
mp = MatchPlayer(
match_id=match_id,
player_id=player_id,
team=team,
elo_before=player.elo_rating,
)
db.add(mp)
db.commit()
db.refresh(mp)
return mp
def start_match(db: Session, match_id: int) -> Optional[Match]:
"""Start a match - auto-assign court if available"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
return None
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
if players < match.max_players:
return None
# Auto-assign an available court
if not match.court_id:
court = find_available_court(db)
if court:
match.court_id = court.id
# Create booking
now = datetime.utcnow()
booking = Booking(
player_id=db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).first().player_id,
court_id=court.id,
start_time=now,
end_time=now + timedelta(hours=1),
duration_hours=1.0,
total_cost=court.hourly_rate,
status=BookingStatus.IN_PROGRESS,
is_match_booking=True,
)
db.add(booking)
match.status = MatchStatus.IN_PROGRESS
match.started_at = datetime.utcnow()
db.commit()
db.refresh(match)
return match
def find_available_court(db: Session) -> Optional[Court]:
"""Find an available court not currently in use"""
from sqlalchemy import and_
busy_court_ids = db.query(Match.court_id).filter(
Match.status == MatchStatus.IN_PROGRESS,
Match.court_id.isnot(None)
).all()
busy_ids = [c[0] for c in busy_court_ids]
court = db.query(Court).filter(
Court.is_active == True,
Court.id.notin_(busy_ids)
).first()
return court
def complete_match(db: Session, match_id: int, team1_score: int, team2_score: int) -> Optional[Match]:
"""Complete a match and update ELO ratings"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match or match.status != MatchStatus.IN_PROGRESS:
return None
match.status = MatchStatus.COMPLETED
match.team1_score = team1_score
match.team2_score = team2_score
match.ended_at = datetime.utcnow()
winner_team = 1 if team1_score > team2_score else 2
# Get players
team1_players = db.query(MatchPlayer).filter(
MatchPlayer.match_id == match_id, MatchPlayer.team == 1
).all()
team2_players = db.query(MatchPlayer).filter(
MatchPlayer.match_id == match_id, MatchPlayer.team == 2
).all()
# Calculate average ELO
team1_avg = sum(p.elo_before or p.player.elo_rating for p in team1_players) / len(team1_players)
team2_avg = sum(p.elo_before or p.player.elo_rating for p in team2_players) / len(team2_players)
if winner_team == 1:
w_change, l_change = calculate_elo_change(team1_avg, team2_avg)
else:
l_change, w_change = calculate_elo_change(team2_avg, team1_avg)
w_change, l_change = -l_change, -w_change
# Update player ELOs
for mp in team1_players:
is_winner = (winner_team == 1)
change = w_change if is_winner else l_change
mp.is_winner = is_winner
mp.elo_change = change
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
mp.player.elo_rating += change
if is_winner:
mp.player.wins += 1
else:
mp.player.losses += 1
mp.player.total_matches += 1
for mp in team2_players:
is_winner = (winner_team == 2)
change = w_change if is_winner else l_change
mp.is_winner = is_winner
mp.elo_change = change
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
mp.player.elo_rating += change
if is_winner:
mp.player.wins += 1
else:
mp.player.losses += 1
mp.player.total_matches += 1
db.commit()
db.refresh(match)
return match
def get_lobby_matches(db: Session, stage: Optional[MatchStage] = None) -> List[Match]:
"""Get all lobby matches, optionally filtered by stage"""
query = db.query(Match).filter(Match.status == MatchStatus.LOBBY)
if stage:
query = query.filter(Match.stage == stage)
return query.order_by(Match.created_at.desc()).all()

View File

@@ -0,0 +1,281 @@
"""Double Elimination Tournament Engine for ServeSync"""
from sqlalchemy.orm import Session
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
from app.models.player import Player
from app.models.court import Court
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.services.matchmaking import find_available_court
from datetime import datetime
from typing import Optional, List
import math
def create_tournament(db: Session, name: str, max_participants: int = 8) -> Tournament:
"""Create a new tournament"""
t = Tournament(name=name, max_participants=max_participants)
db.add(t)
db.commit()
db.refresh(t)
return t
def register_player(db: Session, tournament_id: int, player_id: int) -> Optional[TournamentEntry]:
"""Register a player for the tournament"""
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
if not t or t.status != TournamentStatus.REGISTRATION:
return None
current_count = db.query(TournamentEntry).filter(TournamentEntry.tournament_id == tournament_id).count()
if current_count >= t.max_participants:
return None
existing = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id,
TournamentEntry.player_id == player_id
).first()
if existing:
return existing
entry = TournamentEntry(tournament_id=tournament_id, player_id=player_id)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def start_tournament(db: Session, tournament_id: int) -> Optional[Tournament]:
"""Start tournament and generate initial bracket"""
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
if not t or t.status != TournamentStatus.REGISTRATION:
return None
entries = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id
).join(Player).order_by(Player.elo_rating.desc()).all()
if len(entries) < 4:
return None
# Seed players by ELO
for i, entry in enumerate(entries):
entry.seed = i + 1
# Generate bracket matches
_generate_winners_bracket(db, t, entries)
t.status = TournamentStatus.IN_PROGRESS
t.started_at = datetime.utcnow()
db.commit()
db.refresh(t)
return t
def _generate_winners_bracket(db: Session, tournament: Tournament, entries: List[TournamentEntry]):
"""Generate the initial winners bracket matches"""
n = len(entries)
# Pair up by seeding (1 vs n, 2 vs n-1, etc.)
match_num = 1
pairs = []
for i in range(n // 2):
p1 = entries[i]
p2 = entries[n - 1 - i]
pairs.append((p1, p2))
for p1, p2 in pairs:
tm = TournamentMatch(
tournament_id=tournament.id,
round_number=1,
match_number=match_num,
bracket_type=BracketType.WINNERS,
status=TournamentMatchStatus.PENDING,
player1_id=p1.player_id,
player2_id=p2.player_id,
)
db.add(tm)
match_num += 1
db.flush()
def complete_tournament_match(
db: Session,
tournament_match_id: int,
team1_score: int,
team2_score: int
) -> Optional[TournamentMatch]:
"""Complete a tournament match and advance bracket"""
tm = db.query(TournamentMatch).filter(TournamentMatch.id == tournament_match_id).first()
if not tm:
return None
tm.team1_score = team1_score
tm.team2_score = team2_score
tm.winner_team = 1 if team1_score > team2_score else 2
tm.status = TournamentMatchStatus.COMPLETED
tm.completed_at = datetime.utcnow()
winner_player_id = tm.player1_id if tm.winner_team == 1 else tm.player2_id
loser_player_id = tm.player2_id if tm.winner_team == 1 else tm.player1_id
# Update entry stats
winner_entry = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tm.tournament_id,
TournamentEntry.player_id == winner_player_id
).first()
loser_entry = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tm.tournament_id,
TournamentEntry.player_id == loser_player_id
).first()
if winner_entry:
winner_entry.wins += 1
if loser_entry:
loser_entry.losses += 1
# Double elimination logic
if loser_entry and loser_entry.losses < 2:
# Move to losers bracket
loser_entry.is_in_losers = True
_create_losers_match(db, tm, loser_player_id)
elif loser_entry:
# Eliminated
loser_entry.is_eliminated = True
_assign_final_ranks(db, tm.tournament_id)
db.commit()
db.refresh(tm)
return tm
def _create_losers_match(db: Session, completed_match: TournamentMatch, loser_id: int):
"""Route loser to losers bracket"""
# Find existing pending losers match in next round
next_round = completed_match.round_number + 1
existing = db.query(TournamentMatch).filter(
TournamentMatch.tournament_id == completed_match.tournament_id,
TournamentMatch.bracket_type == BracketType.LOSERS,
TournamentMatch.round_number == next_round,
TournamentMatch.player2_id.is_(None),
).first()
if existing:
existing.player2_id = loser_id
else:
# Create a new losers match
match_count = db.query(TournamentMatch).filter(
TournamentMatch.tournament_id == completed_match.tournament_id,
TournamentMatch.bracket_type == BracketType.LOSERS,
TournamentMatch.round_number == next_round,
).count()
tm = TournamentMatch(
tournament_id=completed_match.tournament_id,
round_number=next_round,
match_number=match_count + 1,
bracket_type=BracketType.LOSERS,
status=TournamentMatchStatus.PENDING,
player1_id=loser_id,
)
db.add(tm)
def _assign_final_ranks(db: Session, tournament_id: int):
"""Assign final ranks to eliminated players"""
entries = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id,
TournamentEntry.is_eliminated == True,
TournamentEntry.final_rank.is_(None)
).order_by(TournamentEntry.losses.desc()).all()
remaining = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id,
TournamentEntry.is_eliminated == False
).count()
base_rank = remaining + 1
for i, entry in enumerate(entries):
entry.final_rank = base_rank + i
def get_bracket_data(db: Session, tournament_id: int) -> dict:
"""Get structured bracket data for frontend visualization"""
tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first()
if not tournament:
return {}
matches = db.query(TournamentMatch).filter(
TournamentMatch.tournament_id == tournament_id
).order_by(TournamentMatch.round_number, TournamentMatch.match_number).all()
entries = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id
).all()
players_map = {}
for entry in entries:
player = db.query(Player).filter(Player.id == entry.player_id).first()
if player:
players_map[player.id] = {
"id": player.id,
"name": player.name,
"elo": player.elo_rating,
"seed": entry.seed,
"wins": entry.wins,
"losses": entry.losses,
"is_eliminated": entry.is_eliminated,
"is_in_losers": entry.is_in_losers,
"final_rank": entry.final_rank,
}
winners_bracket = []
losers_bracket = []
grand_final = []
for m in matches:
match_data = {
"id": m.id,
"round": m.round_number,
"match_number": m.match_number,
"status": m.status.value,
"player1": players_map.get(m.player1_id),
"player2": players_map.get(m.player2_id),
"team1_score": m.team1_score,
"team2_score": m.team2_score,
"winner_team": m.winner_team,
}
if m.bracket_type == BracketType.WINNERS:
winners_bracket.append(match_data)
elif m.bracket_type == BracketType.LOSERS:
losers_bracket.append(match_data)
else:
grand_final.append(match_data)
# Build leaderboard
leaderboard = sorted(
[
{
"player": players_map[e.player_id],
"wins": e.wins,
"losses": e.losses,
"is_eliminated": e.is_eliminated,
"is_in_losers": e.is_in_losers,
"final_rank": e.final_rank,
}
for e in entries
if e.player_id in players_map
],
key=lambda x: (x["final_rank"] or 999, -x["wins"]),
)
return {
"tournament": {
"id": tournament.id,
"name": tournament.name,
"status": tournament.status.value,
"current_round": tournament.current_round,
},
"winners_bracket": winners_bracket,
"losers_bracket": losers_bracket,
"grand_final": grand_final,
"leaderboard": leaderboard,
}

View File

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
redis==5.0.4
arq==0.25.0
websockets==12.0
python-dotenv==1.0.1
pydantic==2.7.1
pydantic-settings==2.2.1
httpx==0.27.0
python-multipart==0.0.9

432
backend/seed.py Normal file
View File

@@ -0,0 +1,432 @@
"""Seed demo data for ServeSync"""
import sys
import os
sys.path.insert(0, '/app')
from sqlalchemy.orm import Session
from app.database import engine, SessionLocal
from app.models.player import Player, MembershipTier
from app.models.court import Court
from app.models.booking import Booking, BookingStatus
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
from datetime import datetime, timedelta
import random
def clear_data(db: Session):
db.query(TournamentMatch).delete()
db.query(TournamentEntry).delete()
db.query(Tournament).delete()
db.query(MatchPlayer).delete()
db.query(Match).delete()
db.query(Booking).delete()
db.query(Court).delete()
db.query(Player).delete()
db.commit()
print("Cleared existing data")
def seed_players(db: Session) -> list:
players_data = [
{"name": "Marco Santos", "email": "marco@demo.com", "elo": 1850, "tier": MembershipTier.ELITE, "color": "#EF4444", "wins": 45, "losses": 8},
{"name": "Sofia Reyes", "email": "sofia@demo.com", "elo": 1720, "tier": MembershipTier.ELITE, "color": "#EC4899", "wins": 38, "losses": 12},
{"name": "Carlos Mendez", "email": "carlos@demo.com", "elo": 1630, "tier": MembershipTier.PLATINUM, "color": "#8B5CF6", "wins": 32, "losses": 15},
{"name": "Ana Cruz", "email": "ana@demo.com", "elo": 1580, "tier": MembershipTier.PLATINUM, "color": "#06B6D4", "wins": 28, "losses": 18},
{"name": "Juan Dela Cruz", "email": "juan@demo.com", "elo": 1450, "tier": MembershipTier.GOLD, "color": "#F59E0B", "wins": 22, "losses": 20},
{"name": "Maria Garcia", "email": "maria@demo.com", "elo": 1380, "tier": MembershipTier.GOLD, "color": "#10B981", "wins": 18, "losses": 22},
{"name": "Pedro Lopez", "email": "pedro@demo.com", "elo": 1250, "tier": MembershipTier.SILVER, "color": "#3B82F6", "wins": 14, "losses": 19},
{"name": "Rosa Martinez", "email": "rosa@demo.com", "elo": 1190, "tier": MembershipTier.SILVER, "color": "#F97316", "wins": 11, "losses": 21},
{"name": "Diego Torres", "email": "diego@demo.com", "elo": 1100, "tier": MembershipTier.SILVER, "color": "#84CC16", "wins": 9, "losses": 18},
{"name": "Lucia Flores", "email": "lucia@demo.com", "elo": 980, "tier": MembershipTier.BRONZE, "color": "#14B8A6", "wins": 6, "losses": 20},
{"name": "Miguel Ortega", "email": "miguel@demo.com", "elo": 920, "tier": MembershipTier.BRONZE, "color": "#6366F1", "wins": 4, "losses": 15},
{"name": "Isabella Vega", "email": "isabella@demo.com", "elo": 850, "tier": MembershipTier.BRONZE, "color": "#D946EF", "wins": 2, "losses": 12},
]
players = []
for pd in players_data:
p = Player(
name=pd["name"],
email=pd["email"],
elo_rating=pd["elo"],
membership_tier=pd["tier"],
avatar_color=pd["color"],
wins=pd["wins"],
losses=pd["losses"],
total_matches=pd["wins"] + pd["losses"],
)
db.add(p)
players.append(p)
db.commit()
for p in players:
db.refresh(p)
print(f"Created {len(players)} players")
return players
def seed_courts(db: Session) -> list:
courts_data = [
{"name": "Court 1", "number": 1, "rate": 200.0, "surface": "Sport Court", "features": "LED Lighting, Pro Net System"},
{"name": "Court 2", "number": 2, "rate": 200.0, "surface": "Sport Court", "features": "LED Lighting, Spectator Seating"},
{"name": "Court 3", "number": 3, "rate": 250.0, "surface": "Cushioned", "features": "Tournament Grade, Scoreboards"},
{"name": "Court 4", "number": 4, "rate": 250.0, "surface": "Cushioned", "features": "Tournament Grade, VIP Lounge Access"},
]
courts = []
for cd in courts_data:
c = Court(
name=cd["name"],
court_number=cd["number"],
hourly_rate=cd["rate"],
surface_type=cd["surface"],
features=cd["features"],
)
db.add(c)
courts.append(c)
db.commit()
for c in courts:
db.refresh(c)
print(f"Created {len(courts)} courts")
return courts
def seed_bookings(db: Session, players: list, courts: list):
now = datetime.utcnow()
bookings = []
# Future bookings
for i in range(8):
player = players[i % len(players)]
court = courts[i % len(courts)]
start = now + timedelta(hours=i + 2)
end = start + timedelta(hours=1)
b = Booking(
player_id=player.id,
court_id=court.id,
start_time=start,
end_time=end,
duration_hours=1.0,
total_cost=court.hourly_rate,
status=BookingStatus.CONFIRMED,
)
db.add(b)
bookings.append(b)
db.commit()
print(f"Created {len(bookings)} bookings")
def seed_active_matches(db: Session, players: list, courts: list) -> list:
matches = []
# Match 1: Active doubles on Court 1 (Open match)
m1 = Match(
court_id=courts[0].id,
stage=MatchStage.OPEN,
match_type=MatchType.DOUBLES,
status=MatchStatus.IN_PROGRESS,
title="Open Doubles - Court 1",
max_players=4,
team1_score=7,
team2_score=5,
started_at=datetime.utcnow() - timedelta(minutes=25),
)
db.add(m1)
db.flush()
for player_id, team in [(players[0].id, 1), (players[1].id, 1), (players[2].id, 2), (players[3].id, 2)]:
mp = MatchPlayer(match_id=m1.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
db.add(mp)
matches.append(m1)
# Match 2: Active skill-based on Court 2
m2 = Match(
court_id=courts[1].id,
stage=MatchStage.SKILL_BASED,
match_type=MatchType.DOUBLES,
status=MatchStatus.IN_PROGRESS,
title="Skill Match - Platinum League",
max_players=4,
min_elo=1300,
max_elo=1700,
team1_score=3,
team2_score=6,
started_at=datetime.utcnow() - timedelta(minutes=15),
)
db.add(m2)
db.flush()
for player_id, team in [(players[4].id, 1), (players[5].id, 1), (players[6].id, 2), (players[7].id, 2)]:
mp = MatchPlayer(match_id=m2.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
db.add(mp)
matches.append(m2)
# Match 3: Lobby (waiting for players)
m3 = Match(
stage=MatchStage.OPEN,
match_type=MatchType.DOUBLES,
status=MatchStatus.LOBBY,
title="Evening Open Game",
max_players=4,
)
db.add(m3)
db.flush()
for player_id, team in [(players[8].id, 1), (players[9].id, 2)]:
mp = MatchPlayer(match_id=m3.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
db.add(mp)
matches.append(m3)
# Match 4: Skill-based lobby
m4 = Match(
stage=MatchStage.SKILL_BASED,
match_type=MatchType.DOUBLES,
status=MatchStatus.LOBBY,
title="Gold Tier Challenge",
max_players=4,
min_elo=1200,
max_elo=1600,
)
db.add(m4)
db.flush()
mp = MatchPlayer(match_id=m4.id, player_id=players[4].id, team=1, elo_before=players[4].elo_rating)
db.add(mp)
matches.append(m4)
db.commit()
for m in matches:
db.refresh(m)
print(f"Created {len(matches)} matches (2 active, 2 lobby)")
return matches
def seed_tournament(db: Session, players: list, courts: list):
"""Create a sample double elimination tournament in progress"""
t = Tournament(
name="ServeSync Grand Prix - March 2024",
status=TournamentStatus.IN_PROGRESS,
max_participants=8,
current_round=2,
started_at=datetime.utcnow() - timedelta(hours=2),
)
db.add(t)
db.flush()
# Register 8 players
tournament_players = players[:8]
entries = []
for i, player in enumerate(tournament_players):
entry = TournamentEntry(
tournament_id=t.id,
player_id=player.id,
seed=i + 1,
wins=0,
losses=0,
)
db.add(entry)
entries.append(entry)
db.flush()
# Round 1 Winners Bracket (4 matches) - all completed
r1_matches = [
(players[0].id, players[7].id, 11, 5), # 1 vs 8
(players[1].id, players[6].id, 11, 7), # 2 vs 7
(players[2].id, players[5].id, 8, 11), # 3 vs 6 - player 5 wins
(players[3].id, players[4].id, 11, 9), # 4 vs 5
]
winners_r1 = [] # winner player IDs after round 1
losers_r1 = [] # loser player IDs after round 1
for i, (p1_id, p2_id, s1, s2) in enumerate(r1_matches):
tm = TournamentMatch(
tournament_id=t.id,
round_number=1,
match_number=i + 1,
bracket_type=BracketType.WINNERS,
status=TournamentMatchStatus.COMPLETED,
player1_id=p1_id,
player2_id=p2_id,
team1_score=s1,
team2_score=s2,
winner_team=1 if s1 > s2 else 2,
completed_at=datetime.utcnow() - timedelta(hours=1, minutes=30),
)
db.add(tm)
winner_id = p1_id if s1 > s2 else p2_id
loser_id = p2_id if s1 > s2 else p1_id
winners_r1.append(winner_id)
losers_r1.append(loser_id)
# Update entry stats
for entry in entries:
if entry.player_id == winner_id:
entry.wins += 1
elif entry.player_id == loser_id:
entry.losses += 1
entry.is_in_losers = True
db.flush()
# Round 2 Winners Bracket - 2 matches - 1 completed, 1 in progress
# Match 1: players[0] vs players[1]
wm1 = TournamentMatch(
tournament_id=t.id,
round_number=2,
match_number=1,
bracket_type=BracketType.WINNERS,
status=TournamentMatchStatus.COMPLETED,
player1_id=winners_r1[0],
player2_id=winners_r1[1],
team1_score=11,
team2_score=8,
winner_team=1,
completed_at=datetime.utcnow() - timedelta(minutes=45),
)
db.add(wm1)
# Update entries
for entry in entries:
if entry.player_id == winners_r1[0]:
entry.wins += 1
elif entry.player_id == winners_r1[1]:
entry.losses += 1
entry.is_in_losers = True
# Match 2: players[3] (seed4 winner) vs players[5] (seed6 winner)
wm2 = TournamentMatch(
tournament_id=t.id,
round_number=2,
match_number=2,
bracket_type=BracketType.WINNERS,
status=TournamentMatchStatus.IN_PROGRESS,
player1_id=winners_r1[2],
player2_id=winners_r1[3],
team1_score=6,
team2_score=7,
)
db.add(wm2)
# Assign court 3 for active tournament match
m_active = Match(
court_id=courts[2].id,
stage=MatchStage.TOURNAMENT,
match_type=MatchType.SINGLES,
status=MatchStatus.IN_PROGRESS,
title=f"Tournament R2 - {db.query(Player).filter(Player.id == winners_r1[2]).first().name} vs {db.query(Player).filter(Player.id == winners_r1[3]).first().name}",
max_players=2,
team1_score=6,
team2_score=7,
started_at=datetime.utcnow() - timedelta(minutes=20),
)
db.add(m_active)
db.flush()
for player_id, team in [(winners_r1[2], 1), (winners_r1[3], 2)]:
mp = MatchPlayer(match_id=m_active.id, player_id=player_id, team=team,
elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating)
db.add(mp)
# Losers Bracket Round 1 - 2 matches (losers from r1 matched up)
lb1 = TournamentMatch(
tournament_id=t.id,
round_number=1,
match_number=1,
bracket_type=BracketType.LOSERS,
status=TournamentMatchStatus.COMPLETED,
player1_id=losers_r1[0],
player2_id=losers_r1[1],
team1_score=9,
team2_score=11,
winner_team=2,
completed_at=datetime.utcnow() - timedelta(minutes=90),
)
db.add(lb1)
lb2 = TournamentMatch(
tournament_id=t.id,
round_number=1,
match_number=2,
bracket_type=BracketType.LOSERS,
status=TournamentMatchStatus.COMPLETED,
player1_id=losers_r1[2],
player2_id=losers_r1[3],
team1_score=11,
team2_score=6,
winner_team=1,
completed_at=datetime.utcnow() - timedelta(minutes=60),
)
db.add(lb2)
# Update loser entries
for entry in entries:
if entry.player_id == losers_r1[0]:
entry.losses += 1
entry.is_eliminated = True
entry.final_rank = 8
elif entry.player_id == losers_r1[3]:
entry.losses += 1
entry.is_eliminated = True
entry.final_rank = 7
db.commit()
db.refresh(t)
print(f"Created tournament: {t.name}")
return t
def seed_completed_matches(db: Session, players: list, courts: list):
"""Create some historical completed matches"""
past_matches = []
for i in range(6):
start = datetime.utcnow() - timedelta(days=i + 1, hours=2)
p_list = random.sample(players, 4)
m = Match(
court_id=courts[i % len(courts)].id,
stage=MatchStage.OPEN if i % 2 == 0 else MatchStage.SKILL_BASED,
match_type=MatchType.DOUBLES,
status=MatchStatus.COMPLETED,
title=f"Completed Match #{i + 1}",
max_players=4,
team1_score=11,
team2_score=random.randint(5, 10),
started_at=start,
ended_at=start + timedelta(hours=1),
)
db.add(m)
db.flush()
for player, team in zip(p_list, [1, 1, 2, 2]):
mp = MatchPlayer(match_id=m.id, player_id=player.id, team=team,
elo_before=player.elo_rating, elo_after=player.elo_rating + random.uniform(-10, 10),
elo_change=random.uniform(-10, 10), is_winner=(team == 1))
db.add(mp)
past_matches.append(m)
db.commit()
print(f"Created {len(past_matches)} completed matches")
def main():
print("🌱 Seeding ServeSync demo data...")
db = SessionLocal()
try:
clear_data(db)
players = seed_players(db)
courts = seed_courts(db)
seed_bookings(db, players, courts)
seed_active_matches(db, players, courts)
seed_tournament(db, players, courts)
seed_completed_matches(db, players, courts)
print("✅ Seed complete!")
finally:
db.close()
if __name__ == "__main__":
main()

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
version: '3.8'
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: servesync
POSTGRES_USER: servesync
POSTGRES_PASSWORD: servesync
healthcheck:
test: ["CMD-SHELL", "pg_isready -U servesync"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
backend:
build: ./backend
environment:
DATABASE_URL: postgresql://servesync:servesync@db:5432/servesync
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
frontend:
build: ./frontend
depends_on:
- backend
restart: unless-stopped
nginx:
build: ./nginx
ports:
- "8092:80"
depends_on:
- backend
- frontend
restart: unless-stopped

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ServeSync — Pickleball Court Management</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "servesync-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"axios": "^1.6.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

105
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,105 @@
<template>
<div class="min-h-screen bg-gray-950">
<!-- Navbar - hidden on screen display pages -->
<nav v-if="!isScreenDisplay" class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<router-link to="/" class="flex items-center gap-2">
<span class="text-2xl">🏓</span>
<span class="text-xl font-bold text-white">Serve<span class="text-green-400">Sync</span></span>
</router-link>
<!-- Nav Links -->
<div class="hidden md:flex items-center gap-1">
<router-link v-for="link in navLinks" :key="link.to" :to="link.to"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
:class="$route.path === link.to ? 'bg-green-500/20 text-green-400' : 'text-gray-400 hover:text-white hover:bg-gray-800'">
{{ link.icon }} {{ link.label }}
</router-link>
</div>
<!-- Current Player -->
<div v-if="store.currentPlayer" class="flex items-center gap-3">
<div class="flex items-center gap-2 bg-gray-800 rounded-lg px-3 py-2">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white"
:style="{ backgroundColor: store.currentPlayer.avatar_color }">
{{ store.currentPlayer.name[0] }}
</div>
<div class="hidden sm:block">
<div class="text-xs text-gray-400">Playing as</div>
<div class="text-sm font-medium text-white">{{ store.currentPlayer.name }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full"
:class="tierClass(store.currentPlayer.membership_tier)">
{{ store.currentPlayer.elo_rating }} ELO
</span>
</div>
<!-- Player Switcher -->
<select @change="switchPlayer($event.target.value)"
class="bg-gray-800 border border-gray-700 text-gray-300 text-xs rounded-lg px-2 py-1.5 focus:outline-none">
<option v-for="p in store.players" :key="p.id" :value="p.id"
:selected="p.id === store.currentPlayer?.id">
{{ p.name }}
</option>
</select>
</div>
</div>
</div>
</nav>
<!-- Mobile nav -->
<div v-if="!isScreenDisplay" class="md:hidden bg-gray-900 border-b border-gray-800 px-4 py-2">
<div class="flex gap-2 overflow-x-auto">
<router-link v-for="link in navLinks" :key="link.to" :to="link.to"
class="flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="$route.path === link.to ? 'bg-green-500/20 text-green-400' : 'text-gray-400 hover:text-white'">
{{ link.icon }} {{ link.label }}
</router-link>
</div>
</div>
<!-- Main Content -->
<router-view />
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from './stores/app'
const route = useRoute()
const store = useAppStore()
const isScreenDisplay = computed(() => route.name === 'Screen')
const navLinks = [
{ to: '/', label: 'Dashboard', icon: '📊' },
{ to: '/courts', label: 'Courts', icon: '🏟️' },
{ to: '/matchmaking', label: 'Matchmaking', icon: '⚔️' },
{ to: '/tournament', label: 'Tournament', icon: '🏆' },
{ to: '/players', label: 'Players', icon: '👥' },
]
const tierClass = (tier) => {
const classes = {
bronze: 'bg-amber-900/50 text-amber-400',
silver: 'bg-gray-700/50 text-gray-300',
gold: 'bg-yellow-900/50 text-yellow-400',
platinum: 'bg-cyan-900/50 text-cyan-400',
elite: 'bg-purple-900/50 text-purple-400',
}
return classes[tier] || 'bg-gray-800 text-gray-400'
}
const switchPlayer = (playerId) => {
const player = store.players.find(p => p.id == playerId)
if (player) store.setCurrentPlayer(player)
}
onMounted(async () => {
await store.loadPlayers()
})
</script>

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import Courts from '../views/Courts.vue'
import Matchmaking from '../views/Matchmaking.vue'
import Tournament from '../views/Tournament.vue'
import ScreenDisplay from '../views/ScreenDisplay.vue'
import Players from '../views/Players.vue'
const routes = [
{ path: '/', name: 'Dashboard', component: Dashboard },
{ path: '/courts', name: 'Courts', component: Courts },
{ path: '/matchmaking', name: 'Matchmaking', component: Matchmaking },
{ path: '/tournament', name: 'Tournament', component: Tournament },
{ path: '/players', name: 'Players', component: Players },
{ path: '/screen/:courtId', name: 'Screen', component: ScreenDisplay },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

125
frontend/src/stores/app.js Normal file
View File

@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export const useAppStore = defineStore('app', () => {
const currentPlayer = ref(null)
const players = ref([])
const courts = ref([])
const matches = ref([])
const activeTournament = ref(null)
const loading = ref(false)
// Set demo player (no auth needed)
const setCurrentPlayer = (player) => {
currentPlayer.value = player
localStorage.setItem('currentPlayerId', player.id)
}
const loadPlayers = async () => {
const res = await api.get('/players/')
players.value = res.data
// Auto-set first player as current if not set
if (!currentPlayer.value && players.value.length > 0) {
const savedId = localStorage.getItem('currentPlayerId')
const saved = savedId ? players.value.find(p => p.id == savedId) : null
currentPlayer.value = saved || players.value[0]
}
return players.value
}
const loadCourts = async () => {
const res = await api.get('/courts/')
courts.value = res.data
return courts.value
}
const loadMatches = async () => {
const res = await api.get('/matches/')
matches.value = res.data
return matches.value
}
const loadLobby = async () => {
const res = await api.get('/matches/lobby')
return res.data
}
const loadActiveMatches = async () => {
const res = await api.get('/matches/active')
return res.data
}
const createMatch = async (data) => {
const res = await api.post('/matches/', data)
return res.data
}
const joinMatch = async (matchId, playerId, team) => {
const res = await api.post(`/matches/${matchId}/join`, { player_id: playerId, team })
return res.data
}
const startMatch = async (matchId) => {
const res = await api.post(`/matches/${matchId}/start`)
return res.data
}
const updateScore = async (matchId, team1Score, team2Score) => {
const res = await api.post(`/matches/${matchId}/score`, { team1_score: team1Score, team2_score: team2Score })
return res.data
}
const completeMatch = async (matchId, team1Score, team2Score) => {
const res = await api.post(`/matches/${matchId}/complete`, { team1_score: team1Score, team2_score: team2Score })
return res.data
}
const bookCourt = async (data) => {
const res = await api.post('/courts/book', data)
return res.data
}
const loadTournaments = async () => {
const res = await api.get('/tournaments/')
return res.data
}
const loadTournament = async (id) => {
const res = await api.get(`/tournaments/${id}`)
activeTournament.value = res.data
return res.data
}
const getLeaderboard = async (limit = 10) => {
const res = await api.get(`/players/leaderboard?limit=${limit}`)
return res.data
}
const getCourtDisplay = async (courtId) => {
const res = await api.get(`/screen/court/${courtId}`)
return res.data
}
const getOverview = async () => {
const res = await api.get('/screen/overview')
return res.data
}
const completeTournamentMatch = async (tournamentId, matchId, s1, s2) => {
const res = await api.post(`/tournaments/${tournamentId}/matches/${matchId}/score`, {
team1_score: s1, team2_score: s2
})
return res.data
}
return {
currentPlayer, players, courts, matches, activeTournament, loading,
setCurrentPlayer, loadPlayers, loadCourts, loadMatches,
loadLobby, loadActiveMatches, createMatch, joinMatch, startMatch,
updateScore, completeMatch, bookCourt, loadTournaments, loadTournament,
getLeaderboard, getCourtDisplay, getOverview, completeTournamentMatch,
}
})

42
frontend/src/style.css Normal file
View File

@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-950 text-gray-100 font-sans;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 cursor-pointer;
}
.btn-primary {
@apply bg-green-500 hover:bg-green-400 text-white;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-500 text-white;
}
.card {
@apply bg-gray-900 rounded-xl border border-gray-800 p-6;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
}
.tier-bronze { @apply text-amber-600; }
.tier-silver { @apply text-gray-400; }
.tier-gold { @apply text-yellow-400; }
.tier-platinum { @apply text-cyan-400; }
.tier-elite { @apply text-purple-400; }
.bg-tier-bronze { @apply bg-amber-900/30 border-amber-700/50; }
.bg-tier-silver { @apply bg-gray-800/50 border-gray-600/50; }
.bg-tier-gold { @apply bg-yellow-900/30 border-yellow-700/50; }
.bg-tier-platinum { @apply bg-cyan-900/30 border-cyan-700/50; }
.bg-tier-elite { @apply bg-purple-900/30 border-purple-700/50; }

View File

@@ -0,0 +1,182 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">🏟 Court Reservations</h1>
<div class="flex gap-2">
<span class="badge bg-green-500/20 text-green-400">{{ availableCourts }} Available</span>
<span class="badge bg-red-500/20 text-red-400">{{ occupiedCourts }} Occupied</span>
</div>
</div>
<!-- Court Grid -->
<div class="grid md:grid-cols-2 gap-6">
<div v-for="court in courts" :key="court.id"
class="card transition-all hover:border-gray-700"
:class="court.current_status === 'occupied' ? 'border-red-800/30' : 'border-green-800/30'">
<!-- Court Header -->
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-bold text-white">{{ court.name }}</h2>
<div class="text-sm text-gray-400">{{ court.surface_type }}</div>
</div>
<div class="text-right">
<span class="badge text-sm"
:class="court.current_status === 'occupied' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'">
{{ court.current_status === 'occupied' ? '🔴 In Use' : '🟢 Available' }}
</span>
<div class="text-sm text-gray-400 mt-1">{{ court.hourly_rate }}/hr</div>
</div>
</div>
<!-- Features -->
<div class="flex flex-wrap gap-1 mb-4">
<span v-for="f in court.features.split(',')" :key="f"
class="badge bg-gray-800 text-gray-300 text-xs">{{ f.trim() }}</span>
</div>
<!-- Active Match -->
<div v-if="court.current_match" class="bg-gray-800 rounded-xl p-4 mb-4">
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Live Match</div>
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-white">{{ court.current_match.team1?.join(' & ') }}</div>
<div class="flex items-center gap-3">
<span class="text-2xl font-bold text-white">{{ court.current_match.team1_score }}</span>
<span class="text-gray-500">vs</span>
<span class="text-2xl font-bold text-white">{{ court.current_match.team2_score }}</span>
</div>
<div class="text-sm font-medium text-white text-right">{{ court.current_match.team2?.join(' & ') }}</div>
</div>
<div class="text-center mt-2">
<router-link :to="`/screen/${court.id}`"
class="text-xs text-blue-400 hover:underline">📺 View on Screen</router-link>
</div>
</div>
<!-- Book Form -->
<div v-if="court.current_status === 'available'" class="space-y-3">
<div class="text-sm font-medium text-gray-300">Book this court</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-400">Date & Time</label>
<input type="datetime-local" v-model="bookingForms[court.id].start_time"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
</div>
<div>
<label class="text-xs text-gray-400">Duration (hours)</label>
<select v-model="bookingForms[court.id].duration"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500">
<option value="1">1 hour {{ court.hourly_rate }}</option>
<option value="2">2 hours {{ court.hourly_rate * 2 }}</option>
<option value="3">3 hours {{ court.hourly_rate * 3 }}</option>
</select>
</div>
</div>
<button @click="bookCourt(court)"
class="btn btn-primary w-full"
:disabled="!store.currentPlayer || loading[court.id]">
{{ loading[court.id] ? 'Booking...' : `Book for ₱${court.hourly_rate * (bookingForms[court.id]?.duration || 1)}` }}
</button>
</div>
<!-- TV Screen Link -->
<router-link :to="`/screen/${court.id}`"
class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-400 hover:text-blue-400 transition-colors">
<span>📺</span> Open TV Display
</router-link>
</div>
</div>
<!-- Upcoming Bookings -->
<div>
<h2 class="text-lg font-bold text-white mb-4">📅 Upcoming Bookings</h2>
<div class="card p-0 overflow-hidden">
<div v-if="upcomingBookings.length === 0" class="text-center py-8 text-gray-500">
No upcoming bookings
</div>
<div v-for="booking in upcomingBookings" :key="booking.id"
class="flex items-center justify-between px-4 py-3 border-b border-gray-800 last:border-0">
<div class="flex items-center gap-3">
<span class="text-2xl">📅</span>
<div>
<div class="text-sm font-medium text-white">{{ booking.player_name }}</div>
<div class="text-xs text-gray-400">{{ booking.court_name }}</div>
</div>
</div>
<div class="text-right">
<div class="text-sm text-white">{{ formatTime(booking.start_time) }}</div>
<div class="text-xs text-gray-400">{{ booking.total_cost }}</div>
</div>
</div>
</div>
</div>
<!-- Success Toast -->
<div v-if="successMsg" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-3 rounded-xl shadow-lg z-50">
{{ successMsg }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useAppStore } from '../stores/app'
import axios from 'axios'
const store = useAppStore()
const courts = ref([])
const upcomingBookings = ref([])
const loading = reactive({})
const bookingForms = reactive({})
const successMsg = ref('')
const availableCourts = computed(() => courts.value.filter(c => c.current_status === 'available').length)
const occupiedCourts = computed(() => courts.value.filter(c => c.current_status === 'occupied').length)
const formatTime = (isoString) => {
return new Date(isoString).toLocaleString('en-PH', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})
}
const bookCourt = async (court) => {
if (!store.currentPlayer) return
loading[court.id] = true
try {
const form = bookingForms[court.id]
await store.bookCourt({
player_id: store.currentPlayer.id,
court_id: court.id,
start_time: new Date(form.start_time).toISOString(),
duration_hours: parseFloat(form.duration),
})
successMsg.value = `Court ${court.name} booked successfully!`
setTimeout(() => successMsg.value = '', 3000)
courts.value = await store.loadCourts()
} catch (e) {
alert(e.response?.data?.detail || 'Booking failed')
} finally {
loading[court.id] = false
}
}
onMounted(async () => {
courts.value = await store.loadCourts()
// Init booking forms
courts.value.forEach(court => {
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(now.getHours() + 1)
bookingForms[court.id] = {
start_time: now.toISOString().slice(0, 16),
duration: '1',
}
})
try {
const res = await axios.get('/api/courts/bookings/upcoming')
upcomingBookings.value = res.data
} catch (e) {}
})
</script>

View File

@@ -0,0 +1,188 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<!-- Hero Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card text-center">
<div class="text-3xl font-bold text-green-400">{{ courts.length }}</div>
<div class="text-gray-400 text-sm mt-1">Total Courts</div>
<div class="text-xs text-gray-500 mt-1">{{ availableCourts }} available</div>
</div>
<div class="card text-center">
<div class="text-3xl font-bold text-blue-400">{{ activeMatches }}</div>
<div class="text-gray-400 text-sm mt-1">Active Matches</div>
<div class="text-xs text-gray-500 mt-1">Live now</div>
</div>
<div class="card text-center">
<div class="text-3xl font-bold text-yellow-400">{{ lobbyMatches }}</div>
<div class="text-gray-400 text-sm mt-1">Lobby Waiting</div>
<div class="text-xs text-gray-500 mt-1">Join now</div>
</div>
<div class="card text-center">
<div class="text-3xl font-bold text-purple-400">{{ players.length }}</div>
<div class="text-gray-400 text-sm mt-1">Players</div>
<div class="text-xs text-gray-500 mt-1">Registered</div>
</div>
</div>
<div class="grid lg:grid-cols-3 gap-6">
<!-- Court Status Grid -->
<div class="lg:col-span-2 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-white">Court Status</h2>
<router-link to="/courts" class="text-green-400 text-sm hover:underline">View all </router-link>
</div>
<div class="grid grid-cols-2 gap-4">
<div v-for="court in courts" :key="court.id"
class="card cursor-pointer hover:border-gray-600 transition-all"
:class="court.current_status === 'occupied' ? 'border-red-800/50' : 'border-green-800/50'">
<div class="flex items-center justify-between mb-3">
<span class="font-bold text-white">{{ court.name }}</span>
<span class="badge text-xs"
:class="court.current_status === 'occupied' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'">
{{ court.current_status === 'occupied' ? '🔴 Occupied' : '🟢 Available' }}
</span>
</div>
<div v-if="court.current_match" class="space-y-2">
<div class="flex items-center justify-between bg-gray-800 rounded-lg p-2">
<span class="text-sm text-gray-300">{{ court.current_match.team1?.join(' & ') || 'Team 1' }}</span>
<span class="text-xl font-bold text-white">{{ court.current_match.team1_score }}</span>
</div>
<div class="flex items-center justify-between bg-gray-800 rounded-lg p-2">
<span class="text-sm text-gray-300">{{ court.current_match.team2?.join(' & ') || 'Team 2' }}</span>
<span class="text-xl font-bold text-white">{{ court.current_match.team2_score }}</span>
</div>
<div class="text-center">
<span class="badge bg-blue-500/20 text-blue-400 text-xs capitalize">
{{ court.current_match.stage?.replace('_', ' ') }}
</span>
</div>
</div>
<div v-else class="text-center py-3">
<router-link to="/courts" class="text-green-400 text-sm hover:underline">Book this court </router-link>
</div>
</div>
</div>
</div>
<!-- Leaderboard -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-white">🏆 Leaderboard</h2>
<router-link to="/players" class="text-green-400 text-sm hover:underline">View all </router-link>
</div>
<div class="card p-0 overflow-hidden">
<div v-for="(player, idx) in leaderboard" :key="player.id"
class="flex items-center gap-3 px-4 py-3 border-b border-gray-800 last:border-0 hover:bg-gray-800/50 transition-colors">
<span class="text-lg font-bold w-6 text-center"
:class="idx === 0 ? 'text-yellow-400' : idx === 1 ? 'text-gray-300' : idx === 2 ? 'text-amber-600' : 'text-gray-500'">
{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : idx + 1 }}
</span>
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white flex-shrink-0"
:style="{ backgroundColor: player.avatar_color }">
{{ player.name[0] }}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-white truncate">{{ player.name }}</div>
<div class="text-xs text-gray-500">{{ player.wins }}W / {{ player.losses }}L</div>
</div>
<div class="text-right">
<div class="text-sm font-bold" :class="tierColorClass(player.membership_tier)">{{ Math.round(player.elo_rating) }}</div>
<div class="text-xs capitalize" :class="tierColorClass(player.membership_tier)">{{ player.membership_tier }}</div>
</div>
</div>
</div>
<!-- Active Tournament Summary -->
<div v-if="tournament" class="card bg-gradient-to-br from-purple-900/30 to-gray-900 border-purple-800/50">
<div class="flex items-center gap-2 mb-3">
<span class="text-xl">🏆</span>
<div>
<div class="font-bold text-white text-sm">{{ tournament.tournament?.name }}</div>
<div class="text-xs text-purple-400 capitalize">{{ tournament.tournament?.status?.replace('_', ' ') }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-center">
<div class="bg-purple-900/30 rounded-lg p-2">
<div class="text-lg font-bold text-white">{{ tournament.winners_bracket?.length || 0 }}</div>
<div class="text-xs text-gray-400">W Bracket</div>
</div>
<div class="bg-purple-900/30 rounded-lg p-2">
<div class="text-lg font-bold text-white">{{ tournament.losers_bracket?.length || 0 }}</div>
<div class="text-xs text-gray-400">L Bracket</div>
</div>
</div>
<router-link to="/tournament" class="btn btn-primary w-full text-center mt-3 block text-sm">
View Bracket
</router-link>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<h2 class="text-lg font-bold text-white mb-4">Quick Actions</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<router-link to="/matchmaking" class="flex flex-col items-center gap-2 bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 rounded-xl p-4 transition-all">
<span class="text-3xl"></span>
<span class="text-sm font-medium text-green-400">Find Match</span>
</router-link>
<router-link to="/courts" class="flex flex-col items-center gap-2 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl p-4 transition-all">
<span class="text-3xl">📅</span>
<span class="text-sm font-medium text-blue-400">Book Court</span>
</router-link>
<router-link to="/tournament" class="flex flex-col items-center gap-2 bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 rounded-xl p-4 transition-all">
<span class="text-3xl">🏆</span>
<span class="text-sm font-medium text-purple-400">Tournament</span>
</router-link>
<router-link to="/screen/1" class="flex flex-col items-center gap-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 transition-all">
<span class="text-3xl">📺</span>
<span class="text-sm font-medium text-yellow-400">Live Screen</span>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '../stores/app'
const store = useAppStore()
const courts = ref([])
const leaderboard = ref([])
const lobbyData = ref({ lobby: [], active: [] })
const tournament = ref(null)
const players = ref([])
const availableCourts = computed(() => courts.value.filter(c => c.current_status === 'available').length)
const activeMatches = computed(() => lobbyData.value.active?.length || 0)
const lobbyMatches = computed(() => lobbyData.value.lobby?.length || 0)
const tierColorClass = (tier) => {
const classes = {
bronze: 'text-amber-600',
silver: 'text-gray-400',
gold: 'text-yellow-400',
platinum: 'text-cyan-400',
elite: 'text-purple-400',
}
return classes[tier] || 'text-gray-400'
}
onMounted(async () => {
courts.value = await store.loadCourts()
leaderboard.value = await store.getLeaderboard(8)
players.value = await store.loadPlayers()
try {
const [lobby, active] = await Promise.all([store.loadLobby(), store.loadActiveMatches()])
lobbyData.value = { lobby, active }
} catch (e) {}
try {
const tournaments = await store.loadTournaments()
const active = tournaments.find(t => t.status === 'in_progress')
if (active) tournament.value = await store.loadTournament(active.id)
} catch (e) {}
})
</script>

View File

@@ -0,0 +1,408 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white"> Matchmaking</h1>
<button @click="refreshData" class="btn btn-secondary text-sm">🔄 Refresh</button>
</div>
<!-- Stage Tabs -->
<div class="flex gap-2 bg-gray-900 p-1 rounded-xl w-fit">
<button v-for="stage in stages" :key="stage.id"
@click="activeStage = stage.id"
class="px-4 py-2 rounded-lg text-sm font-medium transition-all"
:class="activeStage === stage.id ? 'bg-green-500 text-white' : 'text-gray-400 hover:text-white'">
{{ stage.icon }} {{ stage.label }}
</button>
</div>
<!-- Stage Info -->
<div class="card" :class="stageCardClass">
<div class="flex items-start gap-4">
<span class="text-4xl">{{ currentStage.icon }}</span>
<div>
<h2 class="text-lg font-bold text-white">{{ currentStage.label }}</h2>
<p class="text-gray-400 text-sm mt-1">{{ currentStage.description }}</p>
<div class="flex flex-wrap gap-2 mt-2">
<span v-for="rule in currentStage.rules" :key="rule" class="badge bg-gray-800 text-gray-300 text-xs">
{{ rule }}
</span>
</div>
</div>
</div>
</div>
<div class="grid lg:grid-cols-3 gap-6">
<!-- Create Match -->
<div class="lg:col-span-1">
<div class="card space-y-4">
<h2 class="font-bold text-white">Create Match</h2>
<div>
<label class="text-xs text-gray-400">Match Title</label>
<input v-model="createForm.title" type="text" placeholder="e.g. Friday Night Doubles"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
</div>
<div>
<label class="text-xs text-gray-400">Match Type</label>
<select v-model="createForm.match_type"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500">
<option value="doubles">Doubles (4 players)</option>
<option value="singles">Singles (2 players)</option>
</select>
</div>
<div v-if="activeStage === 'skill_based'">
<label class="text-xs text-gray-400">ELO Tolerance (±)</label>
<select v-model="createForm.elo_tolerance"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500">
<option value="100">±100 (Strict)</option>
<option value="200">±200 (Standard)</option>
<option value="300">±300 (Relaxed)</option>
</select>
<div class="text-xs text-gray-500 mt-1">
Your ELO: {{ store.currentPlayer?.elo_rating?.toFixed(0) }}
Range: {{ eloRange.min }}-{{ eloRange.max }}
</div>
</div>
<button @click="createMatch" :disabled="!createForm.title || !store.currentPlayer || creatingMatch"
class="btn btn-primary w-full">
{{ creatingMatch ? 'Creating...' : '+ Create Match' }}
</button>
</div>
<!-- My Active Matches -->
<div v-if="myMatches.length > 0" class="card mt-4">
<h3 class="font-bold text-white mb-3">My Matches</h3>
<div v-for="m in myMatches" :key="m.id" class="bg-gray-800 rounded-lg p-3 mb-2">
<div class="text-sm font-medium text-white">{{ m.title }}</div>
<div class="flex items-center justify-between mt-2">
<span class="badge text-xs capitalize"
:class="m.status === 'in_progress' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'">
{{ m.status.replace('_', ' ') }}
</span>
<div class="flex gap-2">
<button v-if="m.status === 'in_progress'"
@click="openScoreModal(m)"
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-2 py-1 rounded">
Score
</button>
<button v-if="m.status === 'lobby' && m.current_players >= m.max_players"
@click="startMatch(m.id)"
class="text-xs bg-green-600 hover:bg-green-500 text-white px-2 py-1 rounded">
Start
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Lobby -->
<div class="lg:col-span-2 space-y-4">
<div class="flex items-center justify-between">
<h2 class="font-bold text-white">Open Lobbies ({{ filteredLobby.length }})</h2>
<div class="text-sm text-gray-400">Auto-refresh every 5s</div>
</div>
<div v-if="filteredLobby.length === 0" class="card text-center py-12">
<div class="text-4xl mb-3">🏓</div>
<div class="text-gray-400">No matches in lobby</div>
<div class="text-gray-500 text-sm mt-1">Create one above!</div>
</div>
<div v-for="match in filteredLobby" :key="match.id"
class="card hover:border-gray-600 transition-all">
<div class="flex items-start justify-between mb-3">
<div>
<div class="font-bold text-white">{{ match.title }}</div>
<div class="flex gap-2 mt-1">
<span class="badge bg-gray-800 text-gray-300 text-xs capitalize">
{{ match.stage.replace('_', ' ') }}
</span>
<span class="badge bg-gray-800 text-gray-300 text-xs">
{{ match.match_type }}
</span>
<span v-if="match.min_elo" class="badge bg-orange-500/20 text-orange-400 text-xs">
ELO {{ match.min_elo?.toFixed(0) }}-{{ match.max_elo?.toFixed(0) }}
</span>
</div>
</div>
<div class="text-right">
<div class="text-sm font-medium text-white">{{ match.current_players }}/{{ match.max_players }}</div>
<div class="text-xs text-gray-400">players</div>
</div>
</div>
<!-- Teams -->
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="bg-blue-900/20 border border-blue-800/30 rounded-lg p-2">
<div class="text-xs text-blue-400 mb-1">Team 1</div>
<div v-for="p in match.team1" :key="p.id" class="flex items-center gap-1 text-sm text-white">
<div class="w-5 h-5 rounded-full text-xs flex items-center justify-center font-bold"
:style="{ backgroundColor: p.avatar_color }">{{ p.name[0] }}</div>
{{ p.name }}
<span class="text-xs text-gray-400">({{ p.elo.toFixed(0) }})</span>
</div>
<div v-for="i in Math.max(0, (match.max_players/2) - match.team1.length)" :key="`t1-${i}`"
class="text-xs text-gray-600 italic">Empty slot...</div>
</div>
<div class="bg-red-900/20 border border-red-800/30 rounded-lg p-2">
<div class="text-xs text-red-400 mb-1">Team 2</div>
<div v-for="p in match.team2" :key="p.id" class="flex items-center gap-1 text-sm text-white">
<div class="w-5 h-5 rounded-full text-xs flex items-center justify-center font-bold"
:style="{ backgroundColor: p.avatar_color }">{{ p.name[0] }}</div>
{{ p.name }}
<span class="text-xs text-gray-400">({{ p.elo.toFixed(0) }})</span>
</div>
<div v-for="i in Math.max(0, (match.max_players/2) - match.team2.length)" :key="`t2-${i}`"
class="text-xs text-gray-600 italic">Empty slot...</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button v-if="!isInMatch(match)"
@click="joinMatch(match.id, 1)"
class="btn btn-primary text-sm flex-1"
:disabled="match.team1.length >= match.max_players / 2">
Join Team 1
</button>
<button v-if="!isInMatch(match)"
@click="joinMatch(match.id, 2)"
class="btn btn-secondary text-sm flex-1"
:disabled="match.team2.length >= match.max_players / 2">
Join Team 2
</button>
<button v-if="match.current_players >= match.max_players && isCreator(match)"
@click="startMatch(match.id)"
class="btn bg-yellow-500 hover:bg-yellow-400 text-black text-sm flex-1 font-bold">
🚀 Start Match
</button>
<span v-if="isInMatch(match)" class="badge bg-green-500/20 text-green-400"> Joined</span>
</div>
</div>
<!-- Active Matches Section -->
<div v-if="activeMatches.length > 0">
<h2 class="font-bold text-white mb-3">🔥 Active Matches</h2>
<div v-for="match in activeMatches" :key="match.id"
class="card border-green-800/30">
<div class="flex items-center justify-between mb-3">
<div class="font-bold text-white">{{ match.title }}</div>
<div class="flex gap-2">
<span class="badge bg-green-500/20 text-green-400">🔴 Live</span>
<span v-if="match.court_name" class="badge bg-gray-700 text-gray-300 text-xs">{{ match.court_name }}</span>
</div>
</div>
<div class="flex items-center justify-center gap-6 py-4">
<div class="text-center">
<div class="text-sm text-gray-400 mb-1">{{ match.team1.map(p => p.name).join(' & ') }}</div>
<div class="text-5xl font-black text-white">{{ match.team1_score }}</div>
</div>
<div class="text-2xl text-gray-500">vs</div>
<div class="text-center">
<div class="text-sm text-gray-400 mb-1">{{ match.team2.map(p => p.name).join(' & ') }}</div>
<div class="text-5xl font-black text-white">{{ match.team2_score }}</div>
</div>
</div>
<div class="flex gap-2 justify-center">
<button @click="openScoreModal(match)" class="btn btn-secondary text-sm">📊 Update Score</button>
<button @click="openCompleteModal(match)" class="btn btn-danger text-sm">🏁 End Match</button>
</div>
</div>
</div>
</div>
</div>
<!-- Score Update Modal -->
<div v-if="scoreModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">{{ completeMode ? '🏁 End Match' : '📊 Update Score' }}</h3>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="text-center">
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.team1.map(p => p.name).join(' & ') }}</div>
<input type="number" v-model="scoreForm.team1_score" min="0" max="21"
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3 focus:outline-none focus:border-green-500" />
</div>
<div class="text-center">
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.team2.map(p => p.name).join(' & ') }}</div>
<input type="number" v-model="scoreForm.team2_score" min="0" max="21"
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3 focus:outline-none focus:border-green-500" />
</div>
</div>
<div class="flex gap-3">
<button @click="scoreModal = null" class="btn btn-secondary flex-1">Cancel</button>
<button @click="submitScore" :class="completeMode ? 'btn btn-danger' : 'btn btn-primary'" class="flex-1">
{{ completeMode ? 'End & Update ELO' : 'Update Score' }}
</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="toast" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-3 rounded-xl shadow-lg z-50">
{{ toast }}
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { useAppStore } from '../stores/app'
const store = useAppStore()
const lobby = ref([])
const activeMatches = ref([])
const activeStage = ref('open')
const creatingMatch = ref(false)
const scoreModal = ref(null)
const completeMode = ref(false)
const toast = ref('')
const scoreForm = reactive({ team1_score: 0, team2_score: 0 })
let refreshInterval = null
const stages = [
{
id: 'open',
icon: '🎯',
label: 'Stage 1: Open Match',
description: 'Anyone can join. No skill restrictions. Great for casual play and meeting new players.',
rules: ['No ELO requirement', 'Free to join', 'Performance tracked silently'],
},
{
id: 'skill_based',
icon: '⚡',
label: 'Stage 2: Skill-Based',
description: 'ELO-gated matchmaking. Players match within ±200 ELO points for balanced competition.',
rules: ['ELO within ±200', 'Team avg must be in range', 'Affects ELO rating'],
},
]
const createForm = reactive({ title: '', match_type: 'doubles', elo_tolerance: 200 })
const currentStage = computed(() => stages.find(s => s.id === activeStage.value))
const stageCardClass = computed(() => ({
'border-blue-800/50 bg-blue-900/10': activeStage.value === 'open',
'border-orange-800/50 bg-orange-900/10': activeStage.value === 'skill_based',
}))
const eloRange = computed(() => {
const elo = store.currentPlayer?.elo_rating || 1000
const t = parseFloat(createForm.elo_tolerance) || 200
return { min: Math.max(0, Math.round(elo - t)), max: Math.round(elo + t) }
})
const filteredLobby = computed(() => {
return lobby.value.filter(m => m.stage === activeStage.value)
})
const isInMatch = (match) => {
const pid = store.currentPlayer?.id
return [...match.team1, ...match.team2].some(p => p.id === pid)
}
const isCreator = (match) => {
const pid = store.currentPlayer?.id
return match.team1[0]?.id === pid || match.team2[0]?.id === pid
}
const myMatches = computed(() => {
const pid = store.currentPlayer?.id
if (!pid) return []
return activeMatches.value.filter(m => [...m.team1, ...m.team2].some(p => p.id === pid))
})
const createMatch = async () => {
if (!createForm.title || !store.currentPlayer) return
creatingMatch.value = true
try {
await store.createMatch({
title: createForm.title,
stage: activeStage.value,
match_type: createForm.match_type,
creator_player_id: store.currentPlayer.id,
elo_tolerance: parseFloat(createForm.elo_tolerance),
})
createForm.title = ''
showToast('Match created! Players can now join.')
await refreshData()
} catch (e) {
alert(e.response?.data?.detail || 'Error creating match')
} finally {
creatingMatch.value = false
}
}
const joinMatch = async (matchId, team) => {
if (!store.currentPlayer) return
try {
await store.joinMatch(matchId, store.currentPlayer.id, team)
showToast('Joined match!')
await refreshData()
} catch (e) {
alert(e.response?.data?.detail || 'Cannot join match')
}
}
const startMatch = async (matchId) => {
try {
await store.startMatch(matchId)
showToast('Match started! Court auto-assigned.')
await refreshData()
} catch (e) {
alert(e.response?.data?.detail || 'Cannot start match')
}
}
const openScoreModal = (match) => {
scoreModal.value = match
completeMode.value = false
scoreForm.team1_score = match.team1_score || 0
scoreForm.team2_score = match.team2_score || 0
}
const openCompleteModal = (match) => {
scoreModal.value = match
completeMode.value = true
scoreForm.team1_score = match.team1_score || 0
scoreForm.team2_score = match.team2_score || 0
}
const submitScore = async () => {
if (!scoreModal.value) return
try {
if (completeMode.value) {
await store.completeMatch(scoreModal.value.id, scoreForm.team1_score, scoreForm.team2_score)
showToast('Match completed! ELO updated.')
} else {
await store.updateScore(scoreModal.value.id, scoreForm.team1_score, scoreForm.team2_score)
showToast('Score updated!')
}
scoreModal.value = null
await refreshData()
} catch (e) {
alert(e.response?.data?.detail || 'Error')
}
}
const showToast = (msg) => {
toast.value = msg
setTimeout(() => toast.value = '', 3000)
}
const refreshData = async () => {
[lobby.value, activeMatches.value] = await Promise.all([store.loadLobby(), store.loadActiveMatches()])
}
onMounted(async () => {
await refreshData()
refreshInterval = setInterval(refreshData, 5000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
</script>

View File

@@ -0,0 +1,171 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">👥 Players</h1>
<button @click="showRegister = true" class="btn btn-primary text-sm">+ Register Player</button>
</div>
<!-- Tier Distribution -->
<div class="flex gap-3 flex-wrap">
<div v-for="tier in tierStats" :key="tier.name"
class="flex items-center gap-2 bg-gray-900 border border-gray-800 rounded-xl px-4 py-2">
<span class="text-lg">{{ tier.icon }}</span>
<div>
<div class="text-sm font-medium" :class="tier.color">{{ tier.name }}</div>
<div class="text-xs text-gray-500">{{ tier.count }} players</div>
</div>
</div>
</div>
<!-- Players Table -->
<div class="card p-0 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800 text-left">
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Rank</th>
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Player</th>
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">ELO</th>
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Tier</th>
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">W/L</th>
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Win Rate</th>
<th class="px-4 py-3 text-xs text-gray-400 uppercase tracking-wide">Matches</th>
</tr>
</thead>
<tbody>
<tr v-for="(player, idx) in players" :key="player.id"
class="border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 transition-colors cursor-pointer"
:class="player.id === store.currentPlayer?.id ? 'bg-green-900/10' : ''">
<td class="px-4 py-3 text-sm">
<span :class="idx === 0 ? 'text-yellow-400 text-lg' : idx === 1 ? 'text-gray-300 text-lg' : idx === 2 ? 'text-amber-600 text-lg' : 'text-gray-500'">
{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : `#${idx + 1}` }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold text-white"
:style="{ backgroundColor: player.avatar_color }">
{{ player.name[0] }}
</div>
<div>
<div class="text-sm font-medium text-white flex items-center gap-1">
{{ player.name }}
<span v-if="player.id === store.currentPlayer?.id" class="badge bg-green-500/20 text-green-400 text-xs">You</span>
</div>
<div class="text-xs text-gray-500">{{ player.email }}</div>
</div>
</div>
</td>
<td class="px-4 py-3">
<span class="text-white font-mono font-bold">{{ Math.round(player.elo_rating) }}</span>
</td>
<td class="px-4 py-3">
<span class="badge capitalize text-xs px-2 py-1"
:class="tierBadge(player.membership_tier)">
{{ player.membership_tier }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-300">
<span class="text-green-400">{{ player.wins }}W</span>
<span class="text-gray-600 mx-1">/</span>
<span class="text-red-400">{{ player.losses }}L</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-800 rounded-full h-1.5 w-16">
<div class="h-full rounded-full bg-green-500"
:style="{ width: player.win_rate + '%' }"></div>
</div>
<span class="text-xs text-gray-400">{{ player.win_rate }}%</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ player.total_matches }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Register Modal -->
<div v-if="showRegister" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">👤 Register New Player</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-gray-400">Full Name *</label>
<input v-model="registerForm.name" type="text" placeholder="Juan Dela Cruz"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
</div>
<div>
<label class="text-xs text-gray-400">Email *</label>
<input v-model="registerForm.email" type="email" placeholder="juan@email.com"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
</div>
<div>
<label class="text-xs text-gray-400">Phone</label>
<input v-model="registerForm.phone" type="tel" placeholder="+63 912 345 6789"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
</div>
<div class="bg-gray-800 rounded-lg p-3 text-xs text-gray-400">
New players start at <span class="text-white font-bold">1000 ELO</span> (Bronze tier).
Rating updates automatically after each match.
</div>
</div>
<div class="flex gap-3 mt-4">
<button @click="showRegister = false" class="btn btn-secondary flex-1">Cancel</button>
<button @click="registerPlayer" :disabled="!registerForm.name || !registerForm.email"
class="btn btn-primary flex-1">Register</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAppStore } from '../stores/app'
import axios from 'axios'
const store = useAppStore()
const players = ref([])
const showRegister = ref(false)
const registerForm = reactive({ name: '', email: '', phone: '' })
const tierStats = computed(() => {
const tiers = { bronze: 0, silver: 0, gold: 0, platinum: 0, elite: 0 }
players.value.forEach(p => { tiers[p.membership_tier] = (tiers[p.membership_tier] || 0) + 1 })
return [
{ name: 'Bronze', icon: '🥉', color: 'text-amber-600', count: tiers.bronze },
{ name: 'Silver', icon: '🥈', color: 'text-gray-400', count: tiers.silver },
{ name: 'Gold', icon: '🥇', color: 'text-yellow-400', count: tiers.gold },
{ name: 'Platinum', icon: '💎', color: 'text-cyan-400', count: tiers.platinum },
{ name: 'Elite', icon: '👑', color: 'text-purple-400', count: tiers.elite },
]
})
const tierBadge = (tier) => {
const classes = {
bronze: 'bg-amber-900/40 text-amber-500 border border-amber-800/50',
silver: 'bg-gray-700/50 text-gray-300 border border-gray-600/50',
gold: 'bg-yellow-900/40 text-yellow-400 border border-yellow-800/50',
platinum: 'bg-cyan-900/40 text-cyan-400 border border-cyan-800/50',
elite: 'bg-purple-900/40 text-purple-400 border border-purple-800/50',
}
return classes[tier] || 'bg-gray-800 text-gray-400'
}
const registerPlayer = async () => {
try {
const res = await axios.post('/api/players/', registerForm)
players.value = await store.loadPlayers()
showRegister.value = false
registerForm.name = ''
registerForm.email = ''
registerForm.phone = ''
} catch (e) {
alert(e.response?.data?.detail || 'Registration failed')
}
}
onMounted(async () => {
players.value = await store.loadPlayers()
})
</script>

View File

@@ -0,0 +1,386 @@
<template>
<div class="min-h-screen bg-gray-950 overflow-hidden" :class="{ 'cursor-none': isFullscreen }">
<!-- Screen Mode Selector (top bar, hidden when watching) -->
<div v-if="!isFullscreen" class="bg-gray-900 border-b border-gray-800 p-3 flex items-center gap-4">
<router-link to="/" class="text-gray-400 hover:text-white text-sm"> Back</router-link>
<div class="flex gap-2">
<button v-for="c in [1,2,3,4]" :key="c"
@click="switchCourt(c)"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
:class="courtId === c ? 'bg-green-500 text-white' : 'bg-gray-800 text-gray-400 hover:text-white'">
Court {{ c }}
</button>
<button @click="mode = 'overview'"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
:class="mode === 'overview' ? 'bg-blue-500 text-white' : 'bg-gray-800 text-gray-400 hover:text-white'">
Overview
</button>
</div>
<button @click="toggleFullscreen" class="ml-auto bg-gray-800 hover:bg-gray-700 text-white px-3 py-1.5 rounded-lg text-sm">
Fullscreen
</button>
</div>
<!-- COURT DISPLAY MODE -->
<div v-if="mode === 'court'" class="h-screen flex flex-col bg-gray-950">
<!-- Header -->
<div class="flex items-center justify-between px-8 pt-6 pb-4 bg-gray-900 border-b border-gray-800">
<div class="flex items-center gap-4">
<span class="text-4xl">🏓</span>
<div>
<div class="text-3xl font-black text-white">{{ courtData?.court?.name || `Court ${courtId}` }}</div>
<div class="text-gray-400">{{ courtData?.court?.surface_type }}</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<div class="text-sm text-gray-400">ServeSync</div>
<div class="text-lg font-bold text-green-400">🟢 LIVE</div>
</div>
<div class="text-right text-white">
<div class="text-2xl font-mono">{{ currentTime }}</div>
<div class="text-sm text-gray-400">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Active Match Display -->
<div v-if="courtData?.active_match" class="flex-1 flex flex-col items-center justify-center px-8 py-6 gap-8">
<!-- Stage Badge -->
<div class="flex items-center gap-3">
<span class="text-5xl">{{ stageIcon(courtData.active_match.stage) }}</span>
<div class="text-center">
<span class="badge text-lg px-4 py-2 capitalize"
:class="stageClass(courtData.active_match.stage)">
{{ courtData.active_match.stage.replace('_', ' ').toUpperCase() }} MATCH
</span>
</div>
</div>
<!-- Scoreboard -->
<div class="w-full max-w-5xl">
<div class="grid grid-cols-3 gap-6 items-center">
<!-- Team 1 -->
<div class="text-center space-y-4">
<div class="flex justify-center gap-3">
<div v-for="p in courtData.active_match.team1" :key="p.id"
class="flex flex-col items-center gap-2">
<div class="w-20 h-20 rounded-full flex items-center justify-center text-4xl font-black text-white shadow-2xl"
:style="{ backgroundColor: p.avatar_color, boxShadow: `0 0 30px ${p.avatar_color}40` }">
{{ p.name[0] }}
</div>
<div>
<div class="text-2xl font-bold text-white">{{ p.name.split(' ')[0] }}</div>
<div class="text-sm capitalize px-2 py-0.5 rounded-full"
:class="tierBadgeClass(p.membership_tier)">
{{ p.membership_tier }}
</div>
</div>
</div>
</div>
<div class="text-9xl font-black text-white leading-none tabular-nums"
:class="courtData.active_match.team1_score > courtData.active_match.team2_score ? 'text-green-400' : ''">
{{ courtData.active_match.team1_score }}
</div>
</div>
<!-- VS / Timer -->
<div class="text-center space-y-4">
<div class="text-4xl font-black text-gray-600">VS</div>
<div v-if="courtData.active_match.elapsed_seconds" class="space-y-1">
<div class="text-gray-400 text-sm">Elapsed</div>
<div class="text-3xl font-mono text-white">{{ formatElapsed(courtData.active_match.elapsed_seconds) }}</div>
</div>
<div class="flex flex-col gap-2 mt-4">
<div class="h-2 rounded-full bg-gray-800 overflow-hidden">
<div class="h-full bg-blue-500 transition-all duration-500 rounded-full"
:style="{ width: team1Percent + '%' }"></div>
</div>
<div class="text-xs text-gray-500 text-center">Score Progress</div>
</div>
</div>
<!-- Team 2 -->
<div class="text-center space-y-4">
<div class="flex justify-center gap-3">
<div v-for="p in courtData.active_match.team2" :key="p.id"
class="flex flex-col items-center gap-2">
<div class="w-20 h-20 rounded-full flex items-center justify-center text-4xl font-black text-white shadow-2xl"
:style="{ backgroundColor: p.avatar_color, boxShadow: `0 0 30px ${p.avatar_color}40` }">
{{ p.name[0] }}
</div>
<div>
<div class="text-2xl font-bold text-white">{{ p.name.split(' ')[0] }}</div>
<div class="text-sm capitalize px-2 py-0.5 rounded-full"
:class="tierBadgeClass(p.membership_tier)">
{{ p.membership_tier }}
</div>
</div>
</div>
</div>
<div class="text-9xl font-black text-white leading-none tabular-nums"
:class="courtData.active_match.team2_score > courtData.active_match.team1_score ? 'text-green-400' : ''">
{{ courtData.active_match.team2_score }}
</div>
</div>
</div>
</div>
<!-- ELO Info -->
<div class="flex gap-6 text-center">
<div v-for="p in [...(courtData.active_match.team1 || []), ...(courtData.active_match.team2 || [])]"
:key="p.id"
class="bg-gray-900 border border-gray-700 rounded-xl px-4 py-2">
<div class="text-xs text-gray-400">{{ p.name }}</div>
<div class="text-lg font-bold text-white">{{ p.elo?.toFixed(0) }} ELO</div>
</div>
</div>
</div>
<!-- Available Court Display -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-8 px-8">
<div class="text-8xl">🟢</div>
<div class="text-center">
<div class="text-5xl font-black text-green-400 mb-4">COURT AVAILABLE</div>
<div class="text-2xl text-gray-400">Book this court via ServeSync</div>
</div>
<!-- Recent Matches -->
<div v-if="courtData?.recent_matches?.length" class="w-full max-w-2xl">
<div class="text-lg text-gray-400 text-center mb-4">Recent Matches</div>
<div class="space-y-2">
<div v-for="m in courtData.recent_matches" :key="m.team1?.[0]"
class="flex items-center justify-between bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<span class="text-white">{{ m.team1?.join(' & ') }}</span>
<span class="text-2xl font-bold" :class="m.winner === 'team1' ? 'text-green-400' : 'text-white'">{{ m.team1_score }}</span>
<span class="text-gray-500">vs</span>
<span class="text-2xl font-bold" :class="m.winner === 'team2' ? 'text-green-400' : 'text-white'">{{ m.team2_score }}</span>
<span class="text-white">{{ m.team2?.join(' & ') }}</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-900 border-t border-gray-800 px-8 py-3 flex items-center justify-between text-sm text-gray-400">
<span>🏓 ServeSync Court Management System</span>
<span>Auto-refresh every 3s</span>
<span>{{ courtData?.court?.features }}</span>
</div>
</div>
<!-- OVERVIEW MODE -->
<div v-else-if="mode === 'overview'" class="min-h-screen bg-gray-950 p-6 space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-4xl">🏓</span>
<div>
<div class="text-3xl font-black text-white">ServeSync Live</div>
<div class="text-gray-400">All Courts Overview</div>
</div>
</div>
<div class="text-right">
<div class="text-2xl font-mono text-white">{{ currentTime }}</div>
<div class="text-gray-400">{{ currentDate }}</div>
</div>
</div>
<!-- Courts Grid -->
<div class="grid grid-cols-2 gap-6">
<div v-for="court in overview?.courts" :key="court.id"
class="bg-gray-900 border rounded-2xl overflow-hidden"
:class="court.status === 'occupied' ? 'border-green-700/50' : 'border-gray-700/50'">
<div class="flex items-center justify-between px-5 py-3 border-b"
:class="court.status === 'occupied' ? 'bg-green-900/20 border-green-800/30' : 'bg-gray-800/50 border-gray-700/50'">
<span class="text-xl font-bold text-white">{{ court.name }}</span>
<span :class="court.status === 'occupied' ? 'text-green-400' : 'text-gray-500'">
{{ court.status === 'occupied' ? '🔴 MATCH IN PROGRESS' : '⚪ AVAILABLE' }}
</span>
</div>
<div v-if="court.match" class="px-5 py-4">
<div class="flex items-center justify-between">
<div class="text-center">
<div class="text-sm text-gray-400 mb-1">{{ court.match.team1_names?.join(' & ') }}</div>
<div class="text-6xl font-black" :class="court.match.team1_score > court.match.team2_score ? 'text-green-400' : 'text-white'">
{{ court.match.team1_score }}
</div>
</div>
<div class="text-3xl text-gray-600 font-bold">VS</div>
<div class="text-center">
<div class="text-sm text-gray-400 mb-1">{{ court.match.team2_names?.join(' & ') }}</div>
<div class="text-6xl font-black" :class="court.match.team2_score > court.match.team1_score ? 'text-green-400' : 'text-white'">
{{ court.match.team2_score }}
</div>
</div>
</div>
</div>
<div v-else class="px-5 py-8 text-center text-gray-600 text-lg">
Ready for play
</div>
</div>
</div>
<!-- Leaderboard -->
<div class="grid lg:grid-cols-2 gap-6">
<div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
<div class="px-5 py-3 bg-gray-800/50 border-b border-gray-700/50">
<span class="text-lg font-bold text-white">🏆 Top Players</span>
</div>
<div>
<div v-for="(p, idx) in overview?.leaderboard" :key="p.name"
class="flex items-center gap-4 px-5 py-3 border-b border-gray-800/50 last:border-0">
<span class="text-xl w-8 text-center">{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : idx + 1 }}</span>
<div class="w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold text-white"
:style="{ backgroundColor: p.avatar_color }">{{ p.name[0] }}</div>
<div class="flex-1">
<div class="text-white font-medium">{{ p.name }}</div>
<div class="text-sm text-gray-400">{{ p.wins }}W / {{ p.losses }}L</div>
</div>
<div class="text-right">
<div class="text-lg font-bold" :class="tierColorClass(p.tier)">{{ p.elo?.toFixed(0) }}</div>
<div class="text-xs capitalize" :class="tierColorClass(p.tier)">{{ p.tier }}</div>
</div>
</div>
</div>
</div>
<!-- Tournament Summary -->
<div v-if="overview?.tournament" class="bg-gray-900 border border-purple-800/30 rounded-2xl overflow-hidden">
<div class="px-5 py-3 bg-purple-900/20 border-b border-purple-800/30">
<span class="text-lg font-bold text-white">🏆 {{ overview.tournament.tournament?.name }}</span>
</div>
<div class="p-5 space-y-3">
<div v-for="entry in overview.tournament.leaderboard?.slice(0, 6)" :key="entry.player?.id"
class="flex items-center gap-3">
<span class="text-gray-500 w-4 text-sm">{{ entry.final_rank || '—' }}</span>
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
:style="{ backgroundColor: entry.player?.avatar_color || '#555' }">
{{ entry.player?.name?.[0] || '?' }}
</div>
<span class="text-white text-sm flex-1">{{ entry.player?.name }}</span>
<div class="flex gap-1">
<span v-if="entry.is_eliminated" class="badge bg-red-900/30 text-red-400 text-xs">Eliminated</span>
<span v-else-if="entry.is_in_losers" class="badge bg-orange-900/30 text-orange-400 text-xs">Losers Bracket</span>
<span v-else class="badge bg-yellow-900/30 text-yellow-400 text-xs">Winners Bracket</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const courtId = ref(parseInt(route.params.courtId) || 1)
const mode = ref('court')
const courtData = ref(null)
const overview = ref(null)
const currentTime = ref('')
const currentDate = ref('')
const isFullscreen = ref(false)
let refreshInterval = null
let clockInterval = null
const team1Percent = computed(() => {
const m = courtData.value?.active_match
if (!m) return 50
const total = m.team1_score + m.team2_score
if (total === 0) return 50
return Math.round((m.team1_score / total) * 100)
})
const stageIcon = (stage) => {
const icons = { open: '🎯', skill_based: '⚡', tournament: '🏆' }
return icons[stage] || '🏓'
}
const stageClass = (stage) => {
const classes = {
open: 'bg-blue-500/20 text-blue-400',
skill_based: 'bg-orange-500/20 text-orange-400',
tournament: 'bg-yellow-500/20 text-yellow-400',
}
return classes[stage] || 'bg-gray-700 text-gray-300'
}
const tierBadgeClass = (tier) => {
const classes = {
bronze: 'bg-amber-900/60 text-amber-400',
silver: 'bg-gray-700 text-gray-300',
gold: 'bg-yellow-900/60 text-yellow-400',
platinum: 'bg-cyan-900/60 text-cyan-400',
elite: 'bg-purple-900/60 text-purple-400',
}
return classes[tier] || 'bg-gray-800 text-gray-400'
}
const tierColorClass = (tier) => {
const classes = {
bronze: 'text-amber-600',
silver: 'text-gray-400',
gold: 'text-yellow-400',
platinum: 'text-cyan-400',
elite: 'text-purple-400',
}
return classes[tier] || 'text-gray-400'
}
const formatElapsed = (seconds) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const switchCourt = (id) => {
courtId.value = id
mode.value = 'court'
router.push(`/screen/${id}`)
}
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
const updateClock = () => {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
currentDate.value = now.toLocaleDateString('en-PH', { weekday: 'long', month: 'long', day: 'numeric' })
}
const refreshData = async () => {
try {
if (mode.value === 'court') {
courtData.value = await store.getCourtDisplay(courtId.value)
} else {
overview.value = await store.getOverview()
}
} catch (e) {}
}
onMounted(async () => {
updateClock()
clockInterval = setInterval(updateClock, 1000)
await refreshData()
refreshInterval = setInterval(refreshData, 3000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (clockInterval) clearInterval(clockInterval)
})
</script>

View File

@@ -0,0 +1,330 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div class="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 class="text-2xl font-bold text-white">🏆 Tournament</h1>
<div v-if="tournament" class="text-sm text-gray-400 mt-1">
{{ tournament.tournament?.name }}
<span class="ml-2 badge capitalize"
:class="tournament.tournament?.status === 'in_progress' ? 'bg-green-500/20 text-green-400' : 'bg-gray-700 text-gray-400'">
{{ tournament.tournament?.status?.replace('_', ' ') }}
</span>
</div>
</div>
<div class="flex gap-2">
<button @click="loadData" class="btn btn-secondary text-sm">🔄 Refresh</button>
<button v-if="!tournament" @click="showCreateModal = true" class="btn btn-primary text-sm">+ New Tournament</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="text-center py-12 text-gray-400">Loading tournament data...</div>
<!-- No Tournament -->
<div v-else-if="!tournament && !loading" class="card text-center py-16">
<div class="text-6xl mb-4">🏆</div>
<h2 class="text-xl font-bold text-white mb-2">No Active Tournament</h2>
<p class="text-gray-400 mb-6">Create a double elimination tournament to get started</p>
<button @click="showCreateModal = true" class="btn btn-primary">Create Tournament</button>
</div>
<div v-else-if="tournament">
<!-- Leaderboard / Rankings -->
<div class="grid lg:grid-cols-4 gap-6">
<div class="lg:col-span-3 space-y-6">
<!-- Winners Bracket -->
<div>
<h2 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-yellow-400 inline-block"></span>
Winners Bracket
</h2>
<div class="overflow-x-auto">
<div class="flex gap-6 min-w-max pb-4">
<div v-for="round in groupedWinners" :key="round.round" class="flex flex-col gap-4 min-w-48">
<div class="text-center text-xs text-gray-400 font-medium pb-2 border-b border-gray-800">
Round {{ round.round }}
</div>
<div v-for="match in round.matches" :key="match.id"
class="bg-gray-900 border rounded-xl overflow-hidden transition-all"
:class="matchBorderClass(match)">
<div class="px-1 py-0.5 text-center text-xs"
:class="match.status === 'in_progress' ? 'bg-green-500/20 text-green-400' : 'bg-gray-800 text-gray-500'">
{{ match.status === 'completed' ? '✓ Done' : match.status === 'in_progress' ? '🔴 Live' : '⏳ Pending' }}
</div>
<div class="p-3 space-y-1">
<MatchPlayer :player="match.player1" :score="match.team1_score" :is-winner="match.winner_team === 1" />
<div class="text-center text-xs text-gray-600">vs</div>
<MatchPlayer :player="match.player2" :score="match.team2_score" :is-winner="match.winner_team === 2" />
</div>
<div v-if="match.status === 'pending' && match.player1 && match.player2" class="px-3 pb-2">
<button @click="openScore(match)" class="w-full text-xs bg-blue-600/30 hover:bg-blue-600/50 text-blue-400 rounded-lg py-1.5">
Enter Score
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Losers Bracket -->
<div v-if="tournament.losers_bracket?.length > 0">
<h2 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-orange-400 inline-block"></span>
Losers Bracket
</h2>
<div class="overflow-x-auto">
<div class="flex gap-6 min-w-max pb-4">
<div v-for="round in groupedLosers" :key="round.round" class="flex flex-col gap-4 min-w-48">
<div class="text-center text-xs text-gray-400 font-medium pb-2 border-b border-gray-800">
LB Round {{ round.round }}
</div>
<div v-for="match in round.matches" :key="match.id"
class="bg-gray-900 border border-orange-900/40 rounded-xl overflow-hidden">
<div class="p-3 space-y-1">
<MatchPlayer :player="match.player1" :score="match.team1_score" :is-winner="match.winner_team === 1" />
<div class="text-center text-xs text-gray-600">vs</div>
<MatchPlayer :player="match.player2" :score="match.team2_score" :is-winner="match.winner_team === 2" />
</div>
<div v-if="match.status === 'pending' && match.player1 && match.player2" class="px-3 pb-2">
<button @click="openScore(match)" class="w-full text-xs bg-orange-600/30 hover:bg-orange-600/50 text-orange-400 rounded-lg py-1.5">
Enter Score
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Grand Final -->
<div v-if="tournament.grand_final?.length > 0">
<h2 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-xl">🏆</span>
Grand Final
</h2>
<div v-for="match in tournament.grand_final" :key="match.id"
class="card border-yellow-700/50 bg-yellow-900/10 max-w-sm">
<MatchPlayer :player="match.player1" :score="match.team1_score" :is-winner="match.winner_team === 1" />
<div class="text-center text-gray-500 my-1 text-sm font-bold">GRAND FINAL</div>
<MatchPlayer :player="match.player2" :score="match.team2_score" :is-winner="match.winner_team === 2" />
</div>
</div>
</div>
<!-- Leaderboard Sidebar -->
<div class="space-y-4">
<h2 class="text-lg font-bold text-white">📊 Standings</h2>
<div class="space-y-2">
<div v-for="(entry, idx) in tournament.leaderboard" :key="entry.player?.id"
class="flex items-center gap-3 bg-gray-900 rounded-xl p-3 border transition-all"
:class="entry.is_eliminated ? 'border-gray-800 opacity-60' : 'border-gray-700'">
<span class="text-lg font-bold w-6 text-center flex-shrink-0"
:class="idx === 0 ? 'text-yellow-400' : idx === 1 ? 'text-gray-300' : idx === 2 ? 'text-amber-600' : 'text-gray-600'">
{{ entry.final_rank || (entry.is_eliminated ? '✗' : '—') }}
</span>
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
:style="{ backgroundColor: entry.player?.avatar_color || '#555' }">
{{ entry.player?.name?.[0] || '?' }}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-white truncate">{{ entry.player?.name }}</div>
<div class="text-xs text-gray-400">{{ entry.wins }}W / {{ entry.losses }}L</div>
</div>
<div class="flex-shrink-0">
<span v-if="entry.is_eliminated" class="badge bg-red-900/30 text-red-400 text-xs">Out</span>
<span v-else-if="entry.is_in_losers" class="badge bg-orange-900/30 text-orange-400 text-xs">LB</span>
<span v-else class="badge bg-yellow-900/30 text-yellow-400 text-xs">WB</span>
</div>
</div>
</div>
<!-- Registration (if open) -->
<div v-if="tournament.tournament?.status === 'registration'" class="card">
<h3 class="font-bold text-white mb-3">Register Player</h3>
<select v-model="registerPlayerId"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mb-3">
<option v-for="p in availablePlayers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.elo_rating.toFixed(0) }} ELO)
</option>
</select>
<button @click="registerPlayer" class="btn btn-primary w-full text-sm">Register</button>
<button @click="startTournament" class="btn btn-secondary w-full text-sm mt-2"> Start Tournament</button>
</div>
</div>
</div>
</div>
<!-- Create Tournament Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">🏆 Create Tournament</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-gray-400">Tournament Name</label>
<input v-model="createForm.name" type="text" placeholder="ServeSync Grand Prix"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1 focus:outline-none focus:border-green-500" />
</div>
<div>
<label class="text-xs text-gray-400">Max Participants</label>
<select v-model="createForm.max_participants"
class="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 mt-1">
<option value="4">4 players</option>
<option value="8">8 players</option>
<option value="16">16 players</option>
</select>
</div>
</div>
<div class="flex gap-3 mt-4">
<button @click="showCreateModal = false" class="btn btn-secondary flex-1">Cancel</button>
<button @click="createTournament" class="btn btn-primary flex-1">Create</button>
</div>
</div>
</div>
<!-- Score Modal -->
<div v-if="scoreModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 w-full max-w-sm">
<h3 class="text-lg font-bold text-white mb-4">Enter Match Score</h3>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="text-center">
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.player1?.name }}</div>
<input type="number" v-model="scoreForm.s1" min="0" max="21"
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3" />
</div>
<div class="text-center">
<div class="text-sm text-gray-400 mb-2">{{ scoreModal.player2?.name }}</div>
<input type="number" v-model="scoreForm.s2" min="0" max="21"
class="w-full text-center text-3xl font-bold bg-gray-800 border border-gray-700 text-white rounded-xl py-3" />
</div>
</div>
<div class="flex gap-3">
<button @click="scoreModal = null" class="btn btn-secondary flex-1">Cancel</button>
<button @click="submitScore" class="btn btn-primary flex-1">Submit</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useAppStore } from '../stores/app'
import axios from 'axios'
// Inline MatchPlayer component
const MatchPlayer = {
props: ['player', 'score', 'isWinner'],
template: `
<div class="flex items-center gap-2 px-2 py-1 rounded-lg" :class="isWinner ? 'bg-green-900/30' : ''">
<div v-if="player" class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
:style="{ backgroundColor: player.avatar_color || '#555' }">{{ player.name?.[0] }}</div>
<div v-else class="w-6 h-6 rounded-full bg-gray-700 flex-shrink-0"></div>
<span class="text-sm text-white flex-1 truncate">{{ player?.name || 'TBD' }}</span>
<span v-if="score !== null && score !== undefined" class="text-sm font-bold" :class="isWinner ? 'text-green-400' : 'text-white'">{{ score }}</span>
<span v-if="isWinner" class="text-xs">👑</span>
</div>
`
}
const store = useAppStore()
const tournament = ref(null)
const loading = ref(true)
const showCreateModal = ref(false)
const scoreModal = ref(null)
const registerPlayerId = ref(null)
const players = ref([])
const scoreForm = reactive({ s1: 0, s2: 0 })
const createForm = reactive({ name: 'ServeSync Grand Prix', max_participants: 8 })
const groupedWinners = computed(() => {
if (!tournament.value?.winners_bracket) return []
const rounds = {}
for (const m of tournament.value.winners_bracket) {
if (!rounds[m.round]) rounds[m.round] = { round: m.round, matches: [] }
rounds[m.round].matches.push(m)
}
return Object.values(rounds).sort((a, b) => a.round - b.round)
})
const groupedLosers = computed(() => {
if (!tournament.value?.losers_bracket) return []
const rounds = {}
for (const m of tournament.value.losers_bracket) {
if (!rounds[m.round]) rounds[m.round] = { round: m.round, matches: [] }
rounds[m.round].matches.push(m)
}
return Object.values(rounds).sort((a, b) => a.round - b.round)
})
const availablePlayers = computed(() => {
const registeredIds = new Set(tournament.value?.leaderboard?.map(e => e.player?.id) || [])
return players.value.filter(p => !registeredIds.has(p.id))
})
const matchBorderClass = (match) => {
if (match.status === 'completed') return 'border-green-800/30'
if (match.status === 'in_progress') return 'border-green-500/50 shadow-green-500/20 shadow-lg'
return 'border-gray-800'
}
const loadData = async () => {
loading.value = true
try {
const tournaments = await store.loadTournaments()
const active = tournaments.find(t => t.status === 'in_progress' || t.status === 'registration')
if (active) {
tournament.value = await store.loadTournament(active.id)
}
players.value = await store.loadPlayers()
} finally {
loading.value = false
}
}
const createTournament = async () => {
try {
const res = await axios.post('/api/tournaments/', createForm)
showCreateModal.value = false
await loadData()
} catch (e) { alert('Error creating tournament') }
}
const registerPlayer = async () => {
if (!registerPlayerId.value || !tournament.value) return
try {
await axios.post(`/api/tournaments/${tournament.value.tournament.id}/register`, {
player_id: registerPlayerId.value
})
await loadData()
} catch (e) { alert(e.response?.data?.detail || 'Error') }
}
const startTournament = async () => {
if (!tournament.value) return
try {
tournament.value = await axios.post(`/api/tournaments/${tournament.value.tournament.id}/start`).then(r => r.data)
} catch (e) { alert(e.response?.data?.detail || 'Need at least 4 players') }
}
const openScore = (match) => {
scoreModal.value = match
scoreForm.s1 = match.team1_score || 0
scoreForm.s2 = match.team2_score || 0
}
const submitScore = async () => {
if (!scoreModal.value || !tournament.value) return
try {
tournament.value = await store.completeTournamentMatch(
tournament.value.tournament.id,
scoreModal.value.id,
scoreForm.s1,
scoreForm.s2
)
scoreModal.value = null
} catch (e) { alert('Error submitting score') }
}
onMounted(loadData)
</script>

View File

@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'pickle': {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
}
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 2s infinite',
}
},
},
plugins: [],
}

22
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://backend:8000',
ws: true,
}
}
},
build: {
outDir: 'dist',
}
})

3
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

45
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:80;
}
server {
listen 80;
server_name _;
client_max_body_size 20M;
# Backend API
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
# WebSocket
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
# Health check
location /health {
proxy_pass http://backend;
}
# Frontend
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 60s;
}
}