---
name: shark-game-development
description: Build and maintain the Shark Attack Fantasy League at shark.iamgmb.com — FastAPI backend, pure JS frontend, Leaflet map, AI news scraper, JWT auth, snake draft engine, Web Push notifications, iOS PWA.
version: 1.4.0
author: Sho'Nuff
tags: [gaming, fantasy-sports, shark, push-notifications, pwa, fastapi, leaflet, scoring-engine, web-push, map]
platforms: [linux]
---

# Shark Attack Fantasy League

Fantasy sports game at `shark.iamgmb.com`.

Linked references: references/shark-game-recovery-manual.md -- offline recovery manual. references/password-reset-email-invite-logout.md -- Password reset, logout fix, and email invite feature (Jul 2026). references/trademark-and-domain-research.md -- Trademark conflict research for Feeding Frenzy. references/unified-bottom-nav-pattern.md -- Persistent bottom tab bar. references/family-waters-and-join-fix.md -- Family Waters +30 bonus and Join League 500 error fix. references/safety-tips-section.md -- AI sarcastic safety tips. references/ios-pwa-push-testing.md -- PWA push notification testing on iOS. references/shark-dad-jokes-section.md -- Shark dad jokes on the How to Play page. references/draft-reminder-emails.md -- Draft reminder email implementation.

## Architecture

```
shark.iamgmb.com
├── FastAPI backend (port 8083, systemd: shark-game.service)
│   ├── /root/shark-game/backend/server.py
│   └── /root/shark-game/backend/game.db (SQLite)
├── Static frontend: /var/www/shark-game/
│   ├── index.html           — Landing/register/login page
│   ├── draft-room.html      — Draft board, map, tabs (Scoring/Standings/My Team/League/Settings/Help/Learn)
│   ├── league.html          — League settings, members, invite code, push prefs
│   ├── settings.html        — Notification prefs, quiet hours, profile
│   ├── help.html            — PWA setup guide, troubleshooting
│   ├── how-to-play.html     — Full rules, scoring table, draft explainer, shark dad jokes
│   ├── ideas.html           — Idea submission form with mailto fallback
│   ├── data/ideas.json      — JSON storage for submitted ideas
│   └── sw.js                — Service worker for push notifications
├── Scraper: /root/shark-game/scraper/scrape.py (cron every 1h)
└── Caddy: reverse proxy shark.iamgmb.com → 127.0.0.1:8083
```

## Game Rules

- 32 coastal regions with ISAF historical data
- Snake draft: round 1 1->N, round 2 N->1, round 3 1->N ...
- Scoring: Sighting +2pts, Bite +5pts, Fatality +10pts
- AI news scraper (Firecrawl + admin-ai LLM) classifies events hourly
- In leagues of odd player counts, the lowest-performing regions are auto-removed for even distribution

## Backend

### Tech stack
- FastAPI with JWT auth (bcrypt for passwords, PyJWT for tokens)
- SQLite (game.db) via sqlite3 module
- pywebpush for Web Push notifications
- Systemd service at /etc/systemd/system/shark-game.service
- Venv at /root/shark-game/backend/venv/

### Key endpoints
| Method | Path | Purpose |
|--------|------|---------|
| POST | /api/register | Create account |
| POST | /api/login | Get JWT token |
| GET | /api/regions | List 32 regions with lat/lng/stats |
| POST | /api/leagues | Create league (max_players default 6, range 2-10) |
| POST | /api/leagues/{id}/join | Join league |
| GET | /api/leagues/{id} | League details, draft state |
| PATCH | /api/leagues/{id}/settings | Set draft_start_at, season settings (commissioner only) |
| POST | /api/leagues/{id}/draft/start | Commissioner starts draft |
| POST | /api/leagues/{id}/draft/pick | Make a draft pick (auto-sends push) |
| GET | /api/leagues/{id}/draft/board | Current draft board |
| POST | /api/leagues/{id}/invite | Send email invite to player (commissioner only) |
| GET | /api/leagues/{id}/leaderboard | Points leaderboard |
| POST | /api/leagues/{id}/start-draft | Commissioner starts snake draft |
| POST | /api/auth/forgot-password | Send password reset email (no auth) |
| POST | /api/auth/reset-password | Validate token + set new password (no auth) |
| POST | /api/score-events | Scraper inserts score event (auth via API key in X-API-Key header) |
| POST | /api/push/subscribe | Save push subscription (JWT required) |
| POST | /api/push/subscribe-noauth | Save push sub without login (for test pages) |
| POST | /api/push/unsubscribe | Remove push subscription |
| GET | /api/push/vapid-public-key | Return VAPID public key |
| POST | /api/push/test | Send test push to all subscribers |
| GET | /api/push/preferences | Get notification prefs |
| PATCH | /api/push/preferences | Update notification prefs |
| POST | /api/ideas/submit | Submit game idea (saved to ideas.json) |

### Push notification system

VAPID keys stored as module-level variables in server.py:
```python
VAPID_PRIVATE_KEY = "MIIEvQIBADANBgkqhkiG9w0BAQ... (truncated)"
VAPID_PUBLIC_KEY = "BDOkjGQg_FFXALuG0qXl2PtXskrYKMF0nf5mmWd-zTMnnMdY0bRmK3lVoYLia2jxu_pWHlnIuTFylCR4pMPvllI"
VAPID_CLAIMS = {"sub": "mailto:g@germainebrown.com"}
```

