feat: Rebuild as Court Manager Dashboard v2

- Complete rewrite for single Court Manager user
- Feature 1: Player Management (CRUD, ELO, skill level)
- Feature 2: Court Reservation (timeline, override)
- Feature 3: Match Event Builder (3-step wizard)
  - Step 1: Event setup with player selection + stage calculator
  - Step 2: Stage configurator (Open/Skill/RR/Tournament)
  - Step 3: Auto bracket generation
- Feature 4: Live Court View (main dashboard, 2x2 grid)
- Event Control: score entry, bracket advancement, court assignment
- Screen view: TV display per court (/screen/:id)
- Seed: 8 players, 4 courts, 1 completed + 1 active event
- Routes: / players courts events events/new events/:id screen/:id
This commit is contained in:
Nemo
2026-03-19 07:35:51 +08:00
parent e455accc7e
commit 4421f61c24
56 changed files with 2949 additions and 4209 deletions

View File

@@ -2,17 +2,13 @@ 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/*
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/start.sh
EXPOSE 8000
CMD ["/app/start.sh"]
RUN chmod +x start.sh
CMD ["./start.sh"]

View File

@@ -1,227 +0,0 @@
"""Admin/seed endpoints for demo"""
from fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.orm import Session
from app.database import get_db
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
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/seed")
def run_seed(db: Session = Depends(get_db)):
"""Run demo seed data"""
try:
import sys
sys.path.insert(0, '/app')
from seed import main
main()
return {"status": "ok", "message": "Seed complete"}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.post("/update-elos")
def update_elos(db: Session = Depends(get_db)):
"""Update demo player ELO ratings to proper values"""
elo_map = {
"marco@demo.com": (1850, MembershipTier.ELITE, 45, 8, "#EF4444"),
"sofia@demo.com": (1720, MembershipTier.ELITE, 38, 12, "#EC4899"),
"carlos@demo.com": (1630, MembershipTier.PLATINUM, 32, 15, "#8B5CF6"),
"ana@demo.com": (1580, MembershipTier.PLATINUM, 28, 18, "#06B6D4"),
"juan@demo.com": (1450, MembershipTier.GOLD, 22, 20, "#F59E0B"),
"maria@demo.com": (1380, MembershipTier.GOLD, 18, 22, "#10B981"),
"pedro@demo.com": (1250, MembershipTier.SILVER, 14, 19, "#3B82F6"),
"rosa@demo.com": (1190, MembershipTier.SILVER, 11, 21, "#F97316"),
"diego@demo.com": (1100, MembershipTier.SILVER, 9, 18, "#84CC16"),
"lucia@demo.com": (980, MembershipTier.BRONZE, 6, 20, "#14B8A6"),
"miguel@demo.com": (920, MembershipTier.BRONZE, 4, 15, "#6366F1"),
"isabella@demo.com": (850, MembershipTier.BRONZE, 2, 12, "#D946EF"),
}
updated = []
for email, (elo, tier, wins, losses, color) in elo_map.items():
p = db.query(Player).filter(Player.email == email).first()
if p:
p.elo_rating = elo
p.membership_tier = tier
p.wins = wins
p.losses = losses
p.total_matches = wins + losses
p.avatar_color = color
updated.append(p.name)
db.commit()
# Create courts if not exist
if db.query(Court).count() == 0:
courts_data = [
("Court 1", 1, 200.0, "Sport Court", "LED Lighting, Pro Net System"),
("Court 2", 2, 200.0, "Sport Court", "LED Lighting, Spectator Seating"),
("Court 3", 3, 250.0, "Cushioned", "Tournament Grade, Scoreboards"),
("Court 4", 4, 250.0, "Cushioned", "Tournament Grade, VIP Lounge Access"),
]
for name, num, rate, surface, features in courts_data:
c = Court(name=name, court_number=num, hourly_rate=rate, surface_type=surface, features=features)
db.add(c)
db.commit()
# Create active matches
courts = db.query(Court).order_by(Court.court_number).all()
players = db.query(Player).order_by(Player.elo_rating.desc()).all()
if db.query(Match).count() == 0 and len(players) >= 4 and len(courts) >= 2:
# Active match 1
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 pid, team in [(players[0].id, 1), (players[1].id, 1), (players[2].id, 2), (players[3].id, 2)]:
db.add(MatchPlayer(match_id=m1.id, player_id=pid, team=team, elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
# Active match 2 (skill-based)
m2 = Match(
court_id=courts[1].id,
stage=MatchStage.SKILL_BASED,
match_type=MatchType.DOUBLES,
status=MatchStatus.IN_PROGRESS,
title="Skill Match - Gold League",
max_players=4,
min_elo=1200,
max_elo=1600,
team1_score=3,
team2_score=6,
started_at=datetime.utcnow() - timedelta(minutes=15),
)
db.add(m2)
db.flush()
for pid, team in [(players[4].id, 1), (players[5].id, 1), (players[6].id, 2), (players[7].id, 2)]:
db.add(MatchPlayer(match_id=m2.id, player_id=pid, team=team, elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
# Lobby matches
m3 = Match(stage=MatchStage.OPEN, match_type=MatchType.DOUBLES, status=MatchStatus.LOBBY,
title="Evening Open Game", max_players=4)
db.add(m3)
db.flush()
db.add(MatchPlayer(match_id=m3.id, player_id=players[8].id, team=1, elo_before=players[8].elo_rating))
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()
db.add(MatchPlayer(match_id=m4.id, player_id=players[4].id, team=1, elo_before=players[4].elo_rating))
db.commit()
# Create tournament
if db.query(Tournament).count() == 0 and len(players) >= 8 and len(courts) >= 3:
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()
tp = players[:8]
entries = []
for i, player in enumerate(tp):
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()
r1 = [(tp[0].id, tp[7].id, 11, 5), (tp[1].id, tp[6].id, 11, 7),
(tp[2].id, tp[5].id, 8, 11), (tp[3].id, tp[4].id, 11, 9)]
winners = []
losers = []
for i, (p1, p2, s1, s2) in enumerate(r1):
tm = TournamentMatch(tournament_id=t.id, round_number=1, match_number=i+1,
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.COMPLETED,
player1_id=p1, player2_id=p2, 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)
w = p1 if s1>s2 else p2
l = p2 if s1>s2 else p1
winners.append(w)
losers.append(l)
for e in entries:
if e.player_id == w: e.wins += 1
elif e.player_id == l:
e.losses += 1
e.is_in_losers = True
# R2 winners - 1 completed, 1 active
wm1 = TournamentMatch(tournament_id=t.id, round_number=2, match_number=1,
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.COMPLETED,
player1_id=winners[0], player2_id=winners[1], team1_score=11, team2_score=8,
winner_team=1, completed_at=datetime.utcnow() - timedelta(minutes=45))
db.add(wm1)
for e in entries:
if e.player_id == winners[0]: e.wins += 1
elif e.player_id == winners[1]:
e.losses += 1
e.is_in_losers = True
wm2 = TournamentMatch(tournament_id=t.id, round_number=2, match_number=2,
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.IN_PROGRESS,
player1_id=winners[2], player2_id=winners[3], team1_score=6, team2_score=7)
db.add(wm2)
# Losers bracket
lb1 = TournamentMatch(tournament_id=t.id, round_number=1, match_number=1,
bracket_type=BracketType.LOSERS, status=TournamentMatchStatus.COMPLETED,
player1_id=losers[0], player2_id=losers[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[2], player2_id=losers[3], team1_score=11, team2_score=6,
winner_team=1, completed_at=datetime.utcnow() - timedelta(minutes=60))
db.add(lb2)
for e in entries:
if e.player_id == losers[0]:
e.losses += 1
e.is_eliminated = True
e.final_rank = 8
elif e.player_id == losers[3]:
e.losses += 1
e.is_eliminated = True
e.final_rank = 7
# Tournament match on court 3
p_name_2 = db.query(Player).filter(Player.id == winners[2]).first().name
p_name_3 = db.query(Player).filter(Player.id == winners[3]).first().name
m_tour = Match(
court_id=courts[2].id, stage=MatchStage.TOURNAMENT, match_type=MatchType.SINGLES,
status=MatchStatus.IN_PROGRESS,
title=f"Tournament R2 - {p_name_2} vs {p_name_3}",
max_players=2, team1_score=6, team2_score=7,
started_at=datetime.utcnow() - timedelta(minutes=20),
)
db.add(m_tour)
db.flush()
for pid, team in [(winners[2], 1), (winners[3], 2)]:
db.add(MatchPlayer(match_id=m_tour.id, player_id=pid, team=team,
elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
db.commit()
return {"status": "ok", "updated": updated, "players": len(updated)}

View File

@@ -1,218 +0,0 @@
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
]

View File

@@ -1,160 +0,0 @@
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

@@ -1,94 +0,0 @@
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)

View File

@@ -1,163 +0,0 @@
"""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, timezone
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_id = court_number)"""
# Try by court_number first (friendlier URLs), then by id
court = db.query(Court).filter(Court.court_number == court_id).first()
if not court:
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:
now = datetime.now(timezone.utc)
started = active_match.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
elapsed = int((now - started).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.now(timezone.utc).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.now(timezone.utc).isoformat(),
}

View File

@@ -1,82 +0,0 @@
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", [])

148
backend/app/bracket.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Bracket and schedule generation logic for ServeSync
"""
import math
from typing import List, Optional, Tuple
def calculate_stages(num_players: int, format: str, courts: int) -> dict:
"""Calculate suggested stages and rounds for an event"""
if format == "Doubles":
teams = num_players // 2
else:
teams = num_players
matches_per_round = courts
if teams <= 4:
suggested_stages = 2
stage_names = ["Open Stage", "Tournament"]
elif teams <= 8:
suggested_stages = 2
stage_names = ["Open Stage", "Tournament"]
else:
suggested_stages = 3
stage_names = ["Open Stage", "Skill Stage", "Tournament"]
rounds_single_elim = math.ceil(math.log2(max(teams, 2)))
rounds_double_elim = rounds_single_elim * 2
return {
"teams": teams,
"matches_per_round": matches_per_round,
"rounds_single_elim": rounds_single_elim,
"rounds_double_elim": rounds_double_elim,
"suggested_stages": suggested_stages,
"stage_names": stage_names,
}
def generate_round_robin(player_ids: List[int]) -> List[Tuple[int, int]]:
"""Generate all round-robin pairings"""
pairings = []
n = len(player_ids)
players = list(player_ids)
if n % 2 == 1:
players.append(None) # bye
for round_num in range(len(players) - 1):
for i in range(len(players) // 2):
p1 = players[i]
p2 = players[len(players) - 1 - i]
if p1 is not None and p2 is not None:
pairings.append((p1, p2))
# Rotate: keep first fixed, rotate the rest
players = [players[0]] + [players[-1]] + players[1:-1]
return pairings
def generate_single_elim_bracket(player_ids: List[int]) -> List[dict]:
"""
Generate single elimination bracket matches.
Returns list of match dicts with round, match_number, player1, player2, next_winner_match_id
"""
n = len(player_ids)
# Pad to power of 2
bracket_size = 2 ** math.ceil(math.log2(max(n, 2)))
players = list(player_ids) + [None] * (bracket_size - n)
matches = []
match_id_counter = 1
# First round
round1_matches = []
for i in range(0, len(players), 2):
match = {
"temp_id": match_id_counter,
"round_number": 1,
"match_number": i // 2 + 1,
"player1_id": players[i],
"player2_id": players[i + 1],
"next_winner_temp_id": None,
"bracket_position": "winners",
"status": "scheduled",
}
# Handle byes
if players[i] is None or players[i + 1] is None:
match["status"] = "bye"
match["winner_temp"] = players[i] if players[i] is not None else players[i + 1]
round1_matches.append(match)
matches.append(match)
match_id_counter += 1
# Subsequent rounds
current_round_matches = round1_matches
round_num = 2
while len(current_round_matches) > 1:
next_round_matches = []
for i in range(0, len(current_round_matches), 2):
match = {
"temp_id": match_id_counter,
"round_number": round_num,
"match_number": i // 2 + 1,
"player1_id": None,
"player2_id": None,
"next_winner_temp_id": None,
"bracket_position": "winners",
"status": "scheduled",
}
# Set next_winner_temp_id for previous round matches
current_round_matches[i]["next_winner_temp_id"] = match_id_counter
if i + 1 < len(current_round_matches):
current_round_matches[i + 1]["next_winner_temp_id"] = match_id_counter
next_round_matches.append(match)
matches.append(match)
match_id_counter += 1
current_round_matches = next_round_matches
round_num += 1
return matches
def generate_open_schedule(player_ids: List[int], rounds: int, courts: int) -> List[dict]:
"""
Generate open match schedule - round robin up to 'rounds' rounds.
"""
all_pairings = generate_round_robin(player_ids)
matches = []
match_num = 1
round_num = 1
court_idx = 0
for round_round in range(min(rounds, math.ceil(len(all_pairings) / max(courts, 1)))):
start = round_round * courts
end = start + courts
round_pairings = all_pairings[start:end]
for i, (p1, p2) in enumerate(round_pairings):
matches.append({
"round_number": round_num,
"match_number": i + 1,
"player1_id": p1,
"player2_id": p2,
"status": "scheduled",
"bracket_position": "main",
})
round_num += 1
return matches

View File

@@ -1,15 +0,0 @@
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()

View File

@@ -1,12 +1,14 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_engine(settings.DATABASE_URL)
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://servesync:servesync@localhost:5432/servesync")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()

View File

@@ -1,27 +1,15 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .database import engine
from .models import Base
from .routers import players, courts, events, matches, dashboard, admin
from .websocket_manager import manager
import asyncio
import json
from typing import List, Dict
from app.database import engine, SessionLocal
from app.models import player, court, booking, match, tournament
Base.metadata.create_all(bind=engine)
# 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, admin
app = FastAPI(
title="ServeSync API",
description="Pickleball Court Management System",
version="1.0.0",
)
app = FastAPI(title="ServeSync API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
@@ -31,112 +19,37 @@ app.add_middleware(
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")
app.include_router(admin.router, prefix="/api")
app.include_router(players.router)
app.include_router(courts.router)
app.include_router(events.router)
app.include_router(matches.router)
app.include_router(dashboard.router)
app.include_router(admin.router)
# 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")
@app.get("/api/health")
def health():
return {"status": "healthy"}
return {"status": "ok", "version": "2.0.0"}
@app.websocket("/ws/dashboard")
async def ws_dashboard(websocket: WebSocket):
await manager.connect(websocket, "dashboard")
try:
while True:
await asyncio.sleep(3)
await manager.broadcast("dashboard", {"type": "ping"})
except WebSocketDisconnect:
manager.disconnect(websocket, "dashboard")
@app.websocket("/ws/screen/{court_id}")
async def ws_screen(websocket: WebSocket, court_id: int):
channel = f"screen_{court_id}"
await manager.connect(websocket, channel)
try:
while True:
await asyncio.sleep(5)
await manager.broadcast(channel, {"type": "ping", "court_id": court_id})
except WebSocketDisconnect:
manager.disconnect(websocket, channel)

128
backend/app/models.py Normal file
View File

@@ -0,0 +1,128 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text
from sqlalchemy.orm import relationship
from .database import Base
class Player(Base):
__tablename__ = "players"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
contact = Column(String(100), nullable=True)
skill_level = Column(String(20), nullable=False, default="Beginner") # Beginner/Intermediate/Advanced/Elite
matches_played = Column(Integer, default=0)
wins = Column(Integer, default=0)
losses = Column(Integer, default=0)
elo = Column(Float, default=1000.0)
created_at = Column(DateTime, default=datetime.utcnow)
event_players = relationship("EventPlayer", back_populates="player")
class Court(Base):
__tablename__ = "courts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
court_type = Column(String(100), default="Sport Court")
status = Column(String(20), default="available") # available/in_use/reserved/maintenance
created_at = Column(DateTime, default=datetime.utcnow)
reservations = relationship("CourtReservation", back_populates="court")
matches = relationship("Match", back_populates="court")
class CourtReservation(Base):
__tablename__ = "court_reservations"
id = Column(Integer, primary_key=True, index=True)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=True)
group_name = Column(String(100), nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
court = relationship("Court", back_populates="reservations")
player = relationship("Player")
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
format = Column(String(20), default="Singles") # Singles/Doubles
courts_count = Column(Integer, default=1)
status = Column(String(20), default="setup") # setup/active/completed
current_stage_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
event_players = relationship("EventPlayer", back_populates="event", cascade="all, delete-orphan")
stages = relationship("Stage", back_populates="event", order_by="Stage.order", cascade="all, delete-orphan")
class EventPlayer(Base):
__tablename__ = "event_players"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
skill_level_override = Column(String(20), nullable=True)
event = relationship("Event", back_populates="event_players")
player = relationship("Player", back_populates="event_players")
class Stage(Base):
__tablename__ = "stages"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
name = Column(String(100), nullable=False)
match_type = Column(String(30), nullable=False) # Open/Skill-Based/Round Robin/Tournament
rounds = Column(Integer, default=1)
advance_count = Column(Integer, default=0) # 0 = all advance
status = Column(String(20), default="pending") # pending/active/completed
order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
event = relationship("Event", back_populates="stages")
matches = relationship("Match", back_populates="stage", cascade="all, delete-orphan")
class Match(Base):
__tablename__ = "matches"
id = Column(Integer, primary_key=True, index=True)
stage_id = Column(Integer, ForeignKey("stages.id"), nullable=False)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
round_number = Column(Integer, default=1)
match_number = Column(Integer, default=1)
# Singles: player1_id, player2_id
# Doubles: team1_players and team2_players are JSON lists
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
team1_players = Column(JSON, nullable=True) # [player_id, player_id]
team2_players = Column(JSON, nullable=True)
score1 = Column(Integer, nullable=True)
score2 = Column(Integer, nullable=True)
winner_id = Column(Integer, ForeignKey("players.id"), nullable=True) # for singles
winner_team = Column(Integer, nullable=True) # 1 or 2 for doubles
status = Column(String(20), default="scheduled") # scheduled/in_progress/completed/bye
bracket_position = Column(String(20), nullable=True) # winners/losers
next_winner_match_id = Column(Integer, nullable=True)
next_loser_match_id = Column(Integer, nullable=True)
scheduled_time = Column(DateTime, nullable=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
stage = relationship("Stage", back_populates="matches")
court = relationship("Court", back_populates="matches")
player1 = relationship("Player", foreign_keys=[player1_id])
player2 = relationship("Player", foreign_keys=[player2_id])
winner = relationship("Player", foreign_keys=[winner_id])

View File

@@ -1,5 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,73 +0,0 @@
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

@@ -1,46 +0,0 @@
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

@@ -1,93 +0,0 @@
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

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Base
from ..database import engine
import subprocess, sys
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.post("/seed")
def seed_data(db: Session = Depends(get_db)):
"""Re-seed all demo data"""
import subprocess, sys
result = subprocess.run([sys.executable, "/app/seed.py"], capture_output=True, text=True)
return {"ok": result.returncode == 0, "output": result.stdout[-500:], "error": result.stderr[-200:] if result.stderr else None}

View File

@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from typing import List
from datetime import datetime
from ..database import get_db
from ..models import Court, CourtReservation
from ..schemas import CourtCreate, CourtStatusUpdate, ReservationCreate, ReservationOut, CourtOut
router = APIRouter(prefix="/api/courts", tags=["courts"])
@router.get("/", response_model=List[CourtOut])
def list_courts(db: Session = Depends(get_db)):
courts = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).all()
return courts
@router.post("/", response_model=CourtOut)
def create_court(data: CourtCreate, db: Session = Depends(get_db)):
court = Court(**data.model_dump())
db.add(court)
db.commit()
db.refresh(court)
return court
@router.get("/{court_id}", response_model=CourtOut)
def get_court(court_id: int, db: Session = Depends(get_db)):
court = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).filter(Court.id == court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
return court
@router.patch("/{court_id}/status")
def update_court_status(court_id: int, data: CourtStatusUpdate, 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")
valid_statuses = ["available", "in_use", "reserved", "maintenance"]
if data.status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Status must be one of {valid_statuses}")
court.status = data.status
db.commit()
return {"ok": True, "status": data.status}
@router.get("/{court_id}/reservations", response_model=List[ReservationOut])
def list_reservations(court_id: int, db: Session = Depends(get_db)):
return db.query(CourtReservation).filter(CourtReservation.court_id == court_id).order_by(CourtReservation.start_time).all()
@router.post("/{court_id}/reservations", response_model=ReservationOut)
def create_reservation(court_id: int, data: ReservationCreate, 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")
reservation = CourtReservation(court_id=court_id, **data.model_dump())
db.add(reservation)
# Update court status to reserved if it's currently available
if court.status == "available":
court.status = "reserved"
db.commit()
db.refresh(reservation)
return reservation
@router.delete("/reservations/{reservation_id}")
def delete_reservation(reservation_id: int, db: Session = Depends(get_db)):
reservation = db.query(CourtReservation).filter(CourtReservation.id == reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
court = db.query(Court).filter(Court.id == reservation.court_id).first()
db.delete(reservation)
db.commit()
# Check if court has any remaining reservations
remaining = db.query(CourtReservation).filter(CourtReservation.court_id == reservation.court_id).count()
if remaining == 0 and court and court.status == "reserved":
court.status = "available"
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from datetime import datetime
from ..database import get_db
from ..models import Court, Match, Event, Stage, Player, CourtReservation
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/")
def get_dashboard(db: Session = Depends(get_db)):
courts = db.query(Court).all()
courts_data = []
for court in courts:
current_match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == "in_progress"
).first()
match_data = None
elapsed = None
if current_match:
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first() if current_match.player1_id else None
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first() if current_match.player2_id else None
if current_match.started_at:
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
match_data = {
"id": current_match.id,
"player1_id": current_match.player1_id,
"player2_id": current_match.player2_id,
"player1_name": p1.name if p1 else None,
"player2_name": p2.name if p2 else None,
"score1": current_match.score1,
"score2": current_match.score2,
"status": current_match.status,
"elapsed_seconds": elapsed,
"stage_id": current_match.stage_id,
}
# Next reservation
now = datetime.utcnow()
next_reservation = db.query(CourtReservation).filter(
CourtReservation.court_id == court.id,
CourtReservation.start_time > now
).order_by(CourtReservation.start_time).first()
courts_data.append({
"id": court.id,
"name": court.name,
"court_type": court.court_type,
"status": court.status,
"current_match": match_data,
"elapsed_seconds": elapsed,
"next_reservation": {
"start_time": next_reservation.start_time,
"group_name": next_reservation.group_name,
} if next_reservation else None,
})
# Upcoming scheduled matches (not yet in progress)
upcoming = db.query(Match).filter(
Match.status == "scheduled",
Match.player1_id.isnot(None),
Match.player2_id.isnot(None)
).limit(10).all()
upcoming_data = []
for m in upcoming:
p1 = db.query(Player).filter(Player.id == m.player1_id).first()
p2 = db.query(Player).filter(Player.id == m.player2_id).first()
stage = db.query(Stage).filter(Stage.id == m.stage_id).first()
event = db.query(Event).filter(Event.id == stage.event_id).first() if stage else None
upcoming_data.append({
"id": m.id,
"player1_name": p1.name if p1 else None,
"player2_name": p2.name if p2 else None,
"round_number": m.round_number,
"event_name": event.name if event else None,
"stage_name": stage.name if stage else None,
})
# Active event summary
active_event = db.query(Event).filter(Event.status == "active").first()
active_event_data = None
if active_event:
active_stage = None
if active_event.current_stage_id:
active_stage = db.query(Stage).filter(Stage.id == active_event.current_stage_id).first()
active_event_data = {
"id": active_event.id,
"name": active_event.name,
"format": active_event.format,
"current_stage": active_stage.name if active_stage else None,
"current_stage_id": active_event.current_stage_id,
}
return {
"courts": courts_data,
"upcoming_matches": upcoming_data,
"active_event": active_event_data,
}
@router.get("/screen/{court_id}")
def screen_view(court_id: int, db: Session = Depends(get_db)):
"""TV/screen display for a specific court"""
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
return {"error": "Court not found"}
current_match = db.query(Match).filter(
Match.court_id == court_id,
Match.status == "in_progress"
).first()
match_data = None
if current_match:
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first()
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first()
elapsed = None
if current_match.started_at:
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
match_data = {
"id": current_match.id,
"player1_name": p1.name if p1 else "TBD",
"player2_name": p2.name if p2 else "TBD",
"score1": current_match.score1 if current_match.score1 is not None else 0,
"score2": current_match.score2 if current_match.score2 is not None else 0,
"elapsed_seconds": elapsed,
}
return {
"court": {"id": court.id, "name": court.name, "status": court.status},
"current_match": match_data,
}

View File

@@ -0,0 +1,294 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from typing import List
from datetime import datetime
from ..database import get_db
from ..models import Event, EventPlayer, Stage, Match, Player, Court
from ..schemas import (
EventCreate, EventUpdate, EventPlayerAdd, EventPlayerOut,
StageCreate, StageUpdate, StageOut, MatchOut
)
from ..bracket import calculate_stages, generate_single_elim_bracket, generate_open_schedule, generate_round_robin
router = APIRouter(prefix="/api/events", tags=["events"])
def _event_with_details(event_id: int, db: Session):
return db.query(Event).options(
joinedload(Event.event_players).joinedload(EventPlayer.player),
joinedload(Event.stages).joinedload(Stage.matches)
).filter(Event.id == event_id).first()
@router.get("/")
def list_events(db: Session = Depends(get_db)):
events = db.query(Event).order_by(Event.created_at.desc()).all()
return [{"id": e.id, "name": e.name, "format": e.format, "status": e.status,
"courts_count": e.courts_count, "created_at": e.created_at,
"player_count": len(e.event_players), "stage_count": len(e.stages)} for e in events]
@router.post("/")
def create_event(data: EventCreate, db: Session = Depends(get_db)):
event = Event(**data.model_dump())
db.add(event)
db.commit()
db.refresh(event)
return {"id": event.id, "name": event.name, "format": event.format,
"status": event.status, "courts_count": event.courts_count}
@router.get("/calculate")
def calculate_event(players: int, format: str = "Singles", courts: int = 1):
return calculate_stages(players, format, courts)
@router.get("/{event_id}")
def get_event(event_id: int, db: Session = Depends(get_db)):
event = _event_with_details(event_id, db)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
stages_data = []
for stage in event.stages:
matches_data = []
for m in stage.matches:
p1 = db.query(Player).filter(Player.id == m.player1_id).first() if m.player1_id else None
p2 = db.query(Player).filter(Player.id == m.player2_id).first() if m.player2_id else None
court = db.query(Court).filter(Court.id == m.court_id).first() if m.court_id else None
matches_data.append({
"id": m.id, "round_number": m.round_number, "match_number": m.match_number,
"player1_id": m.player1_id, "player2_id": m.player2_id,
"team1_players": m.team1_players, "team2_players": m.team2_players,
"player1_name": p1.name if p1 else None,
"player2_name": p2.name if p2 else None,
"score1": m.score1, "score2": m.score2,
"winner_id": m.winner_id, "winner_team": m.winner_team,
"status": m.status, "bracket_position": m.bracket_position,
"next_winner_match_id": m.next_winner_match_id,
"next_loser_match_id": m.next_loser_match_id,
"court_id": m.court_id,
"court_name": court.name if court else None,
"started_at": m.started_at, "completed_at": m.completed_at,
})
stages_data.append({
"id": stage.id, "name": stage.name, "match_type": stage.match_type,
"rounds": stage.rounds, "advance_count": stage.advance_count,
"status": stage.status, "order": stage.order,
"matches": matches_data,
})
players_data = [{
"id": ep.id, "player_id": ep.player_id,
"skill_level_override": ep.skill_level_override,
"player": {"id": ep.player.id, "name": ep.player.name,
"skill_level": ep.player.skill_level, "elo": ep.player.elo,
"matches_played": ep.player.matches_played,
"wins": ep.player.wins, "losses": ep.player.losses}
} for ep in event.event_players]
return {
"id": event.id, "name": event.name, "format": event.format,
"courts_count": event.courts_count, "status": event.status,
"current_stage_id": event.current_stage_id,
"created_at": event.created_at, "started_at": event.started_at,
"players": players_data,
"stages": stages_data,
}
@router.put("/{event_id}")
def update_event(event_id: int, data: EventUpdate, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
for k, v in data.model_dump(exclude_none=True).items():
setattr(event, k, v)
db.commit()
return {"ok": True}
@router.post("/{event_id}/players")
def add_player_to_event(event_id: int, data: EventPlayerAdd, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
player = db.query(Player).filter(Player.id == data.player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
existing = db.query(EventPlayer).filter(
EventPlayer.event_id == event_id, EventPlayer.player_id == data.player_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Player already in event")
ep = EventPlayer(event_id=event_id, player_id=data.player_id,
skill_level_override=data.skill_level_override)
db.add(ep)
db.commit()
db.refresh(ep)
return {"id": ep.id, "player_id": ep.player_id}
@router.delete("/{event_id}/players/{player_id}")
def remove_player_from_event(event_id: int, player_id: int, db: Session = Depends(get_db)):
ep = db.query(EventPlayer).filter(
EventPlayer.event_id == event_id, EventPlayer.player_id == player_id
).first()
if not ep:
raise HTTPException(status_code=404, detail="Player not in event")
db.delete(ep)
db.commit()
return {"ok": True}
@router.post("/{event_id}/stages")
def add_stage(event_id: int, data: StageCreate, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
stage = Stage(event_id=event_id, **data.model_dump())
db.add(stage)
db.commit()
db.refresh(stage)
return {"id": stage.id, "name": stage.name, "match_type": stage.match_type,
"rounds": stage.rounds, "advance_count": stage.advance_count, "status": stage.status}
@router.put("/{event_id}/stages/{stage_id}")
def update_stage(event_id: int, stage_id: int, data: StageUpdate, db: Session = Depends(get_db)):
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
if not stage:
raise HTTPException(status_code=404, detail="Stage not found")
for k, v in data.model_dump(exclude_none=True).items():
setattr(stage, k, v)
db.commit()
return {"ok": True}
@router.post("/{event_id}/stages/{stage_id}/generate")
def generate_stage_matches(event_id: int, stage_id: int, db: Session = Depends(get_db)):
"""Generate matches for a stage"""
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
if not stage:
raise HTTPException(status_code=404, detail="Stage not found")
event = db.query(Event).filter(Event.id == event_id).first()
# Delete existing matches for this stage
db.query(Match).filter(Match.stage_id == stage_id).delete()
db.commit()
# Get players for this event
event_players = db.query(EventPlayer).filter(EventPlayer.event_id == event_id).all()
player_ids = [ep.player_id for ep in event_players]
if len(player_ids) < 2:
raise HTTPException(status_code=400, detail="Need at least 2 players")
match_type = stage.match_type
matches_to_create = []
if match_type in ["Open", "Open Match"]:
matches_raw = generate_open_schedule(player_ids, stage.rounds, event.courts_count)
for m in matches_raw:
matches_to_create.append(Match(
stage_id=stage_id,
round_number=m["round_number"],
match_number=m["match_number"],
player1_id=m["player1_id"],
player2_id=m["player2_id"],
status=m["status"],
bracket_position=m.get("bracket_position", "main"),
))
elif match_type == "Round Robin":
pairings = generate_round_robin(player_ids)
for i, (p1, p2) in enumerate(pairings):
matches_to_create.append(Match(
stage_id=stage_id,
round_number=1,
match_number=i + 1,
player1_id=p1,
player2_id=p2,
status="scheduled",
bracket_position="main",
))
elif match_type in ["Tournament", "Double Elimination"]:
bracket_matches = generate_single_elim_bracket(player_ids)
# Create matches and track temp IDs
created = {}
# First pass - create all match objects
db_matches = []
for bm in bracket_matches:
m = Match(
stage_id=stage_id,
round_number=bm["round_number"],
match_number=bm["match_number"],
player1_id=bm.get("player1_id"),
player2_id=bm.get("player2_id"),
status=bm["status"],
bracket_position=bm.get("bracket_position", "winners"),
)
db.add(m)
db.flush()
created[bm["temp_id"]] = m
db_matches.append((bm, m))
# Second pass - link next_winner_match_id
for bm, m in db_matches:
if bm.get("next_winner_temp_id") and bm["next_winner_temp_id"] in created:
m.next_winner_match_id = created[bm["next_winner_temp_id"]].id
db.commit()
return {"ok": True, "match_count": len(bracket_matches)}
elif match_type == "Skill-Based":
# Group players by skill level and create matches within same skill groups
players = db.query(Player).filter(Player.id.in_(player_ids)).all()
skill_order = {"Beginner": 0, "Intermediate": 1, "Advanced": 2, "Elite": 3}
players_sorted = sorted(players, key=lambda p: skill_order.get(p.skill_level, 0))
sorted_ids = [p.id for p in players_sorted]
pairings = generate_round_robin(sorted_ids)[:len(sorted_ids)]
for i, (p1, p2) in enumerate(pairings):
matches_to_create.append(Match(
stage_id=stage_id,
round_number=1,
match_number=i + 1,
player1_id=p1,
player2_id=p2,
status="scheduled",
bracket_position="main",
))
for m in matches_to_create:
db.add(m)
db.commit()
return {"ok": True, "match_count": len(matches_to_create)}
@router.post("/{event_id}/start")
def start_event(event_id: int, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
event.status = "active"
event.started_at = datetime.utcnow()
# Activate first stage
first_stage = db.query(Stage).filter(Stage.event_id == event_id).order_by(Stage.order).first()
if first_stage:
first_stage.status = "active"
event.current_stage_id = first_stage.id
db.commit()
return {"ok": True}
@router.post("/{event_id}/complete")
def complete_event(event_id: int, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
event.status = "completed"
event.completed_at = datetime.utcnow()
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,156 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime
from ..database import get_db
from ..models import Match, Player, Court, Stage
from ..schemas import MatchScoreUpdate, MatchCourtAssign
router = APIRouter(prefix="/api/matches", tags=["matches"])
def _match_detail(match: Match, db: Session) -> dict:
p1 = db.query(Player).filter(Player.id == match.player1_id).first() if match.player1_id else None
p2 = db.query(Player).filter(Player.id == match.player2_id).first() if match.player2_id else None
court = db.query(Court).filter(Court.id == match.court_id).first() if match.court_id else None
return {
"id": match.id,
"stage_id": match.stage_id,
"court_id": match.court_id,
"court_name": court.name if court else None,
"round_number": match.round_number,
"match_number": match.match_number,
"player1_id": match.player1_id,
"player2_id": match.player2_id,
"player1_name": p1.name if p1 else None,
"player2_name": p2.name if p2 else None,
"team1_players": match.team1_players,
"team2_players": match.team2_players,
"score1": match.score1,
"score2": match.score2,
"winner_id": match.winner_id,
"winner_team": match.winner_team,
"status": match.status,
"bracket_position": match.bracket_position,
"next_winner_match_id": match.next_winner_match_id,
"next_loser_match_id": match.next_loser_match_id,
"started_at": match.started_at,
"completed_at": match.completed_at,
}
@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_detail(match, db)
@router.post("/{match_id}/start")
def start_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")
match.status = "in_progress"
match.started_at = datetime.utcnow()
# Auto-assign available court if none assigned
if not match.court_id:
stage = db.query(Stage).filter(Stage.id == match.stage_id).first()
if stage:
# Find available court
in_use_court_ids = db.query(Match.court_id).filter(
Match.status == "in_progress", Match.court_id.isnot(None)
).all()
in_use_ids = [r[0] for r in in_use_court_ids]
available_court = db.query(Court).filter(
Court.status.in_(["available", "reserved"]),
Court.id.notin_(in_use_ids)
).first()
if available_court:
match.court_id = available_court.id
available_court.status = "in_use"
db.commit()
return _match_detail(match, db)
@router.post("/{match_id}/score")
def enter_score(match_id: int, data: MatchScoreUpdate, 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")
match.score1 = data.score1
match.score2 = data.score2
match.status = "completed"
match.completed_at = datetime.utcnow()
# Determine winner
if data.score1 > data.score2:
match.winner_id = match.player1_id
match.winner_team = 1
winner_id = match.player1_id
loser_id = match.player2_id
else:
match.winner_id = match.player2_id
match.winner_team = 2
winner_id = match.player2_id
loser_id = match.player1_id
# Free up court
if match.court_id:
court = db.query(Court).filter(Court.id == match.court_id).first()
if court:
court.status = "available"
# Update player stats
if winner_id:
winner = db.query(Player).filter(Player.id == winner_id).first()
if winner:
winner.wins += 1
winner.matches_played += 1
winner.elo += 20 # Simple ELO gain
if loser_id:
loser = db.query(Player).filter(Player.id == loser_id).first()
if loser:
loser.losses += 1
loser.matches_played += 1
loser.elo = max(800, loser.elo - 15)
# Advance bracket: place winner into next match
if match.next_winner_match_id:
next_match = db.query(Match).filter(Match.id == match.next_winner_match_id).first()
if next_match:
if next_match.player1_id is None:
next_match.player1_id = winner_id
elif next_match.player2_id is None:
next_match.player2_id = winner_id
db.commit()
return _match_detail(match, db)
@router.patch("/{match_id}/court")
def assign_court(match_id: int, data: MatchCourtAssign, 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")
# Free old court
if match.court_id:
old_court = db.query(Court).filter(Court.id == match.court_id).first()
if old_court and old_court.status == "in_use":
old_court.status = "available"
match.court_id = data.court_id
# Mark new court in use if match is in progress
if data.court_id and match.status == "in_progress":
new_court = db.query(Court).filter(Court.id == data.court_id).first()
if new_court:
new_court.status = "in_use"
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from ..database import get_db
from ..models import Player
from ..schemas import PlayerCreate, PlayerUpdate, PlayerOut
router = APIRouter(prefix="/api/players", tags=["players"])
@router.get("/", response_model=List[PlayerOut])
def list_players(db: Session = Depends(get_db)):
return db.query(Player).order_by(Player.elo.desc()).all()
@router.post("/", response_model=PlayerOut)
def create_player(data: PlayerCreate, db: Session = Depends(get_db)):
player = Player(**data.model_dump())
db.add(player)
db.commit()
db.refresh(player)
return player
@router.get("/{player_id}", response_model=PlayerOut)
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 player
@router.put("/{player_id}", response_model=PlayerOut)
def update_player(player_id: int, data: PlayerUpdate, 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")
for k, v in data.model_dump(exclude_none=True).items():
setattr(player, k, v)
db.commit()
db.refresh(player)
return player
@router.delete("/{player_id}")
def delete_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")
db.delete(player)
db.commit()
return {"ok": True}

163
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,163 @@
from typing import Optional, List
from pydantic import BaseModel
from datetime import datetime
# Player schemas
class PlayerCreate(BaseModel):
name: str
contact: Optional[str] = None
skill_level: str = "Beginner"
class PlayerUpdate(BaseModel):
name: Optional[str] = None
contact: Optional[str] = None
skill_level: Optional[str] = None
class PlayerOut(BaseModel):
id: int
name: str
contact: Optional[str]
skill_level: str
matches_played: int
wins: int
losses: int
elo: float
created_at: datetime
class Config:
from_attributes = True
# Court schemas
class CourtCreate(BaseModel):
name: str
court_type: str = "Sport Court"
class CourtStatusUpdate(BaseModel):
status: str # available/in_use/reserved/maintenance
class ReservationCreate(BaseModel):
player_id: Optional[int] = None
group_name: Optional[str] = None
start_time: datetime
end_time: datetime
notes: Optional[str] = None
class ReservationOut(BaseModel):
id: int
court_id: int
player_id: Optional[int]
group_name: Optional[str]
start_time: datetime
end_time: datetime
notes: Optional[str]
player: Optional[PlayerOut] = None
class Config:
from_attributes = True
class CourtOut(BaseModel):
id: int
name: str
court_type: str
status: str
reservations: List[ReservationOut] = []
class Config:
from_attributes = True
# Event schemas
class EventCreate(BaseModel):
name: str
format: str = "Singles"
courts_count: int = 1
class EventUpdate(BaseModel):
name: Optional[str] = None
format: Optional[str] = None
courts_count: Optional[int] = None
class EventPlayerAdd(BaseModel):
player_id: int
skill_level_override: Optional[str] = None
class EventPlayerOut(BaseModel):
id: int
player_id: int
skill_level_override: Optional[str]
player: PlayerOut
class Config:
from_attributes = True
# Stage schemas
class StageCreate(BaseModel):
name: str
match_type: str # Open/Skill-Based/Round Robin/Tournament
rounds: int = 1
advance_count: int = 0
order: int = 0
class StageUpdate(BaseModel):
name: Optional[str] = None
match_type: Optional[str] = None
rounds: Optional[int] = None
advance_count: Optional[int] = None
class StageOut(BaseModel):
id: int
event_id: int
name: str
match_type: str
rounds: int
advance_count: int
status: str
order: int
class Config:
from_attributes = True
# Match schemas
class MatchScoreUpdate(BaseModel):
score1: int
score2: int
class MatchCourtAssign(BaseModel):
court_id: Optional[int]
class MatchOut(BaseModel):
id: int
stage_id: int
court_id: Optional[int]
round_number: int
match_number: int
player1_id: Optional[int]
player2_id: Optional[int]
team1_players: Optional[List[int]]
team2_players: Optional[List[int]]
score1: Optional[int]
score2: Optional[int]
winner_id: Optional[int]
winner_team: Optional[int]
status: str
bracket_position: Optional[str]
next_winner_match_id: Optional[int]
next_loser_match_id: Optional[int]
scheduled_time: Optional[datetime]
started_at: Optional[datetime]
completed_at: Optional[datetime]
player1: Optional[PlayerOut] = None
player2: Optional[PlayerOut] = None
class Config:
from_attributes = True
# Dashboard schema
class DashboardCourtData(BaseModel):
court: CourtOut
current_match: Optional[MatchOut] = None
elapsed_seconds: Optional[int] = None
class DashboardData(BaseModel):
courts: List[DashboardCourtData]
upcoming_matches: List[MatchOut]
active_event: Optional[dict] = None

View File

@@ -1,53 +0,0 @@
"""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

@@ -1,214 +0,0 @@
"""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

@@ -1,281 +0,0 @@
"""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

@@ -0,0 +1,40 @@
from typing import Dict, List
from fastapi import WebSocket
import json
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:
try:
self.active_connections[channel].remove(websocket)
except ValueError:
pass
async def broadcast(self, channel: str, data: dict):
if channel not in self.active_connections:
return
dead = []
for ws in self.active_connections[channel]:
try:
await ws.send_json(data)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, channel)
async def broadcast_all(self, data: dict):
for channel in list(self.active_connections.keys()):
await self.broadcast(channel, data)
manager = ConnectionManager()

View File

@@ -1,13 +1,11 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
alembic==1.13.1
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
redis==5.0.4
arq==0.25.0
redis==5.0.1
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
python-multipart==0.0.7
pydantic==2.6.0
alembic==1.13.1
httpx==0.26.0
arq==0.25.0

View File

@@ -1,439 +1,325 @@
"""Seed demo data for ServeSync"""
import sys
"""
Seed data for ServeSync Demo
- 8 players with varied skills
- 4 courts
- 1 completed event (Spring Singles Open)
- 1 in-progress event (Summer Tournament - Round 2)
- Sample court reservations
"""
import os
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
import sys
from datetime import datetime, timedelta
import random
sys.path.insert(0, "/app")
os.environ.setdefault("DATABASE_URL", "postgresql://servesync:servesync@db:5432/servesync")
def clear_data(db: Session):
db.query(TournamentMatch).delete()
db.query(TournamentEntry).delete()
db.query(Tournament).delete()
db.query(MatchPlayer).delete()
from app.database import engine, SessionLocal
from app.models import Base, Player, Court, CourtReservation, Event, EventPlayer, Stage, Match
Base.metadata.create_all(bind=engine)
db = SessionLocal()
# Clear existing data
print("Clearing existing data...")
db.query(Match).delete()
db.query(Booking).delete()
db.query(Stage).delete()
db.query(EventPlayer).delete()
db.query(Event).delete()
db.query(CourtReservation).delete()
db.query(Court).delete()
db.query(Player).delete()
db.commit()
# Reset sequences
for table in ['players', 'courts', 'bookings', 'matches', 'match_players',
'tournaments', 'tournament_entries', 'tournament_matches']:
try:
db.execute(__import__('sqlalchemy').text(f"ALTER SEQUENCE {table}_id_seq RESTART WITH 1"))
except Exception:
pass
db.commit()
print("Cleared existing data")
def seed_players(db: Session) -> list:
# ─── PLAYERS ────────────────────────────────────────────────────────────────
print("Creating players...")
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},
{"name": "Marco Reyes", "contact": "marco@example.com", "skill_level": "Elite", "elo": 1852, "matches_played": 48, "wins": 38, "losses": 10},
{"name": "Ana Santos", "contact": "ana@example.com", "skill_level": "Advanced", "elo": 1645, "matches_played": 35, "wins": 24, "losses": 11},
{"name": "Carlos Tan", "contact": "carlos@example.com", "skill_level": "Advanced", "elo": 1583, "matches_played": 30, "wins": 20, "losses": 10},
{"name": "Diana Flores", "contact": "diana@example.com", "skill_level": "Intermediate", "elo": 1402, "matches_played": 22, "wins": 13, "losses": 9},
{"name": "Tony Garcia", "contact": "tony@example.com", "skill_level": "Intermediate", "elo": 1281, "matches_played": 18, "wins": 9, "losses": 9},
{"name": "Lisa Cruz", "contact": "lisa@example.com", "skill_level": "Intermediate", "elo": 1318, "matches_played": 15, "wins": 8, "losses": 7},
{"name": "Jake Torres", "contact": "jake@example.com", "skill_level": "Beginner", "elo": 883, "matches_played": 8, "wins": 3, "losses": 5},
{"name": "Maria Lim", "contact": "maria@example.com", "skill_level": "Beginner", "elo": 947, "matches_played": 10, "wins": 4, "losses": 6},
]
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"],
)
p = Player(**pd)
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 ─────────────────────────────────────────────────────────────────
print("Creating courts...")
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"},
{"name": "Court A", "court_type": "Sport Court Premium", "status": "available"},
{"name": "Court B", "court_type": "Sport Court Premium", "status": "available"},
{"name": "Court C", "court_type": "Cushioned Tournament Grade", "status": "in_use"},
{"name": "Court D", "court_type": "Cushioned Tournament Grade", "status": "in_use"},
]
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"],
)
c = Court(**cd)
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):
# ─── RESERVATIONS ───────────────────────────────────────────────────────────
print("Creating reservations...")
now = datetime.utcnow()
bookings = []
today_2pm = now.replace(hour=6, minute=0, second=0, microsecond=0) + timedelta(hours=8) # 2pm PHT = 6am UTC
today_4pm = today_2pm + timedelta(hours=2)
tomorrow_10am = (now + timedelta(days=1)).replace(hour=2, minute=0, second=0, microsecond=0)
tomorrow_12pm = tomorrow_10am + timedelta(hours=2)
# 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
reservations = [
CourtReservation(court_id=courts[0].id, player_id=players[5].id,
group_name="Lisa Cruz Group", start_time=today_2pm, end_time=today_4pm,
notes="Training session"),
CourtReservation(court_id=courts[1].id, group_name="Maintenance Crew",
start_time=tomorrow_10am, end_time=tomorrow_12pm,
notes="Net replacement and floor inspection"),
]
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
for r in reservations:
db.add(r)
db.commit()
db.refresh(t)
print(f"Created tournament: {t.name}")
return t
# ─── EVENT 1: SPRING SINGLES OPEN (Completed) ────────────────────────────────
print("Creating completed event...")
event1 = Event(
name="Spring Singles Open",
format="Singles",
courts_count=2,
status="completed",
created_at=now - timedelta(days=7),
started_at=now - timedelta(days=7, hours=2),
completed_at=now - timedelta(days=7),
)
db.add(event1)
db.commit()
db.refresh(event1)
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)
# Add all 8 players to event1
for p in players:
ep = EventPlayer(event_id=event1.id, player_id=p.id, skill_level_override=p.skill_level)
db.add(ep)
db.commit()
# Stage 1 - Open Stage (completed)
stage1_e1 = Stage(event_id=event1.id, name="Open Stage", match_type="Open",
rounds=2, advance_count=4, status="completed", order=0)
db.add(stage1_e1)
db.commit()
db.refresh(stage1_e1)
# Stage 2 - Tournament (completed)
stage2_e1 = Stage(event_id=event1.id, name="Finals Tournament", match_type="Tournament",
rounds=3, advance_count=0, status="completed", order=1)
db.add(stage2_e1)
db.commit()
db.refresh(stage2_e1)
# Create completed open stage matches
open_matches = [
(players[0], players[7], 21, 8), # Marco vs Maria
(players[1], players[6], 21, 14), # Ana vs Jake
(players[2], players[5], 21, 11), # Carlos vs Lisa
(players[3], players[4], 21, 18), # Diana vs Tony
# Round 2
(players[0], players[6], 21, 10), # Marco vs Jake
(players[1], players[4], 21, 16), # Ana vs Tony
(players[2], players[7], 21, 9), # Carlos vs Maria
(players[3], players[5], 18, 21), # Diana vs Lisa (Lisa wins!)
]
completed_at_base = now - timedelta(days=7, hours=1, minutes=30)
for i, (p1, p2, s1, s2) in enumerate(open_matches):
winner = p1 if s1 > s2 else p2
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),
stage_id=stage1_e1.id,
court_id=courts[i % 2].id,
round_number=1 if i < 4 else 2,
match_number=(i % 4) + 1,
player1_id=p1.id, player2_id=p2.id,
score1=s1, score2=s2,
winner_id=winner.id,
status="completed",
bracket_position="main",
started_at=completed_at_base + timedelta(minutes=i * 20),
completed_at=completed_at_base + timedelta(minutes=i * 20 + 18),
)
db.add(m)
db.flush()
db.commit()
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)
# Tournament bracket for event 1 (QF, SF, Final)
# Top 4 by round wins: Marco, Ana, Carlos, Lisa
top4 = [players[0], players[1], players[2], players[5]] # Marco, Ana, Carlos, Lisa
# QF matches
qf1 = Match(stage_id=stage2_e1.id, court_id=courts[0].id, round_number=1, match_number=1,
player1_id=top4[0].id, player2_id=top4[3].id, score1=21, score2=13,
winner_id=top4[0].id, status="completed", bracket_position="winners",
started_at=now - timedelta(days=7, minutes=90),
completed_at=now - timedelta(days=7, minutes=72))
qf2 = Match(stage_id=stage2_e1.id, court_id=courts[1].id, round_number=1, match_number=2,
player1_id=top4[1].id, player2_id=top4[2].id, score1=21, score2=19,
winner_id=top4[1].id, status="completed", bracket_position="winners",
started_at=now - timedelta(days=7, minutes=90),
completed_at=now - timedelta(days=7, minutes=70))
db.add(qf1); db.add(qf2); db.commit()
db.refresh(qf1); db.refresh(qf2)
# Final
final = Match(stage_id=stage2_e1.id, court_id=courts[2].id, round_number=2, match_number=1,
player1_id=top4[0].id, player2_id=top4[1].id, score1=21, score2=17,
winner_id=top4[0].id, status="completed", bracket_position="winners",
started_at=now - timedelta(days=7, minutes=45),
completed_at=now - timedelta(days=7, minutes=25))
db.add(final); db.commit()
# Link bracket
qf1.next_winner_match_id = final.id
qf2.next_winner_match_id = final.id
event1.current_stage_id = stage2_e1.id
db.commit()
print(f" Completed event created: {event1.name}")
# ─── EVENT 2: SUMMER TOURNAMENT (In Progress, Round 2) ───────────────────────
print("Creating in-progress event...")
event2 = Event(
name="Summer Tournament",
format="Singles",
courts_count=2,
status="active",
created_at=now - timedelta(hours=3),
started_at=now - timedelta(hours=2),
)
db.add(event2)
db.commit()
db.refresh(event2)
# Add 8 players
for p in players:
ep = EventPlayer(event_id=event2.id, player_id=p.id)
db.add(ep)
db.commit()
# Stage 1: Open Stage (completed)
stage1_e2 = Stage(event_id=event2.id, name="Open Stage", match_type="Open",
rounds=1, advance_count=4, status="completed", order=0)
db.add(stage1_e2)
db.commit()
db.refresh(stage1_e2)
# Stage 1 completed matches
s1_matches = [
(players[0], players[7], 21, 6, "completed"),
(players[1], players[6], 21, 11, "completed"),
(players[2], players[5], 21, 15, "completed"),
(players[3], players[4], 21, 19, "completed"),
]
for i, (p1, p2, s1, s2, status) in enumerate(s1_matches):
winner = p1 if s1 > s2 else p2
m = Match(
stage_id=stage1_e2.id,
court_id=courts[i % 2].id,
round_number=1, match_number=i + 1,
player1_id=p1.id, player2_id=p2.id,
score1=s1, score2=s2, winner_id=winner.id,
status=status, bracket_position="main",
started_at=now - timedelta(hours=2, minutes=30),
completed_at=now - timedelta(hours=1, minutes=45),
)
db.add(m)
db.commit()
# Stage 2: Tournament (Active, Round 2 in progress)
stage2_e2 = Stage(event_id=event2.id, name="Finals Tournament", match_type="Tournament",
rounds=3, advance_count=0, status="active", order=1)
db.add(stage2_e2)
db.commit()
db.refresh(stage2_e2)
event2.current_stage_id = stage2_e2.id
# Tournament bracket - 8 players → QF (R1), SF (R2), Final (R3)
# QF: Round 1 (all completed)
qf_matches = [
(players[0], players[7], 21, 8, "completed", courts[0].id),
(players[1], players[6], 21, 13, "completed", courts[1].id),
(players[2], players[5], 21, 16, "completed", None),
(players[3], players[4], 19, 21, "completed", None), # Tony beats Diana!
]
# SF: Round 2 (2 in progress, 0 completed)
sf_matches = [
(players[0], players[1], None, None, "in_progress", courts[2].id), # Marco vs Ana - LIVE
(players[2], players[4], None, None, "in_progress", courts[3].id), # Carlos vs Tony - LIVE
]
# Final: Round 3 (scheduled)
final_e2 = Match(
stage_id=stage2_e2.id, court_id=None,
round_number=3, match_number=1,
player1_id=None, player2_id=None,
status="scheduled", bracket_position="winners",
)
db.add(final_e2); db.flush(); db.refresh(final_e2)
# Create QF matches
qf_db = []
for i, (p1, p2, s1, s2, status, court_id) in enumerate(qf_matches):
winner = p1 if (s1 and s1 > s2) else p2
m = Match(
stage_id=stage2_e2.id, court_id=court_id,
round_number=1, match_number=i + 1,
player1_id=p1.id, player2_id=p2.id,
score1=s1, score2=s2, winner_id=winner.id if status == "completed" else None,
status=status, bracket_position="winners",
started_at=now - timedelta(hours=1, minutes=30),
completed_at=now - timedelta(hours=1) if status == "completed" else None,
)
db.add(m)
qf_db.append(m)
db.commit()
print(f"Created {len(past_matches)} completed matches")
for m in qf_db:
db.refresh(m)
# Create SF matches
sf_db = []
for i, (p1, p2, s1, s2, status, court_id) in enumerate(sf_matches):
m = Match(
stage_id=stage2_e2.id, court_id=court_id,
round_number=2, match_number=i + 1,
player1_id=p1.id, player2_id=p2.id,
score1=s1, score2=s2,
status=status, bracket_position="winners",
started_at=now - timedelta(minutes=15) if status == "in_progress" else None,
next_winner_match_id=final_e2.id,
)
db.add(m)
sf_db.append(m)
db.commit()
def main():
print("🌱 Seeding ServeSync demo data...")
# Link QF to SF
qf_db[0].next_winner_match_id = sf_db[0].id
qf_db[1].next_winner_match_id = sf_db[0].id
qf_db[2].next_winner_match_id = sf_db[1].id
qf_db[3].next_winner_match_id = sf_db[1].id
db.commit()
print(f" In-progress event created: {event2.name}")
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()
print("\n✅ Seed complete!")
print(f" Players: {len(players)}")
print(f" Courts: {len(courts)}")
print(f" Events: 2 (1 completed, 1 active)")
print(f" Courts C & D: in_use (live matches)")

View File

@@ -1,21 +1,14 @@
#!/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..."
cd /app && python seed.py && echo "Seed complete" || echo "Seed failed (continuing)"
echo "Waiting for database..."
sleep 3
echo "Running migrations..."
python -c "from app.database import engine; from app.models import Base; Base.metadata.create_all(bind=engine)"
echo "Seeding data..."
python /app/seed.py || echo "Seed already exists or failed (non-fatal)"
echo "Starting server..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1

View File

@@ -1,7 +1,6 @@
FROM node:20-alpine AS builder
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
@@ -9,6 +8,7 @@ COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -2,9 +2,9 @@
<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>
<title>ServeSync — Court Manager</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏓</text></svg>"/>
</head>
<body>
<div id="app"></div>

View File

@@ -1,7 +1,6 @@
{
"name": "servesync-frontend",
"version": "1.0.0",
"type": "module",
"version": "2.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -9,15 +8,16 @@
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.7"
"@vueuse/core": "^10.7.0",
"axios": "^1.6.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0",
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.1.0",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35"
}
}

View File

@@ -1,105 +1,71 @@
<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">
<!-- Hide nav on screen view -->
<nav v-if="!isScreenView" class="bg-gray-900 border-b border-gray-800 px-4 py-3">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div 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"
<span class="text-xl font-bold text-white">ServeSync</span>
<span class="text-xs text-gray-500 ml-1">Court Manager</span>
</div>
<div class="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 }}
:class="isActive(link.to)
? 'bg-green-900 text-green-300'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'"
>
<span class="mr-1.5">{{ link.icon }}</span>{{ 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 class="flex items-center gap-2">
<span class="text-xs text-gray-600">{{ timeStr }}</span>
<router-link to="/events/new" class="btn-primary btn-sm">
+ New Event
</router-link>
</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 -->
<main :class="isScreenView ? '' : 'max-w-7xl mx-auto px-4 py-6'">
<router-view />
</main>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } 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: '👥' },
{ to: '/', icon: '📺', label: 'Dashboard' },
{ to: '/players', icon: '👥', label: 'Players' },
{ to: '/courts', icon: '🏟️', label: 'Courts' },
{ to: '/events', icon: '🎮', label: 'Events' },
]
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 isScreenView = computed(() => route.path.startsWith('/screen'))
const isActive = (path) => {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
const switchPlayer = (playerId) => {
const player = store.players.find(p => p.id == playerId)
if (player) store.setCurrentPlayer(player)
}
const timeStr = ref('')
let timer = null
onMounted(async () => {
await store.loadPlayers()
onMounted(() => {
const update = () => {
timeStr.value = new Date().toLocaleTimeString('en-PH', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
}
update()
timer = setInterval(update, 1000)
})
onUnmounted(() => clearInterval(timer))
</script>

View File

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

View File

@@ -1,23 +1,16 @@
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 },
{ path: '/', component: () => import('../views/Dashboard.vue'), name: 'dashboard' },
{ path: '/players', component: () => import('../views/Players.vue'), name: 'players' },
{ path: '/courts', component: () => import('../views/Courts.vue'), name: 'courts' },
{ path: '/events', component: () => import('../views/Events.vue'), name: 'events' },
{ path: '/events/new', component: () => import('../views/EventBuilder.vue'), name: 'event-new' },
{ path: '/events/:id', component: () => import('../views/EventControl.vue'), name: 'event-detail' },
{ path: '/screen/:id', component: () => import('../views/Screen.vue'), name: 'screen' },
]
const router = createRouter({
export default createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@@ -0,0 +1,53 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export default api
export function skillBadgeClass(level) {
const map = {
'Elite': 'badge-purple',
'Advanced': 'badge-blue',
'Intermediate': 'badge-green',
'Beginner': 'badge-gray',
}
return map[level] || 'badge-gray'
}
export function courtStatusClass(status) {
const map = {
'available': 'badge-green',
'in_use': 'badge-yellow',
'reserved': 'badge-blue',
'maintenance': 'badge-red',
}
return map[status] || 'badge-gray'
}
export function eventStatusClass(status) {
const map = {
'setup': 'badge-gray',
'active': 'badge-green',
'completed': 'badge-blue',
}
return map[status] || 'badge-gray'
}
export function formatElapsed(seconds) {
if (!seconds && seconds !== 0) return '--'
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, '0')}`
}
export function formatTime(dt) {
if (!dt) return ''
const d = new Date(dt)
return d.toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit' })
}
export function formatDate(dt) {
if (!dt) return ''
const d = new Date(dt)
return d.toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })
}

View File

@@ -1,125 +0,0 @@
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,
}
})

View File

@@ -4,39 +4,42 @@
@layer base {
body {
@apply bg-gray-950 text-gray-100 font-sans;
@apply bg-gray-950 text-gray-100 min-h-screen;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 cursor-pointer;
@apply px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-green-500 hover:bg-green-400 text-white;
@apply btn bg-green-600 hover:bg-green-500 text-white;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white;
@apply btn bg-gray-700 hover:bg-gray-600 text-gray-100;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-500 text-white;
@apply btn bg-red-700 hover:bg-red-600 text-white;
}
.btn-sm {
@apply px-2.5 py-1.5 text-sm;
}
.card {
@apply bg-gray-900 rounded-xl border border-gray-800 p-6;
@apply bg-gray-900 border border-gray-800 rounded-xl;
}
.input {
@apply bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-green-500 w-full;
}
.label {
@apply block text-sm text-gray-400 mb-1;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-green { @apply badge bg-green-900 text-green-300; }
.badge-yellow { @apply badge bg-yellow-900 text-yellow-300; }
.badge-red { @apply badge bg-red-900 text-red-300; }
.badge-blue { @apply badge bg-blue-900 text-blue-300; }
.badge-gray { @apply badge bg-gray-800 text-gray-400; }
.badge-purple { @apply badge bg-purple-900 text-purple-300; }
}
.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

@@ -1,182 +1,148 @@
<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 class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Court Management</h1>
<p class="text-gray-500 text-sm">Manage courts and reservations</p>
</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' }}
</div>
<div class="grid grid-cols-2 gap-6">
<div v-for="court in courts" :key="court.id" class="card p-5">
<!-- Header -->
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-lg font-bold text-white">{{ court.name }}</h2>
<p class="text-xs text-gray-500">{{ court.court_type }}</p>
</div>
<span :class="courtStatusClass(court.status)" class="capitalize">
{{ court.status === 'in_use' ? 'In Use' : court.status }}
</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)}` }}
<!-- Status override -->
<div class="flex gap-2 mb-4">
<button v-for="s in statuses" :key="s.val"
@click="setStatus(court.id, s.val)"
:class="court.status === s.val ? 'ring-2 ring-green-500 ' + s.cls : s.cls"
class="btn-sm text-xs flex-1 rounded-lg py-1.5">
{{ s.label }}
</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 -->
<!-- Reservations -->
<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 class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-400">Reservations</h3>
<button @click="openReserve(court)" class="text-xs text-green-400 hover:text-green-300">+ Reserve</button>
</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 v-if="court.reservations && court.reservations.length === 0" class="text-xs text-gray-600 italic">No reservations</div>
<div v-else class="space-y-2">
<div v-for="r in court.reservations" :key="r.id"
class="bg-gray-800 rounded-lg p-3 flex items-center justify-between">
<div>
<div class="text-sm font-medium text-white">{{ booking.player_name }}</div>
<div class="text-xs text-gray-400">{{ booking.court_name }}</div>
<p class="text-sm text-white">{{ r.group_name || r.player?.name || 'Unknown' }}</p>
<p class="text-xs text-gray-500">
{{ formatTime(r.start_time) }} {{ formatTime(r.end_time) }}
{{ formatDate(r.start_time) }}
</p>
<p v-if="r.notes" class="text-xs text-gray-600">{{ r.notes }}</p>
</div>
<button @click="deleteReservation(r.id)" class="text-xs text-red-500 hover:text-red-400 ml-3"></button>
</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 }}
<!-- Reserve Modal -->
<div v-if="reserveModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="reserveModal = null">
<div class="card p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">Reserve {{ reserveModal.court.name }}</h3>
<div class="space-y-4">
<div>
<label class="label">Group / Player Name</label>
<input v-model="reserveForm.group_name" class="input" placeholder="e.g., Lisa Cruz Group" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">Start Time</label>
<input v-model="reserveForm.start_time" type="datetime-local" class="input" />
</div>
<div>
<label class="label">End Time</label>
<input v-model="reserveForm.end_time" type="datetime-local" class="input" />
</div>
</div>
<div>
<label class="label">Notes</label>
<input v-model="reserveForm.notes" class="input" placeholder="Optional notes" />
</div>
</div>
<div class="flex gap-2 mt-6">
<button @click="reserveModal = null" class="btn-secondary flex-1">Cancel</button>
<button @click="submitReservation" class="btn-primary flex-1">Reserve Court</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useAppStore } from '../stores/app'
import axios from 'axios'
import { ref, onMounted } from 'vue'
import api, { courtStatusClass, formatTime, formatDate } from '../stores/api'
const store = useAppStore()
const courts = ref([])
const upcomingBookings = ref([])
const loading = reactive({})
const bookingForms = reactive({})
const successMsg = ref('')
const reserveModal = ref(null)
const reserveForm = 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 statuses = [
{ val: 'available', label: '✓ Available', cls: 'bg-green-900/50 text-green-300 hover:bg-green-900' },
{ val: 'in_use', label: '⚡ In Use', cls: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-900' },
{ val: 'reserved', label: '📅 Reserved', cls: 'bg-blue-900/50 text-blue-300 hover:bg-blue-900' },
{ val: 'maintenance', label: '🔧 Maint.', cls: 'bg-red-900/50 text-red-300 hover:bg-red-900' },
]
const formatTime = (isoString) => {
return new Date(isoString).toLocaleString('en-PH', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})
async function load() {
const { data } = await api.get('/courts/')
courts.value = data
}
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
}
async function setStatus(courtId, status) {
await api.patch(`/courts/${courtId}/status`, { status })
await load()
}
onMounted(async () => {
courts.value = await store.loadCourts()
// Init booking forms
courts.value.forEach(court => {
function openReserve(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',
const pad = n => String(n).padStart(2, '0')
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
reserveModal.value = { court }
reserveForm.value = {
group_name: '',
start_time: fmt(now),
end_time: fmt(new Date(now.getTime() + 2 * 3600000)),
notes: '',
}
}
})
try {
const res = await axios.get('/api/courts/bookings/upcoming')
upcomingBookings.value = res.data
} catch (e) {}
async function submitReservation() {
if (!reserveModal.value) return
await api.post(`/courts/${reserveModal.value.court.id}/reservations`, {
group_name: reserveForm.value.group_name || null,
start_time: new Date(reserveForm.value.start_time).toISOString(),
end_time: new Date(reserveForm.value.end_time).toISOString(),
notes: reserveForm.value.notes || null,
})
reserveModal.value = null
await load()
}
async function deleteReservation(id) {
await api.delete(`/courts/reservations/${id}`)
await load()
}
onMounted(load)
</script>

View File

@@ -1,188 +1,222 @@
<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>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Live Court View</h1>
<p class="text-gray-500 text-sm mt-0.5">Real-time status of all courts</p>
</div>
<div class="flex gap-2">
<button @click="load" class="btn-secondary btn-sm"> Refresh</button>
<button v-if="activeEvent" @click="$router.push(`/events/${activeEvent.id}`)" class="btn-primary btn-sm">
🎮 Run Event: {{ activeEvent.name }}
</button>
</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>
<!-- Active event banner -->
<div v-if="activeEvent" class="card p-4 mb-6 border-green-800 bg-green-950/30">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<span class="text-green-300 font-medium">{{ activeEvent.name }}</span>
<span class="text-gray-500 text-sm"> {{ activeEvent.format }} · Stage: {{ activeEvent.current_stage || 'Setup' }}</span>
</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>
<router-link :to="`/events/${activeEvent.id}`" class="text-green-400 text-sm hover:underline">Manage </router-link>
</div>
</div>
<router-link to="/tournament" class="btn btn-primary w-full text-center mt-3 block text-sm">
View Bracket
</router-link>
<!-- Court Grid (2x2) -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div v-for="court in courts" :key="court.id" class="card p-5"
:class="{
'border-green-700': court.status === 'available',
'border-yellow-600': court.status === 'in_use',
'border-blue-700': court.status === 'reserved',
'border-red-800': court.status === 'maintenance',
}">
<!-- Court header -->
<div class="flex items-start justify-between mb-3">
<div>
<h2 class="text-lg font-bold text-white">{{ court.name }}</h2>
<p class="text-xs text-gray-500">{{ court.court_type }}</p>
</div>
<div class="flex flex-col items-end gap-1">
<span :class="courtStatusClass(court.status)" class="capitalize">
{{ court.status === 'in_use' ? 'In Use' : court.status }}
</span>
<button @click="$router.push(`/screen/${court.id}`)"
class="text-xs text-gray-600 hover:text-gray-400">📺 Screen</button>
</div>
</div>
<!-- Match in progress -->
<div v-if="court.current_match" class="bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">Match #{{ court.current_match.id }}</span>
<span class="text-xs text-yellow-400"> {{ formatElapsed(court.current_match.elapsed_seconds) }}</span>
</div>
<div class="flex items-center justify-between">
<div class="text-center flex-1">
<p class="font-bold text-white text-sm">{{ court.current_match.player1_name || 'TBD' }}</p>
</div>
<div class="text-center px-4">
<div class="text-2xl font-mono font-bold text-white">
{{ court.current_match.score1 ?? '—' }}
<span class="text-gray-600 text-lg">:</span>
{{ court.current_match.score2 ?? '—' }}
</div>
</div>
<div class="text-center flex-1">
<p class="font-bold text-white text-sm">{{ court.current_match.player2_name || 'TBD' }}</p>
</div>
</div>
<div class="mt-3 flex gap-2 justify-center">
<button @click="openScoreModal(court.current_match)" class="btn-primary btn-sm text-xs">
Enter Score
</button>
</div>
</div>
<!-- Available / Reserved state -->
<div v-else class="bg-gray-800/50 rounded-lg p-4 text-center">
<div v-if="court.status === 'available'" class="text-green-500">
<p class="text-3xl mb-1"></p>
<p class="text-sm text-gray-400">Court Available</p>
</div>
<div v-else-if="court.status === 'reserved'">
<p class="text-2xl mb-1">📅</p>
<p class="text-sm text-gray-400">Reserved</p>
<p v-if="court.next_reservation" class="text-xs text-blue-400 mt-1">
{{ court.next_reservation.group_name }} · {{ formatTime(court.next_reservation.start_time) }}
</p>
</div>
<div v-else-if="court.status === 'maintenance'">
<p class="text-2xl mb-1">🔧</p>
<p class="text-sm text-gray-400">Under Maintenance</p>
</div>
</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>
<!-- Bottom split: Upcoming + Bracket -->
<div class="grid grid-cols-3 gap-4">
<!-- Upcoming matches -->
<div class="col-span-2 card p-4">
<h3 class="font-semibold text-gray-300 mb-3">📋 Upcoming Matches</h3>
<div v-if="upcoming.length === 0" class="text-gray-600 text-sm py-4 text-center">No upcoming matches</div>
<div v-else class="space-y-2">
<div v-for="m in upcoming" :key="m.id"
class="flex items-center justify-between p-3 bg-gray-800 rounded-lg text-sm">
<div class="flex items-center gap-3">
<span class="text-gray-500 text-xs">R{{ m.round_number }}</span>
<span class="text-white font-medium">{{ m.player1_name }}</span>
<span class="text-gray-600">vs</span>
<span class="text-white font-medium">{{ m.player2_name }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span>{{ m.stage_name }}</span>
<button @click="startMatch(m.id)" class="btn-primary btn-sm text-xs px-2 py-1"> Start</button>
</div>
</div>
</div>
</div>
<!-- Quick stats -->
<div class="card p-4">
<h3 class="font-semibold text-gray-300 mb-3">📊 Quick Stats</h3>
<div class="space-y-3">
<div class="bg-gray-800 rounded-lg p-3">
<p class="text-xs text-gray-500">Courts In Use</p>
<p class="text-2xl font-bold text-yellow-400">{{ courts.filter(c => c.status === 'in_use').length }}</p>
<p class="text-xs text-gray-600">of {{ courts.length }} total</p>
</div>
<div class="bg-gray-800 rounded-lg p-3">
<p class="text-xs text-gray-500">Live Matches</p>
<p class="text-2xl font-bold text-green-400">{{ courts.filter(c => c.current_match).length }}</p>
</div>
<div class="bg-gray-800 rounded-lg p-3">
<p class="text-xs text-gray-500">Queued Matches</p>
<p class="text-2xl font-bold text-blue-400">{{ upcoming.length }}</p>
</div>
</div>
</div>
</div>
<!-- Score Modal -->
<div v-if="scoreModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="scoreModal = null">
<div class="card p-6 w-full max-w-sm">
<h3 class="text-lg font-bold text-white mb-4">Enter Score</h3>
<div class="flex items-center gap-4 mb-6">
<div class="flex-1 text-center">
<p class="text-xs text-gray-400 mb-1">{{ scoreModal.player1_name }}</p>
<input v-model.number="scoreInput1" type="number" min="0"
class="input text-center text-2xl font-bold h-16" />
</div>
<div class="text-gray-600 text-xl font-bold">:</div>
<div class="flex-1 text-center">
<p class="text-xs text-gray-400 mb-1">{{ scoreModal.player2_name }}</p>
<input v-model.number="scoreInput2" type="number" min="0"
class="input text-center text-2xl font-bold h-16" />
</div>
</div>
<div class="flex gap-2">
<button @click="scoreModal = null" class="btn-secondary flex-1">Cancel</button>
<button @click="submitScore" class="btn-primary flex-1">Confirm</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '../stores/app'
import { ref, onMounted, onUnmounted } from 'vue'
import api, { courtStatusClass, formatElapsed, formatTime } from '../stores/api'
const store = useAppStore()
const courts = ref([])
const leaderboard = ref([])
const lobbyData = ref({ lobby: [], active: [] })
const tournament = ref(null)
const players = ref([])
const upcoming = ref([])
const activeEvent = ref(null)
const scoreModal = ref(null)
const scoreInput1 = ref(0)
const scoreInput2 = ref(0)
let timer = null
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',
async function load() {
try {
const { data } = await api.get('/dashboard/')
courts.value = data.courts
upcoming.value = data.upcoming_matches
activeEvent.value = data.active_event
} catch (e) {
console.error(e)
}
return classes[tier] || 'text-gray-400'
}
onMounted(async () => {
courts.value = await store.loadCourts()
leaderboard.value = await store.getLeaderboard(8)
players.value = await store.loadPlayers()
async function startMatch(id) {
await api.post(`/matches/${id}/start`)
await load()
}
try {
const [lobby, active] = await Promise.all([store.loadLobby(), store.loadActiveMatches()])
lobbyData.value = { lobby, active }
} catch (e) {}
function openScoreModal(match) {
scoreModal.value = match
scoreInput1.value = match.score1 ?? 0
scoreInput2.value = match.score2 ?? 0
}
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) {}
async function submitScore() {
if (!scoreModal.value) return
await api.post(`/matches/${scoreModal.value.id}/score`, {
score1: scoreInput1.value,
score2: scoreInput2.value,
})
scoreModal.value = null
await load()
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
})
onUnmounted(() => clearInterval(timer))
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div class="max-w-3xl mx-auto">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<button @click="$router.push('/events')" class="text-gray-500 hover:text-gray-300"> Back</button>
<div>
<h1 class="text-2xl font-bold text-white">New Match Event</h1>
<p class="text-gray-500 text-sm">Match Event Builder</p>
</div>
</div>
<!-- Step indicator -->
<div class="flex items-center mb-8">
<div v-for="(s, i) in steps" :key="i" class="flex items-center">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors"
:class="step > i ? 'bg-green-600 text-white' : step === i ? 'bg-green-900 border-2 border-green-500 text-green-300' : 'bg-gray-800 text-gray-500'">
{{ step > i ? '✓' : i + 1 }}
</div>
<span class="text-sm" :class="step === i ? 'text-white font-medium' : 'text-gray-500'">{{ s }}</span>
</div>
<div v-if="i < steps.length - 1" class="h-px flex-1 bg-gray-800 mx-4"></div>
</div>
</div>
<!-- Step 1: Event Setup -->
<div v-if="step === 0" class="space-y-6">
<div class="card p-6">
<h2 class="text-lg font-bold text-white mb-4">Event Setup</h2>
<div class="space-y-4">
<div>
<label class="label">Event Name *</label>
<input v-model="form.name" class="input" placeholder="e.g., Summer Singles Open 2026" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Match Format</label>
<div class="flex gap-2">
<button v-for="f in ['Singles', 'Doubles']" :key="f"
@click="form.format = f"
:class="form.format === f ? 'btn-primary' : 'btn-secondary'"
class="btn flex-1">{{ f }}</button>
</div>
</div>
<div>
<label class="label">Courts Available</label>
<input v-model.number="form.courts_count" type="number" min="1" max="10" class="input" />
</div>
</div>
</div>
</div>
<!-- Player selection -->
<div class="card p-6">
<h2 class="text-lg font-bold text-white mb-4">Add Players ({{ selectedPlayers.length }} selected)</h2>
<div class="grid grid-cols-2 gap-2">
<div v-for="p in allPlayers" :key="p.id"
@click="togglePlayer(p)"
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors border"
:class="isSelected(p.id)
? 'bg-green-900/30 border-green-700'
: 'bg-gray-800/50 border-gray-800 hover:border-gray-700'">
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs transition-colors"
:class="isSelected(p.id) ? 'bg-green-500 border-green-500 text-white' : 'border-gray-600'">
{{ isSelected(p.id) ? '✓' : '' }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ p.name }}</p>
<p class="text-xs text-gray-500">{{ p.skill_level }} · ELO {{ Math.round(p.elo) }}</p>
</div>
</div>
</div>
<!-- Doubles warning -->
<div v-if="form.format === 'Doubles' && selectedPlayers.length % 2 !== 0"
class="mt-3 p-3 bg-yellow-900/30 border border-yellow-700 rounded-lg text-sm text-yellow-300">
Doubles requires an even number of players
</div>
<!-- Stage calculation preview -->
<div v-if="selectedPlayers.length >= 2 && calculation" class="mt-4 p-4 bg-gray-800 rounded-lg">
<p class="text-sm text-gray-400 mb-1">📊 Event Preview</p>
<div class="grid grid-cols-3 gap-3 text-center">
<div>
<p class="text-2xl font-bold text-white">{{ calculation.teams }}</p>
<p class="text-xs text-gray-500">{{ form.format === 'Doubles' ? 'Teams' : 'Players' }}</p>
</div>
<div>
<p class="text-2xl font-bold text-white">{{ calculation.suggested_stages }}</p>
<p class="text-xs text-gray-500">Suggested Stages</p>
</div>
<div>
<p class="text-2xl font-bold text-white">{{ calculation.rounds_single_elim }}</p>
<p class="text-xs text-gray-500">Elim Rounds</p>
</div>
</div>
<p class="text-xs text-green-400 mt-2 text-center">
Suggested: {{ calculation.stage_names.join(' → ') }}
</p>
</div>
</div>
<div class="flex justify-end">
<button @click="goToStep2"
:disabled="!form.name || selectedPlayers.length < 2"
class="btn-primary">
Next: Configure Stages
</button>
</div>
</div>
<!-- Step 2: Stage Configurator -->
<div v-if="step === 1" class="space-y-4">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-bold text-white">Configure Stages</h2>
<button @click="addStage" class="btn-secondary btn-sm">+ Add Stage</button>
</div>
<div v-for="(stage, i) in stages" :key="i" class="card p-5">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-gray-700 flex items-center justify-center text-sm font-bold text-gray-300">
{{ i + 1 }}
</div>
<input v-model="stage.name" class="bg-transparent text-white font-semibold text-lg border-b border-transparent hover:border-gray-600 focus:border-green-500 focus:outline-none px-1" />
</div>
<button v-if="stages.length > 1" @click="removeStage(i)" class="text-red-500 hover:text-red-400 text-sm">Remove</button>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Match Type</label>
<select v-model="stage.match_type" class="input">
<option>Open Match</option>
<option>Skill-Based</option>
<option>Round Robin</option>
<option>Tournament</option>
</select>
</div>
<div>
<label class="label">Rounds in this Stage</label>
<input v-model.number="stage.rounds" type="number" min="1" class="input" />
</div>
<div>
<label class="label">Advance Rule</label>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-400">Top</span>
<input v-model.number="stage.advance_count" type="number" min="0" class="input w-20" />
<span class="text-sm text-gray-400">advance</span>
</div>
<p class="text-xs text-gray-600 mt-1">0 = all players advance</p>
</div>
</div>
</div>
<div class="flex gap-3 justify-between">
<button @click="step = 0" class="btn-secondary"> Back</button>
<button @click="createEvent" class="btn-primary">
Generate Event & Brackets
</button>
</div>
</div>
<!-- Step 3: Running summary / redirect -->
<div v-if="step === 2" class="card p-8 text-center">
<p class="text-5xl mb-4">🎉</p>
<h2 class="text-2xl font-bold text-white mb-2">Event Created!</h2>
<p class="text-gray-400 mb-6">{{ form.name }} is ready to go.</p>
<div class="flex gap-3 justify-center">
<router-link :to="`/events/${createdEventId}`" class="btn-primary">
🎮 Run the Event
</router-link>
<router-link to="/events" class="btn-secondary">Back to Events</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed, onMounted } from 'vue'
import api from '../stores/api'
const step = ref(0)
const steps = ['Event Setup', 'Stage Configurator', 'Done!']
const form = ref({ name: '', format: 'Singles', courts_count: 2 })
const allPlayers = ref([])
const selectedPlayerIds = ref([])
const calculation = ref(null)
const createdEventId = ref(null)
const stages = ref([
{ name: 'Open Stage', match_type: 'Open Match', rounds: 2, advance_count: 4 },
{ name: 'Finals Tournament', match_type: 'Tournament', rounds: 3, advance_count: 0 },
])
const selectedPlayers = computed(() => allPlayers.value.filter(p => selectedPlayerIds.value.includes(p.id)))
function isSelected(id) { return selectedPlayerIds.value.includes(id) }
function togglePlayer(p) {
const idx = selectedPlayerIds.value.indexOf(p.id)
if (idx >= 0) selectedPlayerIds.value.splice(idx, 1)
else selectedPlayerIds.value.push(p.id)
}
async function fetchCalc() {
if (selectedPlayers.value.length < 2) { calculation.value = null; return }
const { data } = await api.get('/events/calculate', { params: {
players: selectedPlayers.value.length,
format: form.value.format,
courts: form.value.courts_count,
}})
calculation.value = data
// Auto-update suggested stages
if (data.stage_names && stages.value.length !== data.suggested_stages) {
stages.value = data.stage_names.map((name, i) => ({
name,
match_type: i === data.stage_names.length - 1 ? 'Tournament' : (i === 0 ? 'Open Match' : 'Skill-Based'),
rounds: i === data.stage_names.length - 1 ? data.rounds_single_elim : 2,
advance_count: i === data.stage_names.length - 2 ? Math.ceil(data.teams / 2) : 0,
}))
}
}
watch([() => selectedPlayerIds.value.length, () => form.value.format, () => form.value.courts_count], fetchCalc)
function goToStep2() {
step.value = 1
}
function addStage() {
stages.value.push({ name: `Stage ${stages.value.length + 1}`, match_type: 'Round Robin', rounds: 1, advance_count: 0 })
}
function removeStage(i) {
stages.value.splice(i, 1)
}
async function createEvent() {
// 1. Create event
const { data: event } = await api.post('/events/', {
name: form.value.name,
format: form.value.format,
courts_count: form.value.courts_count,
})
createdEventId.value = event.id
// 2. Add players
for (const pid of selectedPlayerIds.value) {
await api.post(`/events/${event.id}/players`, { player_id: pid })
}
// 3. Create stages + generate brackets
for (let i = 0; i < stages.value.length; i++) {
const s = stages.value[i]
const { data: stage } = await api.post(`/events/${event.id}/stages`, {
name: s.name,
match_type: s.match_type === 'Open Match' ? 'Open' : s.match_type,
rounds: s.rounds,
advance_count: s.advance_count,
order: i,
})
// Generate matches for this stage
try {
await api.post(`/events/${event.id}/stages/${stage.id}/generate`)
} catch (e) {
console.warn('Could not generate bracket for stage', stage.name, e.response?.data)
}
}
step.value = 2
}
onMounted(async () => {
const { data } = await api.get('/players/')
allPlayers.value = data
})
</script>

View File

@@ -0,0 +1,341 @@
<template>
<div v-if="event">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<button @click="$router.push('/events')" class="text-gray-500 hover:text-gray-300"> Events</button>
<div>
<h1 class="text-2xl font-bold text-white">{{ event.name }}</h1>
<p class="text-gray-500 text-sm">
{{ event.format }} · {{ event.players?.length }} players · {{ event.courts_count }} courts
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="eventStatusClass(event.status)" class="capitalize">{{ event.status }}</span>
<button v-if="event.status === 'setup'" @click="startEvent" class="btn-primary"> Start Event</button>
<button v-if="event.status === 'active'" @click="completeEvent" class="btn-secondary"> Complete</button>
</div>
</div>
<div class="grid grid-cols-3 gap-6">
<!-- Main: Stages + Matches -->
<div class="col-span-2 space-y-4">
<!-- Stage tabs -->
<div class="flex gap-2 overflow-x-auto pb-2">
<button v-for="stage in event.stages" :key="stage.id"
@click="activeStageId = stage.id"
class="px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors"
:class="activeStageId === stage.id
? 'bg-green-900 text-green-300'
: 'bg-gray-800 text-gray-400 hover:text-gray-200'">
<span class="mr-1">{{ stageIcon(stage.status) }}</span>
{{ stage.name }}
<span class="ml-1 text-xs opacity-60">{{ stage.match_type }}</span>
</button>
</div>
<!-- Active stage -->
<div v-if="activeStage">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-semibold text-white">{{ activeStage.name }}</h3>
<p class="text-xs text-gray-500">{{ activeStage.match_type }} · {{ activeStage.rounds }} rounds</p>
</div>
<div class="flex gap-2">
<span :class="stageStatusClass(activeStage.status)" class="capitalize">{{ activeStage.status }}</span>
<button v-if="activeStage.status === 'pending' && event.status === 'active'"
@click="activateStage(activeStage.id)" class="btn-primary btn-sm text-xs">
Activate Stage
</button>
</div>
</div>
<!-- Matches grouped by round -->
<div v-for="round in roundsInStage" :key="round" class="mb-4">
<h4 class="text-xs text-gray-500 uppercase tracking-wider mb-2">
{{ roundLabel(round, activeStage) }}
</h4>
<div class="space-y-2">
<div v-for="m in matchesByRound(round)" :key="m.id"
class="card p-4 flex items-center gap-4"
:class="{
'border-yellow-700': m.status === 'in_progress',
'border-green-800 opacity-70': m.status === 'completed',
'border-gray-700': m.status === 'scheduled',
}">
<!-- Status -->
<div class="w-20 text-center">
<span :class="matchStatusClass(m.status)" class="text-xs capitalize">
{{ m.status === 'in_progress' ? '🔴 Live' : m.status }}
</span>
</div>
<!-- Players / Score -->
<div class="flex-1 flex items-center gap-3">
<div class="flex-1 text-right">
<p class="font-medium text-white" :class="m.winner_id === m.player1_id && m.status === 'completed' ? 'text-green-400' : ''">
{{ m.player1_name || 'TBD' }}
</p>
</div>
<div class="text-center min-w-[80px]">
<span v-if="m.status === 'completed'" class="font-mono font-bold text-lg text-white">
{{ m.score1 }} : {{ m.score2 }}
</span>
<span v-else-if="m.status === 'in_progress'" class="font-mono text-yellow-400 text-sm">Live</span>
<span v-else class="text-gray-600 text-sm">vs</span>
</div>
<div class="flex-1">
<p class="font-medium text-white" :class="m.winner_id === m.player2_id && m.status === 'completed' ? 'text-green-400' : ''">
{{ m.player2_name || 'TBD' }}
</p>
</div>
</div>
<!-- Court -->
<div class="text-xs text-gray-500 w-16 text-center">
<div v-if="m.court_id">{{ m.court_name }}</div>
<button v-else-if="m.status !== 'completed'" @click="openCourtAssign(m)"
class="text-gray-600 hover:text-gray-400">Assign</button>
</div>
<!-- Actions -->
<div class="flex gap-1">
<button v-if="m.status === 'scheduled' && m.player1_name && m.player2_name"
@click="startMatch(m.id)" class="btn-secondary btn-sm text-xs"></button>
<button v-if="m.status === 'in_progress'"
@click="openScore(m)" class="btn-primary btn-sm text-xs">Score</button>
<button v-if="m.status === 'completed'"
@click="openScore(m)" class="text-xs text-gray-600 hover:text-gray-400"></button>
</div>
</div>
</div>
</div>
<div v-if="!activeStage.matches || activeStage.matches.length === 0"
class="text-center py-8 text-gray-600">
No matches generated yet.
<button @click="generateMatches(activeStage.id)" class="ml-2 text-green-500 hover:underline">Generate Now</button>
</div>
</div>
</div>
<!-- Sidebar: Players + Bracket summary -->
<div class="space-y-4">
<!-- Players -->
<div class="card p-4">
<h3 class="font-semibold text-gray-300 mb-3">👥 Players ({{ event.players?.length }})</h3>
<div class="space-y-1">
<div v-for="ep in event.players" :key="ep.id"
class="flex items-center justify-between py-1.5">
<span class="text-sm text-white">{{ ep.player.name }}</span>
<span :class="skillBadgeClass(ep.skill_level_override || ep.player.skill_level)" class="text-xs">
{{ ep.skill_level_override || ep.player.skill_level }}
</span>
</div>
</div>
</div>
<!-- Bracket summary for tournament stages -->
<div v-if="tournamentStage" class="card p-4">
<h3 class="font-semibold text-gray-300 mb-3">🏆 Bracket</h3>
<div v-for="round in tournamentRounds" :key="round" class="mb-3">
<p class="text-xs text-gray-500 uppercase mb-1">{{ roundLabel(round, tournamentStage) }}</p>
<div v-for="m in tournamentMatchesByRound(round)" :key="m.id" class="text-xs py-1 border-l-2 pl-2 mb-1"
:class="{
'border-yellow-500': m.status === 'in_progress',
'border-green-600': m.status === 'completed',
'border-gray-700': m.status === 'scheduled',
}">
<div class="flex items-center justify-between">
<span :class="m.winner_id === m.player1_id && m.status === 'completed' ? 'text-green-400' : 'text-gray-300'">
{{ m.player1_name || 'TBD' }}
</span>
<span v-if="m.status === 'completed'" class="text-gray-500 font-mono">{{ m.score1 }}-{{ m.score2 }}</span>
</div>
<div class="flex items-center justify-between">
<span :class="m.winner_id === m.player2_id && m.status === 'completed' ? 'text-green-400' : 'text-gray-300'">
{{ m.player2_name || 'TBD' }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Score Modal -->
<div v-if="scoreModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="scoreModal = null">
<div class="card p-6 w-full max-w-sm">
<h3 class="text-lg font-bold text-white mb-4">Enter Score</h3>
<div class="flex items-center gap-4 mb-6">
<div class="flex-1 text-center">
<p class="text-xs text-gray-400 mb-2">{{ scoreModal.player1_name }}</p>
<input v-model.number="scoreInput1" type="number" min="0" class="input text-center text-2xl font-bold h-16" />
</div>
<div class="text-gray-600 text-xl font-bold">:</div>
<div class="flex-1 text-center">
<p class="text-xs text-gray-400 mb-2">{{ scoreModal.player2_name }}</p>
<input v-model.number="scoreInput2" type="number" min="0" class="input text-center text-2xl font-bold h-16" />
</div>
</div>
<div class="flex gap-2">
<button @click="scoreModal = null" class="btn-secondary flex-1">Cancel</button>
<button @click="submitScore" class="btn-primary flex-1">Confirm Score</button>
</div>
</div>
</div>
<!-- Court Assign Modal -->
<div v-if="courtAssignModal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="courtAssignModal = null">
<div class="card p-6 w-full max-w-sm">
<h3 class="text-lg font-bold text-white mb-4">Assign Court</h3>
<div class="space-y-2 mb-4">
<button v-for="c in courts" :key="c.id"
@click="assignCourt(c.id)"
class="w-full btn-secondary flex items-center justify-between"
:disabled="c.status === 'in_use' || c.status === 'maintenance'">
<span>{{ c.name }}</span>
<span :class="courtStatusClass(c.status)" class="text-xs capitalize">{{ c.status }}</span>
</button>
<button @click="assignCourt(null)" class="w-full btn-secondary text-sm text-gray-500">
Remove Court Assignment
</button>
</div>
<button @click="courtAssignModal = null" class="btn-secondary w-full">Cancel</button>
</div>
</div>
</div>
<div v-else class="text-center py-16 text-gray-500">Loading event...</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import api, { skillBadgeClass, eventStatusClass, courtStatusClass } from '../stores/api'
const route = useRoute()
const event = ref(null)
const courts = ref([])
const activeStageId = ref(null)
const scoreModal = ref(null)
const scoreInput1 = ref(0)
const scoreInput2 = ref(0)
const courtAssignModal = ref(null)
const activeStage = computed(() =>
event.value?.stages?.find(s => s.id === activeStageId.value)
)
const tournamentStage = computed(() =>
event.value?.stages?.find(s => s.match_type === 'Tournament')
)
const roundsInStage = computed(() => {
if (!activeStage.value?.matches) return []
return [...new Set(activeStage.value.matches.map(m => m.round_number))].sort()
})
const tournamentRounds = computed(() => {
if (!tournamentStage.value?.matches) return []
return [...new Set(tournamentStage.value.matches.map(m => m.round_number))].sort()
})
function matchesByRound(round) {
return activeStage.value?.matches?.filter(m => m.round_number === round) || []
}
function tournamentMatchesByRound(round) {
return tournamentStage.value?.matches?.filter(m => m.round_number === round) || []
}
function stageIcon(status) {
return { pending: '⏸', active: '▶', completed: '✓' }[status] || '•'
}
function stageStatusClass(status) {
return { pending: 'badge-gray', active: 'badge-green', completed: 'badge-blue' }[status] || 'badge-gray'
}
function matchStatusClass(status) {
return { scheduled: 'badge-gray', in_progress: 'badge-yellow', completed: 'badge-green', bye: 'badge-gray' }[status] || 'badge-gray'
}
function roundLabel(round, stage) {
if (!stage) return `Round ${round}`
if (stage.match_type === 'Tournament') {
const totalRounds = Math.max(...(stage.matches || []).map(m => m.round_number))
if (round === totalRounds) return 'Final'
if (round === totalRounds - 1) return 'Semifinals'
if (round === totalRounds - 2) return 'Quarterfinals'
}
return `Round ${round}`
}
async function load() {
const [evtRes, courtsRes] = await Promise.all([
api.get(`/events/${route.params.id}`),
api.get('/courts/'),
])
event.value = evtRes.data
courts.value = courtsRes.data
if (!activeStageId.value && event.value.stages?.length > 0) {
const active = event.value.stages.find(s => s.status === 'active') || event.value.stages[0]
activeStageId.value = active.id
}
}
async function startEvent() {
await api.post(`/events/${event.value.id}/start`)
await load()
}
async function completeEvent() {
await api.post(`/events/${event.value.id}/complete`)
await load()
}
async function activateStage(stageId) {
// Update stage status via event - simple approach: direct API
await api.put(`/events/${event.value.id}/stages/${stageId}`, { advance_count: activeStage.value.advance_count })
// Set active stage on event
await load()
}
async function generateMatches(stageId) {
await api.post(`/events/${event.value.id}/stages/${stageId}/generate`)
await load()
}
async function startMatch(id) {
await api.post(`/matches/${id}/start`)
await load()
}
function openScore(m) {
scoreModal.value = m
scoreInput1.value = m.score1 ?? 0
scoreInput2.value = m.score2 ?? 0
}
async function submitScore() {
if (!scoreModal.value) return
await api.post(`/matches/${scoreModal.value.id}/score`, {
score1: scoreInput1.value,
score2: scoreInput2.value,
})
scoreModal.value = null
await load()
}
function openCourtAssign(m) {
courtAssignModal.value = m
}
async function assignCourt(courtId) {
await api.patch(`/matches/${courtAssignModal.value.id}/court`, { court_id: courtId })
courtAssignModal.value = null
await load()
}
onMounted(load)
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Events</h1>
<p class="text-gray-500 text-sm">{{ events.length }} events</p>
</div>
<router-link to="/events/new" class="btn-primary">+ New Event</router-link>
</div>
<div class="space-y-3">
<div v-for="e in events" :key="e.id" class="card p-4 hover:border-gray-700 transition-colors cursor-pointer"
@click="$router.push(`/events/${e.id}`)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center text-xl">
{{ e.format === 'Doubles' ? '👫' : '🏓' }}
</div>
<div>
<h3 class="font-bold text-white">{{ e.name }}</h3>
<p class="text-sm text-gray-500">
{{ e.format }} · {{ e.player_count }} players · {{ e.stage_count }} stages
· {{ e.courts_count }} courts
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="eventStatusClass(e.status)" class="capitalize">{{ e.status }}</span>
<span class="text-gray-600 text-xs">{{ formatDate(e.created_at) }}</span>
<span class="text-gray-500"></span>
</div>
</div>
</div>
</div>
<div v-if="events.length === 0" class="text-center py-16">
<p class="text-5xl mb-4">🏆</p>
<p class="text-gray-500 mb-4">No events yet</p>
<router-link to="/events/new" class="btn-primary">Create Your First Event</router-link>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api, { eventStatusClass, formatDate } from '../stores/api'
const events = ref([])
async function load() {
const { data } = await api.get('/events/')
events.value = data
}
onMounted(load)
</script>

View File

@@ -1,408 +0,0 @@
<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

@@ -1,118 +1,103 @@
<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 class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Player Management</h1>
<p class="text-gray-500 text-sm">{{ players.length }} players registered</p>
</div>
<button @click="openAdd" class="btn-primary">+ Add Player</button>
</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>
<!-- Player Table -->
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-gray-800">
<tr class="text-gray-500 text-xs uppercase tracking-wider">
<th class="text-left px-4 py-3">Player</th>
<th class="text-left px-4 py-3">Contact</th>
<th class="text-center px-4 py-3">Skill</th>
<th class="text-center px-4 py-3">ELO</th>
<th class="text-center px-4 py-3">W / L</th>
<th class="text-center px-4 py-3">Played</th>
<th class="text-right px-4 py-3">Actions</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>
<tbody class="divide-y divide-gray-800/50">
<tr v-for="(p, i) in players" :key="p.id" class="hover:bg-gray-800/30 transition-colors">
<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 class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-sm font-bold text-gray-300">
{{ i + 1 }}
</div>
<span class="font-medium text-white">{{ p.name }}</span>
</div>
</td>
<td class="px-4 py-3">
<span class="text-white font-mono font-bold">{{ Math.round(player.elo_rating) }}</span>
<td class="px-4 py-3 text-gray-400">{{ p.contact || '—' }}</td>
<td class="px-4 py-3 text-center">
<span :class="skillBadgeClass(p.skill_level)">{{ p.skill_level }}</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>
<td class="px-4 py-3 text-center font-mono font-bold text-white">{{ Math.round(p.elo) }}</td>
<td class="px-4 py-3 text-center">
<span class="text-green-400">{{ p.wins }}</span>
<span class="text-gray-600 mx-1">/</span>
<span class="text-red-400">{{ player.losses }}L</span>
<span class="text-red-400">{{ p.losses }}</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 class="px-4 py-3 text-center text-gray-400">{{ p.matches_played }}</td>
<td class="px-4 py-3 text-right">
<button @click="openEdit(p)" class="text-xs text-blue-400 hover:text-blue-300 mr-3">Edit</button>
<button @click="confirmDelete(p)" class="text-xs text-red-500 hover:text-red-400">Delete</button>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ player.total_matches }}</td>
</tr>
</tbody>
</table>
<div v-if="players.length === 0" class="text-center text-gray-600 py-12">
No players yet. Add your first player!
</div>
</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">
<!-- Add/Edit Modal -->
<div v-if="modal" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="modal = null">
<div class="card p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">{{ modal.id ? 'Edit' : 'Add' }} Player</h3>
<div class="space-y-4">
<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" />
<label class="label">Name *</label>
<input v-model="modal.name" class="input" placeholder="Full name" />
</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" />
<label class="label">Contact</label>
<input v-model="modal.contact" class="input" placeholder="Phone or email" />
</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.
<label class="label">Skill Level *</label>
<select v-model="modal.skill_level" class="input">
<option value="Beginner">Beginner</option>
<option value="Intermediate">Intermediate</option>
<option value="Advanced">Advanced</option>
<option value="Elite">Elite</option>
</select>
</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 class="flex gap-2 mt-6">
<button @click="modal = null" class="btn-secondary flex-1">Cancel</button>
<button @click="savePlayer" :disabled="!modal.name" class="btn-primary flex-1">
{{ modal.id ? 'Save Changes' : 'Add Player' }}
</button>
</div>
</div>
</div>
<!-- Delete Confirm -->
<div v-if="deleteTarget" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="deleteTarget = null">
<div class="card p-6 w-full max-w-sm text-center">
<p class="text-xl mb-2"></p>
<h3 class="text-lg font-bold text-white mb-2">Delete Player?</h3>
<p class="text-gray-400 text-sm mb-6">Remove <strong>{{ deleteTarget.name }}</strong> from the system?</p>
<div class="flex gap-2">
<button @click="deleteTarget = null" class="btn-secondary flex-1">Cancel</button>
<button @click="doDelete" class="btn-danger flex-1">Delete</button>
</div>
</div>
</div>
@@ -120,52 +105,55 @@
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAppStore } from '../stores/app'
import axios from 'axios'
import { ref, onMounted } from 'vue'
import api, { skillBadgeClass } from '../stores/api'
const store = useAppStore()
const players = ref([])
const showRegister = ref(false)
const registerForm = reactive({ name: '', email: '', phone: '' })
const modal = ref(null)
const deleteTarget = ref(null)
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 },
]
async function load() {
const { data } = await api.get('/players/')
players.value = data
}
function openAdd() {
modal.value = { name: '', contact: '', skill_level: 'Intermediate' }
}
function openEdit(p) {
modal.value = { ...p }
}
async function savePlayer() {
if (!modal.value) return
if (modal.value.id) {
await api.put(`/players/${modal.value.id}`, {
name: modal.value.name,
contact: modal.value.contact,
skill_level: modal.value.skill_level,
})
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()
} else {
await api.post('/players/', {
name: modal.value.name,
contact: modal.value.contact,
skill_level: modal.value.skill_level,
})
}
modal.value = null
await load()
}
function confirmDelete(p) {
deleteTarget.value = p
}
async function doDelete() {
if (!deleteTarget.value) return
await api.delete(`/players/${deleteTarget.value.id}`)
deleteTarget.value = null
await load()
}
onMounted(load)
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div class="min-h-screen bg-gray-950 flex flex-col items-center justify-center p-8">
<!-- Header -->
<div class="absolute top-6 left-6 flex items-center gap-2">
<span class="text-3xl">🏓</span>
<span class="text-xl font-bold text-gray-500">ServeSync</span>
</div>
<div class="absolute top-6 right-6 text-gray-600 text-2xl font-mono">{{ timeStr }}</div>
<div v-if="data" class="w-full max-w-4xl">
<!-- Court name -->
<div class="text-center mb-12">
<h1 class="text-6xl font-black text-white mb-2">{{ data.court.name }}</h1>
<p class="text-2xl" :class="{
'text-green-400': data.court.status === 'available',
'text-yellow-400': data.court.status === 'in_use',
'text-blue-400': data.court.status === 'reserved',
'text-red-400': data.court.status === 'maintenance',
}">
{{ statusLabel(data.court.status) }}
</p>
</div>
<!-- Match in progress -->
<div v-if="data.current_match" class="text-center">
<!-- Players -->
<div class="flex items-center justify-center gap-12 mb-8">
<div class="flex-1 text-right">
<p class="text-5xl font-black text-white">{{ data.current_match.player1_name }}</p>
</div>
<div class="text-gray-600 text-4xl font-bold">VS</div>
<div class="flex-1 text-left">
<p class="text-5xl font-black text-white">{{ data.current_match.player2_name }}</p>
</div>
</div>
<!-- Score -->
<div class="flex items-center justify-center gap-8 mb-8">
<div class="text-9xl font-mono font-black" :class="data.current_match.score1 > data.current_match.score2 ? 'text-green-400' : 'text-white'">
{{ data.current_match.score1 ?? 0 }}
</div>
<div class="text-6xl text-gray-700 font-bold">:</div>
<div class="text-9xl font-mono font-black" :class="data.current_match.score2 > data.current_match.score1 ? 'text-green-400' : 'text-white'">
{{ data.current_match.score2 ?? 0 }}
</div>
</div>
<!-- Timer -->
<div class="text-3xl font-mono text-gray-500">
{{ formatElapsed(data.current_match.elapsed_seconds) }}
</div>
</div>
<!-- No match -->
<div v-else class="text-center">
<p class="text-8xl mb-6">{{ statusEmoji(data.court.status) }}</p>
<p class="text-3xl text-gray-600">{{ statusLabel(data.court.status) }}</p>
</div>
</div>
<!-- Bottom: Court selector -->
<div class="absolute bottom-6 flex gap-4">
<router-link v-for="i in 4" :key="i" :to="`/screen/${i}`"
class="px-4 py-2 rounded-lg text-sm transition-colors"
:class="Number(route.params.id) === i ? 'bg-green-900 text-green-300' : 'text-gray-700 hover:text-gray-500'">
Court {{ String.fromCharCode(64 + i) }}
</router-link>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import api, { formatElapsed } from '../stores/api'
const route = useRoute()
const data = ref(null)
const timeStr = ref('')
let timer = null
let clockTimer = null
function statusLabel(s) {
return { available: 'Court Available', in_use: 'Match in Progress', reserved: 'Reserved', maintenance: 'Under Maintenance' }[s] || s
}
function statusEmoji(s) {
return { available: '✓', reserved: '📅', maintenance: '🔧' }[s] || '🏓'
}
async function load() {
const { data: d } = await api.get(`/dashboard/screen/${route.params.id}`)
data.value = d
}
onMounted(() => {
load()
timer = setInterval(load, 3000)
const updateClock = () => {
timeStr.value = new Date().toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
updateClock()
clockTimer = setInterval(updateClock, 1000)
})
onUnmounted(() => {
clearInterval(timer)
clearInterval(clockTimer)
})
</script>

View File

@@ -1,386 +0,0 @@
<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

@@ -1,330 +0,0 @@
<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

@@ -1,30 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {
colors: {
'pickle': {
brand: {
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: [],
}

View File

@@ -4,19 +4,9 @@ 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,
'/api': 'http://backend:8000',
'/ws': { target: 'ws://backend:8000', ws: true },
}
}
},
build: {
outDir: 'dist',
}
})