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:
commit
fef6f5318e
7 changed files with 1190 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal 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
11
.gitignore
vendored
Normal 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
87
README.md
Normal 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.
|
||||||
593
docs/2026-03-18-pokertrip-design.md
Normal file
593
docs/2026-03-18-pokertrip-design.md
Normal 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
50
docs/gsd-kickoff.md
Normal 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.
|
||||||
387
docs/manila-poker-tracker.jsx
Normal file
387
docs/manila-poker-tracker.jsx
Normal 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 2PM–6AM daily. Cash 50/100–200+. 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 4–10 · 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 29–May 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>;
|
||||||
|
}
|
||||||
57
docs/saas-considerations.md
Normal file
57
docs/saas-considerations.md
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue