From fef6f5318eca4c5683e564f8a809d54989df5283 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 18 Mar 2026 09:36:56 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20project=20docs=20=E2=80=94=20design?= =?UTF-8?q?=20spec,=20reference=20JSX,=20and=20GSD=20kickoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 5 + .gitignore | 11 + README.md | 87 ++++ docs/2026-03-18-pokertrip-design.md | 593 ++++++++++++++++++++++++++++ docs/gsd-kickoff.md | 50 +++ docs/manila-poker-tracker.jsx | 387 ++++++++++++++++++ docs/saas-considerations.md | 57 +++ 7 files changed, 1190 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/2026-03-18-pokertrip-design.md create mode 100644 docs/gsd-kickoff.md create mode 100644 docs/manila-poker-tracker.jsx create mode 100644 docs/saas-considerations.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..43f9e27 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10d9bf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +uploads/ +dist/ +node_modules/ +frontend/node_modules/ +frontend/dist/ +*.exe +*.db +*.db-journal +tmp/ +.air.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d3a01c --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/2026-03-18-pokertrip-design.md b/docs/2026-03-18-pokertrip-design.md new file mode 100644 index 0000000..d4dcb4a --- /dev/null +++ b/docs/2026-03-18-pokertrip-design.md @@ -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 +``` diff --git a/docs/gsd-kickoff.md b/docs/gsd-kickoff.md new file mode 100644 index 0000000..1ba787d --- /dev/null +++ b/docs/gsd-kickoff.md @@ -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. diff --git a/docs/manila-poker-tracker.jsx b/docs/manila-poker-tracker.jsx new file mode 100644 index 0000000..7245592 --- /dev/null +++ b/docs/manila-poker-tracker.jsx @@ -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 ( +
+ + {/* HEADER */} +
+
+
+
+ πŸƒ +

Manila Poker Trip

+
+

MAY 4–10 Β· OKADA MILLIONS + METRO CC + LOCAL ROOMS

+
+
+ + +
+
+ {showBudget && ( +
+ + + =0?"+":"-")+$cv(Math.abs(budget.net))} s={budget.net>=0?"PROFIT":"LOSS"} c={budget.net>=0?C.teal:C.maroon} /> + 0?`${Math.round(((budget.wn-budget.sp)/budget.sp)*100)}%`:"β€”"} s="" c={C.lavender} /> +
+ )} +
+ + {/* DAY TABS */} +
+ {DAYS.map(d => { + const s = dStats(d.date); const a = d.date===day; + return ; + })} +
+ + {/* FILTERS */} +
+ {[{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 => + + )} + +
+ + {/* OTHER VENUES PANEL */} + {showCash && ( +
+
Other Poker Venues (cash + walk-in tourneys)
+ {["nineD","masters","dynasty","solaire","cod","newport","prime","twoace"].map(k => { + const v = VENUES[k]; + return
+
+ {v.name} + {v.type === "both" && Tourneys} + {v.type === "cash" && Cash only} +
+
{v.area}
+
{v.note}
+
; + })} +
+ )} + + {/* TOURNAMENTS */} +
+ {hasActive && ( +
+ 🎯 Active session! + +
+ )} + + {dayTs.length === 0 ?
No tournaments for this day/filter.
: + dayTs.map(t => 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} />) + } +
+ + {/* FOOTER */} +
+ Okada Manila Millions Β· Apr 29–May 10 + 3% staffing (Okada) Β· 5% svc (Metro) Β· All PHP +
+
+ ); +} + +function SB({ l, v, s, c }) { + return
+
{l}
+
{v}
+ {s &&
{s}
} +
; +} + +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 ( +
+
+
+ {/* Row 1 */} +
+
+ {t.time} + + {t.multiDay && } + +
+ {cfg.icon} {cfg.label} +
+ {/* Row 2 */} +
+ {t.event} + {t.name} +
+ {/* Row 3 */} +
+ {!free &&
Buy-in: {$cv(t.buyin)}{re>0 && (+{re}re={$cv(t.buyin*(1+re))})}
} + {t.guarantee &&
GTD: {$cv(t.guarantee)}
} +
+ {t.notes &&
{t.notes}
} + {t.multiDay && t.multiDayInfo &&
πŸ“… {t.multiDayInfo}
} + + {status==="cashed" && prize>0 && ( +
+
Prize: {$cv(prize)}
+ {place>0 &&
Finish: #{place}
} +
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 +
+
+ )} + + {isEd && ( +
+
Record Result
+
+
+ + 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 }} /> +
+
+ + 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 }} /> +
+ +
+
+ )} + + {/* Actions */} +
+ {status==="upcoming" && !free && <> ss("registered")} /> ss("skipped")} />} + {status==="registered" && <> ss("playing")} /> ss("upcoming")} />} + {status==="playing" && <> + ss("busted")} /> + ss("cashed")} /> + {t.multiDay && ss("day2")} />} +
+ Re: + setRe(Math.max(0,re-1))} l="βˆ’" d={re===0} /> + {re} + setRe(re+1)} l="+" /> +
+ } + {status==="day2" && <> ss("playing")} /> ss("busted")} /> ss("cashed")} />} + {status==="cashed" && !isEd && setEditId(t.id)} />} + {["busted","cashed","skipped"].includes(status) && { ss("upcoming"); setPrize(undefined); setPlace(undefined); setRe(0); }} />} +
+
+
+ ); +} + +function Bdg({ text, color, bg }) { + return {text}; +} + +function AB({ l, c, o }) { + return ; +} + +function SBtn({ o, l, d }) { + return ; +} diff --git a/docs/saas-considerations.md b/docs/saas-considerations.md new file mode 100644 index 0000000..56aa253 --- /dev/null +++ b/docs/saas-considerations.md @@ -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)