# 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 | in minor currency unit | | 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 | | ### 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 | | 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 | | | currency | TEXT | | | guarantee | INT | nullable | | 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" | | created_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 | | | currency | TEXT | | | payout | INT | nullable | | 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" | | 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 | re-entry cost (may differ from original buyin) | ### 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 | | ## 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 write endpoints return `202 Accepted`. All list endpoints support cursor pagination + query param filters. ``` /api/v1/auth POST /register POST /login → JWT access token + refresh token (httpOnly cookie) POST /refresh → rotate tokens /api/v1/me GET / → user profile + settings PATCH / → update settings /api/v1/venues GET / → list (public + user's private) POST / PATCH /:id /api/v1/venue-profiles GET / → user's venue defaults PUT /:venue_id/:dow → set/update default /api/v1/sessions GET / → filterable: venue, trip, date range, buyin range, game type, status POST / PATCH /:id → status changes, payout, notes, end time POST /:id/reentry → add re-entry (triggers tilt guard response) POST /:id/snapshot → upload tournament screen photo + stack size /api/v1/trips GET / POST / PATCH /: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 /api/v1/research POST / → start AI research job GET /:id → job status + results POST /:id/refine → iterate with additional context/images POST /:id/approve → accept results → 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) /api/v1/settings/models GET / → quick picks + full model list from provider API ``` ## 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 (Rebuy, Add-on, Bust, Cash, Made Day 2, Snapshot) - 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) ### 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** or **Jotai** (lightweight state) ### 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 ```