import sqlite3 import threading import time import re from datetime import datetime import pandas as pd import streamlit as st # ========================= # App Config # ========================= st.set_page_config(page_title="Expo Game Timer", page_icon="⏱️", layout="centered") DB_PATH = "game.db" DB_LOCK = threading.Lock() TICK_SECONDS = 0.1 # ~10 fps refresh while the timer is running # ========================= # DB Utilities # ========================= def init_db(): with DB_LOCK: conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) cur = conn.cursor() cur.execute("PRAGMA journal_mode=WAL;") cur.execute( """ CREATE TABLE IF NOT EXISTS results ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL, seconds REAL NOT NULL, created_at TEXT NOT NULL ) """ ) conn.commit() conn.close() def insert_result(name: str, email: str, seconds: float): now = datetime.utcnow().isoformat() with DB_LOCK: conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) cur = conn.cursor() cur.execute( "INSERT INTO results (name, email, seconds, created_at) VALUES (?, ?, ?, ?)", (name.strip(), email.strip().lower(), float(seconds), now), ) conn.commit() conn.close() load_all_results.clear() # bust cache so dashboard updates instantly @st.cache_data(show_spinner=False) def load_all_results() -> pd.DataFrame: with DB_LOCK: conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) df = pd.read_sql_query( "SELECT id, name, email, seconds, created_at FROM results ORDER BY id DESC", conn, ) conn.close() return df # ========================= # Helpers # ========================= EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$") def valid_email(email: str) -> bool: return bool(EMAIL_RE.match(email or "")) def format_seconds(s: float) -> str: # mm:ss.mmm m, sec = divmod(max(float(s), 0.0), 60) return f"{int(m):02d}:{sec:06.3f}" def ensure_session_state(): ss = st.session_state if "start_time" not in ss: # None means not currently running ss.start_time = None if "accumulated" not in ss: # seconds already accrued from past runs ss.accumulated = 0.0 if "name" not in ss: ss.name = "" if "email" not in ss: ss.email = "" def is_running() -> bool: return st.session_state.start_time is not None def current_elapsed() -> float: """Total elapsed = accumulated + (now - start_time if running).""" ss = st.session_state if ss.start_time is None: return ss.accumulated return ss.accumulated + (time.perf_counter() - ss.start_time) def start_timer(): if st.session_state.start_time is None: st.session_state.start_time = time.perf_counter() def stop_timer(): if st.session_state.start_time is not None: st.session_state.accumulated += (time.perf_counter() - st.session_state.start_time) st.session_state.start_time = None def reset_timer(): st.session_state.start_time = None st.session_state.accumulated = 0.0 def safe_rerun(): try: st.rerun() except Exception: st.experimental_rerun() # ========================= # UI # ========================= def header(): st.markdown( """

⏱️ Expo Game Timer

Record participants, time their run, track a live leaderboard, and export results.

""", unsafe_allow_html=True, ) def participant_form(): c1, c2 = st.columns(2) with c1: st.text_input("Participant Name", key="name", placeholder="Jane Doe") with c2: st.text_input("Email", key="email", placeholder="jane@example.com") def stopwatch_card(): ensure_session_state() st.markdown("### Stopwatch") with st.container(border=True): # Display (updates continuously while running) elapsed = current_elapsed() st.markdown( f"
{format_seconds(elapsed)}
", unsafe_allow_html=True, ) b1, b2, b3 = st.columns(3) with b1: if st.button("▶️ Start", use_container_width=True, disabled=is_running()): start_timer() safe_rerun() with b2: if st.button("⏸️ Stop", use_container_width=True, disabled=not is_running()): stop_timer() safe_rerun() with b3: if st.button("↺ Reset", use_container_width=True, disabled=(current_elapsed() == 0.0 and not is_running())): reset_timer() safe_rerun() st.caption("Tip: Start the timer when the game begins and press Stop as soon as they finish. Then Save Result.") st.divider() save_col1, save_col2 = st.columns([2, 1]) with save_col1: st.write("**Save this run**") if not st.session_state.name.strip(): st.info("Enter a participant name.") if not st.session_state.email.strip(): st.info("Enter a valid email.") if st.session_state.email and not valid_email(st.session_state.email): st.error("Please enter a valid email address.") with save_col2: disabled_save = ( not st.session_state.name.strip() or not valid_email(st.session_state.email) or current_elapsed() <= 0.0 or is_running() # don't allow saving while the timer is running ) if st.button("💾 Save Result", type="primary", use_container_width=True, disabled=disabled_save): secs = round(current_elapsed(), 3) try: insert_result(st.session_state.name, st.session_state.email, secs) st.success(f"Saved: {st.session_state.name} — {format_seconds(secs)}") reset_timer() except Exception as e: st.error(f"Failed to save result: {e}") safe_rerun() # Auto-refresh while running (simple, robust pattern) if is_running(): time.sleep(TICK_SECONDS) safe_rerun() def dashboard(): st.markdown("### Dashboard") with st.container(border=True): df = load_all_results() if df.empty: st.info("No results yet. Save the first run to see stats and leaderboard.") return # Quick stats total = len(df) best = df["seconds"].min() avg = df["seconds"].mean() s1, s2, s3 = st.columns(3) s1.metric("Total Participants (runs)", total) s2.metric("Best Time", format_seconds(best)) s3.metric("Average Time", format_seconds(avg)) st.markdown("#### 🏆 Top 3 Fastest") top3 = df.sort_values("seconds", ascending=True).head(3).copy() top3["Time"] = top3["seconds"].apply(format_seconds) st.dataframe( top3[["name", "email", "Time", "created_at"]] .rename(columns={"name": "Name", "email": "Email", "created_at": "Recorded (UTC)"}), hide_index=True, use_container_width=True, ) # --- No "All Results" table displayed --- # Still provide CSV of the full dataset csv_df = df.copy() csv_df["time_formatted"] = csv_df["seconds"].apply(format_seconds) st.download_button( label="⬇️ Download all results (CSV)", data=csv_df.to_csv(index=False).encode("utf-8"), file_name="game_results.csv", mime="text/csv", use_container_width=True, ) def footer_note(): st.caption( "Data is stored in a local SQLite database (`game.db`). " "Note: if the Space restarts or is rebuilt, the DB resets. " "Multiple attempts per email are allowed; use the CSV to post-process if you want best-per-email." ) # ========================= # Main # ========================= def main(): init_db() header() participant_form() stopwatch_card() dashboard() footer_note() if __name__ == "__main__": main()