Initial project docs — design spec, reference JSX, and GSD kickoff

Personal poker tracker: Go + React + PostgreSQL PWA for tracking
home sessions and poker trips with AI-powered schedule research.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-03-18 09:36:56 +00:00
commit fef6f5318e
7 changed files with 1190 additions and 0 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
POKER_DB_URL=postgresql://user:password@localhost:5432/pokertrip
POKER_JWT_SECRET=generate-a-random-64-char-hex-string
POKER_SEARXNG_URL=http://searxng.local:8080
POKER_UPLOAD_PATH=./uploads
POKER_PORT=8080

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.env
uploads/
dist/
node_modules/
frontend/node_modules/
frontend/dist/
*.exe
*.db
*.db-journal
tmp/
.air.toml

87
README.md Normal file
View file

@ -0,0 +1,87 @@
# 🃏 PokerTrip
Personal poker tracker for tournament players. Track ad-hoc home sessions, plan poker trips with AI-powered schedule research, and analyze your game with real data.
## What It Does
- **Home tracking** — Log tournament sessions at your local venues with quick entry and smart defaults
- **Trip planning** — Create poker trips with budgets, tournament schedules, and venue info
- **AI research** — Give the app a location and dates, it searches the web and builds your trip schedule (bring your own OpenRouter/Requesty API key)
- **Tournament snapshots** — Snap the tournament clock screen, AI extracts level/blinds/players, you add your stack — track your trajectory over time
- **Tilt guard** — Re-entry prompts show you the math: BBs you'll get, total invested, break-even needed
- **Stats & analysis** — Filter by venue, buyin range, dates, game type. Compare late reg vs. on time, re-entry ROI, stack trajectories
## Stack
| Layer | Tech |
|-------|------|
| **Backend** | Go, Chi, pgx, sqlc, River (job queue) |
| **Frontend** | React, TypeScript, Vite, Tailwind (Catppuccin Mocha) |
| **Database** | PostgreSQL |
| **AI** | OpenAI-compatible API (OpenRouter / Requesty) |
| **Search** | Self-hosted SearXNG |
| **Deploy** | Single Go binary with embedded frontend |
## Getting Started
### Prerequisites
- Go 1.22+
- Node.js 20+
- PostgreSQL 16+
- SearXNG instance (for AI research web search)
### Setup
```bash
# Clone
git clone ssh://git@10.5.0.14:2222/mikkel/pokertrip.git
cd pokertrip
# Configure
cp .env.example .env
# Edit .env with your database credentials and JWT secret
# Database migrations
make migrate
# Development
make dev
```
### Build & Deploy
```bash
# Build single binary (Go + embedded React frontend)
make build
# Run
./pokertrip
```
## Project Structure
```
pokertrip/
├── cmd/server/ Go entrypoint
├── internal/
│ ├── api/ HTTP handlers + middleware
│ ├── db/ Migrations + sqlc queries
│ ├── jobs/ River workers (research, scraping, OCR)
│ ├── ai/ OpenAI-compatible client + tools
│ ├── storage/ File upload (local fs, swappable to S3)
│ └── config/ Environment config
├── frontend/ React + Vite + Tailwind
├── docs/ Design specs + reference files
├── .env.example Environment template
├── Makefile Dev/build/migrate commands
└── Dockerfile Container deployment
```
## Design
See [`docs/2026-03-18-pokertrip-design.md`](docs/2026-03-18-pokertrip-design.md) for the full design spec.
## License
Private — personal project.

View file

