pokertrip/docs/2026-03-18-pokertrip-design.md
Mikkel Georgsen 38c7877765 Fix spec review findings: data model gaps, API completeness, missing sections
- Add contributed_by_user_id + research_job_id to location_schedules (give-to-get audit trail)
- Add reentry type field (reentry/rebuy/addon distinction)
- Consistent minor currency unit annotations on all monetary columns
- Add exchange_rate_to_home to sessions for cross-currency stats
- Add timestamps to venue_profiles and location_schedules
- Add missing API endpoints: GET session detail, DELETE endpoints, GET snapshots/reentries
- Document 202 Accepted exceptions (auth, research approval are synchronous)
- Add multipart/form-data upload flow documentation
- Add tilt guard response contract
- Add session status state machine diagram
- Add currency conversion strategy
- Add offline sync strategy (localStorage queue, last-write-wins)
- Add API error response format and cursor pagination format
- Add rate limiting on auth endpoints
- Resolve Zustand vs Jotai → Zustand
- Align Go version across docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:40:46 +00:00

27 KiB

PokerTrip — Personal Poker Tracker Design Spec

Overview

A personal poker tracker PWA for a tournament player based in Copenhagen. Tracks ad-hoc home sessions across local venues and structured poker trips abroad, each with isolated budgets. Mobile-first "cockpit" for fast input at the table, desktop "command center" for analysis and trip planning. Includes an AI-powered trip research agent that builds tournament schedules from web searches, URLs, and uploaded images (venue flyers, Facebook posts, etc.).

Designed as a personal tool with architectural choices that support a future SaaS launch (multi-user, shared tournament knowledge base, tiered AI access).

User Context

  • Danish developer in Copenhagen, plays tournaments ad-hoc (2 jobs + family = no fixed schedule)
  • Travels for poker a few times a year (upcoming Manila trip May 2026) — trips are where structured planning and budget tracking matter
  • Runs Proxmox on Hetzner with Nginx Proxy Manager, PBS with off-site backup
  • Prefers Catppuccin Mocha theming, JetBrains Mono font
  • Does not code — Claude builds everything

Core Concepts

Home vs. Trip

Everything is either Home or a Trip.

  • Home: Your default poker life. Ad-hoc sessions at local venues in Denmark. No fixed schedule. One ongoing budget/P&L. Rich filtering by venue(s), buyin range, date range, game type.
  • Trip: A planned poker trip with its own dates, location, budget, and tournament schedule. Budget is soft — you set an intended amount but can exceed it. Trips have a lifecycle: planning → active → completed.

Three Layers of Tournament Data

  1. Global Events (public, system-level)

    • Scraped/curated nightly from Hendon Mob, WPT, WSOP, PokerStars Live, APT, etc.
    • Series-level data: "EPT Barcelona, July 15-28"
    • Background River job, builds a baseline calendar of the poker world
    • Available to all users as a browsable calendar
  2. Location Knowledge (community-contributed, anonymized)

    • When a user runs AI research for a location/date range and approves results, the venue and schedule data enriches a shared pool
    • No attribution — can't see who contributed, just that data exists
    • Another user researching the same location gets this as context fed into their own research run (not free access — you must run your own research to benefit)
  3. My Trips (private, per-user)

    • Your trip: name, dates, budget, which events you're registered for, your results
    • Completely invisible to others unless explicitly shared via a friends feature (SaaS)

Venue Profiles (the "app learns" feature)

Users set up venue profiles: "Casino Copenhagen → Thursday → NLH, 500 DKK, 50K GTD, 10K starting stack". When logging a session at that venue on that day, the app pre-fills these defaults. User can always override. Pattern-based auto-detection from history is a future enhancement — manual profiles first.

Tournament Snapshots

During an active session, you can take a photo of the tournament screen (the TV showing clock/blinds/players). A cheap vision model (e.g. Gemini Flash, Haiku) extracts: level, blinds, ante, players remaining, average stack, total chips, next break, prize pool. You manually enter your own stack size. This creates a timestamped snapshot enabling:

  • Stack trajectory charts per session (your stack in BB over time)
  • Your stack vs. average stack overlay
  • "At what BB count do I tend to bust?" analysis
  • Smarter re-entry tilt guard (uses live data from last snapshot)