**Automatic push on scoring** — when a score event is created (sighting/bite/fatality), `send_push_notification()` is called for the affected user. The function:
1. Queries `push_subscriptions` for the user
2. Checks `notifications_enabled` user preference
3. Checks notification type preference (sightings/bites/fatalities/draft) if `event_type` is provided
4. Checks `notify_my_regions_only` — if enabled and `region_id` is provided, skips users who haven't drafted that region
5. Checks quiet hours window (using `America/New_York` timezone — ET, not UTC. Fixed Jul 2026: was using `datetime.utcnow()` which broke the 9PM-6AM default for non-UTC servers.)
6. Sends via `pywebpush.webpush()` with VAPID auth

**Test push endpoint** — `POST /api/push/test` sends to all subscriptions (no auth required). Useful for verifying PWA integration.

### Database schema (actual — from game.db, Jul 2026)

```sql
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    phone TEXT,
    display_name TEXT NOT NULL,
    password_hash TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    is_admin INTEGER DEFAULT 0,
    notifications_enabled INTEGER DEFAULT 1,
    quiet_hours_start TEXT DEFAULT '21:00',
    quiet_hours_end TEXT DEFAULT '06:00',
    quiet_hours_enabled INTEGER DEFAULT 1,  -- opt-out (default on) since Jul 2026
    notify_sightings INTEGER DEFAULT 1,
    notify_bites INTEGER DEFAULT 1,
    notify_fatalities INTEGER DEFAULT 1,
    notify_draft INTEGER DEFAULT 1,
    notify_my_regions_only INTEGER DEFAULT 0,
    family_waters_bonus INTEGER DEFAULT 0,
    family_waters_count INTEGER DEFAULT 0,
    reset_token TEXT,
    reset_token_expires TEXT
);

CREATE TABLE leagues (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    invite_code TEXT NOT NULL UNIQUE,
    status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','active','closed')),
    season_start TEXT NOT NULL,
    season_end TEXT,
    created_by INTEGER NOT NULL REFERENCES users(id),
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    max_players INTEGER NOT NULL DEFAULT 6,
    draft_start_at TEXT
);

CREATE TABLE league_members (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    league_id INTEGER NOT NULL REFERENCES leagues(id),
    user_id INTEGER NOT NULL REFERENCES users(id),
    draft_order INTEGER,
    is_commissioner INTEGER NOT NULL DEFAULT 0,
    UNIQUE(league_id, user_id)
);

CREATE TABLE regions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    emoji TEXT NOT NULL,
    description TEXT,
    prev_year_sightings INTEGER NOT NULL DEFAULT 0,
    prev_year_bites INTEGER NOT NULL DEFAULT 0,
    prev_year_fatalities INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE draft_picks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    league_id INTEGER NOT NULL REFERENCES leagues(id),
    user_id INTEGER NOT NULL REFERENCES users(id),
    region_id INTEGER NOT NULL REFERENCES regions(id),
    round INTEGER NOT NULL,
    pick_number INTEGER NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    UNIQUE(league_id, region_id)
);

CREATE TABLE scores (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    region_id INTEGER NOT NULL REFERENCES regions(id),
    event_type TEXT NOT NULL CHECK(event_type IN ('sighting','bite','fatality')),
    description TEXT,
    source_url TEXT,
    points INTEGER NOT NULL DEFAULT 1,
    event_date TEXT NOT NULL,
    verified INTEGER NOT NULL DEFAULT 0,
    is_personal INTEGER DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE user_scores (
    user_id INTEGER NOT NULL,
    league_id INTEGER NOT NULL,
    total_points INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (user_id, league_id)
);

CREATE TABLE league_excluded_regions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    league_id INTEGER NOT NULL REFERENCES leagues(id),
    region_id INTEGER NOT NULL REFERENCES regions(id),
    UNIQUE(league_id, region_id)
);

CREATE TABLE push_subscriptions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER,
    endpoint TEXT NOT NULL UNIQUE,
    p256dh TEXT NOT NULL,
    auth TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now'))
);
```

## Push Notifications

### Architecture

The push notification system has 4 parts:

1. **VAPID keys** — generated once, stored in server.py. Public key exposed at GET /api/push/vapid-public-key
2. **Backend send function** — `send_push_notification(user_id, title, body, icon)` queries user's subscriptions and sends via pywebpush
3. **Service Worker** — `/var/www/shark-game/sw.js` listens for push events, shows native notification, opens draft-room.html on click
4. **Subscription UI** — banner in draft-room.html that prompts user to enable notifications

### iOS PWA requirement

Web Push on iOS ONLY works when the page is opened from a Home Screen PWA (not Safari browser tab). If PushManager isn't available, the browser shows `FAIL: No PushManager`. The user must:
1. Tap Share icon → Add to Home Screen
2. Open from the Home Screen icon (fullscreen, no URL bar)
3. Enable notifications from there

### Service worker file location

