Files

42 KiB

name, description, version, author, tags, platforms
name description version author tags platforms
shark-game-development 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. 1.4.0 Sho'Nuff
gaming
fantasy-sports
shark
push-notifications
pwa
fastapi
leaflet
scoring-engine
web-push
map
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:

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 endpointPOST /api/push/test sends to all subscriptions (no auth required). Useful for verifying PWA integration.

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

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 functionsend_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.

/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:

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:

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):

# 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:

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 arearenderActions() 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 codecopyInviteCode() 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:

# 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

*/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 casesearch_files(pattern='Old Name', path='/var/www/shark-game/') finds all occurrences.

  2. Search with lowered/casefolded regexsearch_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 confirmationsearch_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

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 asqlite3.Row, not a dict. Calling row.get("column")raisesAttributeError. Always convert with dict(row)` before accessing fields that might be missing:
    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 successlog() 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:

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.