File size: 8,079 Bytes
5d4959a
 
 
 
69c9e26
1b35f8e
5d4959a
 
69c9e26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b35f8e
5d4959a
69c9e26
 
 
 
 
 
 
 
 
5d4959a
1b35f8e
 
5d4959a
 
 
69c9e26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b35f8e
 
5d4959a
1b35f8e
 
 
 
 
5d4959a
1b35f8e
 
69c9e26
 
 
 
 
 
 
 
 
 
 
 
5d4959a
 
 
 
69c9e26
 
76b596f
 
5d4959a
69c9e26
 
5d4959a
69c9e26
5d4959a
 
 
69c9e26
 
 
5d4959a
 
 
69c9e26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d4959a
 
 
69c9e26
5d4959a
 
 
69c9e26
5d4959a
 
 
69c9e26
 
 
 
 
 
 
 
 
 
 
 
 
5d4959a
69c9e26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
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