The SW file MUST be at the domain root (`/sw.js`) to intercept push events correctly. Caddy reverse-proxies `shark.iamgmb.com/sw.js` → served from `/var/www/shark-game/sw.js`.

### Common pitfalls

- **`json` must be imported at the top of server.py** — pywebpush.send() calls json.dumps() for the notification payload. Without `import json`, push fails with `name 'json' is not defined`.
- **VAPID private key in the correct venv** — systemd runs from `/root/shark-game/backend/venv/bin/python3`. Install pywebpush there: `/root/shark-game/backend/venv/bin/pip install pywebpush`.
- **`{"sent": 0}` means the send failed** — the catch() was silently swallowing errors with `except: pass`. Always add print() logging: `print(f"Push failed: {e}", flush=True)`.
- **user_id must be NULLable in the schema** — if the subscription table has `user_id INTEGER NOT NULL REFERENCES users(id)`, test subscriptions (no-auth endpoint) hit FOREIGN KEY constraint failures. Use `user_id INTEGER` (nullable) for the no-auth path.
- **PushBanner must be unhidden in JS** — the HTML has `<div id="pushBanner" style="display:none">`. The function `showNotificationBanner()` must call `banner.style.display = 'block'` before setting the innerHTML.
- **Subscribe endpoint needs auth OR no-auth variant** — the main `POST /api/push/subscribe` uses JWT auth via Depends(get_current_user). For test pages without login, add `POST /api/push/subscribe-noauth` that accepts user_id=NULL.
**Quiet hours use ET, not UTC** - `send_push_notification()` must use `datetime.now(ZoneInfo("America/New_York"))` with `from zoneinfo import ZoneInfo`. Using `datetime.utcnow()` breaks the 9PM-6AM default for any server not in ET. Verify standalone via `scripts/verify-quiet-hours.py` (18 assertions, no external deps).

## Scraper

### File
`/root/shark-game/scraper/scrape.py` (Python, ~19KB)

### Schedule
### Scraper schedule
Cron `shark-scraper` runs every 1h (changed from 2x/day in Jul 2026). Script wrapper at `/root/.hermes/scripts/shark-scraper.sh`.
```bash
/root/.hermes/scripts/shark-scraper.sh -- wrapper that cd's and runs scrape.py
```

### How it works
1. Fetches recent news articles about shark incidents
2. Uses admin-ai LLM to classify events as sighting/bite/fatality
3. Matches events to regions by proximity
4. Creates score_event records in game.db
5. Sends push notifications to all subscribed players in affected leagues

## Frontend

### UI polish and icon consistency

When doing a QA pass on existing UI, run these checks:

#### Bottom nav audit

All 6 shared-nav pages (`index.html`, `draft-room.html`, `league.html`, `settings.html`, `help.html`, `how-to-play.html`) must have an identical 7-item bottom nav. Steps:

1. **Cross-reference every icon** — the set `🏆 📊 🦈 📋 ⚙️ ❓ 📖` must appear identically in every page's `<div class="bottom-nav">`. Use `search_files` to confirm each icon has the right count.
2. **Check hero/heading icons** — a page like `help.html` might use 🆘 in its H2 but ❓ in the nav. The hero icon should match the nav icon. Grep H2s across all pages.
3. **Verify `class="active"` placement** — each page marks its own nav link as `active`. No page should have two active links, and the active link should point to the current page.
4. **Verify all 5 destination hrefs** — search for each of `draft-room.html`, `league.html`, `settings.html`, `help.html`, `how-to-play.html` inside every bottom-nav block. They must all be present in every page.
5. **Verify `padding-bottom: 80px`** on `<body>` — without it, bottom nav overlaps content. One page missing it is subtle.

#### Full-page HTTP smoke test

After any bulk file edit:

```bash
for page in index.html draft-room.html league.html settings.html help.html how-to-play.html reset-password.html; do
  echo "$page: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:8083/$page)"
done
```

All should return 200. If the domain uses Caddy virtual hosts and curl can't resolve from localhost, verify files on disk instead:
```bash
ls -la /var/www/shark-game/page.html                      # file exists?
grep -n "Expected Text" /var/www/shark-game/page.html      # content is present
```

#### Password reset flow verification

Three independent curl calls (no auth needed, no DB state needed):

```bash
# 1. Forgot-password endpoint (returns generic message)
curl -s http://localhost:8083/api/auth/forgot-password -X POST \
  -H "Content-Type: application/json" -d '{"email":"test@example.com"}'

# 2. Reset-password with invalid token (must return 400 error)
curl -s http://localhost:8083/api/auth/reset-password -X POST \
  -H "Content-Type: application/json" -d '{"token":"invalid-token","new_password":"NewPass123!"}'

# 3. reset-password.html page loads correctly
curl -s -o /dev/null -w '%{http_code}' http://localhost:8083/reset-password.html
```

Expected: #1 returns `{"message":"If that email is registered, you'll receive password reset instructions."}`, #2 returns a 400 with `{"detail":"Invalid or expired reset token."}`, #3 returns 200.

