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

703 lines
27 KiB
Markdown

# 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:
```json
{
"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`):
```json
{
"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
```