Spaces:
Sleeping
Sleeping
Commit ·
69c9e26
1
Parent(s): fcb850d
added good working stuff
Browse files- app.py +29 -32
- {pages → app_pages}/dungeon_draft.py +0 -0
- app_pages/game_detail.py +42 -0
- {pages → app_pages}/home.py +0 -0
- {pages → app_pages}/points_tracker.py +39 -105
- {pages → app_pages}/skull_king.py +0 -0
- requirements.txt +0 -1
- src/cookie_token_store.py +53 -26
- src/scoreboard.py +111 -0
- src/sheets_backend.py +183 -32
- src/workbook_access.py +21 -0
app.py
CHANGED
|
@@ -41,16 +41,22 @@ def _access_token_valid() -> bool:
|
|
| 41 |
)
|
| 42 |
|
| 43 |
|
| 44 |
-
def _handle_drive_oauth_callback(
|
| 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 !=
|
| 51 |
return
|
| 52 |
|
| 53 |
-
|
| 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
|
| 75 |
-
cookie_token_store.save_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(
|
| 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
|
| 95 |
-
refresh_token = cookie_token_store.get_refresh_token(
|
| 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(
|
| 108 |
|
| 109 |
state = uuid.uuid4().hex
|
| 110 |
-
|
| 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:",
|
| 119 |
-
|
| 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(
|
| 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(
|
| 140 |
|
| 141 |
-
home_page = st.Page("
|
| 142 |
-
skull_king_page = st.Page("
|
| 143 |
-
dungeon_draft_page = st.Page("
|
| 144 |
-
points_tracker_page = st.Page("
|
|
|
|
| 145 |
|
| 146 |
-
pg = st.navigation(
|
|
|
|
|
|
|
| 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 |
-
|
| 145 |
-
if not
|
| 146 |
st.caption("No games recorded yet.")
|
| 147 |
return
|
| 148 |
-
for
|
| 149 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
def _setup_phase():
|
| 153 |
-
wb =
|
| 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">▲ +{change}</span>'
|
| 189 |
-
elif change < 0:
|
| 190 |
-
indicator = f' <span style="color:red">▼ {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, _ =
|
| 250 |
sorted_players = sorted(players, key=lambda p: cumsum[p], reverse=True)
|
| 251 |
|
| 252 |
-
|
| 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 |
-
|
| 273 |
|
| 274 |
with st.expander("Points progression chart", expanded=False):
|
| 275 |
-
|
| 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 =
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
encrypted = _fernet().encrypt(refresh_token.encode()).decode()
|
| 35 |
-
|
| 36 |
-
|
| 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(
|
| 43 |
-
encrypted =
|
| 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(
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">▲ +{change}</span>'
|
| 31 |
+
elif change < 0:
|
| 32 |
+
indicator = f' <span style="color:red">▼ {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 = ["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return sh
|
| 37 |
|
| 38 |
|
| 39 |
def list_players(wb: gspread.Spreadsheet) -> list[dict]:
|
| 40 |
-
ws =
|
| 41 |
-
return
|
| 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 =
|
| 53 |
-
existing =
|
| 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 |
-
|
|
|
|
|
|
|
| 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 =
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 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
|