From 4421f61c24c4adefcefb849074fc63f20950087d Mon Sep 17 00:00:00 2001 From: Nemo Date: Thu, 19 Mar 2026 07:35:51 +0800 Subject: [PATCH] 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 --- backend/Dockerfile | 12 +- backend/app/{api => }/__init__.py | 0 backend/app/api/admin.py | 227 ------ backend/app/api/courts.py | 218 ------ backend/app/api/matches.py | 160 ---- backend/app/api/players.py | 94 --- backend/app/api/screen.py | 163 ---- backend/app/api/tournaments.py | 82 -- backend/app/bracket.py | 148 ++++ backend/app/config.py | 15 - backend/app/database.py | 12 +- backend/app/main.py | 161 +--- backend/app/models.py | 128 ++++ backend/app/models/__init__.py | 5 - backend/app/models/booking.py | 33 - backend/app/models/court.py | 22 - backend/app/models/match.py | 73 -- backend/app/models/player.py | 46 -- backend/app/models/tournament.py | 93 --- backend/app/{services => routers}/__init__.py | 0 backend/app/routers/admin.py | 16 + backend/app/routers/courts.py | 81 ++ backend/app/routers/dashboard.py | 136 ++++ backend/app/routers/events.py | 294 ++++++++ backend/app/routers/matches.py | 156 ++++ backend/app/routers/players.py | 52 ++ backend/app/schemas.py | 163 ++++ backend/app/services/elo.py | 53 -- backend/app/services/matchmaking.py | 214 ------ backend/app/services/tournament.py | 281 ------- backend/app/websocket_manager.py | 40 + backend/app/workers/__init__.py | 0 backend/requirements.txt | 20 +- backend/seed.py | 706 ++++++++---------- backend/start.sh | 25 +- frontend/Dockerfile | 6 +- frontend/index.html | 4 +- frontend/package.json | 14 +- frontend/src/App.vue | 134 ++-- frontend/src/main.js | 2 +- frontend/src/router/index.js | 23 +- frontend/src/stores/api.js | 53 ++ frontend/src/stores/app.js | 125 ---- frontend/src/style.css | 41 +- frontend/src/views/Courts.vue | 262 +++---- frontend/src/views/Dashboard.vue | 340 +++++---- frontend/src/views/EventBuilder.vue | 278 +++++++ frontend/src/views/EventControl.vue | 341 +++++++++ frontend/src/views/Events.vue | 56 ++ frontend/src/views/Matchmaking.vue | 408 ---------- frontend/src/views/Players.vue | 286 ++++--- frontend/src/views/Screen.vue | 109 +++ frontend/src/views/ScreenDisplay.vue | 386 ---------- frontend/src/views/Tournament.vue | 330 -------- frontend/tailwind.config.js | 17 +- frontend/vite.config.js | 14 +- 56 files changed, 2949 insertions(+), 4209 deletions(-) rename backend/app/{api => }/__init__.py (100%) delete mode 100644 backend/app/api/admin.py delete mode 100644 backend/app/api/courts.py delete mode 100644 backend/app/api/matches.py delete mode 100644 backend/app/api/players.py delete mode 100644 backend/app/api/screen.py delete mode 100644 backend/app/api/tournaments.py create mode 100644 backend/app/bracket.py delete mode 100644 backend/app/config.py create mode 100644 backend/app/models.py delete mode 100644 backend/app/models/__init__.py delete mode 100644 backend/app/models/booking.py delete mode 100644 backend/app/models/court.py delete mode 100644 backend/app/models/match.py delete mode 100644 backend/app/models/player.py delete mode 100644 backend/app/models/tournament.py rename backend/app/{services => routers}/__init__.py (100%) create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/courts.py create mode 100644 backend/app/routers/dashboard.py create mode 100644 backend/app/routers/events.py create mode 100644 backend/app/routers/matches.py create mode 100644 backend/app/routers/players.py create mode 100644 backend/app/schemas.py delete mode 100644 backend/app/services/elo.py delete mode 100644 backend/app/services/matchmaking.py delete mode 100644 backend/app/services/tournament.py create mode 100644 backend/app/websocket_manager.py delete mode 100644 backend/app/workers/__init__.py create mode 100644 frontend/src/stores/api.js delete mode 100644 frontend/src/stores/app.js create mode 100644 frontend/src/views/EventBuilder.vue create mode 100644 frontend/src/views/EventControl.vue create mode 100644 frontend/src/views/Events.vue delete mode 100644 frontend/src/views/Matchmaking.vue create mode 100644 frontend/src/views/Screen.vue delete mode 100644 frontend/src/views/ScreenDisplay.vue delete mode 100644 frontend/src/views/Tournament.vue diff --git a/backend/Dockerfile b/backend/Dockerfile index 2fcc0b3..a7c8479 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/api/__init__.py b/backend/app/__init__.py similarity index 100% rename from backend/app/api/__init__.py rename to backend/app/__init__.py diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py deleted file mode 100644 index 458b214..0000000 --- a/backend/app/api/admin.py +++ /dev/null @@ -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)} diff --git a/backend/app/api/courts.py b/backend/app/api/courts.py deleted file mode 100644 index f931f2b..0000000 --- a/backend/app/api/courts.py +++ /dev/null @@ -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 - ] diff --git a/backend/app/api/matches.py b/backend/app/api/matches.py deleted file mode 100644 index e02fa33..0000000 --- a/backend/app/api/matches.py +++ /dev/null @@ -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) diff --git a/backend/app/api/players.py b/backend/app/api/players.py deleted file mode 100644 index c4585a0..0000000 --- a/backend/app/api/players.py +++ /dev/null @@ -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) diff --git a/backend/app/api/screen.py b/backend/app/api/screen.py deleted file mode 100644 index e0edfb9..0000000 --- a/backend/app/api/screen.py +++ /dev/null @@ -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(), - } diff --git a/backend/app/api/tournaments.py b/backend/app/api/tournaments.py deleted file mode 100644 index 48bd808..0000000 --- a/backend/app/api/tournaments.py +++ /dev/null @@ -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", []) diff --git a/backend/app/bracket.py b/backend/app/bracket.py new file mode 100644 index 0000000..6d55d7f --- /dev/null +++ b/backend/app/bracket.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index c524c76..0000000 --- a/backend/app/config.py +++ /dev/null @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py index 61662ad..7fae461 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 0c12a4c..7cd9479 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..2266167 --- /dev/null +++ b/backend/app/models.py @@ -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]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 0dc0599..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -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 diff --git a/backend/app/models/booking.py b/backend/app/models/booking.py deleted file mode 100644 index a0f568d..0000000 --- a/backend/app/models/booking.py +++ /dev/null @@ -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") diff --git a/backend/app/models/court.py b/backend/app/models/court.py deleted file mode 100644 index 6202da5..0000000 --- a/backend/app/models/court.py +++ /dev/null @@ -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") diff --git a/backend/app/models/match.py b/backend/app/models/match.py deleted file mode 100644 index 39d9acf..0000000 --- a/backend/app/models/match.py +++ /dev/null @@ -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") diff --git a/backend/app/models/player.py b/backend/app/models/player.py deleted file mode 100644 index bacd1fb..0000000 --- a/backend/app/models/player.py +++ /dev/null @@ -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() diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py deleted file mode 100644 index ed314ba..0000000 --- a/backend/app/models/tournament.py +++ /dev/null @@ -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") diff --git a/backend/app/services/__init__.py b/backend/app/routers/__init__.py similarity index 100% rename from backend/app/services/__init__.py rename to backend/app/routers/__init__.py diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..9009783 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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} diff --git a/backend/app/routers/courts.py b/backend/app/routers/courts.py new file mode 100644 index 0000000..2b9d8e4 --- /dev/null +++ b/backend/app/routers/courts.py @@ -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} diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..e7db760 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -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, + } diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py new file mode 100644 index 0000000..cd1761a --- /dev/null +++ b/backend/app/routers/events.py @@ -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} diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py new file mode 100644 index 0000000..96f2f94 --- /dev/null +++ b/backend/app/routers/matches.py @@ -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} diff --git a/backend/app/routers/players.py b/backend/app/routers/players.py new file mode 100644 index 0000000..aec3ca7 --- /dev/null +++ b/backend/app/routers/players.py @@ -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} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..5b1e9f4 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/elo.py b/backend/app/services/elo.py deleted file mode 100644 index 259dfb7..0000000 --- a/backend/app/services/elo.py +++ /dev/null @@ -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) diff --git a/backend/app/services/matchmaking.py b/backend/app/services/matchmaking.py deleted file mode 100644 index 6293798..0000000 --- a/backend/app/services/matchmaking.py +++ /dev/null @@ -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() diff --git a/backend/app/services/tournament.py b/backend/app/services/tournament.py deleted file mode 100644 index 99581ac..0000000 --- a/backend/app/services/tournament.py +++ /dev/null @@ -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, - } diff --git a/backend/app/websocket_manager.py b/backend/app/websocket_manager.py new file mode 100644 index 0000000..d8a052d --- /dev/null +++ b/backend/app/websocket_manager.py @@ -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() diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/requirements.txt b/backend/requirements.txt index ea4d10e..cf43104 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/seed.py b/backend/seed.py index 8b52c3c..1324a0f 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -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() - db.query(Match).delete() - db.query(Booking).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") +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() -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}, - ] +# Clear existing data +print("Clearing existing data...") +db.query(Match).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() - 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) +# ─── PLAYERS ──────────────────────────────────────────────────────────────── +print("Creating players...") +players_data = [ + {"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}, +] - db.commit() - for p in players: - db.refresh(p) - print(f"Created {len(players)} players") - return players +players = [] +for pd in players_data: + p = Player(**pd) + db.add(p) + players.append(p) +db.commit() +for p in players: + db.refresh(p) +print(f" Created {len(players)} players") +# ─── COURTS ───────────────────────────────────────────────────────────────── +print("Creating courts...") +courts_data = [ + {"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(**cd) + db.add(c) + courts.append(c) +db.commit() +for c in courts: + db.refresh(c) +print(f" Created {len(courts)} courts") -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"}, - ] +# ─── RESERVATIONS ─────────────────────────────────────────────────────────── +print("Creating reservations...") +now = datetime.utcnow() +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) - 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) +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"), +] +for r in reservations: + db.add(r) +db.commit() - db.commit() - for c in courts: - db.refresh(c) - print(f"Created {len(courts)} courts") - return courts +# ─── 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) +# 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() -def seed_bookings(db: Session, players: list, courts: list): - now = datetime.utcnow() - bookings = [] +# 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) - # 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) +# 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) - 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), +# 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( + 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(m1) - db.flush() + db.add(m) +db.commit() - 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) +# 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 - # 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), +# 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(m2) - db.flush() + db.add(m) +db.commit() - 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) +# 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 - # 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, +# 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(m3) - db.flush() + db.add(m) + qf_db.append(m) - 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) +db.commit() +for m in qf_db: + db.refresh(m) - # 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, +# 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(m4) - db.flush() + db.add(m) + sf_db.append(m) +db.commit() - 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) +# 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() - db.commit() - for m in matches: - db.refresh(m) - print(f"Created {len(matches)} matches (2 active, 2 lobby)") - return matches +print(f" In-progress event created: {event2.name}") - -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() +db.close() +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)") diff --git a/backend/start.sh b/backend/start.sh index 39022cb..b360b0f 100644 --- a/backend/start.sh +++ b/backend/start.sh @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 718583c..d0c5c2b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html index 95cea4f..0242ee7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - - ServeSync — Pickleball Court Management + ServeSync — Court Manager +
diff --git a/frontend/package.json b/frontend/package.json index 9c114ec..be8c295 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b697428..5630068 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,105 +1,71 @@ diff --git a/frontend/src/main.js b/frontend/src/main.js index 501dee0..c4b3a5f 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2fcf176..72e92eb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/api.js b/frontend/src/stores/api.js new file mode 100644 index 0000000..d858013 --- /dev/null +++ b/frontend/src/stores/api.js @@ -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' }) +} diff --git a/frontend/src/stores/app.js b/frontend/src/stores/app.js deleted file mode 100644 index 0df6c07..0000000 --- a/frontend/src/stores/app.js +++ /dev/null @@ -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, - } -}) diff --git a/frontend/src/style.css b/frontend/src/style.css index 8fc919a..70cc241 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/views/Courts.vue b/frontend/src/views/Courts.vue index 465e677..62de778 100644 --- a/frontend/src/views/Courts.vue +++ b/frontend/src/views/Courts.vue @@ -1,182 +1,148 @@ diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 106e03e..98da9ad 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,188 +1,222 @@ diff --git a/frontend/src/views/EventBuilder.vue b/frontend/src/views/EventBuilder.vue new file mode 100644 index 0000000..2b85cd0 --- /dev/null +++ b/frontend/src/views/EventBuilder.vue @@ -0,0 +1,278 @@ + + + diff --git a/frontend/src/views/EventControl.vue b/frontend/src/views/EventControl.vue new file mode 100644 index 0000000..88b17c2 --- /dev/null +++ b/frontend/src/views/EventControl.vue @@ -0,0 +1,341 @@ + + + diff --git a/frontend/src/views/Events.vue b/frontend/src/views/Events.vue new file mode 100644 index 0000000..bb7158e --- /dev/null +++ b/frontend/src/views/Events.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/views/Matchmaking.vue b/frontend/src/views/Matchmaking.vue deleted file mode 100644 index 52a8be0..0000000 --- a/frontend/src/views/Matchmaking.vue +++ /dev/null @@ -1,408 +0,0 @@ - - - diff --git a/frontend/src/views/Players.vue b/frontend/src/views/Players.vue index aad6b87..d6ca39f 100644 --- a/frontend/src/views/Players.vue +++ b/frontend/src/views/Players.vue @@ -1,118 +1,103 @@ diff --git a/frontend/src/views/Screen.vue b/frontend/src/views/Screen.vue new file mode 100644 index 0000000..a930f45 --- /dev/null +++ b/frontend/src/views/Screen.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/src/views/ScreenDisplay.vue b/frontend/src/views/ScreenDisplay.vue deleted file mode 100644 index 57960c4..0000000 --- a/frontend/src/views/ScreenDisplay.vue +++ /dev/null @@ -1,386 +0,0 @@ - - - diff --git a/frontend/src/views/Tournament.vue b/frontend/src/views/Tournament.vue deleted file mode 100644 index b9e4ec9..0000000 --- a/frontend/src/views/Tournament.vue +++ /dev/null @@ -1,330 +0,0 @@ - - - diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 779d506..f080f5e 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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: [], } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 34f4066..d5e642d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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', } })