Re-entry Tilt Guard

When tapping "Re-entry", the app calculates and displays: how many BBs you'll get, total invested so far, break-even cash amount needed. Forces a moment of rational thought before emotional re-entry decisions. Uses blind structure from venue profiles or trip schedule data, enhanced by snapshot data if available.

Data Model

Users

Column Type Notes
id UUID PK
email TEXT unique
password_hash TEXT bcrypt
display_name TEXT
settings JSONB preferred currency, AI config, snapshot model, etc.
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Venues

Column Type Notes
id UUID PK
name TEXT
short_name TEXT
city TEXT
country TEXT
area TEXT neighborhood/district
type TEXT tournament / cash / both
notes TEXT
color TEXT hex color for UI
visibility TEXT public / private
created_by_user_id UUID FK → users
created_at TIMESTAMPTZ

Venue Profiles (per-user defaults)

Column Type Notes
id UUID PK
user_id UUID FK → users
venue_id UUID FK → venues
day_of_week INT 0=Sunday, 1=Monday, etc. (nullable for "any day" default)
default_game_type TEXT NLH, PLO, etc.
default_buyin INT minor currency unit (cents/øre)
default_currency TEXT DKK, PHP, USD, EUR, etc.
default_gtd INT nullable
starting_stack INT chips
blind_structure JSONB array of {level, small, big, ante, duration_min}
notes TEXT
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Trips

Column Type Notes
id UUID PK
user_id UUID FK → users
name TEXT "Manila May 2026"
location TEXT "Manila"
country TEXT "Philippines"
start_date DATE
end_date DATE
planned_budget INT soft ceiling, minor currency unit (cents/øre)
currency TEXT
status TEXT planning / active / completed
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Global Events (scraped)

Column Type Notes
id UUID PK
name TEXT "EPT Barcelona"
series TEXT EPT, WPT, WSOP, etc.
location TEXT
country TEXT
start_date DATE
end_date DATE
source_url TEXT where it was scraped from
details JSONB flexible extra data
scraped_at TIMESTAMPTZ

Location Schedules (community-contributed, anonymized)

Column Type Notes
id UUID PK
location_key TEXT e.g. "manila-2026-05" for dedup/lookup
venue_id UUID FK → venues
date DATE
time TIME
event_code TEXT "#29A" etc.
name TEXT
buyin INT minor currency unit (cents/øre)
currency TEXT
guarantee INT nullable, minor currency unit
format TEXT NLH, PLO, NLH PKO, etc.
notes TEXT
multi_day BOOLEAN
multi_day_info TEXT "Day 2: May 9, Final: May 10"
starting_stack INT nullable
blind_structure JSONB nullable
source TEXT "ai_research" / "manual" / "scraped"
contributed_by_user_id UUID FK → users, never exposed via API (for audit/rollback)
research_job_id UUID FK → research_jobs, nullable (traces provenance)
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Sessions (the core table — every tournament/cash game played)

Column Type Notes
id UUID PK
user_id UUID FK → users
venue_id UUID FK → venues
trip_id UUID FK → trips, nullable (null = Home)
schedule_entry_id UUID FK → location_schedules, nullable
date DATE
start_time TIMESTAMPTZ
end_time TIMESTAMPTZ nullable (set when session ends)
game_type TEXT NLH, PLO, etc.
session_type TEXT tournament / cash
buyin INT minor currency unit (cents/øre)
currency TEXT
payout INT nullable, minor currency unit
finish_position INT nullable
field_size INT nullable
status TEXT registered / playing / busted / cashed / day2 / skipped
late_reg BOOLEAN default false
entry_level INT nullable — what level you joined at
entry_blinds TEXT nullable — e.g. "100/200"
exchange_rate_to_home NUMERIC nullable, snapshot of exchange rate at session creation for cross-currency stats
notes TEXT
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Session Re-entries

Column Type Notes
id UUID PK
session_id UUID FK → sessions
timestamp TIMESTAMPTZ when the re-entry happened
level INT nullable
blinds TEXT nullable — e.g. "200/400"
stack_received INT nullable — chips received
cost INT minor currency unit, inherits session's currency
type TEXT reentry / rebuy / addon

Session Snapshots