**Forgot-password backend logic** (`POST /api/auth/forgot-password`):
- Does NOT reveal whether the email exists (same message either way — timing-safe)
- Generates a UUID reset token, stores it on the `users` table with a 1-hour expiry
- Sends email via SMTP (same pipeline as draft reminders and invites)
- Reset link: `{PASSWORD_RESET_BASE_URL}/reset-password.html?token={uuid}`

**Reset-password backend logic** (`POST /api/auth/reset-password`):
- Validates token exists in DB → 400 "Invalid or expired reset token."
- Validates expiry (`datetime.utcnow() > expires`) → 400 "Reset token has expired"
- Hashes new password with bcrypt, updates `users.password_hash`, clears the reset token

**reset-password.html frontend:**
- Parses `?token=` from URL search params on DOMContentLoaded
- Shows `invalidToken` div if token is missing
- Shows a form with new_password + confirm_password fields (both `minlength=6` enforced)
- Client-side validates: both fields filled, ≥6 chars, passwords match
- POSTs to `/api/auth/reset-password` with `{token, new_password}`
- On success: hides form, shows `successMessage` div with "Go to Login" link
- On error: shows error in `#errorMsg` div
- Button disabled during request, shows "Resetting..."

### Page-creation conventions

When adding a new static page to the frontend, follow these invariants:

**Extension-less internal links** — The Caddy server is configured with `try_files {path} {path}.html /index.html` to support clean URLs. All internal navigation links must omit the `.html` extension in their `href` attributes (e.g., `<a href="how-to-play">` instead of `<a href="how-to-play.html">`). If you create a new page or update navigation, ensure all links pointing to it use the extension-less format.

**Design system** — use the same CSS variables and full structure from an existing page:
- Copy the full `<style>` block from `how-to-play.html` or `help.html` — they share the identical `:root` variables, body gradient, wave-overlay, sonar-dots, top-bar, hero, container, card sections, bottom-nav, and all keyframes
- Do NOT drop any CSS — every page must include the complete set of background layers, animations, and shared components even if not all are used inline. It's a self-contained single-file HTML app with no shared CSS.
- Card component class: `section-card` (how-to-play) or `help-card` (help). Both share the same `.card-icon`, `.note`, `.tip-box` substructure. Use `help-card` for non-game documentation pages to distinguish semantic purpose.

**Navigation structure** — all game pages use a single **fixed bottom tab bar** (`.bottom-nav` class, 7 items). See `references/unified-bottom-nav-pattern.md` for the complete pattern, CSS, and per-page checklist.

When adding or modifying a tab in the bottom bar, update ALL 6 HTML files that have `<div class="bottom-nav">`:
- `index.html`, `draft-room.html`, `league.html`, `settings.html`, `help.html`, `how-to-play.html`

Mark the current page's `<a>` with `class="active"`. Each page also needs `padding-bottom: 80px` on `<body>` to prevent content overlap.

**index.html special case** — the tab bar starts with `class="hidden"`. `onAuthSuccess()` removes `hidden`; `logout()` redirects to `index.html` (full page reload, which re-hides it naturally).

**Content cards** — each card has a top gradient border line (`.help-card::before`), an emoji icon (`.card-icon`), an H3 title, and body content. Sub-components:
- `.note` — blue left-border callout (info)
- `.tip-box` — amber left-border callout (tips)
- `.warn-box` — red left-border callout (warnings)
- `.step-list` — numbered step list with circled numbers (used for PWA install steps)
- `.checklist` — unstyled list with icon + text columns (used for troubleshooting)

**Scoring table** — `.score-table-wrap` with nested `.score-table` using three themed color classes: `.pts-sighting` (teal), `.pts-bite` (amber), `.pts-fatality` (red).

**JS requirements** — all inline, no external deps. The only JS on documentation pages is the platform tab switcher (from `help.html`). Do not add jQuery, Bootstrap, or any CDN scripts to non-map pages.

### Landing page (index.html)
- Register/login form
- Tabs: LEARN (species guide, safety tips, conservation), SHARK FEED (TV/movies), SHARK NEWS (headlines)
- How to Play link → how-to-play.html
- **Navigation flow fix (Jul 2026):** The X/back button on draft-room.html now routes to `draft-room.html` when logged in, not `index.html`. The `league.html` Home link also routes to `draft-room.html`. The fix uses a `handlePlayerInfoClick()` function that checks for a token before navigating. See `references/navigation-flow-back-button.md` for the full fix. See `references/navigation-flow-back-button.md` for the full fix.
- **Post-login landing page:** Currently the draft board (`/draft-room.html?league_id=X`). After the draft concludes, this should become the standings/leaderboard. The landing page choice depends on season phase (pre-draft → draft board, mid-season → standings, post-season → recap).

### Draft room (draft-room.html)
- Tabs: DRAFT | MY TEAM | STANDINGS | LEAGUE | LEARN | SHARK FEED
- Draft board with snake order pick display
- Leaflet.js map with 32 region markers (color-coded: teal=available, amber=your pick, red=taken)
- Map/List toggle
- Push notification opt-in banner
- Mobile-first responsive, scales to 1400px max-width on desktop

### League (league.html)
Commissioner settings + league info page. Key patterns:

