Poker Session Tracker
MIT React 18 Vite 5 Node serverless Postgres
Repo: github.com/LMC4S/poker-tracker. This document covers the architecture, data model, HTTP API, and operational details for contributors. For an end-to-end install walkthrough, see the README.
Overview
A single-page React app with two surfaces, distinguished by URL path:
- Admin (
/admin) — PIN-gated. Full read/write over all sessions. This is the only writer. - Observer (
/s/<token>) — public, read-only view of one session by unguessable token, plus aggregate series stats.
The browser never holds a database credential. Every read and write is brokered by a Vercel serverless function that uses the Supabase service_role key. Each session is one Postgres row, and every mutation is a small operation ("add player", "rebuy", "cash out"…) posted individually and recorded in an idempotency ledger — so a retried request applies exactly once, and an entry made on a bad connection can be delayed but never lost.
Stack
| Layer | Tech | Notes |
|---|---|---|
| UI | React 18, Vite 5 | Inline-style components, no CSS framework |
| API | Vercel Serverless Functions | Node, zero-config api/ directory |
| DB | Supabase (Postgres) | Accessed only via @supabase/supabase-js server-side |
| QR | qrcode.react | Per-session share-link QR code |
| Analytics | @vercel/analytics | Injected in main.jsx |
Project structure
api/
auth.js POST PIN hash -> admin secret
op.js POST apply one operation (the only steady-state write path)
sessions.js GET admin read of all sessions; POST legacy blob write (transitional)
session.js GET public read of one session by ?token=
src/
main.jsx React root, Vercel analytics inject
App.jsx Entry; routes /admin -> PinGate+AppContent, else ShareApp
ShareApp.jsx Observer view (/s/<token>), polling, two tabs
ops.js Domain logic: applies ops to sessions. Imported by BOTH the client and api/op.js
storage.js Client data layer (fetch wrappers + persisted op queue)
utils.js Keys, sha256, uid, money/duration formatting, log labels, exportJSON
styles.js Shared inline style objects
components/
PinGate.jsx Admin auth gate with lockout
Header.jsx Title + tab nav (showNav toggle)
Modal.jsx Add player / rebuy / cashout dialogs
SessionCard.jsx Session list item (admin home)
QRModal.jsx QR code for a session's /s/<token> link + revoke/replace link
icons.jsx Inline SVG icons
views/
HomeView.jsx Series stats + session lists (seriesOnly flag)
ActiveView.jsx Live session table + admin actions + activity log
SummaryView.jsx Ended session summary + delete/reopen/QR
supabase/migrations/ SQL (init + per-session-share)
vercel.json SPA rewrites + security headers
vite.config.js Dev server opens /admin
Data model
There is one entity, Session. Each one lives in its own poker_sessions row: the id is the primary key and the rest of the object is the row's data jsonb. A few fields (shareToken, ended, updatedAt) are mirrored into real columns for indexed lookup and versioning.
// Session
{
id: string, // uid(): base36 timestamp + random
name: string, // e.g. "Session 27" or custom
date: string, // ISO 8601, session start
endDate?: string, // ISO 8601, last cash-out time (fallback: End Session time)
updatedAt: string, // ISO 8601 of the last applied op — this session's version token
ended: boolean,
shareToken: string | null, // crypto.randomUUID(), the public link key; null = revoked
players: Player[],
log: LogEntry[] // append-only activity history
}
// Player
{
id: string, // uid()
name: string,
buyins: number[], // each rebuy appended; total = sum
cashout: number | null, // null until cashed out
cashoutAt?: string // ISO 8601, when cashed out (cleared on undo)
}
// LogEntry
{
t: string, // ISO 8601 timestamp
type: string, // "join" | "buyin" | "cashout" | "undo" | "remove"
player: string, // player name
amount?: number // for join / buyin / cashout
}
Derived values (computed, never stored): a player's net is cashout − sum(buyins). A session balances when total cashouts equal total buy-ins. endDate is recomputed from the latest cashoutAt on every cash-out, undo, and player removal (so a forgotten "End Session" tap doesn't corrupt the recorded end time); it falls back to the End Session time only if nobody cashed out. shareToken is assigned on session creation; null means the admin revoked the link. The QR dialog can revoke a link or replace it with a fresh token at any time.
Operations
Every mutation is an op, applied by src/ops.js on both sides — optimistically on the client for instant UI, then authoritatively in api/op.js. Log entries and updatedAt are stamped from the op's own at timestamp, so replaying an op yields the same session bytes on either side.
// Op
{
opId: string, // crypto.randomUUID() — the idempotency key
sessionId: string,
type: string, // createSession | addPlayer | rebuy | cashout | undoCashout |
// removePlayer | endSession | reopenSession | deleteSession |
// revokeShare | regenerateShare
payload: { // type-specific fields, plus:
at: string // ISO 8601, stamped client-side at dispatch
}
}
Ops that don't apply — a duplicate player name, an unknown player id, ending an already-ended session — are treated as successful no-ops, which is what makes replays and races harmless.
Storage & keys
Server (Postgres)
| Table | Purpose |
|---|---|
poker_sessions | one row per session: data jsonb + mirrored share_token/ended/updated_at columns; deleted_at soft-delete |
poker_ops | idempotency ledger and permanent audit trail — one row per applied operation |
poker_data | legacy v2 blob (poker-sessions-v2 row + its :snap: rows), frozen as a pre-migration backup and still written by the transitional blob-POST path |
Client (localStorage)
| Key | Purpose |
|---|---|
poker-unlocked | SESSION_KEY — "admin" once authenticated |
poker-admin-secret | API secret returned by /api/auth, sent as x-admin-secret |
poker-lockout | LOCKOUT_KEY — failed-attempt count + lockout expiry |
poker-op-queue | ops not yet confirmed by the server; survives refresh and tab kill |
Database schema
create table poker_sessions (
id text primary key,
data jsonb not null, -- the Session object minus id
share_token text, -- mirrored for indexed public lookup (partial index)
ended boolean not null default false,
deleted_at timestamptz, -- soft delete: audit survives, deletes can't resurrect
updated_at text not null -- per-session version token (ISO string, CAS target)
);
create table poker_ops (
op_id text primary key, -- client uuid; duplicate insert = retry of an applied op
session_id text not null,
type text not null,
payload jsonb,
applied_at timestamptz not null default now()
);
-- Both tables (and legacy poker_data) carry the same RLS stance:
-- deny all anon access; only the server-side service_role key can touch them.
create policy "deny all anon" on poker_sessions
for all to anon using (false);
Routing
Routing is client-side. App.jsx branches on window.location.pathname:
/admin→<PinGate>wrapping<AppContent>- anything else →
<ShareApp>, which parses/s/<token>with/^\/s\/(.+)$/. No token → the "use a shared link" prompt.
vercel.json makes the SPA work on hard navigation: serverless functions resolve first, then every other path falls back to index.html. It also attaches security headers to every response (see Security model).
{
"rewrites": [
{ "source": "/api/:path*", "destination": "/api/:path*" },
{ "source": "/(.*)", "destination": "/index.html" }
]
}
api/session/[token].js) were not reliably registered on this setup — requests fell through to the index.html rewrite and returned HTML. Public reads therefore use a top-level api/session.js with a ?token= query param. Prefer top-level function files with query params over nested dynamic routes.API reference
Four functions under api/. All responses are JSON. Admin endpoints require the x-admin-secret header (value obtained from /api/auth); it is compared server-side with crypto.timingSafeEqual.
Exchanges a hashed PIN for the API secret.
Body: { "hash": "<sha256(pin) hex>" }
Responses: 200 { "secret": "<ADMIN_API_SECRET>" } · 401 wrong PIN · 400 bad body · 405 non-POST · 500 server not configured
Applies one operation — the only steady-state write path. Header: x-admin-secret. Body: an Op (see Data model).
The op is first inserted into poker_ops; a duplicate op_id means this exact op already committed on an attempt whose response was lost, so the current state is returned without applying twice. Otherwise the session row is read, the op applied via src/ops.js, and the row written with a compare-and-swap on updated_at (re-read and re-applied up to 3 times under contention). If the apply fails after the ledger insert, the ledger row is removed before the error is returned, so the client's retry is not falsely told "already applied".
Responses: 200 { ok, session, version } the authoritative post-op session · 200 { ok, deleted: true } the target session is (now) deleted — drop it client-side · 400 invalid op · 404 unknown session · 401 · 503 CAS contention exhausted (retry) · 500
Returns all non-deleted sessions, newest first. Header: x-admin-secret. Each session carries its own updatedAt version; the X-Data-Version response header (the newest row stamp) is kept only for pre-v3 clients. All /api/sessions responses are Cache-Control: no-store — admin data is never cached.
Responses: 200 Session[] · 401 bad/missing secret · 500
Transitional, for pre-v3 bundles still open on a device. Accepts the old whole-array save and translates it into per-row upserts: a posted session is written only when its updatedAt is newer than the stored row's, soft-deleted rows are never resurrected, and rows missing from the payload are never deleted. A stale tab can add or update, but cannot wipe anything. Remove this path once every device has loaded the v3 bundle.
Responses: 200 { "ok": true, "version": "<now>" } · 401 · 400 invalid body · 500
Public, unauthenticated. Looks up one session by shareToken and returns it with computed aggregate stats. No way to list or enumerate sessions.
Caching: responses are edge-cached per URL via Cache-Control — live sessions s-maxage=5 (one origin hit per poll window no matter how many viewers), ended sessions s-maxage=30, unknown tokens s-maxage=15 (so spraying bad tokens mostly hits the cache instead of invoking the function and reading the database). There is deliberately no stale-while-revalidate: the cache TTL is exactly how long a revoked link can keep serving, so revocation takes effect within 5s (live) / 30s (ended).
Responses: 200 { session: Session, seriesStats: {...} } · 404 unknown token · 400 missing token · 405 non-GET · 500
Auth flow
The PIN is never sent in clear text, and the API secret is never embedded in the bundle — it is fetched at login and held in localStorage.
- PinGate computes
sha256(pin)in the browser and POSTs it to/api/auth. - auth.js hashes
ADMIN_PINserver-side and compares withtimingSafeEqual. On match it returnsADMIN_API_SECRET. - The secret is stored as
poker-admin-secretand sent asx-admin-secreton every/api/opand/api/sessionscall, where it is compared withtimingSafeEqual.
Lockout: 5 failed attempts (MAX_ATTEMPTS) trigger a 15-minute lockout (LOCKOUT_MS), tracked client-side in poker-lockout.
Frontend architecture
Admin (AppContent in App.jsx)
- Load: on mount, fetch all sessions, then replay any queued ops left over from a previous visit on top of them, and resume sending the queue.
- Write: every user action dispatches an op: it is applied to local state immediately via
src/ops.js(optimistic UI) and appended to the localStorage-persisted queue. A single sender posts ops strictly in order, one in flight, retrying network/server failures with 1s→2s→5s backoff (and immediately on the browser'sonlineevent). When an op commits and no later ops for that session are queued, the server's authoritative copy is adopted. Banner: quiet normally, Saving… after the queue has been stuck ~3s, Offline — N entries will sync when the browser is offline or the stall drags on. There is no conflict banner: ops from concurrent admins interleave on the server instead of colliding. - Sync: 5-second polling keeps multiple admin devices in sync, pausing while the tab is hidden or ops are still being sent. The polled list is reconciled per session: a session with queued ops keeps its local copy; otherwise a changed
updatedAtmeans the server copy wins; local sessions missing from the server were deleted elsewhere and are dropped. - Views:
home(HomeView),active(ActiveView),summary(SummaryView). Mutations go through named actions (actions.addPlayer(...),actions.rebuy(...), …) that wrap the op dispatch. - Home layout: the admin homepage lists the entire ended-session history inside a fixed-height scroll area (
min(45vh, 380px)), so the page itself stays compact and New Session / Backup remain reachable without scrolling past the history. - Share-link management: the QR Code button in ActiveView and SummaryView opens QRModal, which besides the QR offers Revoke Link (sets
shareTokentonull) and New Link (freshcrypto.randomUUID()), each behind an inline confirm. The button shows even when the link is revoked — the modal is where a new one is created.
Observer (ShareApp.jsx)
- Parses the token from the URL once on mount; fetches
/api/session?token=and polls every 5s (paused when hidden). - Two tabs via Header: Session (default — renders ActiveView or SummaryView depending on
ended) and Home (HomeView withseriesOnlyandprecomputedStats). - All write handlers are no-ops;
isAdmin=falsehides every mutating control. - States:
loading,ok,notfound(invalid or revoked link),nolink(root prompt).
Series stats
Aggregate stats are computed two ways from the same logic, so observers never receive other sessions' raw data:
- Admin: HomeView computes them client-side from the full array it already holds.
- Observer:
computeSeriesStats()in session.js runs server-side over the full history and returns only the aggregate numbers alongside the single session.
The stats are deliberately "pulse" stats — every one moves after each game night, rather than all-time records that rarely change.
Fields: sessionsThisYear, moneyThisYear (total buy-ins across this year's sessions), lastDate (start date of the most recent ended session), lastWin (top net result of that session), thisYear. All derived only from ended sessions. Two asymmetries between the paths:
lastWinNameexists only in the admin's client-side computation — the server payload never includes player names, so observers see the top-win amount without a name.- "Days since last night" is derived on the client from
lastDate, because the serverless function runs on a UTC clock and its response is edge-cached — a precomputed day count could be stale or off by one across timezones.
Concurrency & recovery
The failure mode this design targets is an entry silently lost on a flaky mobile connection. The guarantees, and where each comes from:
- An entry can't be lost. Ops live in a localStorage queue until the server confirms them, surviving refresh, tab kill, and hours offline. The sender retries with backoff forever; worst case the UI says "Offline — N entries will sync".
- A retry can't double-apply.
poker_opsis an idempotency ledger keyed by the client-generatedopId. A retried op whose first attempt actually committed (response lost in transit) is detected by the duplicate key and answered with current state. - Concurrent admins don't collide. Each session versions independently (
updated_atCAS), so edits to different sessions never interact. Two ops racing on the same session serialize: the loser re-reads and re-applies, which is safe because ops are small and self-contained. Editing the same player's same field last-write-wins — both attempts remain visible in the activity log. - Deletes stay deleted. Sessions are soft-deleted (
deleted_at); a stale device's ops or blob-saves for a deleted session are answered withdeleted: trueand can't resurrect it.
Recovery
poker_ops doubles as a permanent audit trail — every mutation ever applied, with payload and timestamp, queryable in the SQL Editor:
-- what happened to this session, in order
select applied_at, type, payload from poker_ops
where session_id = '<id>' order by applied_at;
-- undelete a session; bump updated_at so polling clients pick it up
update poker_sessions
set deleted_at = null, updated_at = now()::text
where id = '<id>';
The pre-v3 history also survives untouched in poker_data (the poker-sessions-v2 blob and its :snap: snapshots), frozen at migration time as a last-resort backup.
The in-app Backup Data button is an export only — it downloads the current Session[] as JSON. There is no import path; restoring from a file means updating the affected poker_sessions rows' data in the SQL Editor.
Security model
- No client DB credential. The bundle contains no Supabase key. RLS on every table denies all anon access; only the server-side
service_rolekey can touch them. - Admin-only writes. The write paths —
POST /api/opand the transitionalPOST /api/sessions— are both gated byx-admin-secret. Observers have no write endpoint at all. - No enumeration.
GET /api/sessionreturns exactly one session matched by token; there is no list endpoint reachable without the admin secret. - Constant-time comparisons for both the PIN hash and the API secret (
timingSafeEqual). - Token capability. A
shareTokenis a bearer capability: anyone with the link can read that one session. Tokens arecrypto.randomUUID()(122 bits), not guessable or enumerable. The admin can revoke or replace a session's token from the QR dialog; old links 404 within the edge cache TTL (5s live, 30s ended). - Security headers. vercel.json sets a Content-Security-Policy (own origin + Google Fonts + Vercel Analytics,
frame-ancestors 'none'),X-Frame-Options,X-Content-Type-Options,Referrer-Policy: no-referrer(share tokens ride in the URL path), and HSTS on every response. The CSP's main job is making a future XSS bug unable to exfiltrate the localStorage admin secret. - Cost bounding.
/api/sessionis the only unauthenticated endpoint; edge caching (above) bounds how many function invocations and database reads a hostile client can force. - Brute force (known limitation). The 5-attempt lockout is client-side UX, not a security boundary —
/api/authitself accepts unlimited attempts. Online-guessing protection relies on platform-level rate limiting (e.g. a Vercel firewall rule) and a strongADMIN_PIN. - Secret lifetime (known limitation). The admin secret is a static bearer token held in localStorage with no expiry or logout. Revoking a device that once logged in means rotating
ADMIN_API_SECRETin Vercel.
Environment variables
| Variable | Used by | Notes |
|---|---|---|
SUPABASE_URL | all functions | Supabase project URL |
SUPABASE_SERVICE_KEY | all functions | service_role secret. Server-only. |
ADMIN_PIN | auth.js | Admin password (hashed for comparison) |
ADMIN_API_SECRET | auth.js, op.js, sessions.js | Returned on login; sent as x-admin-secret. Generate with openssl rand -hex 32. |
VITE_* variables are needed — nothing in the browser talks to Supabase directly.Build
npm install
npm run build # vite build -> dist/
Deployment is handled by Vercel, which runs npm run build for the front end and serves the api/ directory as serverless functions. There is no standalone local run mode — every data path requires the serverless API backed by a configured Supabase project.
Migrations
| File | Purpose |
|---|---|
20260101000000_init.sql | Creates poker_data + the deny-all-anon RLS policy. |
20260606000000_per_session_share.sql | Drops the legacy poker_public snapshot table (now unused). No-op on fresh installs. |
20260703000000_op_based_backend.sql | v3: creates poker_sessions + poker_ops (with RLS) and backfills one row per session from the v2 blob, which is left untouched as backup. Idempotent; ends with a count check that must show matching numbers. |
Run migrations in the Supabase SQL Editor in filename order — the v3 migration must run before the v3 code deploys, since the API reads poker_sessions. The legacy poker_public table previously held a derived public snapshot read by an anon key; the per-session-share model replaced it with the service-key /api/session endpoint, so it is removed.