Column Type Notes
id UUID PK
session_id UUID FK → sessions
timestamp TIMESTAMPTZ
my_stack INT user-entered
level INT extracted from image
blinds_small INT extracted
blinds_big INT extracted
ante INT nullable, extracted
players_remaining INT nullable, extracted
average_stack INT nullable, extracted
total_chips BIGINT nullable, extracted
next_break TEXT nullable, extracted
prize_pool INT nullable, extracted
bb_count NUMERIC calculated: my_stack / blinds_big
image_url TEXT path to stored image

Research Jobs

Column Type Notes
id UUID PK
user_id UUID FK → users
trip_id UUID FK → trips
status TEXT pending / running / completed / failed
prompt TEXT user's research request
model TEXT model ID used
provider_endpoint TEXT OpenRouter/Requesty URL
attachments JSONB array of {type: "image"/"url", value: "..."}
results JSONB structured venue + schedule data proposed by AI
conversation JSONB full tool-use conversation for refinement context
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Uploads

Column Type Notes
id UUID PK
user_id UUID FK → users
path TEXT filesystem path
content_type TEXT image/jpeg, image/png, etc.
size_bytes BIGINT
purpose TEXT research / snapshot
created_at TIMESTAMPTZ

Monetary Values

All monetary amounts are stored as integers in minor currency units (cents, øre, centavos). This avoids floating-point precision issues. The currency column on the same row (or parent row for re-entries) determines the unit. Examples: 500 DKK = 50000, ₱8000 = 800000, $100 = 10000.

Currency Conversion Strategy

Sessions are stored in their original currency. For cross-currency aggregation (stats, overall P&L), each session records the currency it was played in. Stats views aggregate within the same currency by default. A "home currency" user setting (e.g. DKK) enables a converted view where an exchange rate snapshot is stored at session creation time (exchange_rate_to_home NUMERIC on sessions, nullable). This is a best-effort convenience — not financial-grade precision.

Session Status State Machine

                    ┌──────────┐
                    │ upcoming │ (default for trip schedule entries)
                    └────┬─────┘
                  ┌──────┼──────┐
                  ▼      │      ▼
            ┌──────────┐ │ ┌─────────┐
            │registered│ │ │ skipped │
            └────┬─────┘ │ └─────────┘
                 ▼       │
            ┌──────────┐ │
            │ playing  │◄┘ (Home sessions start here)
            └──┬──┬──┬─┘
               │  │  │
          ┌────┘  │  └────┐
          ▼       ▼       ▼
     ┌────────┐┌──────┐┌──────┐
     │ busted ││cashed││ day2 │
     └────────┘└──────┘└──┬───┘
                          │
                          ▼
                     ┌──────────┐
                     │ playing  │ (day 2 resumes as playing)
                     └──┬──┬───┘
                        │  │
                   ┌────┘  └────┐
                   ▼            ▼
              ┌────────┐  ┌──────┐
              │ busted │  │cashed│
              └────────┘  └──────┘

Any terminal state (busted, cashed, skipped) can be reset back to upcoming/playing via explicit user action (undo).

Offline Sync Strategy

Single-user tool: last write wins. Mutations made while offline are queued in localStorage (ordered array of API calls). On reconnect (navigator.onLine event), the queue is replayed in order. If a replayed mutation fails (409 conflict), the client fetches fresh state from the server and reconciles. For the personal tool this is sufficient. SaaS multi-device sync would need vector clocks or CRDTs — documented in saas-considerations.md.

API Error Response Format

All error responses use a consistent envelope:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "buyin must be a positive integer"
  }
}

HTTP status codes: 400 (validation), 401 (auth), 403 (forbidden), 404 (not found), 409 (conflict), 429 (rate limited), 500 (server error).

API Pagination Format

All list endpoints use cursor-based pagination:

GET /api/v1/sessions?cursor=<opaque_string>&limit=50

Response includes next_cursor (null if no more results). Cursor is an opaque base64-encoded value (typically created_at + id for stable ordering).

Architecture

Phone/Desktop (React PWA)
  ├── Optimistic local state (in-memory + localStorage cache)
  ├── Background sync → API
  └── SSE connection for real-time updates (research progress, etc.)
        │
        ▼