**Invite code hiding** — the invite code card has `id="inviteCodeCard"` on its wrapping `<div class="info-card">`. In `renderLeague()`, it's hidden when the draft has started:
```js
document.getElementById('inviteCodeCard').style.display = (leagueData.status === 'draft') ? '' : 'none';
```
This prevents invite codes from being exposed after the league transitions to `'active'` or `'closed'`.

**Commissioner-only section** — the settings card (draft_start_at datetime, season_end date) is conditionally shown via `document.getElementById('commishSettings').style.display` based on whether the current user `is_commissioner`.

**Push notification preferences** — status (Enabled/Disabled) loaded from `/api/push/preferences` with a toggle button that calls PATCH `/api/push/preferences` and calls `loadPushPrefs()` to refresh.

**Actions area** — `renderActions()` shows different UI based on member role and league status:
- Commissioner + draft status + >= 2 members: "Start the Draft!" button
- Commissioner + draft status + < 2 members: "Need at least 2 members" amber prompt
- Status `active`: "Draft is Live!" green banner with current round
- Status `closed`: "League Closed" muted banner
- Non-commissioner + draft status: "Waiting for commissioner" amber prompt

**Copy invite code** — `copyInviteCode()` tries `navigator.clipboard.writeText()` first, falls back to textarea + `execCommand('copy')`. Button toggles to "Copied!" for 2 seconds.

### Settings (settings.html)
Build after initial release. Includes:
- Notification toggles (on/off for sightings, bites, fatalities, draft)
- **My Regions Only toggle** — live in settings.html. Toggle onchange calls `PATCH /api/settings` with `{notify_my_regions_only: 1|0}`. Backend checks this in `send_push_notification()` before sending — if enabled, only sends pushes for events in regions the user has drafted. Initial state loaded from `settings.notify_my_regions_only` in the init flow.
- Quiet hours with time pickers (default 9 PM - 6 AM)
- Profile info (display name, email)
- League info
- Help link → help.html

### Help (help.html)
- PWA installation guide with **interactive platform tab switcher** (iOS + Android), each with 5 detailed steps including screenshots-like descriptions
- How push notifications work (enablement steps, desktop support note, links to Settings)
- Quiet hours explanation (defaults, configurability)
- Scoring reference table
- Troubleshooting FAQ with checklist layout (notification issues, game loading issues)
- Contact commissioner fallback
- Links to Settings and How to Play pages

**PWA install tabs pattern:**
- Two `.platform-tab` buttons with `data-platform="ios"` / `data-platform="android"`
- Two `.platform-panel` divs with `id="panel-ios"` / `id="panel-android"`
- JS on DOMContentLoaded toggles `active` class: deactivate all → activate clicked tab + matching panel
- iOS steps (Safari): Share icon → Add to Home Screen → name it → open from home screen (full-screen, no browser chrome)
- Android steps (Chrome): three-dot menu (or Install banner) → Install App / Add to Home Screen → confirm → open from home screen (full-screen + push notifications + no address bar)
- Both panels include a final step explicitly naming what the user gains (full-screen, no chrome, push support)

### How to Play (how-to-play.html)
- Full rules: draft, scoring, map, league
- Strategy tips (removed Jul 8 per Germaine)
- **Section 7: Shark Dad Jokes** — 6 dad jokes in `section-card`, no-bullet `<ul>` with emoji prefixes, `.note` callout: "New jokes every visit — powered by AI". See `references/shark-dad-jokes-section.md` for placement and pattern.
- Links back to landing

### Desktop layout
At viewports >= 1024px, content columns scale from single to multi-column. Max-width 1400px centered. Mobile layout unchanged.

## Scoring

| Event | Points | Example push body |
|-------|--------|-------------------|
| Sighting | +2 | "Sighting! +2 pts — Florida East Coast" |
| Bite | +5 | "Bite! +5 pts — Florida East Coast" |
| Fatality | +10 | "FATALITY! +10 pts — Florida East Coast" |
| Family Waters bonus | +30 | "+5 pts + 30 Family Waters — Florida East Coast" |

### Family Waters +30 Bonus (added Jul 2026)

Users can mark score events as `is_personal: true` to claim a +30 Family Waters bonus on top of base points (e.g. a bite gets 5+30=35 total). The bonus is tracked at the user level via:

```python
# users table columns added by migration
users.family_waters_bonus INTEGER DEFAULT 0   # cumulative bonus points
users.family_waters_count INTEGER DEFAULT 0    # how many times they've claimed it
scores.is_personal INTEGER DEFAULT 0           # on the score event
```

**How it works:**
- `POST /api/scores/events` accepts `is_personal` (bool, default false)
- When true: adds +30 to the base points, stores the bonus in the response as `family_waters_bonus: 30`
- Push notification shows `+5 pts + 30 Family Waters — {emoji} {name}`
- Response message: `"Scored 35 points for bite (includes +30 Family Waters bonus)"`
- Updates `users.family_waters_bonus` and `users.family_waters_count` for each user who drafted that region
- Leaderboard endpoint returns `family_waters_bonus` and `family_waters_count` per user
- `recalculate_user_scores()` recalculates family waters aggregates (subtracts base points from total to get bonus)

**Consumption model:** Each Family Waters bonus should be consumed once per personal sighting/bite/fatality. It's tracked non-revocably — the score event itself being the record of consumption.

