diff --git a/PLAN.md b/PLAN.md index 884c0b3..5ff70c5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -10,11 +10,11 @@ ## Initial Deliverables - Thin TeamSnap auth/session backend. - Media upload and clip registration flow. -- Game assignment and gameday session APIs. +- Game assignment and gameday APIs. - Installable React PWA shell with offline-ready game prep scaffolding. - Docker-based local development stack. ## Known Constraints - TeamSnap entities should not be durably mirrored on the backend. -- Gameday lineup changes are local-session state in v1. +- Gameday lineup changes are local state in v1. - Browser clip editing is first-class; backend finalizes playback assets. diff --git a/README.md b/README.md index b34c173..beb60f2 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Walkup is a collaborative baseball walk-up song app built as a React PWA with a - TeamSnap OAuth start/callback/refresh - Session cookie management - Media upload and normalized clip registration -- Game assignments and gameday session APIs +- Game assignments and gameday APIs ## Frontend Responsibilities - TeamSnap SDK bootstrap with server-issued access tokens diff --git a/backend/app/models.py b/backend/app/models.py index d1c3d97..56e3cb6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -81,20 +81,3 @@ class GameAssignment(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) clip: Mapped[AudioClip] = relationship() - - -class PlaybackSession(Base): - __tablename__ = "playback_sessions" - - id: Mapped[int] = mapped_column(primary_key=True) - external_team_id: Mapped[str] = mapped_column(String(128), index=True) - external_game_id: Mapped[str] = mapped_column(String(128), index=True) - gameday_session_id: Mapped[int | None] = mapped_column(ForeignKey("user_sessions.id")) - current_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("game_assignments.id")) - state: Mapped[str] = mapped_column(String(32), default="idle") - last_triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) - - gameday_session: Mapped[UserSession | None] = relationship() - current_assignment: Mapped[GameAssignment | None] = relationship() diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 71cdb6a..fea62af 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -3,19 +3,16 @@ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.orm import Session from ..auth import require_session from ..database import get_db -from ..models import AudioClip, GameAssignment, PlaybackSession, UserSession +from ..models import AudioClip, GameAssignment, UserSession from ..schemas import ( GameAssignmentCreate, GameAssignmentResponse, GamePrepResponse, - PlaybackAction, - PlaybackSessionCreate, - PlaybackSessionResponse, ) router = APIRouter(prefix="/games", tags=["games"]) @@ -133,7 +130,6 @@ def delete_assignment( if external_player_id is not None and assignment.external_player_id != external_player_id: raise HTTPException(status_code=403, detail="Pin does not belong to that player") - db.execute(update(PlaybackSession).where(PlaybackSession.current_assignment_id == assignment.id).values(current_assignment_id=None)) db.delete(assignment) db.commit() @@ -157,61 +153,3 @@ def prepare_game( prepared_at=datetime.now(timezone.utc), assignments=[assignment_to_response(assignment) for assignment in assignments], ) - - -@router.post("/{external_game_id}/gameday/session", response_model=PlaybackSessionResponse) -def create_gameday_session( - external_game_id: str, - payload: PlaybackSessionCreate, - session: UserSession = Depends(require_session), - db: Session = Depends(get_db), -) -> PlaybackSessionResponse: - playback = PlaybackSession( - external_team_id=payload.external_team_id, - external_game_id=external_game_id, - gameday_session_id=session.id, - state="idle", - ) - db.add(playback) - db.commit() - db.refresh(playback) - return PlaybackSessionResponse.model_validate(playback, from_attributes=True) - - -@router.post("/{external_game_id}/gameday/session/{playback_session_id}/trigger", response_model=PlaybackSessionResponse) -def trigger_gameday( - external_game_id: str, - playback_session_id: int, - payload: PlaybackAction, - _: UserSession = Depends(require_session), - db: Session = Depends(get_db), -) -> PlaybackSessionResponse: - playback = db.get(PlaybackSession, playback_session_id) - if playback is None or playback.external_game_id != external_game_id: - raise HTTPException(status_code=404, detail="Playback session not found") - - if payload.assignment_id is None and payload.clip_id is None: - raise HTTPException(status_code=422, detail="Provide a pin or clip to trigger") - - if payload.assignment_id is not None: - assignment = db.get(GameAssignment, payload.assignment_id) - if assignment is None or assignment.external_game_id != external_game_id: - raise HTTPException(status_code=404, detail="Pin not found") - if assignment.clip.hidden: - raise HTTPException(status_code=404, detail="Pin not found") - playback.current_assignment_id = assignment.id - else: - clip = db.get(AudioClip, payload.clip_id) - if clip is None or clip.asset.external_team_id != playback.external_team_id: - raise HTTPException(status_code=404, detail="Clip not found") - if clip.hidden: - raise HTTPException(status_code=404, detail="Clip not found") - if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id: - raise HTTPException(status_code=403, detail="Clip does not belong to that player") - playback.current_assignment_id = None - - playback.state = payload.state - playback.last_triggered_at = datetime.now(timezone.utc) - db.commit() - db.refresh(playback) - return PlaybackSessionResponse.model_validate(playback, from_attributes=True) diff --git a/backend/app/routes/media.py b/backend/app/routes/media.py index 5b7f913..ac6e086 100644 --- a/backend/app/routes/media.py +++ b/backend/app/routes/media.py @@ -6,12 +6,12 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from ..auth import require_session from ..database import get_db -from ..models import AudioAsset, AudioClip, GameAssignment, PlaybackSession, UserSession +from ..models import AudioAsset, AudioClip, GameAssignment, UserSession from ..schemas import ( AudioAssetImportCreate, AudioAssetResponse, @@ -239,14 +239,7 @@ def delete_asset( clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all() clip_ids = [clip.id for clip in clips] if clip_ids: - assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id.in_(clip_ids))).all() db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids))) - if assignment_ids: - db.execute( - update(PlaybackSession) - .where(PlaybackSession.current_assignment_id.in_(assignment_ids)) - .values(current_assignment_id=None) - ) for clip in clips: if clip.normalized_path: storage.delete_relative_path(clip.normalized_path) @@ -361,14 +354,7 @@ def delete_clip( if not can_manage_asset(session, clip.asset, owner_external_player_id): raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads") - assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id == clip.id)).all() db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id)) - if assignment_ids: - db.execute( - update(PlaybackSession) - .where(PlaybackSession.current_assignment_id.in_(assignment_ids)) - .values(current_assignment_id=None) - ) if clip.normalized_path: storage.delete_relative_path(clip.normalized_path) db.delete(clip) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 94f90f1..76dc6db 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -126,21 +126,7 @@ class AudioClipReorder(BaseModel): clip_ids: list[int] = Field(min_length=1) -class PlaybackSessionCreate(BaseModel): - external_team_id: str - - class PlaybackAction(BaseModel): assignment_id: int | None = None clip_id: int | None = None external_player_id: str | None = None - state: str = "playing" - - -class PlaybackSessionResponse(BaseModel): - id: int - external_team_id: str - external_game_id: str - current_assignment_id: int | None - state: str - last_triggered_at: datetime | None diff --git a/docs/architecture.md b/docs/architecture.md index 24d25ae..27480fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Walkup is a baseball walk-up song app with a React PWA frontend and a FastAPI ba - The frontend runs as a browser app and PWA. - The backend owns authentication, persisted app data, and media processing. - TeamSnap is the source of truth for teams, members, events, lineups, and availability. -- The backend stores only app-owned data plus TeamSnap external IDs and tokens needed for the session flow. +- The backend stores only app-owned data plus TeamSnap external IDs and tokens needed for the auth flow. ## Frontend diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index db644fb..1784d12 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,7 +6,6 @@ import type { AudioClipUpdate, GameAssignment, GamePrepResponse, - PlaybackSession, SessionResponse, TeamSnapTokenResponse, } from "./types"; @@ -196,19 +195,4 @@ export const api = { } }), prepareGame: (gameId: string) => request(`/games/${encodeURIComponent(gameId)}/prep`), - createGamedaySession: (gameId: string, teamId: string) => - request(`/games/${encodeURIComponent(gameId)}/gameday/session`, { - method: "POST", - body: JSON.stringify({ external_team_id: teamId }), - }), - triggerGamedayAssignment: (gameId: string, playbackSessionId: number, assignmentId: number) => - request(`/games/${encodeURIComponent(gameId)}/gameday/session/${playbackSessionId}/trigger`, { - method: "POST", - body: JSON.stringify({ assignment_id: assignmentId, state: "playing" }), - }), - triggerGamedayClip: (gameId: string, playbackSessionId: number, clipId: number, playerId: string) => - request(`/games/${encodeURIComponent(gameId)}/gameday/session/${playbackSessionId}/trigger`, { - method: "POST", - body: JSON.stringify({ clip_id: clipId, external_player_id: playerId, state: "playing" }), - }), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 74d2482..670d035 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -86,15 +86,6 @@ export interface GamePrepResponse { assignments: GameAssignment[]; } -export interface PlaybackSession { - id: number; - external_team_id: string; - external_game_id: string; - current_assignment_id?: number | null; - state: string; - last_triggered_at?: string | null; -} - export interface TeamSnapTeam { id: number | string; name?: string; diff --git a/frontend/src/pages/GamedayPage.tsx b/frontend/src/pages/GamedayPage.tsx index 7e1802d..1e109f8 100644 --- a/frontend/src/pages/GamedayPage.tsx +++ b/frontend/src/pages/GamedayPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { api } from "../api/client"; @@ -12,7 +12,6 @@ import { formatGameTitle, formatMemberName, formatMemberJerseyNumber, - formatTeamLabel, findLineupEntryForMember, isPlayerMember, orderMembersByLineupAndRsvps, @@ -30,17 +29,17 @@ type NowPlaying = { const DEFAULT_FADE_OUT_MS = 1000; -function getAvailabilityDotClass(statusCode: number | null | undefined): string { +function getAvailabilityIconClass(statusCode: number | null | undefined): string { if (statusCode === 1) { - return "is-yes"; + return "bi-check-circle-fill text-success"; } if (statusCode === 0) { - return "is-no"; + return "bi-x-circle-fill text-danger"; } if (statusCode === 2) { - return "is-maybe"; + return "bi-question-circle-fill text-primary"; } - return "is-blank"; + return "bi-circle-fill text-secondary"; } function getAvailabilityDotLabel(statusCode: number | null | undefined): string { @@ -64,7 +63,6 @@ export function GamedayPage() { const [expandedPlayerId, setExpandedPlayerId] = useState(""); const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players"); const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false); - const [playbackSessionId, setPlaybackSessionId] = useState(null); const [playingClipKey, setPlayingClipKey] = useState(null); const [nowPlaying, setNowPlaying] = useState(null); const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false); @@ -92,7 +90,6 @@ export function GamedayPage() { useEffect(() => { stopPlayback(); - setPlaybackSessionId(null); setExpandedPlayerId(""); hasInitializedExpandedPlayerRef.current = false; }, [selectedGameId]); @@ -205,7 +202,6 @@ export function GamedayPage() { const selectedPlayer = walkup.members.find((member) => String(member.id) === selectedPlayerId) ?? (selectedPlayerId ? { id: selectedPlayerId } : null); - const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : ""; const selectedPinnedAssignments = useMemo( () => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId), @@ -216,20 +212,6 @@ export function GamedayPage() { [selectedPinnedAssignments], ); - const createSession = useMutation({ - mutationFn: () => api.createGamedaySession(selectedGameId, teamId), - onSuccess: (session) => setPlaybackSessionId(session.id), - }); - - const triggerClipMutation = useMutation({ - mutationFn: (clip: AudioClip) => { - if (!playbackSessionId) { - throw new Error("Start a gameday session first"); - } - return api.triggerGamedayClip(selectedGameId, playbackSessionId, clip.id, selectedPlayerId); - }, - }); - const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null; function selectGame(gameId: string) { @@ -402,7 +384,6 @@ export function GamedayPage() { }, clip.start_ms, clip.end_ms, - () => triggerClipMutation.mutateAsync(clip), ); } @@ -462,9 +443,9 @@ export function GamedayPage() {
-
-

Players

-
+
+

Players

+