Go API (single binary, embeds React frontend)
  ├── Chi router
  ├── Auth middleware (JWT + refresh tokens)
  ├── REST endpoints (/api/v1/*)
  ├── SSE endpoint for live updates
  ├── River job queue (in-process, Postgres-backed)
  │     ├── Sync/write jobs
  │     ├── AI research jobs (search → fetch → structure)
  │     ├── Snapshot OCR jobs
  │     └── Nightly global events scraper
  └── SearXNG client (for AI web search tool-use)
        │
        ▼
Postgres (10.5.0.109)
  ├── Application data
  └── River job queue tables

SearXNG (LXC on Proxmox)
  └── Metasearch engine, self-hosted, no API keys

Write Path (message queue pattern)

Every mutation from the client follows fire-and-forget:

  1. Client makes optimistic UI update (instant)
  2. POST/PATCH to API → returns 202 Accepted immediately
  3. River job processes the actual DB write
  4. If failure, client resyncs on next pull
  5. SSE pushes confirmations for critical operations

The UI is always ahead of the database. No lag, ever.

API Design

All endpoints versioned under /api/v1/. All list endpoints support cursor pagination + query param filters.

Write semantics: Most write endpoints return 202 Accepted (fire-and-forget, processed via River). Exceptions that return synchronously: auth endpoints (must return tokens), and POST /research/:id/approve (returns 200 after writing, so the UI can immediately show approved data).

Uploads: Endpoints that accept images (/sessions/:id/snapshot, /research, /research/:id/refine) accept multipart/form-data. Images are stored via the storage interface and an uploads record is created. The response includes the upload URL.

/api/v1/auth (synchronous — returns tokens directly)
  POST   /register          → 201 Created, returns JWT + refresh token
  POST   /login             → 200 OK, JWT access token + refresh token (httpOnly cookie)
  POST   /refresh           → 200 OK, rotate tokens

/api/v1/me
  GET    /                  → user profile + settings
  PATCH  /                  → update settings

/api/v1/venues
  GET    /                  → list (public + user's private)
  POST   /
  PATCH  /:id
  DELETE /:id               → soft delete (private) or remove user's reference (public)

/api/v1/venue-profiles
  GET    /                  → user's venue defaults
  PUT    /:venue_id         → set/update default (body includes day_of_week, nullable for "any day")
  DELETE /:venue_id/:dow    → remove a specific day's profile ("all" for the any-day default)

/api/v1/sessions
  GET    /                  → filterable: venue, trip, date range, buyin range, game type, status
  GET    /:id               → single session with nested reentries + snapshots + tilt guard data
  POST   /
  PATCH  /:id              → status changes, payout, notes, end time
  DELETE /:id
  POST   /:id/reentry      → add re-entry/rebuy/addon (returns tilt guard calculation)
  GET    /:id/reentries     → list re-entries for a session
  POST   /:id/snapshot      → upload tournament screen photo + stack size (multipart/form-data)
  GET    /:id/snapshots     → list snapshots for a session (for trajectory chart)

/api/v1/trips
  GET    /
  POST   /
  PATCH  /:id
  DELETE /:id
  GET    /:id/schedule      → trip's tournament schedule

/api/v1/schedule
  GET    /                  → location schedules (query by location_key, date range)
  POST   /                  → manually add schedule entry
  DELETE /:id

/api/v1/research
  POST   /                  → start AI research job (multipart/form-data for images)
  GET    /:id               → job status + results
  POST   /:id/refine        → iterate with additional context/images (multipart/form-data)
  POST   /:id/approve       → 200 OK (synchronous) — writes to venues + location_schedules

/api/v1/global-events
  GET    /                  → browse scraped series (filterable by date, location)

/api/v1/events
  GET    /stream            → SSE (research progress, sync confirmations, snapshot OCR results)

/api/v1/settings/models
  GET    /                  → quick picks + full model list from provider API

Tilt guard response (returned by POST /sessions/:id/reentry):

{
  "reentry_id": "uuid",
  "tilt_guard": {
    "cost": 50000,
    "stack_received": 10000,
    "current_blinds": "200/400",
    "bb_count": 25,
    "total_invested": 150000,
    "entry_count": 3,
    "break_even_cash": 150000
  }
}

AI Research System

Research Flow

  1. User creates a trip, hits "Research"
  2. Chat-like interface: text prompt + image uploads + URL links + model picker
  3. POST /research → River job queued
  4. Go backend calls chosen model via OpenRouter/Requesty (OpenAI-compatible endpoint)
  5. Model receives system prompt + tools:
    • search(query) → hits self-hosted SearXNG
    • fetch_url(url) → fetches and extracts page content
    • analyze_image(image) → model's own vision capability on uploaded images
    • create_venue(data) → proposes a new venue
    • create_schedule_entry(data) → proposes a tournament entry
  6. SSE streams progress to browser ("Searching... Found venue... Extracting schedule...")
  7. Results presented as proposed schedule cards with Accept/Edit/Remove per entry
  8. User can refine ("also check 9D Poker Club") → POST /research/:id/refine
  9. User approves → venues (public) + schedule entries written to DB

Shared Knowledge Context

Before starting research, system checks for existing location knowledge for the target location + date range. If found, it's provided as context to the AI ("We already know about these venues and events — build on this"). This requires the user to run their own research — no free access to the knowledge base without contributing.

Snapshot OCR Flow

  1. During active session, tap camera button
  2. Snap tournament screen → upload image
  3. POST /sessions/:id/snapshot with image + user's stack size
  4. River job sends image to snapshot model (cheap/fast vision model, configured separately in settings)
  5. Model extracts: level, blinds, ante, players, average stack, etc.
  6. Returns pre-filled fields → user confirms/corrects → saved

Model Management (Settings UI)

AI Settings:
  Provider:        [OpenRouter ▾]  (OpenRouter / Requesty / Custom)
  Endpoint URL:    [https://openrouter.ai/api/v1]
  API Key:         [••••••••••••]

  Research Model:  Quick picks: Sonnet, Opus, GPT-4o, Gemini Pro, Grok
                   [🔍 Search all models...]

  Snapshot Model:  Quick picks: Haiku, Gemini Flash, GPT-4o-mini
                   [🔍 Search all models...]

  🖼️ Vision support indicator per model

Quick picks populated from provider's /models endpoint, filtered to top models from Anthropic, OpenAI, Google, xAI. Fuzzy search for the full list.

Frontend

Shared Principles

  • Catppuccin Mocha color palette throughout
  • JetBrains Mono / Fira Code font
  • Dark mode only
  • PWA: manifest.json + service worker (Workbox) for offline caching
  • Optimistic UI: every action feels instant

Mobile Cockpit (< 768px)

The in-the-moment tool. Speed and touch-friendliness above all.

Top toggle: Home | Trip (Trip only visible when an active trip exists)

Home mode:

  • "Log a session" prominent button
  • Today's venue suggestion (from venue profiles for this day of week)
  • Last 3-5 sessions as quick history
  • Tap "Log" → pick venue (favorites first, search others) → pre-filled from venue profile → confirm → live session
  • Active session: timer, big thumb-friendly action buttons (Re-entry, Rebuy, Add-on, Bust, Cash, Made Day 2, Snapshot) — re-entry/rebuy/addon all tracked as separate types in session_reentries
  • Re-entry tilt guard modal before confirming re-entry
  • "Cash" → input payout + finish position → end session

Trip mode:

  • Today's schedule (card-style, like Manila JSX)
  • Filter by venue
  • Status badges and action buttons per tournament
  • Budget bar: spent vs. planned, current P&L
  • Swipe between days

Session entry captures:

  • Venue, date, game type, buyin, currency
  • Start time (auto), end time (when you tap "done")
  • Late registration flag + entry level/blinds (optional)
  • Re-entries (tracked individually with timestamp, level, blinds, cost)
  • Payout, finish position, field size (on completion)
  • Notes
  • Snapshots (photos of tournament screen + your stack)

Desktop Dashboard (≥ 768px)

The analysis and planning tool.

Sidebar navigation: Home, Trips, Stats, Venues, Settings

Home view:

  • Session history table with powerful filtering: date range, venue(s), buyin range, game type, result
  • Summary stats bar: total sessions, P&L, ROI, avg buyin, avg cash, ITM%
  • Filters update everything live

Trip view:

  • Trip list → drill into trip → full schedule explorer
  • Side panel: budget tracker (planned vs. actual), session results, daily breakdown
  • Research tab: start/refine AI research, review proposed schedules, approve

Stats view:

  • P&L over time (line chart)
  • By venue (bar chart)
  • By game type, by buyin range
  • Home vs. Trips breakdown
  • Monthly/quarterly/yearly views
  • Late reg vs. on-time performance comparison
  • Stack trajectory charts (from snapshots)
  • Re-entry analysis (ROI with vs. without re-entries)
  • All filterable with same filter system

Venues view:

  • Manage venues (public/private)
  • Set up venue profiles (day-of-week defaults, blind structures, starting stacks)

Settings view:

  • Profile (email, display name, preferred currency)
  • AI configuration (provider, API key, research model, snapshot model)
  • Model picker with quick picks + fuzzy search

Auth & Multi-tenancy

Current (personal tool)

  • Email + password registration (bcrypt)
  • JWT access token (15 min) + refresh token (30 days, httpOnly cookie)
  • All queries scoped by user_id
  • No email verification (single user)
  • Basic rate limiting on auth endpoints: 5 attempts/min per IP (in-memory counter, even for personal tool — it's exposed to the internet)

Data Visibility Rules

Data Visibility
Global events Everyone
Location schedules Users who have run research (give-to-get)
Public venues Everyone
Private venues Creator only (redacted as "Private Venue" in any shared context)
Trips Owner only
Sessions & results Owner only
Budgets Owner only
Snapshots Owner only
Venue profiles Owner only
Research jobs Owner only (approved venues/schedules become shared)

Tech Stack

Backend

  • Go (latest stable)
  • chi — lightweight router
  • pgx — Postgres driver
  • sqlc — type-safe SQL → Go codegen
  • goose — migrations
  • river — Postgres-backed job queue (no Redis)
  • golang-jwt/jwt/v5 — JWT
  • golang.org/x/crypto/bcrypt — password hashing
  • embed — React frontend baked into binary

Frontend

  • React + TypeScript (Vite)
  • Tailwind CSS (Catppuccin Mocha palette)
  • Framer Motion (animations)
  • Recharts (stats/charts)
  • Workbox (PWA/service worker)
  • Zustand (lightweight state management)

Infrastructure

  • Postgres (10.5.0.109, database: pokertrip)
  • SearXNG (self-hosted LXC on Proxmox)
  • Nginx Proxy Manager (existing, SSL termination)

Deployment

Single Go binary embeds the built React frontend. One process, one port.

poker.georgsen.dk
  → Nginx Proxy Manager (SSL)
    → Go binary :8080
      ├── /           → embedded React SPA
      ├── /api/v1/*   → API
      └── /api/v1/events/stream → SSE

Environment config via .env file (gitignored). See .env.example for template.

Dev experience:

  • make dev — Go hot reload (Air) + Vite dev server with proxy
  • make build — builds React, embeds into Go binary
  • make migrate — runs Postgres migrations (goose)

Project Structure

pokertrip/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── api/
│   │   ├── auth.go
│   │   ├── sessions.go
│   │   ├── trips.go
│   │   ├── venues.go
│   │   ├── research.go
│   │   ├── snapshots.go
│   │   └── middleware.go
│   ├── db/
│   │   ├── migrations/
│   │   ├── queries/
│   │   └── sqlc.yaml
│   ├── jobs/
│   │   ├── research.go
│   │   ├── scraper.go
│   │   └── snapshot_ocr.go
│   ├── ai/
│   │   ├── client.go
│   │   ├── tools.go
│   │   └── models.go
│   ├── storage/
│   │   └── local.go
│   └── config/
│       └── config.go
├── frontend/
│   ├── src/
│   │   ├── components/
│   │   │   ├── mobile/
│   │   │   ├── desktop/
│   │   │   └── shared/
│   │   ├── hooks/
│   │   ├── stores/
│   │   ├── api/
│   │   ├── App.tsx
│   │   └── main.tsx
│   ├── public/
│   │   ├── manifest.json
│   │   └── sw.js
│   ├── index.html
│   ├── vite.config.ts
│   ├── tailwind.config.ts
│   └── package.json
├── docs/
│   ├── manila-poker-tracker.jsx   ← reference JSX for Manila trip UI
│   ├── saas-considerations.md
│   └── 2026-03-18-pokertrip-design.md  ← this file
├── .env
├── .env.example
├── .gitignore
├── Makefile
├── Dockerfile
└── go.mod