## Player limits
- League max_players: default 6, range 2-10
- Auto-balancing: odd player counts remove lowest-performing regions so every player has an equal number of regions
- Excluded regions stored in `league_excluded_regions` table

## Commission settings
- Commissioner can set `draft_start_at` (ISO datetime)
- Commissioner starts the draft via POST /api/leagues/{id}/draft/start (was /api/leagues/{id}/start-draft originally, refactored to /draft/start in PATCH /api/leagues/{id}/settings workflow)
- Draft status: `draft_completed` boolean on league record

## Draft reminder emails

When a league commissioner sets `draft_start_at` (ISO 8601 string on the `leagues` table), automated email reminders are sent to all league members. Built Jul 9, 2026.

### Architecture

```
cron (every 15m)
  └─ /root/.hermes/scripts/shark-draft-reminder.sh
       └─ Executes embedded Python3 via heredoc
            ├─ Query leagues WHERE status='draft' AND draft_start_at IS NOT NULL
            ├─ For each member: check if draft is within 24h ±5min or 1h ±5min
            ├─ Send email via SMTP (Sho'Nuff account) if not already sent
            └─ Track sends in /root/shark-game/data/draft-reminders.json
```

### Script

`/root/.hermes/scripts/shark-draft-reminder.sh` — bash wrapper with embedded Python3 (338 lines). Chmod 755. Runs every 15 minutes via cron.

### Tracking & idempotency

File at `/root/shark-game/data/draft-reminders.json` uses composite keys:
- `league_{id}_user_{id}_24h` — 24-hour reminder sent
- `league_{id}_user_{id}_1h` — 1-hour reminder sent

Each key is written with ISO UTC timestamp after a successful send. The check is a simple dict membership test — no duplicates.

### Time window logic

For each user:
- `abs(time_until_draft - 24h) <= 5min` → send 24h reminder
- `abs(time_until_draft - 1h) <= 5min` → send 1h reminder

This ensures the reminder fires within a ~10-minute window around the target time.

### Email content

**From:** Sho'Nuff Brown <shonuff@germainebrown.com>
**BCC:** g@germainebrown.com
**SMTP:** mail.germainebrown.com:2525 with STARTTLS
**Password:** /root/.config/himalaya/shonuff.pass
**Signature:** Random Sho'Nuff title + closing from reference files, HTML badge

**24h email:**
- Subject: `🦈 [League Name] — Draft Starts in 24 Hours!`
- Body: draft time, draft room link, user's current drafted regions preview
- Draft room URL: `https://shark.itpropartner.com/draft-room.html?league_id={id}`

**1h email:**
- Subject: `🦈 [League Name] — Draft Starts in 1 Hour!`
- Body: draft time, direct draft room link, notification/timer tips

### Cron setup

```cron
*/15 * * * * /root/.hermes/scripts/shark-draft-reminder.sh 2>&1 | logger -t shark-draft-reminder
```

### Logging

All runs log to `/var/log/shark-reminder.log`.

## Draft start_at format

ISO 8601 datetime string on `leagues.draft_start_at`. No timezone defaults to UTC. Set via PATCH /api/leagues/{id}/settings (commissioner only). Currently no leagues have this set in the production DB.

## Trademark & naming

The game was originally named "Feeding Frenzy" but Electronic Arts owns live trademark #2979806 (Class 9: game software) for that name since 2005. The game was renamed to "Shark Attack Fantasy League" in July 2026.

"Shark Bait" was also researched and has conflicts:
- Active mobile dating sim called "Shark Bait" (RoseMagpie, 2023)
- VR game "SharkBait" on Meta Quest
- "Captain Sharkbait: Voyage for Treasure" on Steam (2025)
- sharkbaitgame.com is available (~$10/yr) but the name itself is risky

Available domains if the game name changes or needs a separate short domain:
- sharkfantasy.com ✅ (12 chars, highly recommended)
- sharkattackleague.com ✅
- sharkfantasyleague.com ✅
- sharkdraft.com ✅

Full research saved at references/shark-game-trademark-research.md.

## Game name: "Shark Attack Fantasy League" (confirmed Jul 9)

## Renaming game UI text

- **Tracking file must be writable** — if the JSON file can't be written, the script logs an error but continues. The reminder will re-send on the next tick since it couldn't mark sent.
- **Draft window is tight** — with a 15m cron interval and ±5m window, every tick inside the window fires once. If cron misses a tick, the window covers 2-3 ticks.
- **Does NOT check user notification prefs** — sends to all members regardless of `users.notify_draft` setting. Add this check if spam becomes an issue.
- **No quiet-hours check** — sends regardless of time of day. Add check on `users.quiet_hours_*` if needed.

## Renaming game UI text

When renaming game titles, brand names, or UI text across the static frontend, use this procedure to ensure completeness:

### Procedure

1. **Search with exact case** — `search_files(pattern='Old Name', path='/var/www/shark-game/')` finds all occurrences.
2. **Search with lowered/casefolded regex** — `search_files(pattern='old.name', path='/var/www/shark-game/')` catches hidden references in URLs, JS strings, CSS pseudo-content, etc. that may not match the exact case. Yes, this means two searches.
3. **Scan all file types** — static HTML (`*.html`), service worker (`sw.js`), manifest (`manifest.json`), and backend strings if the text appears in server.py or scraper outputs.
4. **Read context around each match** — before patching, read 5-10 lines around each match to build a unique old_string for `patch`. This prevents accidental overmatching on common words or partial substrings.
5. **Batch all patch calls** — independent replacements should be sent in a single turn.
6. **Final zero-count confirmation** — `search_files(...)` should return `"total_count": 0` for both exact and pattern searches.
7. **HTTP smoke test** — run `curl -sk -L -o /dev/null -w '%{http_code}' https://shark.iamgmb.com/page.html` for each modified page (accept 308 redirect as valid — Caddy redirects HTTP → HTTPS).

8. **Fallback verification when curl can't reach the domain** — if running on the server itself and the Caddy config uses domain-based virtual hosts (e.g. `shark.itpropartner.com` routes to the static files, not `shark.iamgmb.com`), localhost curl will fail with exit 6 (Could not resolve host) or exit 7 (refused) even though the files are correctly deployed. In that case:
   - Verify files exist: `ls -la /var/www/shark-game/page.html`
   - Verify the new text is present: `grep -n "New Text" /var/www/shark-game/page.html`
   - This is equally definitive — Caddy is a file_server, so if the file is valid on disk it's valid on the wire.

### Patch tool gotcha — ambiguous matches

When using `patch` with `old_string`, the tool requires a unique match. Two gotchas to watch for:

- **Duplicate HTML content** — identical button text or headings that appear in both the static HTML and a JS string template (e.g. `'Join the Frenzy'` in button HTML + JS variable assignment). The tool returns `Found N matches for old_string`.
- **Fix**: include 2-3 lines of surrounding context in both `old_string` and `new_string` to disambiguate. Avoid `replace_all=True` unless the change truly applies to every match (e.g. renaming a CSS class across the file).
- **Always read +-5 lines around each match** before calling patch (step 4 above). This gives you enough context to build a unique old_string.

### What NOT to touch

- **CSS class names, HTML IDs, JS variable names, API endpoint paths** — these are internal references, not user-facing UI text.
- **Database string values** (region names, user-set league names) — those are user data, not UI strings.
- **File names or directory paths** — only display text inside them.
- **Translation/localization strings** — not yet applicable, but the principle holds.

## Invite by Email (league settings page)

Commissioners can invite players via email from the league settings page. Feature added Jul 9, 2026.

### Backend endpoint

`POST /api/leagues/{league_id}/invite`

**Auth:** Requires commissioner membership (`is_commissioner=1`) + valid JWT. Uses `get_commissioner_membership()` guard.

**Request body:** `{"email": "player@example.com"}`

**Validation chain:**
1. League must exist (404)
2. Caller must be commissioner (403)
3. League status must be `'draft'` (400 — no longer accepting members)
4. Member count must be < max_players (400 — league full)

**Signed invite link:** Generates a JWT with `league_id`, `invite_code`, 7-day expiry, signed with `JWT_SECRET`. The link is:
```
https://shark.iamgmb.com/?invite={jwt_token}
```
The token is NOT yet consumed on the frontend — the page at `/?invite=...` needs implementation. For now the email also includes the plain invite code as a fallback.

**Sho'Nuff-styled invite email:** Calls `send_invite_email()` which builds a hand-crafted HTML email with:
- Dark ocean theme matching the game
- League name and commissioner name
- Prominent invite code (monospace amber)
- "Accept Invitation" CTA button linking to `?invite={signed_token}`
- Random Sho'Nuff title + closing quote from inline lists (duplicated from reference files — keep in sync)
- Embedded base64 signature image from `/root/.hermes/references/shonuff-image-b64.txt`
- SMTP via same pipeline as password reset (`mail.germainebrown.com:2525` STARTTLS)
- BCC to `g@germainebrown.com`

### Pydantic model

```python
class InviteRequest(BaseModel):
    email: str
```

### Frontend

Added to `league.html` inside the commissioner-only section:

- **`<div id="inviteByEmailSection">`** — conditionally shown via JavaScript when user is commissioner (`renderInviteSection()`)
- **Email input** — `<input type="email" id="inviteEmailInput">` matching the existing dark theme
- **"Send Invite" button** — calls `sendInvite()` async function
- **Status display** — `<div id="inviteStatus">` shows green checkmark or red error
- **`renderInviteSection()`** — called from `renderLeague()`, toggles display based on `is_commissioner`
- **`sendInvite()`** — validates email client-side (not empty, has @ and .), calls `POST /api/leagues/{id}/invite`, shows result, clears input on success

### Pitfalls

- **405 Method Not Allowed after server restart** — if the route appears in code but not in the OpenAPI schema, the old server process is still running and holding the port. Kill with `fuser -k 8083/tcp`, wait 1s, then restart. Verify route appears in OpenAPI before testing.
- **The invite endpoint does NOT add the user to the league** — it only sends an email. The recipient must still visit the site and use the invite code to join. This is deliberate — the join flow remains unchanged.
- **Signed token invites are unidirectional** — the ?invite=... query param landing page does not auto-join; the user still creates/logs in and enters the code. The signed token just pre-fills or authenticates the join. Implementation TBD.
- **Inline Sho'Nuff title/closing lists in send_invite_email()** are duplicated from the reference files. If those files are updated, the inline lists in server.py must be updated too. There is no import mechanism — it's inline strings.

