- 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>
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
-
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
-
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)
-
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 |
| 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:
- Client makes optimistic UI update (instant)
POST/PATCHto API → returns202 Acceptedimmediately- River job processes the actual DB write
- If failure, client resyncs on next pull
- 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
- User creates a trip, hits "Research"
- Chat-like interface: text prompt + image uploads + URL links + model picker
POST /research→ River job queued- Go backend calls chosen model via OpenRouter/Requesty (OpenAI-compatible endpoint)
- Model receives system prompt + tools:
search(query)→ hits self-hosted SearXNGfetch_url(url)→ fetches and extracts page contentanalyze_image(image)→ model's own vision capability on uploaded imagescreate_venue(data)→ proposes a new venuecreate_schedule_entry(data)→ proposes a tournament entry
- SSE streams progress to browser ("Searching... Found venue... Extracting schedule...")
- Results presented as proposed schedule cards with Accept/Edit/Remove per entry
- User can refine ("also check 9D Poker Club") →
POST /research/:id/refine - 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
- During active session, tap camera button
- Snap tournament screen → upload image
POST /sessions/:id/snapshotwith image + user's stack size- River job sends image to snapshot model (cheap/fast vision model, configured separately in settings)
- Model extracts: level, blinds, ante, players, average stack, etc.
- 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 proxymake build— builds React, embeds into Go binarymake 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