Poker Session Tracker

Self-hosted poker home-game tracker. React SPA + Vercel serverless functions + Supabase Postgres.
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:

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

LayerTechNotes
UIReact 18, Vite 5Inline-style components, no CSS framework
APIVercel Serverless FunctionsNode, zero-config api/ directory
DBSupabase (Postgres)Accessed only via @supabase/supabase-js server-side
QRqrcode.reactPer-session share-link QR code
Analytics@vercel/analyticsInjected 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)

TablePurpose
poker_sessionsone row per session: data jsonb + mirrored share_token/ended/updated_at columns; deleted_at soft-delete
poker_opsidempotency ledger and permanent audit trail — one row per applied operation
poker_datalegacy 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)

KeyPurpose
poker-unlockedSESSION_KEY — "admin" once authenticated
poker-admin-secretAPI secret returned by /api/auth, sent as x-admin-secret
poker-lockoutLOCKOUT_KEY — failed-attempt count + lockout expiry
poker-op-queueops 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:

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" }
  ]
}
Gotcha: nested dynamic function files (e.g. 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.

POST/api/auth

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

POST/api/op

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

GET/api/sessions

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

POST/api/sessions

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

GET/api/session?token=<uuid>

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.

  1. PinGate computes sha256(pin) in the browser and POSTs it to /api/auth.
  2. auth.js hashes ADMIN_PIN server-side and compares with timingSafeEqual. On match it returns ADMIN_API_SECRET.
  3. The secret is stored as poker-admin-secret and sent as x-admin-secret on every /api/op and /api/sessions call, where it is compared with timingSafeEqual.

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)

Observer (ShareApp.jsx)

Series stats

Aggregate stats are computed two ways from the same logic, so observers never receive other sessions' raw data:

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:

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:

  1. 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".
  2. A retry can't double-apply. poker_ops is an idempotency ledger keyed by the client-generated opId. A retried op whose first attempt actually committed (response lost in transit) is detected by the duplicate key and answered with current state.
  3. Concurrent admins don't collide. Each session versions independently (updated_at CAS), 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.
  4. Deletes stay deleted. Sessions are soft-deleted (deleted_at); a stale device's ops or blob-saves for a deleted session are answered with deleted: true and 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

Environment variables

VariableUsed byNotes
SUPABASE_URLall functionsSupabase project URL
SUPABASE_SERVICE_KEYall functionsservice_role secret. Server-only.
ADMIN_PINauth.jsAdmin password (hashed for comparison)
ADMIN_API_SECRETauth.js, op.js, sessions.jsReturned on login; sent as x-admin-secret. Generate with openssl rand -hex 32.
No 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

FilePurpose
20260101000000_init.sqlCreates poker_data + the deny-all-anon RLS policy.
20260606000000_per_session_share.sqlDrops the legacy poker_public snapshot table (now unused). No-op on fresh installs.
20260703000000_op_based_backend.sqlv3: 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.