@ -0,0 +1,593 @@
# 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
```

50
docs/gsd-kickoff.md Normal file
View file

@ -0,0 +1,50 @@
# PokerTrip — GSD Kickoff Prompt
## Project
Build **PokerTrip**, a personal poker tournament tracker as a PWA. Go backend + React frontend + PostgreSQL.
## Key Documents
Read these before planning:
1. **Design spec**: `docs/2026-03-18-pokertrip-design.md` — the full architecture, data model, API design, frontend layout, AI research system, and tech stack. This is the source of truth.
2. **Manila JSX reference**: `docs/manila-poker-tracker.jsx` — a working React component for a Manila poker trip tracker. Use as UI/UX reference for the trip mode experience (card layout, action buttons, status flow, budget display, Catppuccin Mocha theming). The data is hardcoded here — in the real app it comes from the database.
3. **SaaS considerations**: `docs/saas-considerations.md` — a checklist of things to add when going multi-tenant. Don't build these now, but make architectural choices that don't block them later (user-scoped data, storage interface abstraction, etc.).
## Tech Stack
- **Backend**: Go, Chi (router), pgx (Postgres driver), sqlc (codegen), goose (migrations), River (Postgres-backed job queue), golang-jwt, bcrypt
- **Frontend**: React + TypeScript, Vite, Tailwind CSS (Catppuccin Mocha palette), Framer Motion, Recharts, Workbox (PWA), Zustand or Jotai (state)
- **Database**: PostgreSQL at `10.5.0.109:5432/pokertrip` (credentials in `.env`)
- **AI**: OpenAI-compatible endpoint (OpenRouter/Requesty), self-hosted SearXNG for web search
- **Deploy**: Single Go binary with embedded React frontend via `embed.FS`
## What To Build
The design spec covers everything in detail, but the major systems are:
1. **Database schema + migrations** — all tables from the data model section
2. **Auth** — email/password, JWT access + refresh tokens, user-scoped queries
3. **Venues** — CRUD, public/private visibility, venue profiles (per-user day-of-week defaults)
4. **Sessions** — the core: create, status transitions, re-entries (with tilt guard data), end session, filtering
5. **Trips** — CRUD, budget tracking, link to location schedules
6. **Tournament snapshots** — image upload, vision model OCR extraction, stack tracking
7. **AI research system** — River job, OpenAI-compatible client with tool-use (SearXNG search, URL fetch, image analysis), SSE progress streaming, review/refine/approve flow
8. **Global events scraper** — nightly River job, Hendon Mob etc.
9. **Frontend mobile cockpit** — Home/Trip toggle, fast session entry, action buttons, tilt guard modal, snapshot camera
10. **Frontend desktop dashboard** — session history with filters, trip explorer, stats/charts, venue management, settings (AI config, model picker)
11. **PWA** — manifest, service worker, offline support, optimistic sync
## Architecture Principles
- Every write is fire-and-forget from the client (202 Accepted, River processes async)
- UI is always optimistic — never wait for DB confirmation
- All data is user-scoped (ready for multi-tenant)
- Storage interface abstraction (local filesystem now, S3-compatible later)
- Separate model settings for research (powerful) vs. snapshot OCR (cheap/fast)
- Shared tournament knowledge is anonymized and give-to-get (must run own research to access community data)
## User Context
The user (Mikkel) does not code — Claude builds everything. He plays poker tournaments ad-hoc in Denmark and plans trips abroad a few times a year. Mobile UX is critical — he'll use this at the poker table. Desktop is for analysis and trip planning at home.

View file

@ -0,0 +1,387 @@
import { useState, useEffect, useMemo } from "react";
const C = {
base: "#1e1e2e", mantle: "#181825", crust: "#11111b",
surface0: "#313244", surface1: "#45475a", surface2: "#585b70",
overlay0: "#6c7086", overlay1: "#7f849c", overlay2: "#9399b2",
subtext0: "#a6adc8", subtext1: "#bac2de", text: "#cdd6f4",
lavender: "#b4befe", blue: "#89b4fa", sapphire: "#74c7ec",
sky: "#89dceb", teal: "#94e2d5", green: "#a6e3a1",
yellow: "#f9e2af", peach: "#fab387", maroon: "#eba0ac",
red: "#f38ba8", mauve: "#cba6f7", pink: "#f5c2e7",
flamingo: "#f2cdcd", rosewater: "#f5e0dc",
};
const PHP_TO_USD = 0.0175;
const PHP_TO_DKK = 0.12;
const VENUES = {
okada: { name: "Okada Manila (PokerStars LIVE)", short: "Okada", color: C.blue, area: "Entertainment City", type: "tournament", note: "Main series venue — Okada Manila Millions" },
metro: { name: "Metro Card Club", short: "Metro CC", color: C.teal, area: "Ortigas Center, Pasig", type: "tournament", note: "35 tables, amazing food nearby, live music area. Early bird promo: +20% chips if registered before 2PM, +10% before end of lvl 1. 5% service charge on prize pool." },
letsin: { name: "Let's All In Poker Club", short: "Let's All In", color: C.peach, area: "Macapagal Blvd, Pasay", type: "both", note: "Daily tournaments ~6PM, ₱250+ buy-in. Cash from 10/20. PAGCOR licensed." },
masters: { name: "Masters Poker Club", short: "Masters", color: C.flamingo, area: "J. Bocobo St, Malate", type: "cash", note: "Cash games from late afternoon into early hours. Malate = great nightlife district. Occasional small tourneys." },
dynasty: { name: "Dynasty Poker Club", short: "Dynasty", color: C.pink, area: "Malate, Manila", type: "cash", note: "Cash games from late afternoon. Same Malate area as Masters." },
solaire: { name: "Solaire Resort & Casino", short: "Solaire", color: C.mauve, area: "Entertainment City", type: "cash", note: "24hr cash room (Poker King Club). Stakes 50/100 to 500/1000+ PHP. High-stakes action. Near Okada." },
cod: { name: "City of Dreams Manila", short: "CoD Manila", color: C.yellow, area: "Entertainment City", type: "cash", note: "APT venue historically. Cash games may run — verify on arrival. Poker room status unclear in 2026." },
newport: { name: "Newport World Resorts", short: "Newport", color: C.sapphire, area: "Pasay (across NAIA T3)", type: "cash", note: "24/7 cash games, 50/100 and 100/200 PHP. Literally across from the airport." },
nineD: { name: "9D Poker Club", short: "9D PC", color: C.sky, area: "Aseana Business Park, Parañaque", type: "both", note: "Luxury club near Entertainment City. Opens 2PM6AM daily. Cash 50/100200+. Daily tournaments with GTDs (check FB for schedule). Reopened Jan 2026. Bad Beat Jackpot running. Ph: +63 917 184 9417" },
prime: { name: "Prime Poker Club", short: "Prime", color: C.rosewater, area: "Macapagal Blvd, Pasay", type: "cash", note: "Cash games. HK SunPlaza location. Ph: +63 917 722 8667" },
twoace: { name: "2Ace Poker Manila", short: "2Ace", color: C.overlay2, area: "Manila", type: "both", note: "Community-focused club with lower rakes. Local tournaments and cash games. Check socials for schedule." },
};
const DAYS = [
{ date: "2026-05-04", label: "Monday, May 4", short: "Mon 4", dow: "Monday" },
{ date: "2026-05-05", label: "Tuesday, May 5", short: "Tue 5", dow: "Tuesday" },
{ date: "2026-05-06", label: "Wednesday, May 6", short: "Wed 6", dow: "Wednesday" },
{ date: "2026-05-07", label: "Thursday, May 7", short: "Thu 7", dow: "Thursday" },
{ date: "2026-05-08", label: "Friday, May 8", short: "Fri 8", dow: "Friday" },
{ date: "2026-05-09", label: "Saturday, May 9", short: "Sat 9", dow: "Saturday" },
{ date: "2026-05-10", label: "Sunday, May 10", short: "Sun 10", dow: "Sunday" },
];
const METRO_DAILY = {
Monday: { buyin: 750, guarantee: 50000, chips: 8000, fee: "700+50" },
Tuesday: { buyin: 1100, guarantee: 75000, chips: 10000, fee: "1,000+100" },
Wednesday: { buyin: 1500, guarantee: 100000, chips: 10000, fee: "1,400+100" },
Thursday: { buyin: 1750, guarantee: 120000, chips: 10000, fee: "1,600+150" },
Friday: { buyin: 1500, guarantee: 100000, chips: 10000, fee: "1,400+100" },
Saturday: { buyin: 1750, guarantee: 120000, chips: 10000, fee: "1,600+150" },
Sunday: { buyin: 1750, guarantee: 120000, chips: 10000, fee: "1,600+150" },
};
function buildTournaments() {
const ts = [];
let id = 0;
const nid = () => `t${id++}`;
const ok = (date, time, event, name, buyin, guarantee, format, notes, md, mdInfo) =>
ts.push({ id: nid(), date, time, venue: "okada", event, name, buyin, guarantee, format, notes, multiDay: !!md, multiDayInfo: mdInfo || null });
// MAY 4 Mon
ok("2026-05-04","12:00","#21","Ultra Stack",10000,780000,"NLH","₱780K GTD",false);
ok("2026-05-04","17:00","#22","Deepstack Turbo",8000,null,"NLH","Turbo",false);
ok("2026-05-04","20:00","#23","Okada Millions Qualifier",1900,null,"NLH","WYS at 25K Chips",false);
ok("2026-05-04","21:00","#24","Win the Button Hyper",4000,null,"NLH","Hyper Turbo",false);
// MAY 5 Tue
ok("2026-05-05","12:00","#25","Knockout [₱5K Bounty]",10000,780000,"NLH PKO","₱780K GTD · ₱5K bounty per KO",false);
ok("2026-05-05","17:00","#26","6 Handed Turbo",8000,null,"NLH","6-Max Turbo",false);
ok("2026-05-05","20:00","#27","Millions Qualifier",1900,null,"NLH","WYS at 25K Chips",false);
ok("2026-05-05","21:00","#28","Run it Twice Hyper",4000,null,"NLH","Hyper Turbo",false);
// MAY 6 Wed
ok("2026-05-06","12:00","#29A","Okada Millions Flt A ★",8000,6000000,"NLH","₱6M GTD — MAIN EVENT Flight A",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-06","15:00","#30","NLH Freezeout",10000,null,"NLH","Freezeout — no re-entry",false);
ok("2026-05-06","17:00","#29B","Okada Millions Flt B ★",8000,6000000,"NLH","₱6M GTD — Flt B [20min lvls]",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-06","19:00","#31","Megastack Turbo",5000,null,"NLH","Turbo",false);
ok("2026-05-06","20:00","#32","Millions Qualifier",1900,null,"NLH","WYS at 25K Chips",false);
// MAY 7 Thu
ok("2026-05-07","12:00","#29C","Okada Millions Flt C ★",8000,6000000,"NLH","₱6M GTD — Flight C",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-07","15:00","#33","Shot Clock",10000,null,"NLH","Shot Clock format",false);
ok("2026-05-07","17:00","#29D","Okada Millions Flt D ★",8000,6000000,"NLH","₱6M GTD — Flt D [20min lvls]",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-07","19:00","#34","KO Turbo [₱1K Bounty]",5000,null,"NLH PKO","₱1K Bounty Turbo",false);
ok("2026-05-07","20:00","#35","Millions Qualifier",1900,null,"NLH","WYS at 25K Chips",false);
// MAY 8 Fri
ok("2026-05-08","12:00","#29E","Okada Millions Flt E ★",8000,6000000,"NLH","₱6M GTD — Flight E",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-08","15:00","#36A","Mystery Bounty Flt A",15000,2000000,"NLH MB","₱2M GTD — Mystery Bounty",true,"Day 2 Final: May 10");
ok("2026-05-08","15:00","#37","Millions Qualifier",1900,null,"NLH","WYS at 25K Chips",false);
ok("2026-05-08","17:00","#29F","Okada Millions Flt F ★",8000,6000000,"NLH","₱6M GTD — Flt F [20min]",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-08","19:00","#38","SuperStack Turbo FO",5000,null,"NLH","Turbo Freezeout",false);
ok("2026-05-08","21:00","#29G","Okada Millions Flt G ★",8000,6000000,"NLH","₱6M GTD — Turbo Flight G",true,"Day 2: May 9 · Final: May 10");
ok("2026-05-08","22:00","#39","Mystery Bounty Qual",3000,null,"NLH","WYS at 30K Chips",false);
// MAY 9 Sat
ok("2026-05-09","12:00","#36B","Mystery Bounty Flt B",15000,2000000,"NLH MB","₱2M GTD",true,"Day 2 Final: May 10");
ok("2026-05-09","12:00","#40","Superstack Freezeout",8000,null,"NLH","Freezeout",false);
ok("2026-05-09","13:00","#29 D2","Millions — Day 2",0,6000000,"NLH","Day 2 — qualified only",true,"Final 9: May 10");
ok("2026-05-09","15:00","#41","Pot Limit Omaha",8000,null,"PLO","",false);
ok("2026-05-09","15:00","#42","Mystery Bounty Qual",3000,null,"NLH","WYS at 30K",false);
ok("2026-05-09","17:00","#36C","Mystery Bounty Flt C",15000,2000000,"NLH MB","₱2M GTD [20/15min]",true,"Day 2 Final: May 10");
ok("2026-05-09","20:00","#43","Swap Hold'em Turbo",5000,null,"Swap","Turbo",false);
ok("2026-05-09","21:00","#44","NLH 10/10/10",10000,null,"NLH","10/10/10 format",false);
// MAY 10 Sun
ok("2026-05-10","12:00","#45","Survivor KO [₱25K!]",12500,1000000,"NLH PKO","₱1M GTD · ₱25K bounty!",false);
ok("2026-05-10","12:00","#36 Fin","Mystery Bounty Final",0,2000000,"NLH MB","Day 2 Final — qualified only",true,"FINAL DAY");
ok("2026-05-10","13:00","#29 Fin","Millions — Final 9",0,6000000,"NLH","Final Table — qualified only",true,"FINAL DAY");
ok("2026-05-10","17:00","#46","6 Handed Turbo",8000,null,"NLH","6-Max Turbo",false);
ok("2026-05-10","19:00","#47","Last Chance Super Hyper",5000,null,"NLH","LAST EVENT of the series!",false);
// Metro Card Club dailies
DAYS.forEach(d => {
const m = METRO_DAILY[d.dow];
ts.push({ id: nid(), date: d.date, time: "15:00", venue: "metro", event: "Daily", name: `${d.dow} ${(m.guarantee/1000)}K GTD`, buyin: m.buyin, guarantee: m.guarantee, format: "NLH", notes: `${m.fee} PHP · ${m.chips.toLocaleString()} chips · 20min blinds · Late reg thru lvl 9 · Early bird: +20% chips before 2PM`, multiDay: false, multiDayInfo: null });
});
// Let's All In dailies
DAYS.forEach(d => {
ts.push({ id: nid(), date: d.date, time: "18:00", venue: "letsin", event: "Daily", name: "Evening Tournament", buyin: 500, guarantee: null, format: "NLH", notes: "~₱250-₱500 range (varies day-to-day). Cash games also from 10/20 PHP.", multiDay: false, multiDayInfo: null });
});
// 9D Poker Club dailies (schedule varies check FB)
DAYS.forEach(d => {
ts.push({ id: nid(), date: d.date, time: "17:00", venue: "nineD", event: "Daily", name: "Daily GTD Tournament", buyin: 2000, guarantee: 50000, format: "NLH", notes: "~₱1K-₱3K range (varies). GTDs from ₱50K₱500K+ depending on day. Check 9D FB page for exact schedule. Opens 2PM.", multiDay: false, multiDayInfo: null });
});
return ts;
}
const ALL_T = buildTournaments();
const STATUS = {
upcoming: { label: "Upcoming", color: C.overlay1, icon: "⏳" },
registered: { label: "Registered", color: C.yellow, icon: "🎫" },
playing: { label: "Playing", color: C.green, icon: "🃏" },
busted: { label: "Busted", color: C.red, icon: "💀" },
cashed: { label: "Cashed!", color: C.teal, icon: "💰" },
day2: { label: "Made Day 2", color: C.mauve, icon: "🌟" },
skipped: { label: "Skipped", color: C.surface2, icon: "⏭️" },
};
const SK = "manila-poker-v3";
const load = () => { try { return JSON.parse(localStorage.getItem(SK)); } catch { return null; } };
const save = s => { try { localStorage.setItem(SK, JSON.stringify(s)); } catch {} };
const cv = (php, cur) => { if (php == null) return "—"; if (cur==="USD") return `$${Math.round(php*PHP_TO_USD).toLocaleString()}`; if (cur==="DKK") return `${Math.round(php*PHP_TO_DKK).toLocaleString()} kr`; return `${php.toLocaleString()}`; };
export default function App() {
const sv = load();
const [day, setDay] = useState("2026-05-04");
const [sts, setSts] = useState(sv?.sts || {});
const [prizes, setPrizes] = useState(sv?.prizes || {});
const [places, setPlaces] = useState(sv?.places || {});
const [reents, setReents] = useState(sv?.reents || {});
const [editId, setEditId] = useState(null);
const [showBudget, setShowBudget] = useState(false);
const [cur, setCur] = useState("PHP");
const [vf, setVf] = useState("all");
const [showCash, setShowCash] = useState(false);
const [dimOthers, setDimOthers] = useState(true);
useEffect(() => { save({ sts, prizes, places, reents }); }, [sts, prizes, places, reents]);
const gs = id => sts[id] || "upcoming";
const ss = (id, s) => { setSts(p => ({...p,[id]:s})); if(s==="cashed") setEditId(id); else setEditId(null); };
const dayTs = useMemo(() => {
let t = ALL_T.filter(t => t.date === day);
if (vf !== "all") t = t.filter(t => t.venue === vf);
return t.sort((a,b) => a.time.localeCompare(b.time) || a.venue.localeCompare(b.venue));
}, [day, vf]);
const hasActive = dayTs.some(t => ["playing","registered"].includes(gs(t.id)));
const budget = useMemo(() => {
let sp=0, wn=0, cnt=0, cs=0;
ALL_T.forEach(t => {
const s = gs(t.id);
if (["registered","playing","busted","cashed","day2"].includes(s)) { sp += t.buyin*(1+(reents[t.id]||0)); cnt++; }
if (s==="cashed" && prizes[t.id]) { wn += prizes[t.id]; cs++; }
});
return { sp, wn, net: wn-sp, cnt, cs };
}, [sts, prizes, reents]);
const dStats = date => {
const t = ALL_T.filter(t => t.date === date);
const p = t.filter(t => !["upcoming","skipped"].includes(gs(t.id)));
const w = t.filter(t => gs(t.id)==="cashed");
return { tot: t.length, pl: p.length, wn: w.length };
};
const $cv = php => cv(php, cur);
return (
<div style={{ fontFamily: "'JetBrains Mono','Fira Code','SF Mono',monospace", background: `linear-gradient(170deg,${C.crust},${C.base} 40%,${C.mantle})`, color: C.text, minHeight: "100vh" }}>
{/* HEADER */}
<div style={{ background: `linear-gradient(135deg,${C.mantle},${C.crust}ee)`, borderBottom: `1px solid ${C.surface0}`, padding: "14px 16px 10px", position: "sticky", top: 0, zIndex: 100, backdropFilter: "blur(16px)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: 8 }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 22 }}>🃏</span>
<h1 style={{ margin: 0, fontSize: 17, fontWeight: 700, background: `linear-gradient(135deg,${C.blue},${C.mauve})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>Manila Poker Trip</h1>
</div>
<p style={{ margin: "2px 0 0", fontSize: 9, color: C.overlay1, letterSpacing: "0.5px" }}>MAY 410 · OKADA MILLIONS + METRO CC + LOCAL ROOMS</p>
</div>
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
<button onClick={() => setShowBudget(!showBudget)} style={{ background: showBudget ? C.surface1 : C.surface0, border: `1px solid ${C.surface1}`, color: C.text, padding: "4px 8px", borderRadius: 5, fontSize: 10, cursor: "pointer", fontFamily: "inherit" }}>💰</button>
<select value={cur} onChange={e => setCur(e.target.value)} style={{ background: C.surface0, border: `1px solid ${C.surface1}`, color: C.text, padding: "4px 5px", borderRadius: 5, fontSize: 10, cursor: "pointer", fontFamily: "inherit" }}>
<option value="PHP"></option><option value="USD">$</option><option value="DKK">kr</option>
</select>
</div>
</div>
{showBudget && (
<div style={{ marginTop: 8, padding: 10, background: C.surface0, borderRadius: 8, display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(100px,1fr))", gap: 6 }}>
<SB l="Buy-ins" v={$cv(budget.sp)} s={`${budget.cnt} events`} c={C.red} />
<SB l="Won" v={$cv(budget.wn)} s={`${budget.cs} cashes`} c={C.green} />
<SB l="Net" v={(budget.net>=0?"+":"-")+$cv(Math.abs(budget.net))} s={budget.net>=0?"PROFIT":"LOSS"} c={budget.net>=0?C.teal:C.maroon} />
<SB l="ROI" v={budget.sp>0?`${Math.round(((budget.wn-budget.sp)/budget.sp)*100)}%`:"—"} s="" c={C.lavender} />
</div>
)}
</div>
{/* DAY TABS */}
<div style={{ display: "flex", gap: 3, padding: "8px 10px", overflowX: "auto" }}>
{DAYS.map(d => {
const s = dStats(d.date); const a = d.date===day;
return <button key={d.date} onClick={() => setDay(d.date)} style={{ flex: "0 0 auto", padding: "5px 10px", borderRadius: 7, border: a?`2px solid ${C.blue}`:`1px solid ${C.surface1}50`, background: a?`${C.blue}18`:"transparent", color: a?C.blue:C.subtext1, cursor: "pointer", fontFamily: "inherit", fontSize: 10, fontWeight: a?700:500, textAlign: "center" }}>
<div>{d.short}</div>
{s.pl > 0 && <div style={{ fontSize: 8, marginTop: 1, color: s.wn>0?C.green:C.overlay0 }}>{s.pl}/{s.tot}{s.wn>0?` 💰${s.wn}`:""}</div>}
</button>;
})}
</div>
{/* FILTERS */}
<div style={{ padding: "3px 10px", display: "flex", gap: 3, overflowX: "auto", flexWrap: "wrap" }}>
{[{k:"all",l:"All",c:C.text},{k:"okada",l:"Okada",c:VENUES.okada.color},{k:"metro",l:"Metro CC",c:VENUES.metro.color},{k:"letsin",l:"Let's All In",c:VENUES.letsin.color},{k:"nineD",l:"9D PC",c:VENUES.nineD.color}].map(f =>
<button key={f.k} onClick={() => { setVf(f.k); setShowCash(false); }} style={{ padding: "2px 7px", borderRadius: 4, border: `1px solid ${vf===f.k?f.c:C.surface1}40`, background: vf===f.k?`${f.c}20`:"transparent", color: vf===f.k?f.c:C.overlay1, cursor: "pointer", fontFamily: "inherit", fontSize: 9, fontWeight: vf===f.k?600:400, whiteSpace: "nowrap" }}>{f.l}</button>
)}
<button onClick={() => setShowCash(!showCash)} style={{ padding: "2px 7px", borderRadius: 4, border: `1px solid ${showCash?C.peach:C.surface1}40`, background: showCash?`${C.peach}20`:"transparent", color: showCash?C.peach:C.overlay1, cursor: "pointer", fontFamily: "inherit", fontSize: 9, whiteSpace: "nowrap" }}>
🎰 More Venues {showCash?"▴":"▾"}
</button>
</div>
{/* OTHER VENUES PANEL */}
{showCash && (
<div style={{ margin: "6px 10px", padding: 10, background: C.surface0, borderRadius: 8, fontSize: 10 }}>
<div style={{ fontWeight: 700, marginBottom: 6, color: C.subtext1, fontSize: 11 }}>Other Poker Venues (cash + walk-in tourneys)</div>
{["nineD","masters","dynasty","solaire","cod","newport","prime","twoace"].map(k => {
const v = VENUES[k];
return <div key={k} style={{ marginBottom: 6, padding: "6px 8px", background: `${v.color}08`, borderRadius: 6, borderLeft: `3px solid ${v.color}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontWeight: 600, color: v.color, fontSize: 11 }}>{v.name}</span>
{v.type === "both" && <span style={{ fontSize: 8, padding: "1px 4px", borderRadius: 3, background: `${C.green}20`, color: C.green, fontWeight: 600 }}>Tourneys</span>}
{v.type === "cash" && <span style={{ fontSize: 8, padding: "1px 4px", borderRadius: 3, background: `${C.overlay0}20`, color: C.overlay0, fontWeight: 600 }}>Cash only</span>}
</div>
<div style={{ color: C.overlay1, marginTop: 1 }}>{v.area}</div>
<div style={{ color: C.subtext0, marginTop: 1, lineHeight: 1.3 }}>{v.note}</div>
</div>;
})}
</div>
)}
{/* TOURNAMENTS */}
<div style={{ padding: "6px 10px 90px" }}>
{hasActive && (
<div style={{ padding: "5px 8px", background: `${C.green}10`, border: `1px solid ${C.green}30`, borderRadius: 7, marginBottom: 8, fontSize: 10, color: C.green, display: "flex", alignItems: "center", gap: 5 }}>
<span style={{ fontSize: 13 }}>🎯</span> Active session!
<label style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 3, color: C.overlay1, cursor: "pointer", fontSize: 9 }}>
<input type="checkbox" checked={dimOthers} onChange={e => setDimOthers(e.target.checked)} style={{ accentColor: C.green, width: 12, height: 12 }} /> Dim others
</label>
</div>
)}
{dayTs.length === 0 ? <div style={{ textAlign: "center", padding: 30, color: C.overlay0, fontSize: 12 }}>No tournaments for this day/filter.</div> :
dayTs.map(t => <TC key={t.id} t={t} status={gs(t.id)} ss={s => ss(t.id,s)} editId={editId} setEditId={setEditId} prize={prizes[t.id]} setPrize={v => setPrizes(p => ({...p,[t.id]:v}))} place={places[t.id]} setPlace={v => setPlaces(p => ({...p,[t.id]:v}))} re={reents[t.id]||0} setRe={v => setReents(p => ({...p,[t.id]:v}))} $cv={$cv} cur={cur} dim={hasActive && dimOthers} />)
}
</div>
{/* FOOTER */}
<div style={{ position: "fixed", bottom: 0, left: 0, right: 0, background: `${C.crust}f0`, backdropFilter: "blur(12px)", borderTop: `1px solid ${C.surface0}`, padding: "5px 10px", fontSize: 8, color: C.overlay0, display: "flex", justifyContent: "space-between" }}>
<span>Okada Manila Millions · Apr 29May 10</span>
<span>3% staffing (Okada) · 5% svc (Metro) · All PHP</span>
</div>
</div>
);
}
function SB({ l, v, s, c }) {
return <div style={{ padding: "6px 8px", background: `${c}08`, borderRadius: 5, border: `1px solid ${c}20` }}>
<div style={{ fontSize: 8, color: C.overlay1, textTransform: "uppercase", letterSpacing: 0.7 }}>{l}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: c }}>{v}</div>
{s && <div style={{ fontSize: 8, color: C.overlay0 }}>{s}</div>}
</div>;
}
function TC({ t, status, ss, editId, setEditId, prize, setPrize, place, setPlace, re, setRe, $cv, cur, dim }) {
const v = VENUES[t.venue];
const cfg = STATUS[status];
const isAct = ["playing","registered","day2"].includes(status);
const isDone = ["busted","cashed","skipped"].includes(status);
const dimmed = dim && !isAct && status==="upcoming";
const isEd = editId === t.id;
const free = t.buyin === 0;
return (
<div style={{ marginBottom: 5, borderRadius: 8, border: `1px solid ${isAct?cfg.color:C.surface0}${isAct?"60":"80"}`, background: isAct?`linear-gradient(135deg,${cfg.color}10,${C.surface0}60)`:`${C.surface0}60`, overflow: "hidden", opacity: isDone&&status!=="cashed"?0.45:dimmed?0.35:1, transition: "all 0.2s" }}>
<div style={{ height: 2, background: v.color, opacity: 0.5 }} />
<div style={{ padding: "8px 10px" }}>
{/* Row 1 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 3, flexWrap: "wrap", gap: 3 }}>
<div style={{ display: "flex", alignItems: "center", gap: 5, flexWrap: "wrap" }}>
<span style={{ fontWeight: 700, fontSize: 12, color: C.blue, fontVariantNumeric: "tabular-nums" }}>{t.time}</span>
<Bdg text={v.short} color={v.color} />
{t.multiDay && <Bdg text="Multi-Day" color={C.mauve} />}
<Bdg text={t.format} color={C.overlay1} bg={`${C.surface1}60`} />
</div>
<span style={{ fontSize: 9, color: cfg.color, fontWeight: 600 }}>{cfg.icon} {cfg.label}</span>
</div>
{/* Row 2 */}
<div style={{ marginBottom: 3 }}>
<span style={{ color: C.overlay0, fontSize: 9, marginRight: 4 }}>{t.event}</span>
<span style={{ fontWeight: 600, fontSize: 12, color: t.name.includes("★")?C.yellow:C.text }}>{t.name}</span>
</div>
{/* Row 3 */}
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 2, flexWrap: "wrap" }}>
{!free && <div style={{ fontSize: 10 }}><span style={{ color: C.overlay0 }}>Buy-in: </span><span style={{ fontWeight: 600, color: C.yellow }}>{$cv(t.buyin)}</span>{re>0 && <span style={{ color: C.peach, fontSize: 9, marginLeft: 2 }}>(+{re}re={$cv(t.buyin*(1+re))})</span>}</div>}
{t.guarantee && <div style={{ fontSize: 10 }}><span style={{ color: C.overlay0 }}>GTD: </span><span style={{ fontWeight: 600, color: C.green }}>{$cv(t.guarantee)}</span></div>}
</div>
{t.notes && <div style={{ fontSize: 9, color: C.overlay1, marginBottom: 2, lineHeight: 1.3 }}>{t.notes}</div>}
{t.multiDay && t.multiDayInfo && <div style={{ fontSize: 9, color: C.mauve, marginBottom: 3 }}>📅 {t.multiDayInfo}</div>}
{status==="cashed" && prize>0 && (
<div style={{ padding: "5px 7px", background: `${C.teal}15`, borderRadius: 5, marginBottom: 5, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<div><span style={{ fontSize: 8, color: C.overlay0 }}>Prize: </span><span style={{ fontWeight: 700, color: C.teal, fontSize: 12 }}>{$cv(prize)}</span></div>
{place>0 && <div><span style={{ fontSize: 8, color: C.overlay0 }}>Finish: </span><span style={{ fontWeight: 700, color: C.lavender, fontSize: 12 }}>#{place}</span></div>}
<div style={{ marginLeft: "auto", fontSize: 9, color: prize>t.buyin*(1+(re||0))?C.green:C.red, fontWeight: 600 }}>
{prize>t.buyin*(1+(re||0))?"+":""}{$cv(prize-t.buyin*(1+(re||0)))} net
</div>
</div>
)}
{isEd && (
<div style={{ padding: 8, background: C.surface0, borderRadius: 5, marginBottom: 5 }}>
<div style={{ fontSize: 9, color: C.subtext0, marginBottom: 5, fontWeight: 600 }}>Record Result</div>
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label style={{ fontSize: 8, color: C.overlay0 }}>Prize ()</label>
<input type="number" value={prize||""} onChange={e => setPrize(parseInt(e.target.value)||0)} placeholder="0" style={{ display: "block", marginTop: 2, padding: "3px 5px", background: C.surface1, border: `1px solid ${C.surface2}`, borderRadius: 3, color: C.text, fontFamily: "inherit", fontSize: 11, width: 80 }} />
</div>
<div>
<label style={{ fontSize: 8, color: C.overlay0 }}>Finish #</label>
<input type="number" value={place||""} onChange={e => setPlace(parseInt(e.target.value)||0)} placeholder="#" style={{ display: "block", marginTop: 2, padding: "3px 5px", background: C.surface1, border: `1px solid ${C.surface2}`, borderRadius: 3, color: C.text, fontFamily: "inherit", fontSize: 11, width: 45 }} />
</div>
<button onClick={() => setEditId(null)} style={{ padding: "3px 10px", background: C.teal, border: "none", borderRadius: 3, color: C.crust, fontFamily: "inherit", fontSize: 10, fontWeight: 700, cursor: "pointer" }}>Save</button>
</div>
</div>
)}
{/* Actions */}
<div style={{ display: "flex", gap: 3, flexWrap: "wrap", marginTop: 2 }}>
{status==="upcoming" && !free && <><AB l="🎫 Register" c={C.yellow} o={() => ss("registered")} /><AB l="⏭️ Skip" c={C.surface2} o={() => ss("skipped")} /></>}
{status==="registered" && <><AB l="🃏 Playing" c={C.green} o={() => ss("playing")} /><AB l="↩️" c={C.overlay0} o={() => ss("upcoming")} /></>}
{status==="playing" && <>
<AB l="💀 Bust" c={C.red} o={() => ss("busted")} />
<AB l="💰 Cash!" c={C.teal} o={() => ss("cashed")} />
{t.multiDay && <AB l="🌟 Day 2" c={C.mauve} o={() => ss("day2")} />}
<div style={{ display: "flex", alignItems: "center", gap: 2, marginLeft: 4 }}>
<span style={{ fontSize: 8, color: C.overlay0 }}>Re:</span>
<SBtn o={() => setRe(Math.max(0,re-1))} l="" d={re===0} />
<span style={{ fontSize: 10, fontWeight: 600, minWidth: 10, textAlign: "center" }}>{re}</span>
<SBtn o={() => setRe(re+1)} l="+" />
</div>
</>}
{status==="day2" && <><AB l="🃏 D2" c={C.green} o={() => ss("playing")} /><AB l="💀" c={C.red} o={() => ss("busted")} /><AB l="💰" c={C.teal} o={() => ss("cashed")} /></>}
{status==="cashed" && !isEd && <AB l="✏️ Edit" c={C.lavender} o={() => setEditId(t.id)} />}
{["busted","cashed","skipped"].includes(status) && <AB l="↩️ Reset" c={C.overlay0} o={() => { ss("upcoming"); setPrize(undefined); setPlace(undefined); setRe(0); }} />}
</div>
</div>
</div>
);
}
function Bdg({ text, color, bg }) {
return <span style={{ fontSize: 8, padding: "1px 4px", borderRadius: 3, background: bg||`${color}20`, color, fontWeight: 600 }}>{text}</span>;
}
function AB({ l, c, o }) {
return <button onClick={o} style={{ padding: "3px 7px", borderRadius: 4, border: `1px solid ${c}40`, background: `${c}15`, color: c, cursor: "pointer", fontFamily: "inherit", fontSize: 9, fontWeight: 600 }} onMouseEnter={e=>e.target.style.background=`${c}30`} onMouseLeave={e=>e.target.style.background=`${c}15`}>{l}</button>;
}
function SBtn({ o, l, d }) {
return <button onClick={o} style={{ width: 16, height: 16, borderRadius: 2, border: `1px solid ${C.surface2}`, background: C.surface1, color: C.text, fontSize: 11, fontWeight: 700, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit", padding: 0, opacity: d?0.3:1 }}>{l}</button>;
}

View file

@ -0,0 +1,57 @@
# SaaS Considerations
A running list of switches to flip and features to add when transitioning from personal tool to multi-tenant SaaS.
## Auth & User Management
- [ ] Email verification flow (send verification link on registration)
- [ ] Password reset flow (forgot password → email → reset)
- [ ] Rate limiting on auth endpoints (5 attempts/min/IP)
- [ ] OAuth providers (Google, Discord — poker community lives on Discord)
- [ ] Terms of service acceptance on registration
- [ ] Account deletion / data export (GDPR)
## Access Control
- [ ] Admin role for managing shared venue data, global events curation
- [ ] Friends system: mutual opt-in to share trip plans and compare results
- [ ] Trip sharing: private by default, shareable with friends
- [ ] Location knowledge gating: require at least one research run before accessing community-contributed data (give-to-get model)
## Image Uploads
- [ ] Max file size limit (e.g. 10MB per image)
- [ ] Rate limiting on uploads (e.g. 50/day per user)
- [ ] Per-user storage quota (e.g. 500MB free, 5GB paid)
- [ ] File type validation (accept only image/jpeg, image/png, image/webp)
- [ ] Image resizing/compression on upload (keep originals, serve optimized)
- [ ] Virus/malware scanning (ClamAV or similar)
- [ ] Switch storage backend from local filesystem to Garage (S3-compatible)
- [ ] Signed URLs for image access (don't serve uploads directly)
## AI / Research
- [ ] Tiered access: Free (no AI) → BYO Key (bring your own OpenRouter/Requesty key) → Paid (use our key)
- [ ] Usage tracking per user (research jobs, tokens consumed)
- [ ] Cost estimation before starting research job
- [ ] Rate limiting on research jobs (e.g. 10/day for BYO key, 3/day for paid tier)
- [ ] Model allowlisting (prevent abuse of expensive models on paid tier)
## Infrastructure
- [ ] Connection pooling (PgBouncer) if user count warrants it
- [ ] CDN for static frontend assets
- [ ] Horizontal scaling: separate API instances behind load balancer
- [ ] Monitoring / alerting (Grafana, Prometheus)
- [ ] Structured logging for multi-tenant debugging
## Payments
- [ ] Stripe integration for paid AI tier
- [ ] Subscription management (monthly/yearly)
- [ ] Usage-based billing option for heavy AI users
## Legal
- [ ] Privacy policy
- [ ] Terms of service
- [ ] Cookie consent (if applicable)
- [ ] GDPR compliance (data export, right to deletion, data processing agreements)
## Community
- [ ] Public venue data moderation (flag/report incorrect info)
- [ ] Global events data quality review pipeline
- [ ] Leaderboards / opt-in public stats (careful — gambling-adjacent, consider regulations)