commit 46fd2953c37dae16b67efba7585c23c608831612 Author: Forge Date: Thu Mar 19 00:58:17 2026 +0800 🏓 Initial ServeSync demo build - FastAPI backend with PostgreSQL + Redis - 4 core features: Registration, Courts, Matchmaking, Tournament - Double elimination tournament with bracket visualization - ELO-based matchmaking (Stage 1 Open, Stage 2 Skill-Based) - Real-time WebSocket updates - TV Screen display for courts - Vue 3 + Tailwind CSS frontend - Seed data: 12 players, 4 courts, active matches, tournament in progress - Docker compose stack with Nginx reverse proxy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..326c4c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv +node_modules/ +dist/ +.DS_Store +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c125646 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Create startup script +RUN cat > /start.sh << 'EOF' +#!/bin/bash +set -e +echo "Waiting for database..." +until python -c " +import psycopg2, os +try: + conn = psycopg2.connect(os.environ.get('DATABASE_URL', 'postgresql://servesync:servesync@db:5432/servesync')) + conn.close() + print('DB ready') +except Exception as e: + print(f'DB not ready: {e}') + exit(1) +"; do + sleep 2 +done + +echo "Running seed..." +python seed.py || echo "Seed already ran or error (continuing)" + +echo "Starting server..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +EOF +RUN chmod +x /start.sh + +EXPOSE 8000 +CMD ["/start.sh"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/courts.py b/backend/app/api/courts.py new file mode 100644 index 0000000..f931f2b --- /dev/null +++ b/backend/app/api/courts.py @@ -0,0 +1,218 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import and_ +from app.database import get_db +from app.models.court import Court +from app.models.booking import Booking, BookingStatus +from app.models.player import Player +from app.models.match import Match, MatchStatus +from pydantic import BaseModel +from datetime import datetime, timedelta +from typing import Optional, List + +router = APIRouter(prefix="/courts", tags=["courts"]) + + +class BookingCreate(BaseModel): + player_id: int + court_id: int + start_time: datetime + duration_hours: float = 1.0 + + +class CourtResponse(BaseModel): + id: int + name: str + court_number: int + hourly_rate: float + is_active: bool + surface_type: str + features: str + current_status: str + current_match: Optional[dict] = None + + class Config: + from_attributes = True + + +class BookingResponse(BaseModel): + id: int + player_id: int + player_name: str + court_id: int + court_name: str + start_time: datetime + end_time: datetime + duration_hours: float + total_cost: float + status: str + + class Config: + from_attributes = True + + +def get_court_status(court: Court, db: Session) -> tuple: + """Get current status of a court""" + # Check if match is in progress + match = db.query(Match).filter( + Match.court_id == court.id, + Match.status == MatchStatus.IN_PROGRESS + ).first() + + if match: + team1 = [mp.player.name for mp in match.match_players if mp.team == 1] + team2 = [mp.player.name for mp in match.match_players if mp.team == 2] + return "occupied", { + "match_id": match.id, + "team1": team1, + "team2": team2, + "team1_score": match.team1_score, + "team2_score": match.team2_score, + "stage": match.stage.value, + "started_at": match.started_at.isoformat() if match.started_at else None, + } + + # Check if there's a booking now + now = datetime.utcnow() + booking = db.query(Booking).filter( + Booking.court_id == court.id, + Booking.start_time <= now, + Booking.end_time >= now, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]) + ).first() + + if booking: + return "booked", None + + return "available", None + + +@router.get("/", response_model=List[CourtResponse]) +def get_courts(db: Session = Depends(get_db)): + courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all() + result = [] + for court in courts: + status, match_info = get_court_status(court, db) + result.append(CourtResponse( + id=court.id, + name=court.name, + court_number=court.court_number, + hourly_rate=court.hourly_rate, + is_active=court.is_active, + surface_type=court.surface_type, + features=court.features, + current_status=status, + current_match=match_info, + )) + return result + + +@router.get("/{court_id}") +def get_court(court_id: int, db: Session = Depends(get_db)): + court = db.query(Court).filter(Court.id == court_id).first() + if not court: + raise HTTPException(status_code=404, detail="Court not found") + status, match_info = get_court_status(court, db) + return { + "id": court.id, + "name": court.name, + "court_number": court.court_number, + "hourly_rate": court.hourly_rate, + "surface_type": court.surface_type, + "features": court.features, + "current_status": status, + "current_match": match_info, + } + + +@router.get("/{court_id}/schedule") +def get_court_schedule(court_id: int, db: Session = Depends(get_db)): + """Get bookings for a court for the next 7 days""" + now = datetime.utcnow() + end = now + timedelta(days=7) + bookings = db.query(Booking).filter( + Booking.court_id == court_id, + Booking.start_time >= now, + Booking.end_time <= end, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]) + ).order_by(Booking.start_time).all() + + return [ + { + "id": b.id, + "player_name": b.player.name, + "start_time": b.start_time.isoformat(), + "end_time": b.end_time.isoformat(), + "duration_hours": b.duration_hours, + "is_match_booking": b.is_match_booking, + } + for b in bookings + ] + + +@router.post("/book") +def book_court(booking_data: BookingCreate, db: Session = Depends(get_db)): + court = db.query(Court).filter(Court.id == booking_data.court_id).first() + if not court: + raise HTTPException(status_code=404, detail="Court not found") + + player = db.query(Player).filter(Player.id == booking_data.player_id).first() + if not player: + raise HTTPException(status_code=404, detail="Player not found") + + end_time = booking_data.start_time + timedelta(hours=booking_data.duration_hours) + + # Check for conflicts + conflict = db.query(Booking).filter( + Booking.court_id == booking_data.court_id, + Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]), + and_(Booking.start_time < end_time, Booking.end_time > booking_data.start_time) + ).first() + + if conflict: + raise HTTPException(status_code=400, detail="Court is already booked for this time slot") + + total_cost = court.hourly_rate * booking_data.duration_hours + booking = Booking( + player_id=booking_data.player_id, + court_id=booking_data.court_id, + start_time=booking_data.start_time, + end_time=end_time, + duration_hours=booking_data.duration_hours, + total_cost=total_cost, + status=BookingStatus.CONFIRMED, + ) + db.add(booking) + db.commit() + db.refresh(booking) + + return { + "id": booking.id, + "court_name": court.name, + "player_name": player.name, + "start_time": booking.start_time.isoformat(), + "end_time": booking.end_time.isoformat(), + "total_cost": booking.total_cost, + "status": booking.status.value, + } + + +@router.get("/bookings/upcoming") +def get_upcoming_bookings(db: Session = Depends(get_db)): + now = datetime.utcnow() + bookings = db.query(Booking).filter( + Booking.start_time >= now, + Booking.status == BookingStatus.CONFIRMED + ).order_by(Booking.start_time).limit(20).all() + + return [ + { + "id": b.id, + "player_name": b.player.name, + "court_name": b.court.name, + "start_time": b.start_time.isoformat(), + "end_time": b.end_time.isoformat(), + "total_cost": b.total_cost, + } + for b in bookings + ] diff --git a/backend/app/api/matches.py b/backend/app/api/matches.py new file mode 100644 index 0000000..e02fa33 --- /dev/null +++ b/backend/app/api/matches.py @@ -0,0 +1,160 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType +from app.models.player import Player +from app.services import matchmaking +from pydantic import BaseModel +from typing import Optional, List + +router = APIRouter(prefix="/matches", tags=["matches"]) + + +class CreateMatchRequest(BaseModel): + title: str + stage: str = "open" + match_type: str = "doubles" + creator_player_id: int + elo_tolerance: float = 200 + + +class JoinMatchRequest(BaseModel): + player_id: int + team: int + + +class ScoreUpdateRequest(BaseModel): + team1_score: int + team2_score: int + + +def match_to_dict(match: Match, db: Session) -> dict: + players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match.id).all() + team1 = [] + team2 = [] + for mp in players: + p = db.query(Player).filter(Player.id == mp.player_id).first() + if p: + pdata = { + "id": p.id, + "name": p.name, + "elo": round(p.elo_rating, 1), + "avatar_color": p.avatar_color, + "elo_change": mp.elo_change, + } + if mp.team == 1: + team1.append(pdata) + else: + team2.append(pdata) + + return { + "id": match.id, + "title": match.title, + "stage": match.stage.value, + "match_type": match.match_type.value, + "status": match.status.value, + "team1": team1, + "team2": team2, + "team1_score": match.team1_score, + "team2_score": match.team2_score, + "max_players": match.max_players, + "current_players": len(players), + "min_elo": match.min_elo, + "max_elo": match.max_elo, + "court_id": match.court_id, + "court_name": match.court.name if match.court else None, + "started_at": match.started_at.isoformat() if match.started_at else None, + "ended_at": match.ended_at.isoformat() if match.ended_at else None, + "created_at": match.created_at.isoformat() if match.created_at else None, + } + + +@router.get("/") +def get_matches(status: Optional[str] = None, stage: Optional[str] = None, db: Session = Depends(get_db)): + query = db.query(Match) + if status: + query = query.filter(Match.status == status) + if stage: + query = query.filter(Match.stage == stage) + matches = query.order_by(Match.created_at.desc()).limit(50).all() + return [match_to_dict(m, db) for m in matches] + + +@router.get("/lobby") +def get_lobby(db: Session = Depends(get_db)): + matches = matchmaking.get_lobby_matches(db) + return [match_to_dict(m, db) for m in matches] + + +@router.get("/active") +def get_active_matches(db: Session = Depends(get_db)): + matches = db.query(Match).filter(Match.status == MatchStatus.IN_PROGRESS).all() + return [match_to_dict(m, db) for m in matches] + + +@router.get("/{match_id}") +def get_match(match_id: int, db: Session = Depends(get_db)): + match = db.query(Match).filter(Match.id == match_id).first() + if not match: + raise HTTPException(status_code=404, detail="Match not found") + return match_to_dict(match, db) + + +@router.post("/") +def create_match(req: CreateMatchRequest, db: Session = Depends(get_db)): + player = db.query(Player).filter(Player.id == req.creator_player_id).first() + if not player: + raise HTTPException(status_code=404, detail="Player not found") + + match_type = MatchType.DOUBLES if req.match_type == "doubles" else MatchType.SINGLES + + if req.stage == "open": + match = matchmaking.create_open_match(db, req.title, match_type) + elif req.stage == "skill_based": + match = matchmaking.create_skill_match(db, req.title, player.elo_rating, match_type, req.elo_tolerance) + else: + raise HTTPException(status_code=400, detail="Invalid stage. Use 'open' or 'skill_based'") + + # Auto-join creator to team 1 + matchmaking.join_match(db, match.id, player.id, 1) + return match_to_dict(match, db) + + +@router.post("/{match_id}/join") +def join_match(match_id: int, req: JoinMatchRequest, db: Session = Depends(get_db)): + result = matchmaking.join_match(db, match_id, req.player_id, req.team) + if not result: + raise HTTPException(status_code=400, detail="Cannot join match (full, ELO constraint, or already joined)") + + match = db.query(Match).filter(Match.id == match_id).first() + return match_to_dict(match, db) + + +@router.post("/{match_id}/start") +def start_match(match_id: int, db: Session = Depends(get_db)): + match = matchmaking.start_match(db, match_id) + if not match: + raise HTTPException(status_code=400, detail="Cannot start match (not enough players or already started)") + return match_to_dict(match, db) + + +@router.post("/{match_id}/score") +def update_score(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)): + match = db.query(Match).filter(Match.id == match_id).first() + if not match: + raise HTTPException(status_code=404, detail="Match not found") + if match.status != MatchStatus.IN_PROGRESS: + raise HTTPException(status_code=400, detail="Match not in progress") + + match.team1_score = req.team1_score + match.team2_score = req.team2_score + db.commit() + return match_to_dict(match, db) + + +@router.post("/{match_id}/complete") +def complete_match(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)): + match = matchmaking.complete_match(db, match_id, req.team1_score, req.team2_score) + if not match: + raise HTTPException(status_code=400, detail="Cannot complete match") + return match_to_dict(match, db) diff --git a/backend/app/api/players.py b/backend/app/api/players.py new file mode 100644 index 0000000..c4585a0 --- /dev/null +++ b/backend/app/api/players.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models.player import Player, MembershipTier +from app.services.elo import get_tier_from_elo +from pydantic import BaseModel +from typing import Optional, List + +router = APIRouter(prefix="/players", tags=["players"]) + + +class PlayerCreate(BaseModel): + name: str + email: str + phone: Optional[str] = None + + +class PlayerUpdate(BaseModel): + name: Optional[str] = None + phone: Optional[str] = None + + +class PlayerResponse(BaseModel): + id: int + name: str + email: str + phone: Optional[str] + elo_rating: float + membership_tier: str + wins: int + losses: int + total_matches: int + win_rate: float + avatar_color: str + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, player: Player): + return cls( + id=player.id, + name=player.name, + email=player.email, + phone=player.phone, + elo_rating=round(player.elo_rating, 1), + membership_tier=player.membership_tier.value, + wins=player.wins, + losses=player.losses, + total_matches=player.total_matches, + win_rate=player.win_rate, + avatar_color=player.avatar_color, + ) + + +@router.get("/", response_model=List[PlayerResponse]) +def get_players(db: Session = Depends(get_db)): + players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).all() + return [PlayerResponse.from_orm(p) for p in players] + + +@router.get("/leaderboard", response_model=List[PlayerResponse]) +def get_leaderboard(limit: int = 10, db: Session = Depends(get_db)): + players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).limit(limit).all() + return [PlayerResponse.from_orm(p) for p in players] + + +@router.get("/{player_id}", response_model=PlayerResponse) +def get_player(player_id: int, db: Session = Depends(get_db)): + player = db.query(Player).filter(Player.id == player_id).first() + if not player: + raise HTTPException(status_code=404, detail="Player not found") + return PlayerResponse.from_orm(player) + + +@router.post("/", response_model=PlayerResponse) +def create_player(player_data: PlayerCreate, db: Session = Depends(get_db)): + existing = db.query(Player).filter(Player.email == player_data.email).first() + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + + colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"] + color = colors[db.query(Player).count() % len(colors)] + + player = Player( + name=player_data.name, + email=player_data.email, + phone=player_data.phone, + avatar_color=color, + ) + db.add(player) + db.commit() + db.refresh(player) + return PlayerResponse.from_orm(player) diff --git a/backend/app/api/screen.py b/backend/app/api/screen.py new file mode 100644 index 0000000..b24633d --- /dev/null +++ b/backend/app/api/screen.py @@ -0,0 +1,156 @@ +"""Screen Display API - for TV/display views""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import desc +from app.database import get_db +from app.models.court import Court +from app.models.match import Match, MatchPlayer, MatchStatus +from app.models.player import Player +from app.models.tournament import Tournament, TournamentStatus +from app.services.tournament import get_bracket_data +from datetime import datetime + +router = APIRouter(prefix="/screen", tags=["screen"]) + + +@router.get("/court/{court_id}") +def get_court_display(court_id: int, db: Session = Depends(get_db)): + """Full court display data for TV screen""" + court = db.query(Court).filter(Court.id == court_id).first() + if not court: + return {"error": "Court not found"} + + # Current active match + active_match = db.query(Match).filter( + Match.court_id == court_id, + Match.status == MatchStatus.IN_PROGRESS + ).first() + + match_data = None + if active_match: + team1_players = [] + team2_players = [] + for mp in active_match.match_players: + p = db.query(Player).filter(Player.id == mp.player_id).first() + if p: + pd = { + "id": p.id, + "name": p.name, + "elo": round(p.elo_rating, 1), + "avatar_color": p.avatar_color, + "membership_tier": p.membership_tier.value, + } + if mp.team == 1: + team1_players.append(pd) + else: + team2_players.append(pd) + + elapsed = None + if active_match.started_at: + elapsed = int((datetime.utcnow() - active_match.started_at).total_seconds()) + + match_data = { + "id": active_match.id, + "stage": active_match.stage.value, + "match_type": active_match.match_type.value, + "team1": team1_players, + "team2": team2_players, + "team1_score": active_match.team1_score, + "team2_score": active_match.team2_score, + "elapsed_seconds": elapsed, + } + + # Recent completed matches on this court + recent_matches = db.query(Match).filter( + Match.court_id == court_id, + Match.status == MatchStatus.COMPLETED + ).order_by(desc(Match.ended_at)).limit(3).all() + + recent = [] + for m in recent_matches: + team1 = [db.query(Player).filter(Player.id == mp.player_id).first().name + for mp in m.match_players if mp.team == 1] + team2 = [db.query(Player).filter(Player.id == mp.player_id).first().name + for mp in m.match_players if mp.team == 2] + recent.append({ + "team1": team1, + "team2": team2, + "team1_score": m.team1_score, + "team2_score": m.team2_score, + "winner": "team1" if m.team1_score > m.team2_score else "team2", + }) + + return { + "court": { + "id": court.id, + "name": court.name, + "court_number": court.court_number, + "surface_type": court.surface_type, + }, + "active_match": match_data, + "recent_matches": recent, + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/overview") +def get_overview(db: Session = Depends(get_db)): + """All courts overview for main display""" + courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all() + result = [] + for court in courts: + active_match = db.query(Match).filter( + Match.court_id == court.id, + Match.status == MatchStatus.IN_PROGRESS + ).first() + + court_data = { + "id": court.id, + "name": court.name, + "court_number": court.court_number, + "status": "occupied" if active_match else "available", + } + + if active_match: + team1 = [mp.player.name for mp in active_match.match_players if mp.team == 1] + team2 = [mp.player.name for mp in active_match.match_players if mp.team == 2] + court_data["match"] = { + "team1_names": team1, + "team2_names": team2, + "team1_score": active_match.team1_score, + "team2_score": active_match.team2_score, + "stage": active_match.stage.value, + } + + result.append(court_data) + + # Active tournament + tournament_data = None + active_tournament = db.query(Tournament).filter( + Tournament.status == TournamentStatus.IN_PROGRESS + ).first() + if active_tournament: + tournament_data = get_bracket_data(db, active_tournament.id) + + # Leaderboard top 5 + top_players = db.query(Player).filter(Player.is_active == True).order_by( + Player.elo_rating.desc() + ).limit(5).all() + + return { + "courts": result, + "tournament": tournament_data, + "leaderboard": [ + { + "rank": i + 1, + "name": p.name, + "elo": round(p.elo_rating, 1), + "tier": p.membership_tier.value, + "wins": p.wins, + "losses": p.losses, + "avatar_color": p.avatar_color, + } + for i, p in enumerate(top_players) + ], + "timestamp": datetime.utcnow().isoformat(), + } diff --git a/backend/app/api/tournaments.py b/backend/app/api/tournaments.py new file mode 100644 index 0000000..48bd808 --- /dev/null +++ b/backend/app/api/tournaments.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus +from app.services import tournament as tournament_service +from pydantic import BaseModel +from typing import Optional, List + +router = APIRouter(prefix="/tournaments", tags=["tournaments"]) + + +class CreateTournamentRequest(BaseModel): + name: str + max_participants: int = 8 + + +class RegisterPlayerRequest(BaseModel): + player_id: int + + +class MatchScoreRequest(BaseModel): + team1_score: int + team2_score: int + + +@router.get("/") +def get_tournaments(db: Session = Depends(get_db)): + tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all() + return [ + { + "id": t.id, + "name": t.name, + "status": t.status.value, + "max_participants": t.max_participants, + "current_participants": len(t.entries), + "started_at": t.started_at.isoformat() if t.started_at else None, + } + for t in tournaments + ] + + +@router.get("/{tournament_id}") +def get_tournament(tournament_id: int, db: Session = Depends(get_db)): + return tournament_service.get_bracket_data(db, tournament_id) + + +@router.post("/") +def create_tournament(req: CreateTournamentRequest, db: Session = Depends(get_db)): + t = tournament_service.create_tournament(db, req.name, req.max_participants) + return {"id": t.id, "name": t.name, "status": t.status.value} + + +@router.post("/{tournament_id}/register") +def register_player(tournament_id: int, req: RegisterPlayerRequest, db: Session = Depends(get_db)): + entry = tournament_service.register_player(db, tournament_id, req.player_id) + if not entry: + raise HTTPException(status_code=400, detail="Cannot register player (tournament full or already registered)") + return {"message": "Registered successfully", "player_id": req.player_id} + + +@router.post("/{tournament_id}/start") +def start_tournament(tournament_id: int, db: Session = Depends(get_db)): + t = tournament_service.start_tournament(db, tournament_id) + if not t: + raise HTTPException(status_code=400, detail="Cannot start tournament (need at least 4 players)") + return tournament_service.get_bracket_data(db, tournament_id) + + +@router.post("/{tournament_id}/matches/{match_id}/score") +def complete_tournament_match( + tournament_id: int, match_id: int, req: MatchScoreRequest, db: Session = Depends(get_db) +): + tm = tournament_service.complete_tournament_match(db, match_id, req.team1_score, req.team2_score) + if not tm: + raise HTTPException(status_code=400, detail="Cannot complete match") + return tournament_service.get_bracket_data(db, tournament_id) + + +@router.get("/{tournament_id}/leaderboard") +def get_leaderboard(tournament_id: int, db: Session = Depends(get_db)): + data = tournament_service.get_bracket_data(db, tournament_id) + return data.get("leaderboard", []) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c524c76 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql://servesync:servesync@db:5432/servesync" + REDIS_URL: str = "redis://redis:6379" + SECRET_KEY: str = "servesync-demo-secret-key-2024" + DEBUG: bool = True + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..61662ad --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.config import settings + +engine = create_engine(settings.DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..6d35305 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,141 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +import asyncio +import json +from typing import List, Dict + +from app.database import engine, SessionLocal +from app.models import player, court, booking, match, tournament + +# Create all tables +player.Base.metadata.create_all(bind=engine) +court.Base.metadata.create_all(bind=engine) +booking.Base.metadata.create_all(bind=engine) +match.Base.metadata.create_all(bind=engine) +tournament.Base.metadata.create_all(bind=engine) + +from app.api import players, courts, matches, tournaments, screen + +app = FastAPI( + title="ServeSync API", + description="Pickleball Court Management System", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(players.router, prefix="/api") +app.include_router(courts.router, prefix="/api") +app.include_router(matches.router, prefix="/api") +app.include_router(tournaments.router, prefix="/api") +app.include_router(screen.router, prefix="/api") + + +# WebSocket connection manager +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + + async def connect(self, websocket: WebSocket, channel: str): + await websocket.accept() + if channel not in self.active_connections: + self.active_connections[channel] = [] + self.active_connections[channel].append(websocket) + + def disconnect(self, websocket: WebSocket, channel: str): + if channel in self.active_connections: + self.active_connections[channel].remove(websocket) + + async def broadcast(self, channel: str, data: dict): + if channel in self.active_connections: + disconnected = [] + for connection in self.active_connections[channel]: + try: + await connection.send_text(json.dumps(data)) + except Exception: + disconnected.append(connection) + for conn in disconnected: + self.active_connections[channel].remove(conn) + + +manager = ConnectionManager() + + +@app.websocket("/ws/court/{court_id}") +async def websocket_court(websocket: WebSocket, court_id: int): + """WebSocket for real-time court display updates""" + channel = f"court_{court_id}" + await manager.connect(websocket, channel) + try: + while True: + # Send court data every 3 seconds + db = SessionLocal() + try: + from app.api.screen import get_court_display + data = get_court_display(court_id, db) + await websocket.send_text(json.dumps(data)) + finally: + db.close() + await asyncio.sleep(3) + except WebSocketDisconnect: + manager.disconnect(websocket, channel) + + +@app.websocket("/ws/overview") +async def websocket_overview(websocket: WebSocket): + """WebSocket for real-time overview display""" + channel = "overview" + await manager.connect(websocket, channel) + try: + while True: + db = SessionLocal() + try: + from app.api.screen import get_overview + data = get_overview(db) + await websocket.send_text(json.dumps(data)) + finally: + db.close() + await asyncio.sleep(3) + except WebSocketDisconnect: + manager.disconnect(websocket, channel) + + +@app.websocket("/ws/matches") +async def websocket_matches(websocket: WebSocket): + """WebSocket for real-time match lobby updates""" + channel = "matches" + await manager.connect(websocket, channel) + try: + while True: + db = SessionLocal() + try: + from app.api.matches import get_lobby + from app.api.matches import get_active_matches + data = { + "lobby": get_lobby(db), + "active": get_active_matches(db), + } + await websocket.send_text(json.dumps(data)) + finally: + db.close() + await asyncio.sleep(2) + except WebSocketDisconnect: + manager.disconnect(websocket, channel) + + +@app.get("/") +def root(): + return {"message": "ServeSync API v1.0", "status": "running"} + + +@app.get("/health") +def health(): + return {"status": "healthy"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..0dc0599 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.player import Player +from app.models.court import Court +from app.models.booking import Booking +from app.models.match import Match, MatchPlayer +from app.models.tournament import Tournament, TournamentEntry, TournamentMatch diff --git a/backend/app/models/booking.py b/backend/app/models/booking.py new file mode 100644 index 0000000..a0f568d --- /dev/null +++ b/backend/app/models/booking.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base +import enum + + +class BookingStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class Booking(Base): + __tablename__ = "bookings" + + id = Column(Integer, primary_key=True, index=True) + player_id = Column(Integer, ForeignKey("players.id"), nullable=False) + court_id = Column(Integer, ForeignKey("courts.id"), nullable=False) + start_time = Column(DateTime(timezone=True), nullable=False) + end_time = Column(DateTime(timezone=True), nullable=False) + duration_hours = Column(Float, default=1.0) + total_cost = Column(Float, default=0.0) + status = Column(Enum(BookingStatus), default=BookingStatus.CONFIRMED) + is_match_booking = Column(Boolean, default=False) + notes = Column(String(500)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + player = relationship("Player", back_populates="bookings") + court = relationship("Court", back_populates="bookings") diff --git a/backend/app/models/court.py b/backend/app/models/court.py new file mode 100644 index 0000000..6202da5 --- /dev/null +++ b/backend/app/models/court.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base +import enum + + +class Court(Base): + __tablename__ = "courts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), nullable=False) + court_number = Column(Integer, unique=True, nullable=False) + hourly_rate = Column(Float, default=200.0) + is_active = Column(Boolean, default=True) + surface_type = Column(String(50), default="Sport Court") + features = Column(String(500), default="LED Lighting, Spectator Seating") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + bookings = relationship("Booking", back_populates="court") + matches = relationship("Match", back_populates="court") diff --git a/backend/app/models/match.py b/backend/app/models/match.py new file mode 100644 index 0000000..39d9acf --- /dev/null +++ b/backend/app/models/match.py @@ -0,0 +1,73 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base +import enum + + +class MatchStage(str, enum.Enum): + OPEN = "open" + SKILL_BASED = "skill_based" + TOURNAMENT = "tournament" + + +class MatchStatus(str, enum.Enum): + LOBBY = "lobby" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class MatchType(str, enum.Enum): + SINGLES = "singles" + DOUBLES = "doubles" + + +class Match(Base): + __tablename__ = "matches" + + id = Column(Integer, primary_key=True, index=True) + court_id = Column(Integer, ForeignKey("courts.id"), nullable=True) + stage = Column(Enum(MatchStage), default=MatchStage.OPEN) + match_type = Column(Enum(MatchType), default=MatchType.DOUBLES) + status = Column(Enum(MatchStatus), default=MatchStatus.LOBBY) + + # Scores + team1_score = Column(Integer, default=0) + team2_score = Column(Integer, default=0) + team1_games = Column(Integer, default=0) + team2_games = Column(Integer, default=0) + + # Game details + max_players = Column(Integer, default=4) + min_elo = Column(Float, nullable=True) + max_elo = Column(Float, nullable=True) + + # Match metadata + title = Column(String(200)) + started_at = Column(DateTime(timezone=True), nullable=True) + ended_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + court = relationship("Court", back_populates="matches") + match_players = relationship("MatchPlayer", back_populates="match", cascade="all, delete-orphan") + + +class MatchPlayer(Base): + __tablename__ = "match_players" + + id = Column(Integer, primary_key=True, index=True) + match_id = Column(Integer, ForeignKey("matches.id"), nullable=False) + player_id = Column(Integer, ForeignKey("players.id"), nullable=False) + team = Column(Integer, nullable=False) # 1 or 2 + elo_before = Column(Float, nullable=True) + elo_after = Column(Float, nullable=True) + elo_change = Column(Float, default=0.0) + is_winner = Column(Boolean, nullable=True) + joined_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + match = relationship("Match", back_populates="match_players") + player = relationship("Player", back_populates="match_players") diff --git a/backend/app/models/player.py b/backend/app/models/player.py new file mode 100644 index 0000000..bacd1fb --- /dev/null +++ b/backend/app/models/player.py @@ -0,0 +1,46 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base +import enum + + +class MembershipTier(str, enum.Enum): + BRONZE = "bronze" + SILVER = "silver" + GOLD = "gold" + PLATINUM = "platinum" + ELITE = "elite" + + +class Player(Base): + __tablename__ = "players" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + email = Column(String(255), unique=True, nullable=False) + phone = Column(String(20)) + elo_rating = Column(Float, default=1000.0) + membership_tier = Column(Enum(MembershipTier), default=MembershipTier.BRONZE) + wins = Column(Integer, default=0) + losses = Column(Integer, default=0) + total_matches = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + avatar_color = Column(String(7), default="#3B82F6") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + bookings = relationship("Booking", back_populates="player") + match_players = relationship("MatchPlayer", back_populates="player") + tournament_entries = relationship("TournamentEntry", back_populates="player") + + @property + def win_rate(self): + if self.total_matches == 0: + return 0 + return round(self.wins / self.total_matches * 100, 1) + + @property + def tier_label(self): + return self.membership_tier.value.capitalize() diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py new file mode 100644 index 0000000..ed314ba --- /dev/null +++ b/backend/app/models/tournament.py @@ -0,0 +1,93 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base +import enum + + +class TournamentStatus(str, enum.Enum): + REGISTRATION = "registration" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class BracketType(str, enum.Enum): + WINNERS = "winners" + LOSERS = "losers" + GRAND_FINAL = "grand_final" + + +class TournamentMatchStatus(str, enum.Enum): + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class Tournament(Base): + __tablename__ = "tournaments" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + status = Column(Enum(TournamentStatus), default=TournamentStatus.REGISTRATION) + max_participants = Column(Integer, default=8) + current_round = Column(Integer, default=1) + bracket_data = Column(JSON, nullable=True) + started_at = Column(DateTime(timezone=True), nullable=True) + ended_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + entries = relationship("TournamentEntry", back_populates="tournament") + tournament_matches = relationship("TournamentMatch", back_populates="tournament") + + +class TournamentEntry(Base): + __tablename__ = "tournament_entries" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False) + player_id = Column(Integer, ForeignKey("players.id"), nullable=False) + seed = Column(Integer, nullable=True) + final_rank = Column(Integer, nullable=True) + is_eliminated = Column(Boolean, default=False) + losses = Column(Integer, default=0) + wins = Column(Integer, default=0) + is_in_losers = Column(Boolean, default=False) + registered_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + tournament = relationship("Tournament", back_populates="entries") + player = relationship("Player", back_populates="tournament_entries") + + +class TournamentMatch(Base): + __tablename__ = "tournament_matches" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False) + match_id = Column(Integer, ForeignKey("matches.id"), nullable=True) + round_number = Column(Integer, nullable=False) + match_number = Column(Integer, nullable=False) + bracket_type = Column(Enum(BracketType), default=BracketType.WINNERS) + status = Column(Enum(TournamentMatchStatus), default=TournamentMatchStatus.PENDING) + + # Players + player1_id = Column(Integer, ForeignKey("players.id"), nullable=True) + player2_id = Column(Integer, ForeignKey("players.id"), nullable=True) + player3_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p1 + player4_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p2 + + # Scores + team1_score = Column(Integer, default=0) + team2_score = Column(Integer, default=0) + winner_team = Column(Integer, nullable=True) + + # Next match routing + winner_next_match = Column(Integer, nullable=True) + loser_next_match = Column(Integer, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + tournament = relationship("Tournament", back_populates="tournament_matches") diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/elo.py b/backend/app/services/elo.py new file mode 100644 index 0000000..259dfb7 --- /dev/null +++ b/backend/app/services/elo.py @@ -0,0 +1,53 @@ +"""ELO Rating System for ServeSync""" + +K_FACTOR = 32 # Standard K-factor + + +def expected_score(rating_a: float, rating_b: float) -> float: + """Calculate expected score for player A vs player B""" + return 1 / (1 + 10 ** ((rating_b - rating_a) / 400)) + + +def calculate_elo_change(winner_rating: float, loser_rating: float) -> tuple[float, float]: + """ + Calculate ELO changes after a match. + Returns (winner_change, loser_change) + """ + expected_winner = expected_score(winner_rating, loser_rating) + expected_loser = expected_score(loser_rating, winner_rating) + + winner_change = K_FACTOR * (1 - expected_winner) + loser_change = K_FACTOR * (0 - expected_loser) + + return round(winner_change, 2), round(loser_change, 2) + + +def calculate_team_elo(player1_rating: float, player2_rating: float = None) -> float: + """Calculate team ELO (average for doubles)""" + if player2_rating is None: + return player1_rating + return (player1_rating + player2_rating) / 2 + + +def get_tier_from_elo(elo: float) -> str: + """Get membership tier based on ELO rating""" + if elo < 1000: + return "bronze" + elif elo < 1200: + return "silver" + elif elo < 1500: + return "gold" + elif elo < 1800: + return "platinum" + else: + return "elite" + + +def is_valid_match(player_elo: float, match_min_elo: float, match_max_elo: float) -> bool: + """Check if player is within ELO range for skill-based match""" + return match_min_elo <= player_elo <= match_max_elo + + +def get_elo_range(player_elo: float, tolerance: float = 200) -> tuple[float, float]: + """Get valid ELO range for matching""" + return (max(0, player_elo - tolerance), player_elo + tolerance) diff --git a/backend/app/services/matchmaking.py b/backend/app/services/matchmaking.py new file mode 100644 index 0000000..6293798 --- /dev/null +++ b/backend/app/services/matchmaking.py @@ -0,0 +1,214 @@ +"""Matchmaking Engine for ServeSync""" +from sqlalchemy.orm import Session +from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType +from app.models.court import Court +from app.models.player import Player +from app.models.booking import Booking, BookingStatus +from app.services.elo import calculate_elo_change, get_elo_range, is_valid_match +from datetime import datetime, timedelta +from typing import Optional, List + + +def create_open_match(db: Session, title: str, match_type: MatchType = MatchType.DOUBLES) -> Match: + """Stage 1: Create an open match lobby - no restrictions""" + max_players = 4 if match_type == MatchType.DOUBLES else 2 + match = Match( + stage=MatchStage.OPEN, + match_type=match_type, + status=MatchStatus.LOBBY, + title=title, + max_players=max_players, + ) + db.add(match) + db.commit() + db.refresh(match) + return match + + +def create_skill_match( + db: Session, + title: str, + creator_elo: float, + match_type: MatchType = MatchType.DOUBLES, + tolerance: float = 200, +) -> Match: + """Stage 2: Create a skill-based match with ELO constraints""" + min_elo, max_elo = get_elo_range(creator_elo, tolerance) + max_players = 4 if match_type == MatchType.DOUBLES else 2 + match = Match( + stage=MatchStage.SKILL_BASED, + match_type=match_type, + status=MatchStatus.LOBBY, + title=title, + max_players=max_players, + min_elo=min_elo, + max_elo=max_elo, + ) + db.add(match) + db.commit() + db.refresh(match) + return match + + +def join_match(db: Session, match_id: int, player_id: int, team: int) -> Optional[MatchPlayer]: + """Add a player to a match lobby""" + match = db.query(Match).filter(Match.id == match_id).first() + if not match or match.status != MatchStatus.LOBBY: + return None + + player = db.query(Player).filter(Player.id == player_id).first() + if not player: + return None + + # Check player count + current_players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count() + if current_players >= match.max_players: + return None + + # Check if already joined + existing = db.query(MatchPlayer).filter( + MatchPlayer.match_id == match_id, + MatchPlayer.player_id == player_id + ).first() + if existing: + return existing + + # For skill-based matches, check ELO constraint + if match.stage == MatchStage.SKILL_BASED: + if match.min_elo and match.max_elo: + if not is_valid_match(player.elo_rating, match.min_elo, match.max_elo): + return None + + mp = MatchPlayer( + match_id=match_id, + player_id=player_id, + team=team, + elo_before=player.elo_rating, + ) + db.add(mp) + db.commit() + db.refresh(mp) + return mp + + +def start_match(db: Session, match_id: int) -> Optional[Match]: + """Start a match - auto-assign court if available""" + match = db.query(Match).filter(Match.id == match_id).first() + if not match: + return None + + players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count() + if players < match.max_players: + return None + + # Auto-assign an available court + if not match.court_id: + court = find_available_court(db) + if court: + match.court_id = court.id + # Create booking + now = datetime.utcnow() + booking = Booking( + player_id=db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).first().player_id, + court_id=court.id, + start_time=now, + end_time=now + timedelta(hours=1), + duration_hours=1.0, + total_cost=court.hourly_rate, + status=BookingStatus.IN_PROGRESS, + is_match_booking=True, + ) + db.add(booking) + + match.status = MatchStatus.IN_PROGRESS + match.started_at = datetime.utcnow() + db.commit() + db.refresh(match) + return match + + +def find_available_court(db: Session) -> Optional[Court]: + """Find an available court not currently in use""" + from sqlalchemy import and_ + busy_court_ids = db.query(Match.court_id).filter( + Match.status == MatchStatus.IN_PROGRESS, + Match.court_id.isnot(None) + ).all() + busy_ids = [c[0] for c in busy_court_ids] + + court = db.query(Court).filter( + Court.is_active == True, + Court.id.notin_(busy_ids) + ).first() + return court + + +def complete_match(db: Session, match_id: int, team1_score: int, team2_score: int) -> Optional[Match]: + """Complete a match and update ELO ratings""" + match = db.query(Match).filter(Match.id == match_id).first() + if not match or match.status != MatchStatus.IN_PROGRESS: + return None + + match.status = MatchStatus.COMPLETED + match.team1_score = team1_score + match.team2_score = team2_score + match.ended_at = datetime.utcnow() + + winner_team = 1 if team1_score > team2_score else 2 + + # Get players + team1_players = db.query(MatchPlayer).filter( + MatchPlayer.match_id == match_id, MatchPlayer.team == 1 + ).all() + team2_players = db.query(MatchPlayer).filter( + MatchPlayer.match_id == match_id, MatchPlayer.team == 2 + ).all() + + # Calculate average ELO + team1_avg = sum(p.elo_before or p.player.elo_rating for p in team1_players) / len(team1_players) + team2_avg = sum(p.elo_before or p.player.elo_rating for p in team2_players) / len(team2_players) + + if winner_team == 1: + w_change, l_change = calculate_elo_change(team1_avg, team2_avg) + else: + l_change, w_change = calculate_elo_change(team2_avg, team1_avg) + w_change, l_change = -l_change, -w_change + + # Update player ELOs + for mp in team1_players: + is_winner = (winner_team == 1) + change = w_change if is_winner else l_change + mp.is_winner = is_winner + mp.elo_change = change + mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change + mp.player.elo_rating += change + if is_winner: + mp.player.wins += 1 + else: + mp.player.losses += 1 + mp.player.total_matches += 1 + + for mp in team2_players: + is_winner = (winner_team == 2) + change = w_change if is_winner else l_change + mp.is_winner = is_winner + mp.elo_change = change + mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change + mp.player.elo_rating += change + if is_winner: + mp.player.wins += 1 + else: + mp.player.losses += 1 + mp.player.total_matches += 1 + + db.commit() + db.refresh(match) + return match + + +def get_lobby_matches(db: Session, stage: Optional[MatchStage] = None) -> List[Match]: + """Get all lobby matches, optionally filtered by stage""" + query = db.query(Match).filter(Match.status == MatchStatus.LOBBY) + if stage: + query = query.filter(Match.stage == stage) + return query.order_by(Match.created_at.desc()).all() diff --git a/backend/app/services/tournament.py b/backend/app/services/tournament.py new file mode 100644 index 0000000..99581ac --- /dev/null +++ b/backend/app/services/tournament.py @@ -0,0 +1,281 @@ +"""Double Elimination Tournament Engine for ServeSync""" +from sqlalchemy.orm import Session +from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus +from app.models.player import Player +from app.models.court import Court +from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType +from app.services.matchmaking import find_available_court +from datetime import datetime +from typing import Optional, List +import math + + +def create_tournament(db: Session, name: str, max_participants: int = 8) -> Tournament: + """Create a new tournament""" + t = Tournament(name=name, max_participants=max_participants) + db.add(t) + db.commit() + db.refresh(t) + return t + + +def register_player(db: Session, tournament_id: int, player_id: int) -> Optional[TournamentEntry]: + """Register a player for the tournament""" + t = db.query(Tournament).filter(Tournament.id == tournament_id).first() + if not t or t.status != TournamentStatus.REGISTRATION: + return None + + current_count = db.query(TournamentEntry).filter(TournamentEntry.tournament_id == tournament_id).count() + if current_count >= t.max_participants: + return None + + existing = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tournament_id, + TournamentEntry.player_id == player_id + ).first() + if existing: + return existing + + entry = TournamentEntry(tournament_id=tournament_id, player_id=player_id) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def start_tournament(db: Session, tournament_id: int) -> Optional[Tournament]: + """Start tournament and generate initial bracket""" + t = db.query(Tournament).filter(Tournament.id == tournament_id).first() + if not t or t.status != TournamentStatus.REGISTRATION: + return None + + entries = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tournament_id + ).join(Player).order_by(Player.elo_rating.desc()).all() + + if len(entries) < 4: + return None + + # Seed players by ELO + for i, entry in enumerate(entries): + entry.seed = i + 1 + + # Generate bracket matches + _generate_winners_bracket(db, t, entries) + + t.status = TournamentStatus.IN_PROGRESS + t.started_at = datetime.utcnow() + db.commit() + db.refresh(t) + return t + + +def _generate_winners_bracket(db: Session, tournament: Tournament, entries: List[TournamentEntry]): + """Generate the initial winners bracket matches""" + n = len(entries) + # Pair up by seeding (1 vs n, 2 vs n-1, etc.) + match_num = 1 + pairs = [] + for i in range(n // 2): + p1 = entries[i] + p2 = entries[n - 1 - i] + pairs.append((p1, p2)) + + for p1, p2 in pairs: + tm = TournamentMatch( + tournament_id=tournament.id, + round_number=1, + match_number=match_num, + bracket_type=BracketType.WINNERS, + status=TournamentMatchStatus.PENDING, + player1_id=p1.player_id, + player2_id=p2.player_id, + ) + db.add(tm) + match_num += 1 + + db.flush() + + +def complete_tournament_match( + db: Session, + tournament_match_id: int, + team1_score: int, + team2_score: int +) -> Optional[TournamentMatch]: + """Complete a tournament match and advance bracket""" + tm = db.query(TournamentMatch).filter(TournamentMatch.id == tournament_match_id).first() + if not tm: + return None + + tm.team1_score = team1_score + tm.team2_score = team2_score + tm.winner_team = 1 if team1_score > team2_score else 2 + tm.status = TournamentMatchStatus.COMPLETED + tm.completed_at = datetime.utcnow() + + winner_player_id = tm.player1_id if tm.winner_team == 1 else tm.player2_id + loser_player_id = tm.player2_id if tm.winner_team == 1 else tm.player1_id + + # Update entry stats + winner_entry = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tm.tournament_id, + TournamentEntry.player_id == winner_player_id + ).first() + loser_entry = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tm.tournament_id, + TournamentEntry.player_id == loser_player_id + ).first() + + if winner_entry: + winner_entry.wins += 1 + if loser_entry: + loser_entry.losses += 1 + + # Double elimination logic + if loser_entry and loser_entry.losses < 2: + # Move to losers bracket + loser_entry.is_in_losers = True + _create_losers_match(db, tm, loser_player_id) + elif loser_entry: + # Eliminated + loser_entry.is_eliminated = True + _assign_final_ranks(db, tm.tournament_id) + + db.commit() + db.refresh(tm) + return tm + + +def _create_losers_match(db: Session, completed_match: TournamentMatch, loser_id: int): + """Route loser to losers bracket""" + # Find existing pending losers match in next round + next_round = completed_match.round_number + 1 + existing = db.query(TournamentMatch).filter( + TournamentMatch.tournament_id == completed_match.tournament_id, + TournamentMatch.bracket_type == BracketType.LOSERS, + TournamentMatch.round_number == next_round, + TournamentMatch.player2_id.is_(None), + ).first() + + if existing: + existing.player2_id = loser_id + else: + # Create a new losers match + match_count = db.query(TournamentMatch).filter( + TournamentMatch.tournament_id == completed_match.tournament_id, + TournamentMatch.bracket_type == BracketType.LOSERS, + TournamentMatch.round_number == next_round, + ).count() + + tm = TournamentMatch( + tournament_id=completed_match.tournament_id, + round_number=next_round, + match_number=match_count + 1, + bracket_type=BracketType.LOSERS, + status=TournamentMatchStatus.PENDING, + player1_id=loser_id, + ) + db.add(tm) + + +def _assign_final_ranks(db: Session, tournament_id: int): + """Assign final ranks to eliminated players""" + entries = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tournament_id, + TournamentEntry.is_eliminated == True, + TournamentEntry.final_rank.is_(None) + ).order_by(TournamentEntry.losses.desc()).all() + + remaining = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tournament_id, + TournamentEntry.is_eliminated == False + ).count() + + base_rank = remaining + 1 + for i, entry in enumerate(entries): + entry.final_rank = base_rank + i + + +def get_bracket_data(db: Session, tournament_id: int) -> dict: + """Get structured bracket data for frontend visualization""" + tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first() + if not tournament: + return {} + + matches = db.query(TournamentMatch).filter( + TournamentMatch.tournament_id == tournament_id + ).order_by(TournamentMatch.round_number, TournamentMatch.match_number).all() + + entries = db.query(TournamentEntry).filter( + TournamentEntry.tournament_id == tournament_id + ).all() + + players_map = {} + for entry in entries: + player = db.query(Player).filter(Player.id == entry.player_id).first() + if player: + players_map[player.id] = { + "id": player.id, + "name": player.name, + "elo": player.elo_rating, + "seed": entry.seed, + "wins": entry.wins, + "losses": entry.losses, + "is_eliminated": entry.is_eliminated, + "is_in_losers": entry.is_in_losers, + "final_rank": entry.final_rank, + } + + winners_bracket = [] + losers_bracket = [] + grand_final = [] + + for m in matches: + match_data = { + "id": m.id, + "round": m.round_number, + "match_number": m.match_number, + "status": m.status.value, + "player1": players_map.get(m.player1_id), + "player2": players_map.get(m.player2_id), + "team1_score": m.team1_score, + "team2_score": m.team2_score, + "winner_team": m.winner_team, + } + if m.bracket_type == BracketType.WINNERS: + winners_bracket.append(match_data) + elif m.bracket_type == BracketType.LOSERS: + losers_bracket.append(match_data) + else: + grand_final.append(match_data) + + # Build leaderboard + leaderboard = sorted( + [ + { + "player": players_map[e.player_id], + "wins": e.wins, + "losses": e.losses, + "is_eliminated": e.is_eliminated, + "is_in_losers": e.is_in_losers, + "final_rank": e.final_rank, + } + for e in entries + if e.player_id in players_map + ], + key=lambda x: (x["final_rank"] or 999, -x["wins"]), + ) + + return { + "tournament": { + "id": tournament.id, + "name": tournament.name, + "status": tournament.status.value, + "current_round": tournament.current_round, + }, + "winners_bracket": winners_bracket, + "losers_bracket": losers_bracket, + "grand_final": grand_final, + "leaderboard": leaderboard, + } diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ea4d10e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +psycopg2-binary==2.9.9 +redis==5.0.4 +arq==0.25.0 +websockets==12.0 +python-dotenv==1.0.1 +pydantic==2.7.1 +pydantic-settings==2.2.1 +httpx==0.27.0 +python-multipart==0.0.9 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..08d6cbb --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,432 @@ +"""Seed demo data for ServeSync""" +import sys +import os +sys.path.insert(0, '/app') + +from sqlalchemy.orm import Session +from app.database import engine, SessionLocal +from app.models.player import Player, MembershipTier +from app.models.court import Court +from app.models.booking import Booking, BookingStatus +from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType +from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus +from datetime import datetime, timedelta +import random + + +def clear_data(db: Session): + db.query(TournamentMatch).delete() + db.query(TournamentEntry).delete() + db.query(Tournament).delete() + db.query(MatchPlayer).delete() + db.query(Match).delete() + db.query(Booking).delete() + db.query(Court).delete() + db.query(Player).delete() + db.commit() + print("Cleared existing data") + + +def seed_players(db: Session) -> list: + players_data = [ + {"name": "Marco Santos", "email": "marco@demo.com", "elo": 1850, "tier": MembershipTier.ELITE, "color": "#EF4444", "wins": 45, "losses": 8}, + {"name": "Sofia Reyes", "email": "sofia@demo.com", "elo": 1720, "tier": MembershipTier.ELITE, "color": "#EC4899", "wins": 38, "losses": 12}, + {"name": "Carlos Mendez", "email": "carlos@demo.com", "elo": 1630, "tier": MembershipTier.PLATINUM, "color": "#8B5CF6", "wins": 32, "losses": 15}, + {"name": "Ana Cruz", "email": "ana@demo.com", "elo": 1580, "tier": MembershipTier.PLATINUM, "color": "#06B6D4", "wins": 28, "losses": 18}, + {"name": "Juan Dela Cruz", "email": "juan@demo.com", "elo": 1450, "tier": MembershipTier.GOLD, "color": "#F59E0B", "wins": 22, "losses": 20}, + {"name": "Maria Garcia", "email": "maria@demo.com", "elo": 1380, "tier": MembershipTier.GOLD, "color": "#10B981", "wins": 18, "losses": 22}, + {"name": "Pedro Lopez", "email": "pedro@demo.com", "elo": 1250, "tier": MembershipTier.SILVER, "color": "#3B82F6", "wins": 14, "losses": 19}, + {"name": "Rosa Martinez", "email": "rosa@demo.com", "elo": 1190, "tier": MembershipTier.SILVER, "color": "#F97316", "wins": 11, "losses": 21}, + {"name": "Diego Torres", "email": "diego@demo.com", "elo": 1100, "tier": MembershipTier.SILVER, "color": "#84CC16", "wins": 9, "losses": 18}, + {"name": "Lucia Flores", "email": "lucia@demo.com", "elo": 980, "tier": MembershipTier.BRONZE, "color": "#14B8A6", "wins": 6, "losses": 20}, + {"name": "Miguel Ortega", "email": "miguel@demo.com", "elo": 920, "tier": MembershipTier.BRONZE, "color": "#6366F1", "wins": 4, "losses": 15}, + {"name": "Isabella Vega", "email": "isabella@demo.com", "elo": 850, "tier": MembershipTier.BRONZE, "color": "#D946EF", "wins": 2, "losses": 12}, + ] + + players = [] + for pd in players_data: + p = Player( + name=pd["name"], + email=pd["email"], + elo_rating=pd["elo"], + membership_tier=pd["tier"], + avatar_color=pd["color"], + wins=pd["wins"], + losses=pd["losses"], + total_matches=pd["wins"] + pd["losses"], + ) + db.add(p) + players.append(p) + + db.commit() + for p in players: + db.refresh(p) + print(f"Created {len(players)} players") + return players + + +def seed_courts(db: Session) -> list: + courts_data = [ + {"name": "Court 1", "number": 1, "rate": 200.0, "surface": "Sport Court", "features": "LED Lighting, Pro Net System"}, + {"name": "Court 2", "number": 2, "rate": 200.0, "surface": "Sport Court", "features": "LED Lighting, Spectator Seating"}, + {"name": "Court 3", "number": 3, "rate": 250.0, "surface": "Cushioned", "features": "Tournament Grade, Scoreboards"}, + {"name": "Court 4", "number": 4, "rate": 250.0, "surface": "Cushioned", "features": "Tournament Grade, VIP Lounge Access"}, + ] + + courts = [] + for cd in courts_data: + c = Court( + name=cd["name"], + court_number=cd["number"], + hourly_rate=cd["rate"], + surface_type=cd["surface"], + features=cd["features"], + ) + db.add(c) + courts.append(c) + + db.commit() + for c in courts: + db.refresh(c) + print(f"Created {len(courts)} courts") + return courts + + +def seed_bookings(db: Session, players: list, courts: list): + now = datetime.utcnow() + bookings = [] + + # Future bookings + for i in range(8): + player = players[i % len(players)] + court = courts[i % len(courts)] + start = now + timedelta(hours=i + 2) + end = start + timedelta(hours=1) + b = Booking( + player_id=player.id, + court_id=court.id, + start_time=start, + end_time=end, + duration_hours=1.0, + total_cost=court.hourly_rate, + status=BookingStatus.CONFIRMED, + ) + db.add(b) + bookings.append(b) + + db.commit() + print(f"Created {len(bookings)} bookings") + + +def seed_active_matches(db: Session, players: list, courts: list) -> list: + matches = [] + + # Match 1: Active doubles on Court 1 (Open match) + m1 = Match( + court_id=courts[0].id, + stage=MatchStage.OPEN, + match_type=MatchType.DOUBLES, + status=MatchStatus.IN_PROGRESS, + title="Open Doubles - Court 1", + max_players=4, + team1_score=7, + team2_score=5, + started_at=datetime.utcnow() - timedelta(minutes=25), + ) + db.add(m1) + db.flush() + + for player_id, team in [(players[0].id, 1), (players[1].id, 1), (players[2].id, 2), (players[3].id, 2)]: + mp = MatchPlayer(match_id=m1.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating) + db.add(mp) + matches.append(m1) + + # Match 2: Active skill-based on Court 2 + m2 = Match( + court_id=courts[1].id, + stage=MatchStage.SKILL_BASED, + match_type=MatchType.DOUBLES, + status=MatchStatus.IN_PROGRESS, + title="Skill Match - Platinum League", + max_players=4, + min_elo=1300, + max_elo=1700, + team1_score=3, + team2_score=6, + started_at=datetime.utcnow() - timedelta(minutes=15), + ) + db.add(m2) + db.flush() + + for player_id, team in [(players[4].id, 1), (players[5].id, 1), (players[6].id, 2), (players[7].id, 2)]: + mp = MatchPlayer(match_id=m2.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating) + db.add(mp) + matches.append(m2) + + # Match 3: Lobby (waiting for players) + m3 = Match( + stage=MatchStage.OPEN, + match_type=MatchType.DOUBLES, + status=MatchStatus.LOBBY, + title="Evening Open Game", + max_players=4, + ) + db.add(m3) + db.flush() + + for player_id, team in [(players[8].id, 1), (players[9].id, 2)]: + mp = MatchPlayer(match_id=m3.id, player_id=player_id, team=team, elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating) + db.add(mp) + matches.append(m3) + + # Match 4: Skill-based lobby + m4 = Match( + stage=MatchStage.SKILL_BASED, + match_type=MatchType.DOUBLES, + status=MatchStatus.LOBBY, + title="Gold Tier Challenge", + max_players=4, + min_elo=1200, + max_elo=1600, + ) + db.add(m4) + db.flush() + + mp = MatchPlayer(match_id=m4.id, player_id=players[4].id, team=1, elo_before=players[4].elo_rating) + db.add(mp) + matches.append(m4) + + db.commit() + for m in matches: + db.refresh(m) + print(f"Created {len(matches)} matches (2 active, 2 lobby)") + return matches + + +def seed_tournament(db: Session, players: list, courts: list): + """Create a sample double elimination tournament in progress""" + t = Tournament( + name="ServeSync Grand Prix - March 2024", + status=TournamentStatus.IN_PROGRESS, + max_participants=8, + current_round=2, + started_at=datetime.utcnow() - timedelta(hours=2), + ) + db.add(t) + db.flush() + + # Register 8 players + tournament_players = players[:8] + entries = [] + for i, player in enumerate(tournament_players): + entry = TournamentEntry( + tournament_id=t.id, + player_id=player.id, + seed=i + 1, + wins=0, + losses=0, + ) + db.add(entry) + entries.append(entry) + db.flush() + + # Round 1 Winners Bracket (4 matches) - all completed + r1_matches = [ + (players[0].id, players[7].id, 11, 5), # 1 vs 8 + (players[1].id, players[6].id, 11, 7), # 2 vs 7 + (players[2].id, players[5].id, 8, 11), # 3 vs 6 - player 5 wins + (players[3].id, players[4].id, 11, 9), # 4 vs 5 + ] + + winners_r1 = [] # winner player IDs after round 1 + losers_r1 = [] # loser player IDs after round 1 + + for i, (p1_id, p2_id, s1, s2) in enumerate(r1_matches): + tm = TournamentMatch( + tournament_id=t.id, + round_number=1, + match_number=i + 1, + bracket_type=BracketType.WINNERS, + status=TournamentMatchStatus.COMPLETED, + player1_id=p1_id, + player2_id=p2_id, + team1_score=s1, + team2_score=s2, + winner_team=1 if s1 > s2 else 2, + completed_at=datetime.utcnow() - timedelta(hours=1, minutes=30), + ) + db.add(tm) + + winner_id = p1_id if s1 > s2 else p2_id + loser_id = p2_id if s1 > s2 else p1_id + winners_r1.append(winner_id) + losers_r1.append(loser_id) + + # Update entry stats + for entry in entries: + if entry.player_id == winner_id: + entry.wins += 1 + elif entry.player_id == loser_id: + entry.losses += 1 + entry.is_in_losers = True + + db.flush() + + # Round 2 Winners Bracket - 2 matches - 1 completed, 1 in progress + # Match 1: players[0] vs players[1] + wm1 = TournamentMatch( + tournament_id=t.id, + round_number=2, + match_number=1, + bracket_type=BracketType.WINNERS, + status=TournamentMatchStatus.COMPLETED, + player1_id=winners_r1[0], + player2_id=winners_r1[1], + team1_score=11, + team2_score=8, + winner_team=1, + completed_at=datetime.utcnow() - timedelta(minutes=45), + ) + db.add(wm1) + + # Update entries + for entry in entries: + if entry.player_id == winners_r1[0]: + entry.wins += 1 + elif entry.player_id == winners_r1[1]: + entry.losses += 1 + entry.is_in_losers = True + + # Match 2: players[3] (seed4 winner) vs players[5] (seed6 winner) + wm2 = TournamentMatch( + tournament_id=t.id, + round_number=2, + match_number=2, + bracket_type=BracketType.WINNERS, + status=TournamentMatchStatus.IN_PROGRESS, + player1_id=winners_r1[2], + player2_id=winners_r1[3], + team1_score=6, + team2_score=7, + ) + db.add(wm2) + + # Assign court 3 for active tournament match + m_active = Match( + court_id=courts[2].id, + stage=MatchStage.TOURNAMENT, + match_type=MatchType.SINGLES, + status=MatchStatus.IN_PROGRESS, + title=f"Tournament R2 - {db.query(Player).filter(Player.id == winners_r1[2]).first().name} vs {db.query(Player).filter(Player.id == winners_r1[3]).first().name}", + max_players=2, + team1_score=6, + team2_score=7, + started_at=datetime.utcnow() - timedelta(minutes=20), + ) + db.add(m_active) + db.flush() + + for player_id, team in [(winners_r1[2], 1), (winners_r1[3], 2)]: + mp = MatchPlayer(match_id=m_active.id, player_id=player_id, team=team, + elo_before=db.query(Player).filter(Player.id == player_id).first().elo_rating) + db.add(mp) + + # Losers Bracket Round 1 - 2 matches (losers from r1 matched up) + lb1 = TournamentMatch( + tournament_id=t.id, + round_number=1, + match_number=1, + bracket_type=BracketType.LOSERS, + status=TournamentMatchStatus.COMPLETED, + player1_id=losers_r1[0], + player2_id=losers_r1[1], + team1_score=9, + team2_score=11, + winner_team=2, + completed_at=datetime.utcnow() - timedelta(minutes=90), + ) + db.add(lb1) + + lb2 = TournamentMatch( + tournament_id=t.id, + round_number=1, + match_number=2, + bracket_type=BracketType.LOSERS, + status=TournamentMatchStatus.COMPLETED, + player1_id=losers_r1[2], + player2_id=losers_r1[3], + team1_score=11, + team2_score=6, + winner_team=1, + completed_at=datetime.utcnow() - timedelta(minutes=60), + ) + db.add(lb2) + + # Update loser entries + for entry in entries: + if entry.player_id == losers_r1[0]: + entry.losses += 1 + entry.is_eliminated = True + entry.final_rank = 8 + elif entry.player_id == losers_r1[3]: + entry.losses += 1 + entry.is_eliminated = True + entry.final_rank = 7 + + db.commit() + db.refresh(t) + print(f"Created tournament: {t.name}") + return t + + +def seed_completed_matches(db: Session, players: list, courts: list): + """Create some historical completed matches""" + past_matches = [] + for i in range(6): + start = datetime.utcnow() - timedelta(days=i + 1, hours=2) + p_list = random.sample(players, 4) + m = Match( + court_id=courts[i % len(courts)].id, + stage=MatchStage.OPEN if i % 2 == 0 else MatchStage.SKILL_BASED, + match_type=MatchType.DOUBLES, + status=MatchStatus.COMPLETED, + title=f"Completed Match #{i + 1}", + max_players=4, + team1_score=11, + team2_score=random.randint(5, 10), + started_at=start, + ended_at=start + timedelta(hours=1), + ) + db.add(m) + db.flush() + + for player, team in zip(p_list, [1, 1, 2, 2]): + mp = MatchPlayer(match_id=m.id, player_id=player.id, team=team, + elo_before=player.elo_rating, elo_after=player.elo_rating + random.uniform(-10, 10), + elo_change=random.uniform(-10, 10), is_winner=(team == 1)) + db.add(mp) + past_matches.append(m) + + db.commit() + print(f"Created {len(past_matches)} completed matches") + + +def main(): + print("🌱 Seeding ServeSync demo data...") + + db = SessionLocal() + try: + clear_data(db) + players = seed_players(db) + courts = seed_courts(db) + seed_bookings(db, players, courts) + seed_active_matches(db, players, courts) + seed_tournament(db, players, courts) + seed_completed_matches(db, players, courts) + print("✅ Seed complete!") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..352e997 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: '3.8' + +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: servesync + POSTGRES_USER: servesync + POSTGRES_PASSWORD: servesync + healthcheck: + test: ["CMD-SHELL", "pg_isready -U servesync"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql://servesync:servesync@db:5432/servesync + REDIS_URL: redis://redis:6379 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + frontend: + build: ./frontend + depends_on: + - backend + restart: unless-stopped + + nginx: + build: ./nginx + ports: + - "8092:80" + depends_on: + - backend + - frontend + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..718583c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..95cea4f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + ServeSync — Pickleball Court Management + + +
+ + + diff --git a/frontend/nginx-frontend.conf b/frontend/nginx-frontend.conf new file mode 100644 index 0000000..f749e02 --- /dev/null +++ b/frontend/nginx-frontend.conf @@ -0,0 +1,9 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9c114ec --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "servesync-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "axios": "^1.6.7" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.2.0", + "tailwindcss": "^3.4.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.35" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..b697428 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..501dee0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..2fcf176 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from '../views/Dashboard.vue' +import Courts from '../views/Courts.vue' +import Matchmaking from '../views/Matchmaking.vue' +import Tournament from '../views/Tournament.vue' +import ScreenDisplay from '../views/ScreenDisplay.vue' +import Players from '../views/Players.vue' + +const routes = [ + { path: '/', name: 'Dashboard', component: Dashboard }, + { path: '/courts', name: 'Courts', component: Courts }, + { path: '/matchmaking', name: 'Matchmaking', component: Matchmaking }, + { path: '/tournament', name: 'Tournament', component: Tournament }, + { path: '/players', name: 'Players', component: Players }, + { path: '/screen/:courtId', name: 'Screen', component: ScreenDisplay }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/stores/app.js b/frontend/src/stores/app.js new file mode 100644 index 0000000..0df6c07 --- /dev/null +++ b/frontend/src/stores/app.js @@ -0,0 +1,125 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import axios from 'axios' + +const api = axios.create({ baseURL: '/api' }) + +export const useAppStore = defineStore('app', () => { + const currentPlayer = ref(null) + const players = ref([]) + const courts = ref([]) + const matches = ref([]) + const activeTournament = ref(null) + const loading = ref(false) + + // Set demo player (no auth needed) + const setCurrentPlayer = (player) => { + currentPlayer.value = player + localStorage.setItem('currentPlayerId', player.id) + } + + const loadPlayers = async () => { + const res = await api.get('/players/') + players.value = res.data + // Auto-set first player as current if not set + if (!currentPlayer.value && players.value.length > 0) { + const savedId = localStorage.getItem('currentPlayerId') + const saved = savedId ? players.value.find(p => p.id == savedId) : null + currentPlayer.value = saved || players.value[0] + } + return players.value + } + + const loadCourts = async () => { + const res = await api.get('/courts/') + courts.value = res.data + return courts.value + } + + const loadMatches = async () => { + const res = await api.get('/matches/') + matches.value = res.data + return matches.value + } + + const loadLobby = async () => { + const res = await api.get('/matches/lobby') + return res.data + } + + const loadActiveMatches = async () => { + const res = await api.get('/matches/active') + return res.data + } + + const createMatch = async (data) => { + const res = await api.post('/matches/', data) + return res.data + } + + const joinMatch = async (matchId, playerId, team) => { + const res = await api.post(`/matches/${matchId}/join`, { player_id: playerId, team }) + return res.data + } + + const startMatch = async (matchId) => { + const res = await api.post(`/matches/${matchId}/start`) + return res.data + } + + const updateScore = async (matchId, team1Score, team2Score) => { + const res = await api.post(`/matches/${matchId}/score`, { team1_score: team1Score, team2_score: team2Score }) + return res.data + } + + const completeMatch = async (matchId, team1Score, team2Score) => { + const res = await api.post(`/matches/${matchId}/complete`, { team1_score: team1Score, team2_score: team2Score }) + return res.data + } + + const bookCourt = async (data) => { + const res = await api.post('/courts/book', data) + return res.data + } + + const loadTournaments = async () => { + const res = await api.get('/tournaments/') + return res.data + } + + const loadTournament = async (id) => { + const res = await api.get(`/tournaments/${id}`) + activeTournament.value = res.data + return res.data + } + + const getLeaderboard = async (limit = 10) => { + const res = await api.get(`/players/leaderboard?limit=${limit}`) + return res.data + } + + const getCourtDisplay = async (courtId) => { + const res = await api.get(`/screen/court/${courtId}`) + return res.data + } + + const getOverview = async () => { + const res = await api.get('/screen/overview') + return res.data + } + + const completeTournamentMatch = async (tournamentId, matchId, s1, s2) => { + const res = await api.post(`/tournaments/${tournamentId}/matches/${matchId}/score`, { + team1_score: s1, team2_score: s2 + }) + return res.data + } + + return { + currentPlayer, players, courts, matches, activeTournament, loading, + setCurrentPlayer, loadPlayers, loadCourts, loadMatches, + loadLobby, loadActiveMatches, createMatch, joinMatch, startMatch, + updateScore, completeMatch, bookCourt, loadTournaments, loadTournament, + getLeaderboard, getCourtDisplay, getOverview, completeTournamentMatch, + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..8fc919a --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,42 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-950 text-gray-100 font-sans; + } +} + +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 cursor-pointer; + } + .btn-primary { + @apply bg-green-500 hover:bg-green-400 text-white; + } + .btn-secondary { + @apply bg-gray-700 hover:bg-gray-600 text-white; + } + .btn-danger { + @apply bg-red-600 hover:bg-red-500 text-white; + } + .card { + @apply bg-gray-900 rounded-xl border border-gray-800 p-6; + } + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } +} + +.tier-bronze { @apply text-amber-600; } +.tier-silver { @apply text-gray-400; } +.tier-gold { @apply text-yellow-400; } +.tier-platinum { @apply text-cyan-400; } +.tier-elite { @apply text-purple-400; } + +.bg-tier-bronze { @apply bg-amber-900/30 border-amber-700/50; } +.bg-tier-silver { @apply bg-gray-800/50 border-gray-600/50; } +.bg-tier-gold { @apply bg-yellow-900/30 border-yellow-700/50; } +.bg-tier-platinum { @apply bg-cyan-900/30 border-cyan-700/50; } +.bg-tier-elite { @apply bg-purple-900/30 border-purple-700/50; } diff --git a/frontend/src/views/Courts.vue b/frontend/src/views/Courts.vue new file mode 100644 index 0000000..465e677 --- /dev/null +++ b/frontend/src/views/Courts.vue @@ -0,0 +1,182 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..106e03e --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,188 @@ + + + diff --git a/frontend/src/views/Matchmaking.vue b/frontend/src/views/Matchmaking.vue new file mode 100644 index 0000000..52a8be0 --- /dev/null +++ b/frontend/src/views/Matchmaking.vue @@ -0,0 +1,408 @@ + + + diff --git a/frontend/src/views/Players.vue b/frontend/src/views/Players.vue new file mode 100644 index 0000000..aad6b87 --- /dev/null +++ b/frontend/src/views/Players.vue @@ -0,0 +1,171 @@ + + + diff --git a/frontend/src/views/ScreenDisplay.vue b/frontend/src/views/ScreenDisplay.vue new file mode 100644 index 0000000..57960c4 --- /dev/null +++ b/frontend/src/views/ScreenDisplay.vue @@ -0,0 +1,386 @@ + + + diff --git a/frontend/src/views/Tournament.vue b/frontend/src/views/Tournament.vue new file mode 100644 index 0000000..b9e4ec9 --- /dev/null +++ b/frontend/src/views/Tournament.vue @@ -0,0 +1,330 @@ + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..779d506 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,30 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + 'pickle': { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + } + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'bounce-slow': 'bounce 2s infinite', + } + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..34f4066 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + '/ws': { + target: 'ws://backend:8000', + ws: true, + } + } + }, + build: { + outDir: 'dist', + } +}) diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..bc38538 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..d949af8 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,45 @@ +upstream backend { + server backend:8000; +} + +upstream frontend { + server frontend:80; +} + +server { + listen 80; + server_name _; + client_max_body_size 20M; + + # Backend API + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + } + + # WebSocket + location /ws/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + } + + # Health check + location /health { + proxy_pass http://backend; + } + + # Frontend + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; + } +}