RandomCatLover commited on
Commit
69c9e26
·
1 Parent(s): fcb850d

added good working stuff

Browse files
app.py CHANGED
@@ -41,16 +41,22 @@ def _access_token_valid() -> bool:
41
  )
42
 
43
 
44
- def _handle_drive_oauth_callback(cookie_manager) -> None:
45
- """Completes the one-time "Connect Google Drive" exchange, if we're mid-flow."""
 
 
 
 
 
 
46
  if not st.user.is_logged_in:
47
  return
48
  code = st.query_params.get("code")
49
  state = st.query_params.get("state")
50
- if not code or not state or state != st.session_state.get("drive_oauth_state"):
51
  return
52
 
53
- st.session_state.pop("drive_oauth_state", None)
54
  try:
55
  tokens = google_oauth.exchange_code(
56
  st.secrets["auth"]["client_id"],
@@ -71,19 +77,13 @@ def _handle_drive_oauth_callback(cookie_manager) -> None:
71
  "Drive access will work for this session only -- try disconnecting "
72
  "the app at myaccount.google.com/permissions and reconnecting."
73
  )
74
- elif cookie_manager is not None:
75
- cookie_token_store.save_refresh_token(cookie_manager, st.user.email, refresh_token)
76
  _store_access_token(tokens)
77
  st.query_params.clear()
78
- # Deliberately no st.rerun() here: it would abandon this script run before
79
- # the browser has a chance to mount the cookie-manager's "set" component
80
- # and actually execute the JS that writes document.cookie, so the saved
81
- # refresh_token would never actually reach the browser. Letting this run
82
- # finish naturally still reflects the fresh access token immediately,
83
- # since the rest of app.py (sidebar, page) renders right after this call.
84
 
85
 
86
- def _ensure_drive_access(cookie_manager) -> None:
87
  """Populates session_state's Drive access token, silently refreshing from a
88
  stored refresh_token when possible. Shows a one-time "Connect Google Drive"
89
  link only when neither a live token nor a stored refresh_token exists.
@@ -91,8 +91,8 @@ def _ensure_drive_access(cookie_manager) -> None:
91
  if not st.user.is_logged_in or _access_token_valid():
92
  return
93
 
94
- if cookie_manager is not None:
95
- refresh_token = cookie_token_store.get_refresh_token(cookie_manager, st.user.email)
96
  if refresh_token:
97
  try:
98
  tokens = google_oauth.refresh_access_token(
@@ -104,10 +104,10 @@ def _ensure_drive_access(cookie_manager) -> None:
104
  return
105
  except Exception as e:
106
  st.warning(f"Stored Drive access expired or was revoked ({e}); reconnecting.")
107
- cookie_token_store.delete_refresh_token(cookie_manager, st.user.email)
108
 
109
  state = uuid.uuid4().hex
110
- st.session_state["drive_oauth_state"] = state
111
  authorize_url = google_oauth.build_authorize_url(
112
  st.secrets["auth"]["client_id"], _drive_redirect_uri(), state
113
  )
@@ -115,18 +115,12 @@ def _ensure_drive_access(cookie_manager) -> None:
115
 
116
  with st.expander("Drive connection diagnostics"):
117
  st.write("Logged in as:", st.user.email)
118
- st.write("Cookie storage configured:", cookie_manager is not None)
119
- if cookie_manager is not None:
120
- found = cookie_token_store.get_refresh_token(cookie_manager, st.user.email) is not None
121
- st.write("Refresh token cookie present:", found)
122
- st.write("Raw cookies seen by the app:", list(cookie_manager.cookies.keys()))
123
-
124
 
125
- # Constructed at most once per script run -- see get_cookie_manager's docstring.
126
- _cookie_manager = cookie_token_store.get_cookie_manager() if "cookies" in st.secrets else None
127
 
128
  if "auth" in st.secrets:
129
- _handle_drive_oauth_callback(_cookie_manager)
130
 
131
  with st.sidebar:
132
  if "auth" not in st.secrets:
@@ -136,12 +130,15 @@ with st.sidebar:
136
  else:
137
  st.caption(f"Logged in as {st.user.email}")
138
  st.button("Log out", on_click=st.logout)
139
- _ensure_drive_access(_cookie_manager)
140
 
141
- home_page = st.Page("pages/home.py", title="Home", url_path="home", default=True)
142
- skull_king_page = st.Page("pages/skull_king.py", title="Skull King", url_path="skull_king")
143
- dungeon_draft_page = st.Page("pages/dungeon_draft.py", title="Dungeon Draft", url_path="dungeon_draft")
144
- points_tracker_page = st.Page("pages/points_tracker.py", title="Points Tracker", url_path="points_tracker")
 
145
 
146
- pg = st.navigation([home_page, skull_king_page, dungeon_draft_page, points_tracker_page])
 
 
147
  pg.run()
 
41
  )
42
 
43
 
44
+ def _handle_drive_oauth_callback() -> None:
45
+ """Completes the one-time "Connect Google Drive" exchange, if we're mid-flow.
46
+
47
+ st.link_button always opens the authorize URL in a new tab, so this runs
48
+ in a session that never had st.session_state populated by the tab that
49
+ started the flow -- the CSRF state has to be compared against a cookie
50
+ (shared across tabs), not session_state (tab-scoped).
51
+ """
52
  if not st.user.is_logged_in:
53
  return
54
  code = st.query_params.get("code")
55
  state = st.query_params.get("state")
56
+ if not code or not state or state != cookie_token_store.get_oauth_state():
57
  return
58
 
59
+ cookie_token_store.clear_oauth_state()
60
  try:
61
  tokens = google_oauth.exchange_code(
62
  st.secrets["auth"]["client_id"],
 
77
  "Drive access will work for this session only -- try disconnecting "
78
  "the app at myaccount.google.com/permissions and reconnecting."
79
  )
80
+ elif "cookies" in st.secrets:
81
+ cookie_token_store.save_refresh_token(st.user.email, refresh_token)
82
  _store_access_token(tokens)
83
  st.query_params.clear()
 
 
 
 
 
 
84
 
85
 
86
+ def _ensure_drive_access() -> None:
87
  """Populates session_state's Drive access token, silently refreshing from a