## User style preferences (Germaine)

- **Decisions over options** — when presenting a choice, pick a recommendation and state it clearly. Do not list 3-4 options and ask "which one?" unless the trade-off is genuinely meaningful and irreversible.
- **Concise summaries, not prose** — when reporting status on multiple items, use a table or bullet list, not paragraphs. Prefer vertical compactness.
- **No boilerplate volume** — Germaine called out verbose Agent DRE responses. If a single sentence answers the question, don't add two more.
- **`/queue` is for deferral only** — when Germaine says `/queue <topic>`, save it and do NOT execute. Un-queue explicitly before action.
- **Confirm before destructive** — state what you're about to do, why, and ask to proceed. Then execute immediately on approval without re-explaining.

## Pitfalls

- **Undefined variable in `showToast()` causes blank page on interactions** — if `el.textContent = msg;` is called without a preceding `const el = document.getElementById('toast')`, toggling any setting throws a `ReferenceError` that silently breaks the page. The error only surfaces on interaction, not during init. Fix: always define `el` locally in `showToast()`, and wrap the `DOMContentLoaded` handler in `.catch()` so init failures don't leave the spinner forever. See `settings.html` for the canonical pattern.
- **`sqlite3.Row` has no `.get()` method`** — `fetchone()` returns a `sqlite3.Row`, not a dict. Calling `row.get("column")` raises `AttributeError`. Always convert with `dict(row)` before accessing fields that might be missing:
  ```python
  row = conn.execute("SELECT * FROM leagues WHERE ...").fetchone()
  row = dict(row)  # must do this first
  max_p = row.get("max_players", 6)  # now safe
  ```
  This was the direct cause of the Join League 500 error in Jul 2026.
- **Password reset requires email SMTP** — the forgot-password endpoint generates a token and sends an email via the same SMTP pipeline as draft reminders. If SMTP is down, password reset fails silently.
- **Silent push failures** — every exception handler must log the error: `print(f"Push failed: {e}", flush=True)`.
- **Pywebpush in wrong venv** — always install in the systemd service's venv, not system python.
- **Service worker at domain root** — sw.js must be at the same origin as the page.
- **Push subscription table FK** — user_id must allow NULL for no-auth test subscriptions.
- **`/queue` is a Telegram chat command only** — Germaine uses `/queue` in our conversation to defer tasks. It is NOT a web browser feature, and does NOT work from any ops portal or game page. When Germaine says `/queue <something>`, save it, do not execute. Un-queue explicitly before action.
- **Do NOT send a test email in watchdog scripts** — login-only health checks are sufficient.
- **Watchdog/no_agent scripts must exit silently on success** — `log()` must use `>>` not `tee -a`.
- **`/queue` usage** — Germaine defers tasks with `/queue`. Do not execute unless explicitly un-queued.
- **Click-to-expand needs two-way toggle** — dashboard details must alternate open/close, not just open.
- **Back navigation routes to landing page instead of game (FIXED Jul 2026)** — the X button on draft-room.html and Home link on league.html now route to `draft-room.html` when logged in. Fix: `handlePlayerInfoClick()` checks token before navigating. See `references/navigation-flow-back-button.md` for pattern. Remaining gap: `index.html` does not auto-redirect logged-in users to the draft room on load.
- **Family Waters +30 — field naming match** — the DB columns are `family_waters_bonus` and `family_waters_count`. When adding new scoring features, the bonus field must be surfaced in the leaderboard API response AND the frontend standings display. Don't add the DB column without updating both the API endpoint and the frontend render.
- **Caddy static file permissions (write_file)** — when using `write_file` or `cat >` to create new HTML pages directly in `/var/www/shark-game/`, the files are created with `-rw------- root root` (600) permissions. Caddy runs as the `caddy` user and will fail to read them, falling back to serving `index.html` via the `try_files` rule or returning a 403/404. ALWAYS run `chmod 644 /var/www/shark-game/<file>` after creating or overwriting static files as root.
- **Patching `server.py` with new endpoints** — when adding new Pydantic models and FastAPI endpoints via script, always insert them at the bottom of the file (e.g. just before `# ---- Run ----`). Inserting endpoints earlier in the file than their Pydantic models will cause a `NameError` crash on server startup.

### Caddyfile Routing for SPAs alongside FastAPI

To natively serve the SPA frontend while routing API traffic to the FastAPI backend at `127.0.0.1:8083`, configure Caddy like this:

```caddyfile
shark.iamgmb.com {
	@api path /api/*
	handle @api {
		reverse_proxy 127.0.0.1:8083
	}
	handle {
		root * /var/www/shark-game
		try_files {path} {path}.html /index.html
		file_server
	}
}
```
*Do not* rely on the FastAPI catch-all route (`/{path_name:path}`) to serve static files in production. Caddy handles `try_files` far more robustly.
