import uuid from datetime import datetime, timezone import gspread import streamlit as st from google.oauth2.credentials import Credentials _PLAYERS_HEADER = ["player_id", "name", "created_at"] _GAMES_HEADER = ["game_id", "name", "created_at"] # game_name/player_name stay alongside the id columns (rather than being # replaced) so rows written before this schema existed keep displaying # correctly, and so display code never strictly depends on an id lookup # succeeding. _GAMES_PLAYED_HEADER = [ "timestamp", "game_id", "game_name", "session_id", "round_number", "player_id", "player_name", "score", ] _WORKBOOK_TITLE_PREFIX = "BoardGameTracker" # Sheets API enforces a strict per-minute read quota. Every worksheet lookup # and every full-table read counts against it, and a Streamlit rerun fires on # every widget interaction -- so naive per-call reads blow through the quota # almost immediately during an active game. These two caches are the fix: # worksheet *handles* (including the one-time header-migration check) are # cached for the life of the session, and row *data* is cached briefly with # an explicit invalidation on every write. _RECORDS_CACHE_TTL = 20 # seconds def _get_user_client(access_token: str) -> gspread.Client: creds = Credentials(token=access_token) return gspread.authorize(creds) def _ensure_columns(ws: gspread.Worksheet, required_columns: list[str]) -> list[str]: """Appends any missing columns to the end of the header row. Existing data is never touched or reordered -- only new columns get added at the end -- so sheets created before a schema change keep working unmodified; old rows simply have blank cells for the new columns. """ header = ws.row_values(1) missing = [c for c in required_columns if c not in header] if missing: header = header + missing ws.update([header], "A1") return header def _get_or_create_worksheet( wb: gspread.Spreadsheet, title: str, header: list[str] ) -> gspread.Worksheet: cache_key = f"_ws_handle_{wb.id}_{title}" if cache_key in st.session_state: return st.session_state[cache_key] try: ws = wb.worksheet(title) _ensure_columns(ws, header) except gspread.exceptions.WorksheetNotFound: ws = wb.add_worksheet(title=title, rows=1, cols=len(header)) ws.append_row(header) st.session_state[cache_key] = ws return ws @st.cache_data(ttl=_RECORDS_CACHE_TTL, show_spinner=False) def _cached_records(_ws: gspread.Worksheet, cache_key: str) -> list[dict]: return _ws.get_all_records() def _get_records(wb: gspread.Spreadsheet, ws: gspread.Worksheet, title: str) -> list[dict]: return _cached_records(ws, f"{wb.id}:{title}") def _invalidate_records_cache() -> None: _cached_records.clear() def _append_row(ws: gspread.Worksheet, row: dict) -> None: column_order = ws.row_values(1) ws.append_row([row.get(c, "") for c in column_order]) _invalidate_records_cache() def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spreadsheet: """Returns the user's own Sheets workbook, creating it in their Drive on first use. Uses the user's own OAuth access token (drive.file scope), so the file is owned by them directly -- no service account or shared storage involved. """ client = _get_user_client(access_token) title = f"{_WORKBOOK_TITLE_PREFIX} - {email}" existing = client.list_spreadsheet_files(title=title) if existing: sh = client.open_by_key(existing[0]["id"]) else: sh = client.create(title) players_ws = sh.sheet1 players_ws.update_title("players") players_ws.append_row(_PLAYERS_HEADER) # Idempotent: creates "games" and migrates "games_played" to the current # schema for pre-existing workbooks too, not just brand-new ones. _get_or_create_worksheet(sh, "players", _PLAYERS_HEADER) _get_or_create_worksheet(sh, "games", _GAMES_HEADER) _get_or_create_worksheet(sh, "games_played", _GAMES_PLAYED_HEADER) return sh def list_players(wb: gspread.Spreadsheet) -> list[dict]: ws = _get_or_create_worksheet(wb, "players", _PLAYERS_HEADER) return _get_records(wb, ws, "players") def add_player(wb: gspread.Spreadsheet, name: str) -> str: ws = _get_or_create_worksheet(wb, "players", _PLAYERS_HEADER) existing = _get_records(wb, ws, "players") for row in existing: if str(row.get("name", "")).strip().lower() == name.strip().lower(): return row["player_id"] player_id = uuid.uuid4().hex _append_row( ws, {"player_id": player_id, "name": name, "created_at": datetime.now(timezone.utc).isoformat()} ) return player_id def list_games(wb: gspread.Spreadsheet) -> list[dict]: ws = _get_or_create_worksheet(wb, "games", _GAMES_HEADER) return _get_records(wb, ws, "games") def get_or_create_game(wb: gspread.Spreadsheet, name: str) -> str: ws = _get_or_create_worksheet(wb, "games", _GAMES_HEADER) existing = _get_records(wb, ws, "games") for row in existing: if str(row.get("name", "")).strip().lower() == name.strip().lower(): return row["game_id"] game_id = uuid.uuid4().hex _append_row( ws, {"game_id": game_id, "name": name, "created_at": datetime.now(timezone.utc).isoformat()} ) return game_id def list_game_names(wb: gspread.Spreadsheet) -> list[str]: """Distinct game names played, most-recently-used first.""" ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) names = [row["game_name"] for row in _get_records(wb, ws, "games_played") if row.get("game_name")] return list(dict.fromkeys(reversed(names))) def record_round( wb: gspread.Spreadsheet, *, game_id: str, game_name: str, session_id: str, round_number: int, player_id: str, player_name: str, score: int, ) -> None: ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) _append_row( ws, { "timestamp": datetime.now(timezone.utc).isoformat(), "game_id": game_id, "game_name": game_name, "session_id": session_id, "round_number": round_number, "player_id": player_id, "player_name": player_name, "score": score, }, ) def list_game_sessions(wb: gspread.Spreadsheet) -> list[dict]: """One summary entry per distinct session_id, most recently played first.""" ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) rows = _get_records(wb, ws, "games_played") sessions: dict[str, dict] = {} order: list[str] = [] for row in rows: sid = row.get("session_id") if not sid: continue if sid not in sessions: sessions[sid] = { "game_name": row.get("game_name") or "Untitled", "timestamp": row.get("timestamp", ""), "players": set(), "rounds": set(), } order.append(sid) if row.get("player_name"): sessions[sid]["players"].add(row["player_name"]) sessions[sid]["rounds"].add(row.get("round_number")) return [ { "session_id": sid, "game_name": sessions[sid]["game_name"], "timestamp": sessions[sid]["timestamp"], "num_players": len(sessions[sid]["players"]), "num_rounds": len(sessions[sid]["rounds"]), } for sid in reversed(order) ] def get_session_rounds(wb: gspread.Spreadsheet, session_id: str) -> list[dict]: """Raw games_played rows for one session, sorted by round number.""" ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) rows = _get_records(wb, ws, "games_played") matching = [r for r in rows if str(r.get("session_id")) == str(session_id)] matching.sort(key=lambda r: int(r.get("round_number") or 0)) return matching