88
  stored refresh_token when possible. Shows a one-time "Connect Google Drive"
89
  link only when neither a live token nor a stored refresh_token exists.
 
91
  if not st.user.is_logged_in or _access_token_valid():
92
  return
93
 
94
+ if "cookies" in st.secrets:
95
+ refresh_token = cookie_token_store.get_refresh_token(st.user.email)
96
  if refresh_token:
97
  try:
98
  tokens = google_oauth.refresh_access_token(
 
104
  return
105
  except Exception as e:
106
  st.warning(f"Stored Drive access expired or was revoked ({e}); reconnecting.")
107
+ cookie_token_store.delete_refresh_token(st.user.email)
108
 
109
  state = uuid.uuid4().hex
110
+ cookie_token_store.save_oauth_state(state)
111
  authorize_url = google_oauth.build_authorize_url(
112
  st.secrets["auth"]["client_id"], _drive_redirect_uri(), state
113
  )
 
115
 
116
  with st.expander("Drive connection diagnostics"):
117
  st.write("Logged in as:", st.user.email)
118
+ st.write("Cookie storage configured:", "cookies" in st.secrets)
119
+ st.write("Raw cookies seen by the app:", list(st.context.cookies.keys()))
 
 
 
 
120
 
 
 
121
 
122
  if "auth" in st.secrets:
123
+ _handle_drive_oauth_callback()
124
 
125
  with st.sidebar:
126
  if "auth" not in st.secrets:
 
130
  else:
131
  st.caption(f"Logged in as {st.user.email}")
132
  st.button("Log out", on_click=st.logout)
133
+ _ensure_drive_access()
134
 
135
+ home_page = st.Page("app_pages/home.py", title="Home", url_path="home", default=True)
136
+ skull_king_page = st.Page("app_pages/skull_king.py", title="Skull King", url_path="skull_king")
137
+ dungeon_draft_page = st.Page("app_pages/dungeon_draft.py", title="Dungeon Draft", url_path="dungeon_draft")
138
+ points_tracker_page = st.Page("app_pages/points_tracker.py", title="Points Tracker", url_path="points_tracker")
139
+ game_detail_page = st.Page("app_pages/game_detail.py", title="Game Detail", url_path="game_detail")
140
 
141
+ pg = st.navigation(
142
+ [home_page, skull_king_page, dungeon_draft_page, points_tracker_page, game_detail_page]
143
+ )
144
  pg.run()
{pages → app_pages}/dungeon_draft.py RENAMED
File without changes
app_pages/game_detail.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ import scoreboard
4
+ import sheets_backend as sheets
5
+ from workbook_access import get_workbook
6
+
7
+ st.title("Game Detail")
8
+
9
+ session_id = st.query_params.get("session_id")
10
+
11
+ if not session_id:
12
+ st.info("No game selected -- go to Points Tracker's Game History tab and click View on a game.")
13
+ else:
14
+ wb = get_workbook()
15
+ if wb is None:
16
+ st.info("Log in with Google to view game history.")
17
+ else:
18
+ rows = sheets.get_session_rounds(wb, session_id)
19
+ if not rows:
20
+ st.warning("No data found for this game session.")
21
+ else:
22
+ game_name = rows[0].get("game_name") or "Untitled"
23
+ timestamp = rows[0].get("timestamp", "")
24
+ st.subheader(f"{game_name}")
25
+ st.caption(f"Played {timestamp}")
26
+
27
+ players, scores = scoreboard.rows_to_players_and_scores(rows)
28
+ cumsum, _ = scoreboard.compute_cumulative(players, scores)
29
+ sorted_players = sorted(players, key=lambda p: cumsum[p], reverse=True)
30
+
31
+ scoreboard.render_leaderboard(sorted_players, cumsum)
32
+
33
+ with st.expander("Scoreboard", expanded=True):
34
+ # No stored winning-score threshold for historical games, so
35
+ # nothing is ever flagged as "reached the target" here.
36
+ scoreboard.render_scoreboard_transposed(sorted_players, scores, float("inf"))
37
+
38
+ with st.expander("Points progression chart", expanded=False):
39
+ scoreboard.render_chart(players, scores)
40
+
41
+ if st.button("Back to Game History"):
42
+ st.switch_page("app_pages/points_tracker.py")
{pages → app_pages}/home.py RENAMED
File without changes
{pages → app_pages}/points_tracker.py RENAMED
@@ -2,14 +2,17 @@ import uuid
2
 
3
  import streamlit as st
4
  import pandas as pd
5
- import altair as alt
6
 
 
7
  import sheets_backend as sheets
 
8
 
9
 
10
  def _init_state():
11
  if "pt_players" not in st.session_state:
12
  st.session_state.pt_players = []
 
 
13
  if "pt_game_started" not in st.session_state:
14
  st.session_state.pt_game_started = False
15
  if "pt_scores" not in st.session_state:
@@ -20,20 +23,8 @@ def _init_state():
20
  st.session_state.pt_game_name = ""
21
  if "pt_session_id" not in st.session_state:
22
  st.session_state.pt_session_id = None
23
-
24
-
25
- def _get_workbook():
26
- """Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
27
- if "auth" not in st.secrets or not st.user.is_logged_in:
28
- return None
29
- access_token = st.session_state.get("google_access_token")
30
- if not access_token:
31
- return None
32
- if "pt_workbook" not in st.session_state:
33
- st.session_state.pt_workbook = sheets.get_or_create_user_workbook(
34
- access_token, st.user.email
35
- )
36
- return st.session_state.pt_workbook
37
 
38
 
39
  _NEW_GAME_NAME_SENTINEL = "+ New game name"
@@ -82,7 +73,7 @@ def _render_new_game_tab(wb):
82
  if name and name not in st.session_state.pt_players:
83
  st.session_state.pt_players.append(name)
84
  if wb is not None:
85
- sheets.add_player(wb, name)
86
  st.session_state.pt_new_player = ""
87
 
88
  col1, col2 = st.columns([3, 1])
@@ -102,6 +93,7 @@ def _render_new_game_tab(wb):
102
  for i, name in enumerate(remaining):
103
  if qcols[i % len(qcols)].button(name, key=f"pt_quickadd_{name}"):
104
  st.session_state.pt_players.append(name)
 
105
  st.rerun()
106
 
107
  if st.session_state.pt_players:
@@ -118,6 +110,14 @@ def _render_new_game_tab(wb):
118
  st.session_state.pt_game_started = True
119
  st.session_state.pt_scores = []
120
  st.session_state.pt_session_id = uuid.uuid4().hex
 
 
 
 
 
 
 
 
121
  st.rerun()
122
  else:
123
  st.info("Add at least 2 players to start.")
@@ -141,16 +141,27 @@ def _render_game_history_tab(wb):
141
  if wb is None:
142
  st.info("Log in with Google to see your game history.")
143
  return
144
- names = sheets.list_game_names(wb)
145
- if not names:
146
  st.caption("No games recorded yet.")
147
  return
148
- for name in names:
149
- st.write(f"- {name}")
 
 
 
 
 
 
 
 
 
 
 
150
 
151
 
152
  def _setup_phase():
153
- wb = _get_workbook()
154
  tab_new, tab_players, tab_history = st.tabs(["New Game", "Saved Players", "Game History"])
155
 
156
  with tab_new:
@@ -161,95 +172,16 @@ def _setup_phase():
161
  _render_game_history_tab(wb)
162
 
163
 
164
- def _compute_cumulative(players, scores):
165
- cumsum = {p: 0 for p in players}
166
- history = []
167
- for round_data in scores:
168
- for p in players:
169
- cumsum[p] += round_data.get(p, 0)
170
- history.append(dict(cumsum))
171
- return cumsum, history
172
-
173
-
174
- def _render_scoreboard_transposed(players, scores, winning_score):
175
- header_cols = ["Player"] + [f"R{i + 1}" for i in range(len(scores))]
176
- md = "| " + " | ".join(header_cols) + " |\n"
177
- md += "| " + " | ".join(["---"] * len(header_cols)) + " |\n"
178
-
179
- for p in players:
180
- row = [f"**{p}**"]
181
- running = 0
182
- for round_data in scores:
183
- pts = round_data.get(p, 0)
184
- prev = running
185
- running += pts
186
- change = running - prev
187
- if change > 0:
188
- indicator = f' <span style="color:green">&#9650; +{change}</span>'
189
- elif change < 0:
190
- indicator = f' <span style="color:red">&#9660; {change}</span>'
191
- else:
192
- indicator = ""
193
- winner_flag = " ★" if running >= winning_score else ""
194
- row.append(f"**{running}**{indicator}{winner_flag}")
195
- md += "| " + " | ".join(row) + " |\n"
196
-
197
- st.markdown(md, unsafe_allow_html=True)
198
-
199
-
200
- def _render_leaderboard(players, cumsum):
201
- st.subheader("🏆 Leaderboard")
202
- if not players:
203
- return
204
-
205
- medals = ["🥇", "🥈", "🥉"]
206
- cols = st.columns(len(players))
207
- for i, p in enumerate(players):
208
- label = f"{medals[i]} {p}" if i < len(medals) else f"#{i + 1} {p}"
209
- with cols[i]:
210
- st.metric(label, cumsum[p])
211
-
212
-
213
- def _render_chart(players, scores):
214
- if not scores:
215
- return
216
-
217
- cumsum = {p: 0 for p in players}
218
- rows = [{"Round": 0, **{p: 0 for p in players}}]
219
- for i, round_data in enumerate(scores):
220
- for p in players:
221
- cumsum[p] += round_data.get(p, 0)
222
- rows.append({"Round": i + 1, **dict(cumsum)})
223
-
224
- df = pd.DataFrame(rows)
225
- df_long = df.melt(id_vars="Round", var_name="Player", value_name="Points")
226
-
227
- chart = (
228
- alt.Chart(df_long)
229
- .mark_line(point=True)
230
- .encode(
231
- x=alt.X("Round:Q", axis=alt.Axis(tickMinStep=1)),
232
- y=alt.Y("Points:Q"),
233
- color=alt.Color("Player:N"),
234
- tooltip=["Round", "Player", "Points"],
235
- )
236
- .properties(height=300)
237
- .interactive()
238
- )
239
-
240
- st.altair_chart(chart, use_container_width=True)
241
-
242
-
243
  def _game_phase():
244
  players = st.session_state.pt_players
245
  scores = st.session_state.pt_scores
246
  winning_score = st.session_state.pt_winning_score
247
  game_name = st.session_state.pt_game_name
248
 
249
- cumsum, _ = _compute_cumulative(players, scores)
250
  sorted_players = sorted(players, key=lambda p: cumsum[p], reverse=True)
251
 
252
- _render_leaderboard(sorted_players, cumsum)
253
 
254
  if game_name:
255
  st.subheader(f"Tracking: {game_name}")
@@ -269,10 +201,10 @@ def _game_phase():
269
 
270
  if scores:
271
  with st.expander("Scoreboard", expanded=True):
272
- _render_scoreboard_transposed(sorted_players, scores, winning_score)
273
 
274
  with st.expander("Points progression chart", expanded=False):
275
- _render_chart(players, scores)
276
 
277
  if winners:
278
  st.success(f"Game over! Winner{'s' if len(winners) > 1 else ''}: **{', '.join(winners)}** reached {winning_score} points!")
@@ -301,14 +233,16 @@ def _game_phase():
301
 
302
  if st.button("Save Round", type="primary", key=f"pt_save_r{next_round}"):
303
  st.session_state.pt_scores.append(round_scores)
304
- wb = _get_workbook()
305
  if wb is not None:
306
  for player, score in round_scores.items():
307
  sheets.record_round(
308
  wb,
 
309
  game_name=game_name or "Points Tracker",
310
  session_id=st.session_state.pt_session_id,
311
  round_number=next_round,
 
312
  player_name=player,
313
  score=score,
314
  )
 
2
 
3
  import streamlit as st
4
  import pandas as pd
 
5
 
6
+ import scoreboard
7
  import sheets_backend as sheets
8
+ from workbook_access import get_workbook
9
 
10
 
11
  def _init_state():
12
  if "pt_players" not in st.session_state:
13
  st.session_state.pt_players = []
14
+ if "pt_player_ids" not in st.session_state:
15
+ st.session_state.pt_player_ids = {}
16
  if "pt_game_started" not in st.session_state:
17
  st.session_state.pt_game_started = False
18
  if "pt_scores" not in st.session_state:
 
23
  st.session_state.pt_game_name = ""
24
  if "pt_session_id" not in st.session_state:
25
  st.session_state.pt_session_id = None
26
+ if "pt_game_id" not in st.session_state:
27
+ st.session_state.pt_game_id = None
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
 
30
  _NEW_GAME_NAME_SENTINEL = "+ New game name"
 
73
  if name and name not in st.session_state.pt_players:
74
  st.session_state.pt_players.append(name)
75
  if wb is not None:
76
+ st.session_state.pt_player_ids[name] = sheets.add_player(wb, name)
77
  st.session_state.pt_new_player = ""
78
 
79
  col1, col2 = st.columns([3, 1])
 
93
  for i, name in enumerate(remaining):
94
  if qcols[i % len(qcols)].button(name, key=f"pt_quickadd_{name}"):
95
  st.session_state.pt_players.append(name)
96
+ st.session_state.pt_player_ids[name] = sheets.add_player(wb, name)
97
  st.rerun()
98
 
99
  if st.session_state.pt_players:
 
110
  st.session_state.pt_game_started = True
111
  st.session_state.pt_scores = []
112
  st.session_state.pt_session_id = uuid.uuid4().hex
113
+ if wb is not None:
114
+ game_name = st.session_state.pt_game_name or "Points Tracker"
115
+ st.session_state.pt_game_id = sheets.get_or_create_game(wb, game_name)
116
+ # Covers players already in the list before a workbook was
117
+ # available (e.g. added while logged out then logged in).
118
+ for name in st.session_state.pt_players:
119
+ if name not in st.session_state.pt_player_ids:
120
+ st.session_state.pt_player_ids[name] = sheets.add_player(wb, name)
121
  st.rerun()
122
  else:
123
  st.info("Add at least 2 players to start.")
 
141
  if wb is None:
142
  st.info("Log in with Google to see your game history.")
143
  return
144
+ sessions = sheets.list_game_sessions(wb)
145
+ if not sessions:
146
  st.caption("No games recorded yet.")
147
  return
148
+ for session in sessions:
149
+ col_info, col_link = st.columns([4, 1])
150
+ with col_info:
151
+ st.write(
152
+ f"**{session['game_name']}** -- {session['timestamp']} -- "
153
+ f"{session['num_players']} players, {session['num_rounds']} rounds"
154
+ )
155
+ with col_link:
156
+ if st.button("View", key=f"pt_view_{session['session_id']}"):
157
+ st.switch_page(
158
+ "app_pages/game_detail.py",
159
+ query_params={"session_id": session["session_id"]},
160
+ )
161
 
162
 
163
  def _setup_phase():
164
+ wb = get_workbook()
165
  tab_new, tab_players, tab_history = st.tabs(["New Game", "Saved Players", "Game History"])
166
 
167
  with tab_new:
 
172
  _render_game_history_tab(wb)
173
 
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  def _game_phase():
176
  players = st.session_state.pt_players
177
  scores = st.session_state.pt_scores
178
  winning_score = st.session_state.pt_winning_score
179
  game_name = st.session_state.pt_game_name
180
 
181
+ cumsum, _ = scoreboard.compute_cumulative(players, scores)
182
  sorted_players = sorted(players, key=lambda p: cumsum[p], reverse=True)
183
 
184
+ scoreboard.render_leaderboard(sorted_players, cumsum)
185
 
186
  if game_name:
187
  st.subheader(f"Tracking: {game_name}")
 
201
 
202
  if scores:
203
  with st.expander("Scoreboard", expanded=True):
204
+ scoreboard.render_scoreboard_transposed(sorted_players, scores, winning_score)
205
 
206
  with st.expander("Points progression chart", expanded=False):
207
+ scoreboard.render_chart(players, scores)
208
 
209
  if winners:
210
  st.success(f"Game over! Winner{'s' if len(winners) > 1 else ''}: **{', '.join(winners)}** reached {winning_score} points!")
 
233
 
234
  if st.button("Save Round", type="primary", key=f"pt_save_r{next_round}"):
235
  st.session_state.pt_scores.append(round_scores)
236
+ wb = get_workbook()
237
  if wb is not None:
238
  for player, score in round_scores.items():
239
  sheets.record_round(
240
  wb,
241
+ game_id=st.session_state.pt_game_id or "",
242
  game_name=game_name or "Points Tracker",
243
  session_id=st.session_state.pt_session_id,
244
  round_number=next_round,
245
+ player_id=st.session_state.pt_player_ids.get(player, ""),
246
  player_name=player,
247
  score=score,
248
  )
{pages → app_pages}/skull_king.py RENAMED
File without changes
requirements.txt CHANGED
@@ -8,4 +8,3 @@ httpx
8
  tomli_w
9
  cryptography
10
  requests
11
- extra-streamlit-components
 
8
  tomli_w
9
  cryptography
10
  requests
 
src/cookie_token_store.py CHANGED
@@ -1,24 +1,12 @@
1
  import hashlib
2
  from datetime import datetime, timedelta, timezone
3
 
4
- import extra_streamlit_components as stx
5
  import streamlit as st
6
  from cryptography.fernet import Fernet
7
 
8
  _COOKIE_MAX_AGE_DAYS = 400 # ~ the longest lifetime browsers allow anyway (Chrome caps at 400 days)
9
-
10
-
11
- def get_cookie_manager() -> stx.CookieManager:
12
- """Constructs the cookie-sync component.
13
-
14
- Must be called exactly once per script run (not cached, not called more
15
- than once) -- the underlying component re-renders every rerun to report
16
- the browser's current cookies back to Python, so caching it (via
17
- st.cache_resource or otherwise) freezes it stale, and constructing a
18
- second instance with the same key in the same run raises
19
- DuplicateWidgetID.
20
- """
21
- return stx.CookieManager(key="bgt_cookie_manager")
22
 
23
 
24
  def _fernet() -> Fernet:
@@ -30,17 +18,34 @@ def _cookie_name(email: str) -> str:
30
  return "bgt_rt_" + hashlib.sha256(email.encode()).hexdigest()[:16]
31
 
32
 
33
- def save_refresh_token(cookie_manager: stx.CookieManager, email: str, refresh_token: str) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  encrypted = _fernet().encrypt(refresh_token.encode()).decode()
35
- expires_at = datetime.now(timezone.utc) + timedelta(days=_COOKIE_MAX_AGE_DAYS)
36
- name = _cookie_name(email)
37
- # same_site="lax" (this library defaults to "strict") to match the SameSite
38
- # policy Streamlit's own auth cookie uses.
39
- cookie_manager.set(name, encrypted, expires_at=expires_at, key=f"set_{name}", same_site="lax")
40
 
41
 
42
- def get_refresh_token(cookie_manager: stx.CookieManager, email: str) -> str | None:
43
- encrypted = cookie_manager.get(_cookie_name(email))
44
  if not encrypted:
45
  return None
46
  try:
@@ -53,7 +58,29 @@ def get_refresh_token(cookie_manager: stx.CookieManager, email: str) -> str | No
53
  return None
54
 
55
 
56
- def delete_refresh_token(cookie_manager: stx.CookieManager, email: str) -> None:
57
- name = _cookie_name(email)
58
- if cookie_manager.get(name) is not None:
59
- cookie_manager.delete(name, key=f"delete_{name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import hashlib
2
  from datetime import datetime, timedelta, timezone
3
 
 
4
  import streamlit as st
5
  from cryptography.fernet import Fernet
6
 
7
  _COOKIE_MAX_AGE_DAYS = 400 # ~ the longest lifetime browsers allow anyway (Chrome caps at 400 days)
8
+ _HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
9
+ _OAUTH_STATE_COOKIE = "bgt_oauth_state"
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  def _fernet() -> Fernet:
 
18
  return "bgt_rt_" + hashlib.sha256(email.encode()).hexdigest()[:16]
19
 
20
 
21
+ def _write_cookie(name: str, value: str, expires: datetime, *, reload: bool = False) -> None:
22
+ # st.context.cookies is read-only, so writing needs a JS-side assignment --
23
+ # same technique as analytics.py's Umami injection. Fernet's base64url
24
+ # alphabet (letters, digits, -, _, =) is all cookie-safe per RFC 6265, so
25
+ # no extra encoding is needed.
26
+ #
27
+ # st.context.cookies reflects whatever the browser sent in the HTTP
28
+ # request that opened the *current* session/WebSocket -- it does not
29
+ # magically pick up a cookie set via JS partway through that same live
30
+ # session (nothing forces a fresh HTTP request for that). So after saving
31
+ # a token we care about reading back later, force a real page reload
32
+ # (not st.rerun(), which reuses the existing session) so the very next
33
+ # request actually carries the new cookie.
34
+ expires_str = expires.strftime(_HTTP_DATE_FORMAT)
35
+ script = f'document.cookie = "{name}={value}; expires={expires_str}; path=/; SameSite=Lax";'
36
+ if reload:
37
+ script += "window.location.replace(window.location.pathname);"
38
+ st.html(f"<script>{script}</script>", unsafe_allow_javascript=True)
39
+
40
+
41
+ def save_refresh_token(email: str, refresh_token: str) -> None:
42
  encrypted = _fernet().encrypt(refresh_token.encode()).decode()
43
+ expires = datetime.now(timezone.utc) + timedelta(days=_COOKIE_MAX_AGE_DAYS)
44
+ _write_cookie(_cookie_name(email), encrypted, expires, reload=True)
 
 
 
45
 
46
 
47
+ def get_refresh_token(email: str) -> str | None:
48
+ encrypted = st.context.cookies.get(_cookie_name(email))
49
  if not encrypted:
50
  return None
51
  try:
 
58
  return None
59
 
60
 
61
+ def delete_refresh_token(email: str) -> None:
62
+ # Expiring in the past is the standard way to delete a cookie via JS.
63
+ _write_cookie(_cookie_name(email), "", datetime(1970, 1, 1, tzinfo=timezone.utc))
64
+
65
+
66
+ def save_oauth_state(state: str) -> None:
67
+ """Stashes the Connect-Drive CSRF state in a cookie rather than
68
+ st.session_state. st.link_button always opens the authorize URL in a new
69
+ tab (Streamlit's own documented behavior), and Google's redirect lands in
70
+ that new tab -- a completely separate session with its own empty
71
+ session_state. Cookies, unlike session_state, are shared browser-wide
72
+ across tabs, so this is the only place both tabs can agree on the value.
73
+ No reload needed here (unlike save_refresh_token): the *read* of this
74
+ cookie happens in the new tab after a genuinely fresh navigation, which
75
+ already carries whatever cookies exist at that point.
76
+ """
77
+ expires = datetime.now(timezone.utc) + timedelta(minutes=10)
78
+ _write_cookie(_OAUTH_STATE_COOKIE, state, expires)
79
+
80
+
81
+ def get_oauth_state() -> str | None:
82
+ return st.context.cookies.get(_OAUTH_STATE_COOKIE)
83
+
84
+
85
+ def clear_oauth_state() -> None:
86
+ _write_cookie(_OAUTH_STATE_COOKIE, "", datetime(1970, 1, 1, tzinfo=timezone.utc))
src/scoreboard.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import altair as alt
2
+ import pandas as pd
3
+ import streamlit as st
4
+
5
+
6
+ def compute_cumulative(players, scores):
7
+ cumsum = {p: 0 for p in players}
8
+ history = []
9
+ for round_data in scores:
10
+ for p in players:
11
+ cumsum[p] += round_data.get(p, 0)
12
+ history.append(dict(cumsum))
13
+ return cumsum, history
14
+
15
+
16
+ def render_scoreboard_transposed(players, scores, winning_score):
17
+ header_cols = ["Player"] + [f"R{i + 1}" for i in range(len(scores))]
18
+ md = "| " + " | ".join(header_cols) + " |\n"
19
+ md += "| " + " | ".join(["---"] * len(header_cols)) + " |\n"
20
+
21
+ for p in players:
22
+ row = [f"**{p}**"]
23
+ running = 0
24
+ for round_data in scores:
25
+ pts = round_data.get(p, 0)
26
+ prev = running
27
+ running += pts
28
+ change = running - prev
29
+ if change > 0:
30
+ indicator = f' <span style="color:green">&#9650; +{change}</span>'
31
+ elif change < 0:
32
+ indicator = f' <span style="color:red">&#9660; {change}</span>'
33
+ else:
34
+ indicator = ""
35
+ winner_flag = " ★" if running >= winning_score else ""
36
+ row.append(f"**{running}**{indicator}{winner_flag}")
37
+ md += "| " + " | ".join(row) + " |\n"
38
+
39
+ st.markdown(md, unsafe_allow_html=True)
40
+
41
+
42
+ def render_leaderboard(players, cumsum):
43
+ st.subheader("🏆 Leaderboard")
44
+ if not players:
45
+ return
46
+
47
+ medals = ["🥇", "🥈", "🥉"]
48
+ cols = st.columns(len(players))
49
+ for i, p in enumerate(players):
50
+ label = f"{medals[i]} {p}" if i < len(medals) else f"#{i + 1} {p}"
51
+ with cols[i]:
52
+ st.metric(label, cumsum[p])
53
+
54
+
55
+ def render_chart(players, scores):
56
+ if not scores:
57
+ return
58
+
59
+ cumsum = {p: 0 for p in players}
60
+ rows = [{"Round": 0, **{p: 0 for p in players}}]
61
+ for i, round_data in enumerate(scores):
62
+ for p in players:
63
+ cumsum[p] += round_data.get(p, 0)
64
+ rows.append({"Round": i + 1, **dict(cumsum)})
65
+
66
+ df = pd.DataFrame(rows)
67
+ df_long = df.melt(id_vars="Round", var_name="Player", value_name="Points")
68
+
69
+ chart = (
70
+ alt.Chart(df_long)
71
+ .mark_line(point=True)
72
+ .encode(
73
+ x=alt.X("Round:Q", axis=alt.Axis(tickMinStep=1)),
74
+ y=alt.Y("Points:Q"),
75
+ color=alt.Color("Player:N"),
76
+ tooltip=["Round", "Player", "Points"],
77
+ )
78
+ .properties(height=300)
79
+ .interactive()
80
+ )
81
+
82
+ st.altair_chart(chart, use_container_width=True)
83
+
84
+
85
+ def rows_to_players_and_scores(rows: list[dict]) -> tuple[list[str], list[dict]]:
86
+ """Converts raw games_played rows (one row per player per round) into the
87
+ same (players, scores) shape the live game view uses, so historical
88
+ sessions can reuse the exact same rendering functions.
89
+
90
+ Uses player_name (not player_id) since that's guaranteed present on both
91
+ pre- and post-migration rows -- id lookups are for future robustness, not
92
+ a requirement for basic display.
93
+ """
94
+ players_seen: list[str] = []
95
+ by_round: dict[int, dict[str, int]] = {}
96
+ for row in rows:
97
+ name = row.get("player_name") or row.get("player_id") or "Unknown"
98
+ if name not in players_seen:
99
+ players_seen.append(name)
100
+ try:
101
+ rnd = int(row.get("round_number") or 0)
102
+ except (TypeError, ValueError):
103
+ rnd = 0
104
+ try:
105
+ score = int(row.get("score") or 0)
106
+ except (TypeError, ValueError):
107
+ score = 0
108
+ by_round.setdefault(rnd, {})[name] = score
109
+
110
+ scores = [by_round[r] for r in sorted(by_round)]
111
+ return players_seen, scores
src/sheets_backend.py CHANGED
@@ -2,18 +2,95 @@ import uuid
2
  from datetime import datetime, timezone
3
 
4
  import gspread
 
5
  from google.oauth2.credentials import Credentials
6
 
7
  _PLAYERS_HEADER = ["player_id", "name", "created_at"]
8
- _GAMES_HEADER = ["timestamp", "game_name", "session_id", "round_number", "player_name", "score"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  _WORKBOOK_TITLE_PREFIX = "BoardGameTracker"
10
 
 
 
 
 
 
 
 
 
 
11
 
12
  def _get_user_client(access_token: str) -> gspread.Client:
13
  creds = Credentials(token=access_token)
14
  return gspread.authorize(creds)
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spreadsheet:
18
  """Returns the user's own Sheets workbook, creating it in their Drive on first use.
19
 
@@ -25,58 +102,132 @@ def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spread
25
 
26
  existing = client.list_spreadsheet_files(title=title)
27
  if existing:
28
- return client.open_by_key(existing[0]["id"])
29
-
30
- sh = client.create(title)
31
- players_ws = sh.sheet1
32
- players_ws.update_title("players")
33
- players_ws.append_row(_PLAYERS_HEADER)
34
- games_ws = sh.add_worksheet(title="games_played", rows=1, cols=len(_GAMES_HEADER))
35
- games_ws.append_row(_GAMES_HEADER)
 
 
 
 
36
  return sh
37
 
38
 
39
  def list_players(wb: gspread.Spreadsheet) -> list[dict]:
40
- ws = wb.worksheet("players")
41
- return ws.get_all_records()
42
-
43
-
44
- def list_game_names(wb: gspread.Spreadsheet) -> list[str]:
45
- """Distinct game names played, most-recently-used first."""
46
- ws = wb.worksheet("games_played")
47
- names = [row["game_name"] for row in ws.get_all_records() if row.get("game_name")]
48
- return list(dict.fromkeys(reversed(names)))
49
 
50
 
51
  def add_player(wb: gspread.Spreadsheet, name: str) -> str:
52
- ws = wb.worksheet("players")
53
- existing = ws.get_all_records()
54
  for row in existing:
55
- if row.get("name", "").strip().lower() == name.strip().lower():
56
  return row["player_id"]
57
 
58
  player_id = uuid.uuid4().hex
59
- ws.append_row([player_id, name, datetime.now(timezone.utc).isoformat()])
 
 
60
  return player_id
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def record_round(
64
  wb: gspread.Spreadsheet,
65
  *,
 
66
  game_name: str,
67
  session_id: str,
68
  round_number: int,
 
69
  player_name: str,
70
  score: int,
71
  ) -> None:
72
- ws = wb.worksheet("games_played")
73
- ws.append_row(
74
- [
75
- datetime.now(timezone.utc).isoformat(),
76
- game_name,
77
- session_id,
78
- round_number,
79
- player_name,
80
- score,
81
- ]
 
 
 
82
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from datetime import datetime, timezone
3
 
4
  import gspread
5
+ import streamlit as st
6
  from google.oauth2.credentials import Credentials
7
 
8
  _PLAYERS_HEADER = ["player_id", "name", "created_at"]
9
+ _GAMES_HEADER = ["game_id", "name", "created_at"]
10
+ # game_name/player_name stay alongside the id columns (rather than being
11
+ # replaced) so rows written before this schema existed keep displaying
12
+ # correctly, and so display code never strictly depends on an id lookup
13
+ # succeeding.
14
+ _GAMES_PLAYED_HEADER = [
15
+ "timestamp",
16
+ "game_id",
17
+ "game_name",
18
+ "session_id",
19
+ "round_number",
20
+ "player_id",
21
+ "player_name",
22
+ "score",
23
+ ]
24
  _WORKBOOK_TITLE_PREFIX = "BoardGameTracker"
25
 
26
+ # Sheets API enforces a strict per-minute read quota. Every worksheet lookup
27
+ # and every full-table read counts against it, and a Streamlit rerun fires on
28
+ # every widget interaction -- so naive per-call reads blow through the quota
29
+ # almost immediately during an active game. These two caches are the fix:
30
+ # worksheet *handles* (including the one-time header-migration check) are
31
+ # cached for the life of the session, and row *data* is cached briefly with
32
+ # an explicit invalidation on every write.
33
+ _RECORDS_CACHE_TTL = 20 # seconds
34
+
35
 
36
  def _get_user_client(access_token: str) -> gspread.Client:
37
  creds = Credentials(token=access_token)
38
  return gspread.authorize(creds)
39
 
40
 
41
+ def _ensure_columns(ws: gspread.Worksheet, required_columns: list[str]) -> list[str]:
42
+ """Appends any missing columns to the end of the header row.
43
+
44
+ Existing data is never touched or reordered -- only new columns get
45
+ added at the end -- so sheets created before a schema change keep
46
+ working unmodified; old rows simply have blank cells for the new
47
+ columns.
48
+ """
49
+ header = ws.row_values(1)
50
+ missing = [c for c in required_columns if c not in header]
51
+ if missing:
52
+ header = header + missing
53
+ ws.update([header], "A1")
54
+ return header
55
+
56
+
57
+ def _get_or_create_worksheet(
58
+ wb: gspread.Spreadsheet, title: str, header: list[str]
59
+ ) -> gspread.Worksheet:
60
+ cache_key = f"_ws_handle_{wb.id}_{title}"
61
+ if cache_key in st.session_state:
62
+ return st.session_state[cache_key]
63
+
64
+ try:
65
+ ws = wb.worksheet(title)
66
+ _ensure_columns(ws, header)
67
+ except gspread.exceptions.WorksheetNotFound:
68
+ ws = wb.add_worksheet(title=title, rows=1, cols=len(header))
69
+ ws.append_row(header)
70
+
71
+ st.session_state[cache_key] = ws
72
+ return ws
73
+
74
+
75
+ @st.cache_data(ttl=_RECORDS_CACHE_TTL, show_spinner=False)
76
+ def _cached_records(_ws: gspread.Worksheet, cache_key: str) -> list[dict]:
77
+ return _ws.get_all_records()
78
+
79
+
80
+ def _get_records(wb: gspread.Spreadsheet, ws: gspread.Worksheet, title: str) -> list[dict]:
81
+ return _cached_records(ws, f"{wb.id}:{title}")
82
+
83
+
84
+ def _invalidate_records_cache() -> None:
85
+ _cached_records.clear()
86
+
87
+
88
+ def _append_row(ws: gspread.Worksheet, row: dict) -> None:
89
+ column_order = ws.row_values(1)
90
+ ws.append_row([row.get(c, "") for c in column_order])
91
+ _invalidate_records_cache()
92
+
93
+
94
  def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spreadsheet:
95
  """Returns the user's own Sheets workbook, creating it in their Drive on first use.
96
 
 
102
 
103
  existing = client.list_spreadsheet_files(title=title)
104
  if existing:
105
+ sh = client.open_by_key(existing[0]["id"])
106
+ else:
107
+ sh = client.create(title)
108
+ players_ws = sh.sheet1
109
+ players_ws.update_title("players")
110
+ players_ws.append_row(_PLAYERS_HEADER)
111
+
112
+ # Idempotent: creates "games" and migrates "games_played" to the current
113
+ # schema for pre-existing workbooks too, not just brand-new ones.
114
+ _get_or_create_worksheet(sh, "players", _PLAYERS_HEADER)
115
+ _get_or_create_worksheet(sh, "games", _GAMES_HEADER)
116
+ _get_or_create_worksheet(sh, "games_played", _GAMES_PLAYED_HEADER)
117
  return sh
118
 
119
 
120
  def list_players(wb: gspread.Spreadsheet) -> list[dict]:
121
+ ws = _get_or_create_worksheet(wb, "players", _PLAYERS_HEADER)
122
+ return _get_records(wb, ws, "players")
 
 
 
 
 
 
 
123
 
124
 
125
  def add_player(wb: gspread.Spreadsheet, name: str) -> str:
126
+ ws = _get_or_create_worksheet(wb, "players", _PLAYERS_HEADER)
127
+ existing = _get_records(wb, ws, "players")
128
  for row in existing:
129
+ if str(row.get("name", "")).strip().lower() == name.strip().lower():
130
  return row["player_id"]
131
 
132
  player_id = uuid.uuid4().hex
133
+ _append_row(
134
+ ws, {"player_id": player_id, "name": name, "created_at": datetime.now(timezone.utc).isoformat()}
135
+ )
136
  return player_id
137
 
138
 
139
+ def list_games(wb: gspread.Spreadsheet) -> list[dict]:
140
+ ws = _get_or_create_worksheet(wb, "games", _GAMES_HEADER)
141
+ return _get_records(wb, ws, "games")
142
+
143
+
144
+ def get_or_create_game(wb: gspread.Spreadsheet, name: str) -> str:
145
+ ws = _get_or_create_worksheet(wb, "games", _GAMES_HEADER)
146
+ existing = _get_records(wb, ws, "games")
147
+ for row in existing:
148
+ if str(row.get("name", "")).strip().lower() == name.strip().lower():
149
+ return row["game_id"]
150
+
151
+ game_id = uuid.uuid4().hex
152
+ _append_row(
153
+ ws, {"game_id": game_id, "name": name, "created_at": datetime.now(timezone.utc).isoformat()}
154
+ )
155
+ return game_id
156
+
157
+
158
+ def list_game_names(wb: gspread.Spreadsheet) -> list[str]:
159
+ """Distinct game names played, most-recently-used first."""
160
+ ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER)
161
+ names = [row["game_name"] for row in _get_records(wb, ws, "games_played") if row.get("game_name")]
162
+ return list(dict.fromkeys(reversed(names)))
163
+
164
+
165
  def record_round(
166
  wb: gspread.Spreadsheet,
167
  *,
168
+ game_id: str,
169
  game_name: str,
170
  session_id: str,
171
  round_number: int,
172
+ player_id: str,
173
  player_name: str,
174
  score: int,
175
  ) -> None:
176
+ ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER)
177
+ _append_row(
178
+ ws,
179
+ {
180
+ "timestamp": datetime.now(timezone.utc).isoformat(),
181
+ "game_id": game_id,
182
+ "game_name": game_name,
183
+ "session_id": session_id,
184
+ "round_number": round_number,
185
+ "player_id": player_id,
186
+ "player_name": player_name,
187
+ "score": score,
188
+ },
189
  )
190
+
191
+
192
+ def list_game_sessions(wb: gspread.Spreadsheet) -> list[dict]:
193
+ """One summary entry per distinct session_id, most recently played first."""
194
+ ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER)
195
+ rows = _get_records(wb, ws, "games_played")
196
+
197
+ sessions: dict[str, dict] = {}
198
+ order: list[str] = []
199
+ for row in rows:
200
+ sid = row.get("session_id")
201
+ if not sid:
202
+ continue
203
+ if sid not in sessions:
204
+ sessions[sid] = {
205
+ "game_name": row.get("game_name") or "Untitled",
206
+ "timestamp": row.get("timestamp", ""),
207
+ "players": set(),
208
+ "rounds": set(),
209
+ }
210
+ order.append(sid)
211
+ if row.get("player_name"):
212
+ sessions[sid]["players"].add(row["player_name"])
213
+ sessions[sid]["rounds"].add(row.get("round_number"))
214
+
215
+ return [
216
+ {
217
+ "session_id": sid,
218
+ "game_name": sessions[sid]["game_name"],
219
+ "timestamp": sessions[sid]["timestamp"],
220
+ "num_players": len(sessions[sid]["players"]),
221
+ "num_rounds": len(sessions[sid]["rounds"]),
222
+ }
223
+ for sid in reversed(order)
224
+ ]
225
+
226
+
227
+ def get_session_rounds(wb: gspread.Spreadsheet, session_id: str) -> list[dict]:
228
+ """Raw games_played rows for one session, sorted by round number."""
229
+ ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER)
230
+ rows = _get_records(wb, ws, "games_played")
231
+ matching = [r for r in rows if str(r.get("session_id")) == str(session_id)]
232
+ matching.sort(key=lambda r: int(r.get("round_number") or 0))
233
+ return matching
src/workbook_access.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ import sheets_backend as sheets
4
+
5
+
6
+ def get_workbook():
7
+ """Returns the logged-in user's Sheets workbook, or None for anonymous sessions.
8
+
9
+ Shared by every page that needs Sheets data, keyed off the access token
10
+ app.py's login/Connect-Drive flow populates in session_state.
11
+ """
12
+ if "auth" not in st.secrets or not st.user.is_logged_in:
13
+ return None
14
+ access_token = st.session_state.get("google_access_token")
15
+ if not access_token:
16
+ return None
17
+ if "shared_workbook" not in st.session_state:
18
+ st.session_state.shared_workbook = sheets.get_or_create_user_workbook(
19
+ access_token, st.user.email
20
+ )
21
+ return st.session_state.shared_workbook