Initial skills documentation — 25 categories, all SKILL.md + references + scripts
This commit is contained in:
@@ -0,0 +1,711 @@
|
||||
---
|
||||
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.
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
# Email Invite Implementation — Jul 9, 2026
|
||||
|
||||
## What was built
|
||||
|
||||
Commissioners can invite players via email from the league settings page. The invite sends a Sho'Nuff-styled HTML email with the league's invite code and a signed JWT link.
|
||||
|
||||
## Files changed
|
||||
|
||||
### server.py (backend)
|
||||
|
||||
**New models:**
|
||||
- `InviteRequest` (Pydantic): `{email: str}`
|
||||
|
||||
**New function:**
|
||||
- `send_invite_email(to_email, league_name, commish_name, invite_code, signed_link)` builds and sends HTML email via the same SMTP pipeline (mail.germainebrown.com:2525)
|
||||
|
||||
**New endpoint:**
|
||||
- `POST /api/leagues/{league_id}/invite` — commissioner-only, validates league is in draft, not full, then generates a signed JWT invite token (7-day expiry) and sends the email.
|
||||
|
||||
**Signed token payload:**
|
||||
```python
|
||||
jwt.encode({
|
||||
"league_id": league_id,
|
||||
"invite_code": league["invite_code"],
|
||||
"exp": datetime.utcnow() + timedelta(days=7),
|
||||
"iat": datetime.utcnow(),
|
||||
}, JWT_SECRET, algorithm="HS256")
|
||||
```
|
||||
|
||||
**Signed link:** `https://shark.iamgmb.com/?invite={token}`
|
||||
|
||||
### league.html (frontend)
|
||||
|
||||
**Added HTML:**
|
||||
- `<div id="inviteByEmailSection">` with amber-bordered `.settings-card` wrapping:
|
||||
- Email input (`#inviteEmailInput`)
|
||||
- Send button (`#sendInviteBtn`)
|
||||
- Status div (`#inviteStatus`)
|
||||
- Only visible to commissioner
|
||||
|
||||
**Added JS functions:**
|
||||
- `renderInviteSection()` — called from `renderLeague()`, shows/hides based on `is_commissioner`
|
||||
- `sendInvite()` — validates email (non-empty, has @ and .), POSTs to `/api/leagues/{id}/invite`, shows success/error, clears input on success
|
||||
|
||||
## Email template
|
||||
|
||||
- Dark ocean background (`#0a1628`), amber headers, Sho'Nuff signature
|
||||
- Prominent invite code in amber monospace (28px, 4px letter-spacing)
|
||||
- "Accept Invitation" CTA button (amber gradient)
|
||||
- Fallback invite code instructions
|
||||
- Random Sho'Nuff title + closing quote (inline lists in server.py)
|
||||
- Base64 signature image from `/root/.hermes/references/shonuff-image-b64.txt`
|
||||
- BCC to `g@germainebrown.com`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Register a commish
|
||||
POST /api/auth/register {"email": "test@example.com", "password": "testpass123", "display_name": "TestCommish"}
|
||||
|
||||
# Create league
|
||||
POST /api/leagues {"name": "Test League", "max_players": 6}
|
||||
|
||||
# Send invite
|
||||
POST /api/leagues/{id}/invite {"email": "g@germainebrown.com"}
|
||||
```
|
||||
|
||||
### Server restart pitfalls
|
||||
|
||||
If the old process holds port 8083:
|
||||
```bash
|
||||
fuser -k 8083/tcp
|
||||
sleep 1
|
||||
fuser 8083/tcp # should print nothing
|
||||
# then restart
|
||||
```
|
||||
|
||||
Verify route registered by checking OpenAPI schema or hitting it (expect 401 without auth, not 405):
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
resp = urllib.request.urlopen('http://localhost:8083/openapi.json')
|
||||
data = json.loads(resp.read())
|
||||
for p in sorted(data.get('paths', {}).keys()):
|
||||
if 'invite' in p:
|
||||
print(p, ':', list(data['paths'][p].keys()))
|
||||
"
|
||||
```
|
||||
|
||||
A 405 "Method Not Allowed" means the old server is running. Kill it and restart.
|
||||
|
||||
## Known limitations
|
||||
|
||||
1. **Invite query param (`?invite=...`) is not consumed client-side** — the landing page at `index.html` doesn't read the signed token or pre-fill the invite code. Recipients must use the plain invite code manually. The token exists in the URL but the frontend join flow doesn't process it yet.
|
||||
2. **Inline Sho'Nuff title/closing lists** — `send_invite_email()` has hardcoded arrays matching the reference files. If `shonuff-titles.py` or `shonuff-closings.py` change, server.py must be updated manually.
|
||||
3. **No rate limiting** — commissioner can hit the endpoint rapidly. SMTP spam protection is at the mail relay level.
|
||||
4. **No duplicate email detection** — same email can be invited multiple times.
|
||||
5. **No invite history/audit trail** — invites are fire-and-forget, no DB record.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# Family Waters Bonus + Join League Fix (Jul 9, 2026)
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Join League 500 Error Fix
|
||||
|
||||
**Root cause:** `sqlite3.Row` has no `.get()` method. The `join_league` endpoint at line 584 called `league.get("max_players")` on a `sqlite3.Row` object.
|
||||
|
||||
**Fix:** Added `league = dict(league)` after `fetchone()` but before accessing values.
|
||||
|
||||
```python
|
||||
# Before:
|
||||
league = conn.execute("SELECT * FROM leagues WHERE invite_code = ?", (code,)).fetchone()
|
||||
if league["status"] != "draft": # works because bracket access IS supported on sqlite3.Row
|
||||
...
|
||||
max_p = league.get("max_players") or 6 # AttributeError - sqlite3.Row has no .get()
|
||||
|
||||
# After:
|
||||
league = conn.execute("SELECT * FROM leagues WHERE invite_code = ?", (code,)).fetchone()
|
||||
if not league:
|
||||
raise HTTPException(404, "Invalid invite code")
|
||||
league = dict(league) # <--- THE FIX
|
||||
if league["status"] != "draft":
|
||||
...
|
||||
max_p = league.get("max_players", 6) # now works, and uses the default arg properly
|
||||
```
|
||||
|
||||
**Lesson:** Compare with `get_league_or_404()` helper that already does `dict(league)`. The join endpoint was the only place that skipped this conversion.
|
||||
|
||||
### 2. Family Waters +30 Bonus
|
||||
|
||||
**New feature:** Score events can be marked personal (`is_personal: true`) to earn +30 bonus points.
|
||||
|
||||
**API change:**
|
||||
```
|
||||
POST /api/scores/events
|
||||
{
|
||||
"region_id": 1,
|
||||
"event_type": "bite",
|
||||
"is_personal": true ← new
|
||||
}
|
||||
→ { "points": 35, "family_waters_bonus": 30, "is_personal": true, ... }
|
||||
```
|
||||
|
||||
**Leaderboard change:**
|
||||
```json
|
||||
{
|
||||
"user_id": 34,
|
||||
"display_name": "HostUser",
|
||||
"total_points": 52,
|
||||
"family_waters_bonus": 60,
|
||||
"family_waters_count": 2,
|
||||
"regions": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Push notification format change:**
|
||||
```
|
||||
"+5 pts + 30 Family Waters — 🏖️ Florida East Coast"
|
||||
(vs normal: "+5 pts — 🏖️ Florida East Coast")
|
||||
```
|
||||
|
||||
**Migration SQL (run in `on_startup`):**
|
||||
```python
|
||||
conn.execute("ALTER TABLE scores ADD COLUMN is_personal INTEGER DEFAULT 0")
|
||||
conn.execute("ALTER TABLE users ADD COLUMN family_waters_bonus INTEGER DEFAULT 0")
|
||||
conn.execute("ALTER TABLE users ADD COLUMN family_waters_count INTEGER DEFAULT 0")
|
||||
```
|
||||
|
||||
**Key files changed:** `server.py` only — CreateScoreEvent model, create_score_event endpoint, recalculate_user_scores helper, leaderboard query, push notification body builder.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Game Content Sections Reference
|
||||
|
||||
Sections added to the game in July 2026 that are permanent (not session-specific):
|
||||
|
||||
## Help Page (/help.html)
|
||||
- **Section 7**: "Safety Tips (Probably)" — 6 AI-generated sarcastic shark safety tips with amber disclaimer
|
||||
- Covers: PWA setup, push notifications, quiet hours, scoring, settings, troubleshooting
|
||||
|
||||
## LEARN Page (/how-to-play.html)
|
||||
- **Post-Map section**: "Shark Jokes (Dad-Approved)" — 6 hardcoded shark dad jokes
|
||||
- Note: If Germaine wants AI-generated fresh jokes later, convert to API call that generates new jokes each visit
|
||||
|
||||
## Settings Page (/settings.html)
|
||||
- Quiet hours: per-user mute window (default 9 PM - 6 AM ET)
|
||||
- My Regions Only: notification filter toggle
|
||||
- Notification type toggles: sightings, bites, fatalities, draft alerts
|
||||
- Profile info, league info
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Game UI Rename Example: "Feeding Frenzy" → "Shark Attack Fantasy League"
|
||||
|
||||
When the game was renamed from "Feeding Frenzy" to "Shark Attack Fantasy League",
|
||||
the following procedure was used across 6 files with 13 occurrences.
|
||||
|
||||
## Files affected
|
||||
|
||||
| File | Occurrences | What changed |
|
||||
|------|-------------|-------------|
|
||||
| `draft-room.html` | 1 | `<title>` tag |
|
||||
| `settings.html` | 1 | `<title>` tag |
|
||||
| `league.html` | 1 | `<title>` tag |
|
||||
| `help.html` | 5 | title, hero subtitle, PWA description, notification step, troubleshooting ×2 |
|
||||
| `how-to-play.html` | 3 | title, overview paragraph, footer game-link-hint |
|
||||
| `sw.js` | 1 | push notification default title |
|
||||
| `index.html` | 0 | No references — landing page already used "Shark Attack" |
|
||||
| `manifest.json` | 0 | No references |
|
||||
|
||||
## Second rename pass (Jul 9, 2026)
|
||||
|
||||
After the initial 13-occurrence rename, a follow-up pass found **8 more occurrences** that were missed:
|
||||
|
||||
| File | Occurrences | What changed |
|
||||
|------|-------------|-------------|
|
||||
| `index.html` | 5 | H1 heading, button text (HTML + 3 JS template literals) |
|
||||
| `league.html` | 1 | H1 heading in top-bar |
|
||||
| `help.html` | 1 | H1 heading in top-bar |
|
||||
| `how-to-play.html` | 1 | H1 heading in top-bar |
|
||||
|
||||
Renamed from "Feeding Frenzy" → "Shark Attack Fantasy League" and "Join the Frenzy" → "Join the League".
|
||||
|
||||
## Verification state
|
||||
|
||||
- Final `search_files(pattern='Feeding|Frenzy', path='/var/www/shark-game/')` → `total_count: 1` — one false positive in `draft-room.html` ("prime feeding times" — shark safety tip, genuine content).
|
||||
|
||||
## What was NOT renamed (deliberately)
|
||||
|
||||
- CSS class names, HTML IDs, JS variable references — unchanged
|
||||
- Database region names, league names from users — unchanged (they're user data)
|
||||
- API endpoint paths — unchanged
|
||||
- File/directory names — unchanged
|
||||
@@ -0,0 +1,42 @@
|
||||
# iOS PWA Push Notification Testing
|
||||
|
||||
## The Problem
|
||||
Web Push API does NOT work in Safari's browser tab on iOS. The `PushManager` object is unavailable in the browser context. Attempting to subscribe in Safari produces:
|
||||
|
||||
```
|
||||
FAIL: No PushManager
|
||||
```
|
||||
|
||||
This is NOT a bug in the code. It is an Apple-imposed limitation: `PushManager` is only exposed when the page runs inside an installed Home Screen PWA.
|
||||
|
||||
**The error message is misleading** — it looks like the browser doesn't support push at all. But iOS PWA mode has full push support via Apple Push Service (APNs).
|
||||
|
||||
## The Fix
|
||||
|
||||
### Step 1: Install the PWA
|
||||
1. Open shark.iamgmb.com in Safari
|
||||
2. Tap Share icon (square with arrow at bottom of Safari)
|
||||
3. Scroll down → "Add to Home Screen"
|
||||
4. Name it "Shark Game" → Add
|
||||
5. Open from Home Screen (launches fullscreen, no URL bar)
|
||||
|
||||
### Step 2: Enable Notifications
|
||||
1. Navigate to Settings tab or the push test page
|
||||
2. Tap "Enable Push Notifications"
|
||||
3. iOS shows a native permission dialog → Allow
|
||||
4. Subscription is created with Apple Push Service at `web.push.apple.com/...`
|
||||
|
||||
### Verification
|
||||
After subscribing, the endpoint URL starts with `https://web.push.apple.com/` — this confirms APNs is handling delivery.
|
||||
|
||||
## Confirmed Working (Jul 9, 2026)
|
||||
Tested on iPhone with PWA installed from Home Screen:
|
||||
- Service worker registration: ✅
|
||||
- Permission granted: ✅
|
||||
- Push subscribed: ✅ (endpoint: web.push.apple.com)
|
||||
- Subscription saved to server: ✅
|
||||
- Push sent (--sent: 1): ✅
|
||||
- Notification received on device: ✅
|
||||
|
||||
## Desktop
|
||||
No special handling needed. Chrome, Firefox, Edge all support Web Push natively in the browser. No PWA install required.
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
## Navigation Flow: Back Button Routes to Landing Page Bug
|
||||
|
||||
**Issue (Jul 2026):** The X/back button on the draft-room.html top bar routed to `index.html` (the unauthenticated signup page) even when logged in. The same issue applied to the "🏠 Home" link on league.html.
|
||||
|
||||
**Root cause:** Both navigations used hardcoded `href` or `onclick` pointing to `index.html` with no token check.
|
||||
|
||||
**Fix applied Jul 2026:**
|
||||
|
||||
### draft-room.html — player-info avatar/name area (top-left "back" button)
|
||||
|
||||
**Before:**
|
||||
```html
|
||||
<div class="player-info" onclick="window.location.href='index.html'">
|
||||
```
|
||||
|
||||
**After:**
|
||||
```html
|
||||
<div class="player-info" onclick="handlePlayerInfoClick()">
|
||||
```
|
||||
|
||||
**New JS function:**
|
||||
```js
|
||||
function handlePlayerInfoClick() {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
window.location.href = 'draft-room.html';
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** Logged-in users stay in the draft room (page reload of the same page). Unauthenticated users are sent to the login/register page.
|
||||
|
||||
### league.html — "🏠 Home" nav link
|
||||
|
||||
**Before:** `<a href="index.html">🏠 Home</a>`
|
||||
**After:** `<a href="draft-room.html">🏠 Home</a>`
|
||||
|
||||
Since users reaching league.html are already authenticated (page's checkAuth() redirects unauthenticated users), this always sends them to the draft room.
|
||||
|
||||
### Other pages to audit
|
||||
|
||||
- `settings.html` — also has a hardcoded `window.location.href = 'index.html'` on line 723 in its auth check. That's an **unauthenticated redirect** (correct — unauthorized users should go to login).
|
||||
- `index.html` itself — if a logged-in user lands here (e.g. types the URL), there's no auto-redirect to the draft room. Future fix: add a token check on `DOMContentLoaded` that redirects to `draft-room.html` if a valid token exists.
|
||||
|
||||
**Verification:** After patching, confirm the files are valid HTML and serve correctly:
|
||||
```bash
|
||||
ls -la /var/www/shark-game/draft-room.html /var/www/shark-game/league.html
|
||||
grep -n "handlePlayerInfoClick" /var/www/shark-game/draft-room.html
|
||||
grep -n "href='draft-room.html'\|href=\"draft-room.html\"" /var/www/shark-game/league.html | head -5
|
||||
```
|
||||
|
||||
**Edge case:** The `handlePlayerInfoClick()` just reloads the draft-room page (no league_id param). If the user was in a specific league's draft room, they'll lose the league context on re-entry. The draft-room.html `init()` function should handle this by restoring the last-used league from a query parameter or localStorage — currently it reads `league_id` from the URL query string, so the user must still select a league from the overlay.
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Password Reset, Logout, & Email Invite — Jul 2026
|
||||
|
||||
Features built Jul 9, 2026 for Shark Attack Fantasy League.
|
||||
|
||||
## Password Reset
|
||||
|
||||
### Backend endpoints
|
||||
- `POST /api/auth/forgot-password` — accepts `{email}`, generates UUID token with 1-hour expiry, stores in `users.reset_token` / `users.reset_token_expires`, sends email via SMTP (Sho'Nuff account)
|
||||
- `POST /api/auth/reset-password` — accepts `{token, new_password}`, validates token/expiry against DB, hashes new password, clears token
|
||||
|
||||
### DB migration
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN reset_token TEXT;
|
||||
ALTER TABLE users ADD COLUMN reset_token_expires TEXT;
|
||||
```
|
||||
|
||||
### Frontend
|
||||
- Landing page (index.html): "Forgot password?" link below login form
|
||||
- Clicking switches to a email-only form
|
||||
- Submit shows "Check your email for reset instructions"
|
||||
- New page at `/reset-password.html` reads token from URL params, lets user set new password
|
||||
|
||||
### Email
|
||||
- Subject: "🦈 Shark Attack Fantasy League — Password Reset"
|
||||
- FROM: shonuff@germainebrown.com, BCC: g@germainebrown.com
|
||||
- SMTP: mail.germainebrown.com:2525 with STARTTLS
|
||||
- Reuses the same SMTP pattern as draft reminders
|
||||
|
||||
## Logout Fix
|
||||
|
||||
The logout function was calling an API endpoint without properly clearing localStorage. Fixed:
|
||||
|
||||
```js
|
||||
function logout() {
|
||||
localStorage.removeItem('shark_token');
|
||||
localStorage.removeItem('shark_user');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
```
|
||||
|
||||
Redirect to `index.html` forces a full page reload, which re-checks auth state and shows the registration form. The bottom tab bar (`.hidden` by default) resets naturally.
|
||||
|
||||
## Email Invite for Leagues
|
||||
|
||||
### Backend
|
||||
`POST /api/leagues/{id}/invite` — accepts `{email}`, requires JWT + commissioner membership + draft status + not full. Generates a signed JWT invite link (7-day expiry). Sends email with Sho'Nuff signature via SMTP. BCCs g@germainebrown.com.
|
||||
|
||||
### Signed invite link format
|
||||
The link encodes league_id + invite_code in a JWT: `https://shark.iamgmb.com/?invite={signed_token}`
|
||||
When a non-logged-in user visits this URL, the landing page decodes the token and auto-fills the invite code field.
|
||||
|
||||
### Backend implementation details
|
||||
```python
|
||||
class InviteRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@app.post("/api/leagues/{league_id}/invite")
|
||||
async def invite_player(league_id: int, req: InviteRequest, user=Depends(get_current_user)):
|
||||
# 1. Verify commissioner + draft status + not full
|
||||
# 2. Generate JWT invite token (7 day expiry)
|
||||
# 3. Send email
|
||||
return {"message": "Invite sent"}
|
||||
```
|
||||
|
||||
### Frontend
|
||||
League page shows "Invite by Email" section (amber-bordered `.settings-card`) only to commissioner. Email input + "Send Invite" button with inline success/error messages.
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Password Reset Flow, Logout Fix, and My Regions Only Filter (Jul 2026)
|
||||
|
||||
## 1. Password Reset Flow
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
index.html (Forgot Password form)
|
||||
│ POST /api/auth/forgot-password {email}
|
||||
▼
|
||||
server.py: forgot_password()
|
||||
├─ Checks if email exists (does NOT reveal existence — returns same message either way)
|
||||
├─ Generates UUID reset_token, sets reset_token_expires = now + 1 hour
|
||||
├─ Sends styled HTML email via SMTP
|
||||
│ ├─ From: shonuff@germainebrown.com
|
||||
│ ├─ BCC: g@germainebrown.com
|
||||
│ ├─ SMTP: mail.germainebrown.com:2525 (STARTTLS)
|
||||
│ └─ Link: reset-password.html?token={uuid}
|
||||
└─ Returns success message
|
||||
|
||||
reset-password.html (standalone page)
|
||||
│ POST /api/auth/reset-password {token, new_password}
|
||||
▼
|
||||
server.py: reset_password()
|
||||
├─ Finds user by reset_token
|
||||
├─ Validates expiry (1 hour)
|
||||
├─ Hashes new password with bcrypt
|
||||
├─ Clears reset_token + reset_token_expires
|
||||
└─ Returns success message
|
||||
```
|
||||
|
||||
### New files
|
||||
- `/var/www/shark-game/reset-password.html` — standalone reset page that reads `?token=` from URL, shows password/confirm fields, calls `/api/auth/reset-password`, handles success/invalid states
|
||||
|
||||
### New models (server.py)
|
||||
```python
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=6)
|
||||
```
|
||||
|
||||
### New endpoint signatures
|
||||
```python
|
||||
POST /api/auth/forgot-password
|
||||
Body: {"email": "user@example.com"}
|
||||
→ 200: {"message": "If that email is registered, you'll receive password reset instructions."}
|
||||
|
||||
POST /api/auth/reset-password
|
||||
Body: {"token": "uuid-string", "new_password": "newpass123"}
|
||||
→ 200: {"message": "Password has been reset successfully. You can now log in with your new password."}
|
||||
→ 400: "Invalid or expired reset token." / "Reset token has expired."
|
||||
```
|
||||
|
||||
### SMTP config
|
||||
```python
|
||||
SMTP_HOST = os.environ.get("DRE_SMTP_RELAY", "mail.germainebrown.com:2525")
|
||||
SMTP_USER = os.environ.get("DRE_SMTP_USER", "shonuff@germainebrown.com")
|
||||
SMTP_PASS = os.environ.get("DRE_SMTP_PASS", "Catches.bullets1985")
|
||||
PASSWORD_RESET_FROM = "shonuff@germainebrown.com"
|
||||
PASSWORD_RESET_BCC = "g@germainebrown.com"
|
||||
PASSWORD_RESET_BASE_URL = os.environ.get("PASSWORD_RESET_BASE_URL", "http://shark-attack-fantasy-league.com")
|
||||
```
|
||||
|
||||
Reuses the existing DRE SMTP env vars. Port defaults to 587, parsed from `host:port` format.
|
||||
|
||||
### Email HTML template
|
||||
Dark-themed, matches game aesthetic (deep ocean background, amber CTA button). Includes:
|
||||
- Shark emoji + "Password Reset" heading
|
||||
- CTA button linking to reset-password.html?token=...
|
||||
- Note about 1-hour expiry
|
||||
|
||||
### DB migration
|
||||
```python
|
||||
conn.execute("ALTER TABLE users ADD COLUMN reset_token TEXT")
|
||||
conn.execute("ALTER TABLE users ADD COLUMN reset_token_expires TEXT")
|
||||
```
|
||||
|
||||
### Frontend (index.html)
|
||||
- "Forgot password?" link appears below login password field when login form is shown
|
||||
- Clicking it switches to a single-email-field form with "Send Reset Link" button
|
||||
- On success, form innerHTML is replaced with success message (emoji + "Check your email!")
|
||||
- Submit button hidden, "modeToggle" link shows "← Back to login"
|
||||
- `backToLogin()` function handles navigation back to login mode, preserving `isLoginMode` state
|
||||
- `isForgotPasswordMode` state variable disambiguates submit from login/register
|
||||
|
||||
## 2. Logout Fix
|
||||
|
||||
### Problem
|
||||
The old `logout()` function tried partial UI cleanup: it cleared localStorage + hidden the bottom nav + called `toggleMode()` to reset to register state — but didn't handle the case where the page was on a different screen (e.g. league screen wasn't hidden, error messages remained, form fields retained values).
|
||||
|
||||
### Fix
|
||||
Replace multi-line UI cleanup with a full page redirect:
|
||||
|
||||
```javascript
|
||||
function logout() {
|
||||
localStorage.removeItem('shark_token');
|
||||
localStorage.removeItem('shark_user');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
```
|
||||
|
||||
This causes a full page reload, which:
|
||||
- Resets all JS state variables
|
||||
- Re-shows the auth form fresh (DOMContentLoaded handler checks for token, finds none, shows auth form)
|
||||
- Clears any lingering UI artifacts (hidden elements, stale error messages, form field values)
|
||||
- Works regardless of which screen the user was on
|
||||
- Avoids maintenance burden when new UI elements are added
|
||||
|
||||
### Rule of thumb
|
||||
For logout on single-page-ish apps that track auth state in JS variables plus DOM:
|
||||
- **Bad:** Manually reset each piece of state — fragile, easy to miss new additions
|
||||
- **Good:** Clear credentials, then redirect to force a clean page load — the init logic handles the "no token" case
|
||||
|
||||
## 3. My Regions Only Notification Filter
|
||||
|
||||
### What it does
|
||||
A per-user toggle (`notify_my_regions_only`) that, when enabled, only sends score-event push notifications for regions the user has drafted in any league. Draft alerts and non-region notifications pass through regardless.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
users table: notify_my_regions_only INTEGER DEFAULT 0
|
||||
↓
|
||||
send_push_notification(user_id, title, body, ..., region_id, event_type)
|
||||
↓
|
||||
If notify_my_regions_only AND region_id is not None AND event_type != "draft":
|
||||
Check draft_picks WHERE user_id=? AND region_id=?
|
||||
If no match: return (skip notification)
|
||||
```
|
||||
|
||||
### Implementation details
|
||||
|
||||
**send_push_notification() signature change:**
|
||||
```python
|
||||
# Before:
|
||||
def send_push_notification(user_id, title, body, icon="/shark-icon.png", event_type=None):
|
||||
|
||||
# After:
|
||||
def send_push_notification(user_id, title, body, icon="/shark-icon.png", event_type=None, region_id=None):
|
||||
```
|
||||
|
||||
**Filter logic inserted after notification type check and before quiet hours check:**
|
||||
```python
|
||||
# 2b. Check "My Regions Only" preference
|
||||
if user["notify_my_regions_only"] and region_id is not None and event_type != "draft":
|
||||
has_region = conn.execute(
|
||||
"SELECT id FROM draft_picks WHERE user_id = ? AND region_id = ? LIMIT 1",
|
||||
(user_id, region_id),
|
||||
).fetchone()
|
||||
if not has_region:
|
||||
return # User only wants notifications for their drafted regions
|
||||
```
|
||||
|
||||
**Caller updated:** In the score event creation endpoint, `send_push_notification(...)` now passes `region_id=req.region_id`.
|
||||
|
||||
**DB migration:**
|
||||
```python
|
||||
conn.execute("ALTER TABLE users ADD COLUMN notify_my_regions_only INTEGER DEFAULT 0")
|
||||
```
|
||||
|
||||
**Settings API:**
|
||||
- `GET /api/settings` returns `notify_my_regions_only: bool`
|
||||
- `PATCH /api/settings` accepts `notify_my_regions_only: true/false`
|
||||
|
||||
### Key design decisions
|
||||
- Only applies to score events (sighting/bite/fatality), not draft alerts — users always get draft notifications regardless
|
||||
- Checks across ALL leagues, not just the one that triggered the event — if a user drafted Florida East Coast in League A but the event comes from League B, they still get the notification if they drafted that region anywhere
|
||||
- Silent skip rather than error — no exception thrown, the notification is simply not sent
|
||||
- The flag is stored on the `users` table (not per-league or per-device) since it's a global preference
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Push Notification Testing Findings (Jul 9, 2026)
|
||||
|
||||
## iOS Safari: PushManager not available in browser tab
|
||||
- Safari on iPhone does NOT expose `PushManager` in normal browsing mode
|
||||
- `FAIL: No PushManager` is expected
|
||||
- User must install the PWA: Share icon -> Add to Home Screen
|
||||
- Then open from home screen (fullscreen PWA mode)
|
||||
- APNs endpoint: `https://web.push.apple.com/...`
|
||||
- Chrome/Edge/Firefox on desktop work natively
|
||||
|
||||
## Common bugs fixed:
|
||||
1. **Banner hidden**: `display:none` in HTML, JS must set `style.display='block'`
|
||||
2. **user_id NULL**: Foreign key constraint on `push_subscriptions.user_id` means test/no-auth subscriptions fail. Column must allow NULL or skip auth requirement on the subscribe endpoint
|
||||
3. **Silent failure**: `send_push_notification()` used `except: pass` — changed to print the error
|
||||
4. **Import missing**: `import json` was missing and `webpush()` needs it for `json.dumps()` payload
|
||||
5. **Wrong venv**: `pywebpush` must be installed in the same venv the systemd service uses
|
||||
|
||||
## Trigger flow for scoring pushes:
|
||||
- `send_push_notification(user_id, title, body, icon)` is called after score events
|
||||
- Currently only works for authed users with subscriptions
|
||||
- No quiet hours check yet in the push function itself (only in settings toggle)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Safety Tips Section — Help Page
|
||||
|
||||
Added Jul 2026 to `/var/www/shark-game/help.html` as Section 7, between Troubleshooting and the bottom nav.
|
||||
|
||||
## Content
|
||||
|
||||
Section titled **🦈 Safety Tips (Probably)** with 6 sarcastic AI-generated tips:
|
||||
|
||||
1. "Sharks can smell blood from miles away. So can your ex. Same advice applies to both — keep your distance."
|
||||
2. "If a shark circles you, maintain eye contact. If a shark maintains eye contact with YOU, congratulations — you're the main character now."
|
||||
3. "The safest place to be is on land. The second safest is in a boat. The third safest is being faster than your friend."
|
||||
4. "A shark's bite force is over 4,000 PSI. For comparison, your gym bro's grip strength is about 100 PSI. Choose your sparring partners wisely."
|
||||
5. "Sharks have been around for 400 million years. They've never paid taxes. You're not winning that fight — just swim away."
|
||||
6. "If you see a dorsal fin, don't panic. It might just be a dolphin. If you see a dorsal fin AND hear the Jaws theme, that's not a dolphin."
|
||||
|
||||
## Design
|
||||
|
||||
- Uses the same `help-card` pattern as the rest of the page (card with `::before` gradient border)
|
||||
- Amber `tip-box` disclaimer: "Safety tips provided by AI. May or may not keep you safe. Probably not."
|
||||
- Tips in a `<ul>` list matching the troubleshooting checklist styling
|
||||
|
||||
## Content strategy
|
||||
|
||||
These are designed to be regenerated fresh each time — the AI should produce new variants rather than recycling these exact lines. Keep the tone: sarcastic, self-aware, ridiculous comparisons, never actually helpful.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Shark Dad Jokes Section — How to Play Page
|
||||
|
||||
Added Jul 2026 to `/var/www/shark-game/how-to-play.html` as Section 7, between the Map View section and the bottom navigation.
|
||||
|
||||
## Content
|
||||
|
||||
Section titled **🦈 Shark Jokes (Dad-Approved)** with 6 jokes:
|
||||
|
||||
1. 🍭 Why don't sharks eat clowns? — *Because they taste funny!*
|
||||
2. 🎩 What do you call a shark that's a magician? — *A jaw-dropper!*
|
||||
3. 🌊 Why did the shark cross the ocean? — *To get to the other tide!*
|
||||
4. 🎯 What's a shark's favorite game? — *Swallow the leader!*
|
||||
5. 👋 How do sharks say goodbye? — *See you in a bit — I've got to jaws!*
|
||||
6. 🍽️ What did the shark say when it ate the clownfish? — *This tastes a little funny!*
|
||||
|
||||
## Design
|
||||
|
||||
- Uses the same `section-card` class as the rest of the page
|
||||
- `<ul style="list-style:none;padding-left:0;">` — removes bullet markers, each joke has an emoji prefix instead
|
||||
- `margin-bottom: 10px` on first 5 items, `margin-bottom: 0` on last to avoid excess space before `.note`
|
||||
- `<h3>` uses `<span class="highlight">(Dad-Approved)</span>` to match the amber accent throughout the page
|
||||
- `.note` callout at the bottom: **🤖 New jokes every visit** — powered by AI
|
||||
|
||||
## Placement anchor
|
||||
|
||||
Inserted between the Map View section (Section 6, ends `</div>`) and the bottom nav (`<!-- ===== NAVIGATION ===== -->`):
|
||||
|
||||
```
|
||||
</div> ← closes Map View section-card
|
||||
|
||||
<!-- Section 7: Shark Jokes -->
|
||||
<div class="section-card">...</div>
|
||||
|
||||
<!-- ===== NAVIGATION ===== -->
|
||||
<div class="bottom-nav">
|
||||
```
|
||||
|
||||
No changes to any other sections — this was a pure insertion.
|
||||
|
||||
## Content strategy
|
||||
|
||||
These are placeholders meant to be regenerated fresh each time by AI. Keep the tone: classic dad-joke format (question → pun), ocean/shark themed, family-friendly. The "(Dad-Approved)" label and the "powered by AI" note are intentional brand elements — keep both.
|
||||
|
||||
## How to add new jokes
|
||||
|
||||
1. Copy the `<li>` pattern: `<li style="padding-left:0;margin-bottom:10px;">EMOJI Q? <em>Punchline!</em></li>`
|
||||
2. Give each joke an appropriate emoji prefix related to the topic
|
||||
3. Set `margin-bottom: 0` on the last item
|
||||
4. Keep jokes within 6-8 items total — don't let the section outgrow the cards above it
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
# 🦈 Shark Attack Fantasy League — Offline Recovery Manual
|
||||
|
||||
**Author:** Sho'Nuff | **Audience:** Germaine
|
||||
**Last updated:** July 9, 2026
|
||||
**Purpose:** Recover the full shark game system without Sho'Nuff being online.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Is This? (Quick Overview)
|
||||
|
||||
The Shark Attack Fantasy League is a game where players draft shark-attack regions and score points when real shark events hit those regions. It has three parts:
|
||||
|
||||
| Part | What it does | Where it lives |
|
||||
|------|-------------|----------------|
|
||||
| **Backend** | Python web server + database | `/root/shark-game/backend/` — port 8083 |
|
||||
| **Frontend** | Web pages you see in the browser | `/var/www/shark-game/` |
|
||||
| **Scraper** | Fetches shark news and scores points | `/root/shark-game/scraper/` — runs via cron |
|
||||
|
||||
**Domains:** `shark.iamgmb.com` (the main site) and `shark.itpropartner.com` (backup URL)
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites — What You MUST Have Installed
|
||||
|
||||
If you're reading this on the server, check these are available:
|
||||
|
||||
```bash
|
||||
which sqlite3 # Should show /usr/bin/sqlite3
|
||||
which python3 # Should show /usr/bin/python3
|
||||
which aws # Should show /opt/awscli-venv/bin/aws
|
||||
which curl # Should show /usr/bin/curl
|
||||
which systemctl # Should show /usr/bin/systemctl
|
||||
which journalctl # Should show /usr/bin/journalctl
|
||||
```
|
||||
|
||||
If any of those are missing, install them:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
apt-get install -y sqlite3 curl systemd
|
||||
|
||||
# AWS CLI (if missing)
|
||||
apt-get install -y python3-pip
|
||||
pip3 install awscli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. System Architecture — What Runs Where
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Internet │
|
||||
└──────────┬──────────────────────────────┬────────────────┘
|
||||
│ │
|
||||
shark.iamgmb.com shark.itpropartner.com
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Caddy (reverse proxy) │
|
||||
│ /etc/caddy/Caddyfile │
|
||||
│ shark.iamgmb.com → 127.0.0.1:8083 │
|
||||
│ shark.itpropartner.com → static files │
|
||||
└──────────────┬───────────────────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────────────────┐
|
||||
│ FastAPI Backend (port 8083) │
|
||||
│ /root/shark-game/backend/server.py │
|
||||
│ Runs as: shark-game.service (systemd) │
|
||||
└──────────────┬───────────────────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ /root/shark-game/backend/game.db │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Frontend files** are served by Caddy from `/var/www/shark-game/`:
|
||||
- `index.html` — Main game page
|
||||
- `draft-room.html` — Draft interface
|
||||
- `league.html` — League dashboard
|
||||
- `settings.html` — User settings
|
||||
- `how-to-play.html` — Rules
|
||||
- `help.html` — Help page
|
||||
- `sw.js` — Service worker (push notifications)
|
||||
|
||||
---
|
||||
|
||||
## 4. Daily Operations — How to Check the Game
|
||||
|
||||
### 4.1 Is the game running?
|
||||
|
||||
```bash
|
||||
systemctl status shark-game
|
||||
```
|
||||
|
||||
**What to look for:**
|
||||
- **Active: active (running)** ✅ — everything is fine
|
||||
- **Active: inactive (dead)** ❌ — the service has stopped
|
||||
- **Active: failed** ❌ — something crashed
|
||||
|
||||
### 4.2 View recent logs
|
||||
|
||||
```bash
|
||||
# Last 50 lines
|
||||
journalctl -u shark-game -n 50
|
||||
|
||||
# Follow live logs (like tail -f)
|
||||
journalctl -u shark-game -n 50 -f
|
||||
|
||||
# View today's logs
|
||||
journalctl -u shark-game --since today
|
||||
|
||||
# Filter for errors
|
||||
journalctl -u shark-game -n 200 | grep -i "error\|traceback\|exception"
|
||||
```
|
||||
|
||||
### 4.3 Check the database
|
||||
|
||||
```bash
|
||||
# Open interactive SQLite shell
|
||||
sqlite3 /root/shark-game/backend/game.db
|
||||
|
||||
# Or run a query directly
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT * FROM users;"
|
||||
|
||||
# Useful queries:
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT id, email, display_name, is_admin FROM users;"
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT id, name, status, invite_code FROM leagues;"
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT * FROM scores ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
### 4.4 Check disk space
|
||||
|
||||
```bash
|
||||
df -h /root/shark-game/
|
||||
```
|
||||
|
||||
### 4.5 Restart the game
|
||||
|
||||
```bash
|
||||
systemctl restart shark-game
|
||||
```
|
||||
|
||||
Then verify it came back up:
|
||||
|
||||
```bash
|
||||
sleep 3 && systemctl status shark-game
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup & Restore
|
||||
|
||||
### 5.1 How backups work
|
||||
|
||||
The game database (`game.db`) is NOT backed up separately — it's included in the **Hermes live-sync** backup, which runs every 15 minutes and uploads to Wasabi S3.
|
||||
|
||||
### 5.2 Checking backups exist
|
||||
|
||||
```bash
|
||||
aws s3 ls s3://hermes-vps-backups/ --endpoint-url https://s3.us-east-1.wasabisys.com
|
||||
```
|
||||
|
||||
You should see folders like `live/`, `live-sync/`, `standby/`.
|
||||
|
||||
### 5.3 Restoring the database from S3
|
||||
|
||||
**Step 1:** Stop the game (so it doesn't write to the DB while restoring)
|
||||
|
||||
```bash
|
||||
systemctl stop shark-game
|
||||
```
|
||||
|
||||
**Step 2:** Download the latest state.db backup
|
||||
|
||||
```bash
|
||||
aws s3 cp s3://hermes-vps-backups/live-sync/state.db /root/shark-game/backend/game.db \
|
||||
--endpoint-url https://s3.us-east-1.wasabisys.com
|
||||
```
|
||||
|
||||
*(Note: The backup is named `state.db` on S3 but we save it locally as `game.db`)*
|
||||
|
||||
**Step 3:** Restart the game
|
||||
|
||||
```bash
|
||||
systemctl start shark-game
|
||||
```
|
||||
|
||||
### 5.4 Restoring the frontend files
|
||||
|
||||
The frontend files are in `/var/www/shark-game/`. If they get deleted, you can rebuild them from:
|
||||
|
||||
1. **If the git repo exists:** `cd /root/shark-game && git pull`
|
||||
2. **If the S3 backup has them:** Download them from the `live/` prefix and copy to `/var/www/shark-game/`
|
||||
3. **If neither exists:** Contact me — I'll need to regenerate the frontend files
|
||||
|
||||
### 5.5 Taking a manual backup (for safety)
|
||||
|
||||
```bash
|
||||
cp /root/shark-game/backend/game.db /root/shark-game/backend/game.db.backup.$(date +%Y%m%d-%H%M%S)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Common Issues & How to Fix Them
|
||||
|
||||
### 6.1 "Port 8083 already in use"
|
||||
|
||||
**Symptom:** `systemctl start shark-game` fails, or you see "Address already in use" in logs.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
fuser -k 8083/tcp
|
||||
systemctl restart shark-game
|
||||
```
|
||||
|
||||
### 6.2 "Database locked" errors
|
||||
|
||||
**Symptom:** Users see errors when trying to join leagues, make picks, or view scores. Logs show "database is locked".
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
rm -f /root/shark-game/backend/game.db.lock
|
||||
rm -f /root/shark-game/backend/game.db-shm
|
||||
rm -f /root/shark-game/backend/game.db-wal
|
||||
systemctl restart shark-game
|
||||
```
|
||||
|
||||
### 6.3 "500 Internal Server Error" when joining a league
|
||||
|
||||
**Symptom:** Users can't join leagues. You see "500" errors in the browser.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
journalctl -u shark-game -n 50 | grep -A10 "500\|Error\|Traceback"
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Missing environment variables
|
||||
- Database schema mismatch (after restoring wrong backup)
|
||||
- Invalid invite code
|
||||
|
||||
### 6.4 Push notifications not sending
|
||||
|
||||
**Symptom:** Users report they're not getting push notifications.
|
||||
|
||||
**Check 1:** Is the service worker loaded?
|
||||
```bash
|
||||
curl -I https://shark.iamgmb.com/sw.js
|
||||
```
|
||||
|
||||
**Check 2:** Are the VAPID keys set?
|
||||
```bash
|
||||
systemctl show shark-game | grep -i vapid
|
||||
```
|
||||
|
||||
**Check 3:** Test push
|
||||
```bash
|
||||
curl -X POST https://shark.iamgmb.com/api/push/test
|
||||
```
|
||||
|
||||
**Fix:** If VAPID keys are missing, restart the service:
|
||||
```bash
|
||||
systemctl restart shark-game
|
||||
```
|
||||
|
||||
### 6.5 Website shows blank page / 502 Bad Gateway
|
||||
|
||||
**Symptom:** `shark.iamgmb.com` shows a blank page or 502 error.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
systemctl status shark-game
|
||||
systemctl status caddy
|
||||
systemctl restart shark-game
|
||||
systemctl restart caddy
|
||||
```
|
||||
|
||||
### 6.6 SSL certificate expired
|
||||
|
||||
**Symptom:** Browser shows "Your connection is not private" warning.
|
||||
|
||||
**Fix:** Caddy auto-renews certificates. Restart if needed:
|
||||
```bash
|
||||
systemctl restart caddy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Scraper & Scoring
|
||||
|
||||
### 7.1 How scoring works
|
||||
|
||||
The scraper searches for shark news articles, scores them, and writes pending scores to:
|
||||
`/root/shark-game/data/pending-scores.json`
|
||||
|
||||
The backend picks up these files and awards points to players who drafted the affected regions.
|
||||
|
||||
### 7.2 Scheduled runs
|
||||
|
||||
The scraper runs automatically via system cron at **8:00 AM ET** every day:
|
||||
|
||||
```
|
||||
0 8 * * * /root/shark-game/scraper/run.sh
|
||||
```
|
||||
|
||||
There's also a **draft reminder** that runs every 15 minutes during draft season.
|
||||
|
||||
### 7.3 Manual scrape
|
||||
|
||||
If you need to run the scraper right now:
|
||||
|
||||
```bash
|
||||
cd /root/shark-game
|
||||
source .venv/bin/activate
|
||||
python3 scraper/scrape.py >> data/scraper.log 2>&1
|
||||
deactivate
|
||||
```
|
||||
|
||||
Or use the wrapper script:
|
||||
|
||||
```bash
|
||||
cd /root/shark-game && bash scraper/run.sh
|
||||
```
|
||||
|
||||
### 7.4 Check scraper results
|
||||
|
||||
```bash
|
||||
tail -50 /root/shark-game/data/scraper.log
|
||||
```
|
||||
|
||||
```bash
|
||||
cat /root/shark-game/data/pending-scores.json
|
||||
```
|
||||
|
||||
### 7.5 View the current cron jobs
|
||||
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
You should see entries for the scraper and draft reminder.
|
||||
|
||||
---
|
||||
|
||||
## 8. Push Notifications — How They Work
|
||||
|
||||
The game uses **Web Push API** (browser push notifications):
|
||||
|
||||
1. **VAPID keys** authenticate the server to send pushes
|
||||
2. **Service worker** (`/var/www/shark-game/sw.js`) receives pushes in the browser
|
||||
3. **Users** subscribe via the browser's permission prompt when they visit the site
|
||||
|
||||
**VAPID keys** are stored as environment variables and embedded in the server code at `/root/shark-game/backend/server.py`. They include:
|
||||
- `VAPID_PRIVATE_KEY`
|
||||
- `VAPID_PUBLIC_KEY`
|
||||
- `VAPID_CLAIMS` → `mailto:g@germainebrown.com`
|
||||
|
||||
**Push subscription data** is stored in the `push_subscriptions` table in the database.
|
||||
|
||||
### Push notification test
|
||||
|
||||
```bash
|
||||
curl -X POST https://shark.iamgmb.com/api/push/test
|
||||
```
|
||||
|
||||
### Check if users are subscribed
|
||||
|
||||
```bash
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT COUNT(*) FROM push_subscriptions;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Quick Command Reference
|
||||
|
||||
### Service Management
|
||||
```bash
|
||||
systemctl status shark-game # Check if running
|
||||
systemctl restart shark-game # Restart the backend
|
||||
systemctl stop shark-game # Stop the backend
|
||||
systemctl start shark-game # Start the backend
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
journalctl -u shark-game -n 100 # Last 100 log lines
|
||||
journalctl -u shark-game -n 100 -f # Live tail
|
||||
journalctl -u shark-game --since today # Today's logs
|
||||
journalctl -u shark-game -n 200 | grep -i error # Errors only
|
||||
```
|
||||
|
||||
### Database
|
||||
```bash
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT * FROM users;"
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT * FROM leagues;"
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT * FROM scores ORDER BY created_at DESC LIMIT 20;"
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT id, display_name, email FROM users WHERE is_admin=1;"
|
||||
sqlite3 /root/shark-game/backend/game.db "PRAGMA integrity_check;"
|
||||
```
|
||||
|
||||
### Scraper
|
||||
```bash
|
||||
cd /root/shark-game && bash scraper/run.sh # Force scrape now
|
||||
tail -50 /root/shark-game/data/scraper.log # View scraper log
|
||||
cat /root/shark-game/data/pending-scores.json # View pending scores
|
||||
crontab -l # List scheduled jobs
|
||||
```
|
||||
|
||||
### Backups
|
||||
```bash
|
||||
cp /root/shark-game/backend/game.db /root/shark-game/backend/game.db.backup.$(date +%Y%m%d)
|
||||
aws s3 ls s3://hermes-vps-backups/ --endpoint-url https://s3.us-east-1.wasabisys.com
|
||||
systemctl stop shark-game
|
||||
aws s3 cp s3://hermes-vps-backups/live-sync/state.db /root/shark-game/backend/game.db \
|
||||
--endpoint-url https://s3.us-east-1.wasabisys.com
|
||||
systemctl start shark-game
|
||||
```
|
||||
|
||||
### Port / Network
|
||||
```bash
|
||||
fuser -k 8083/tcp
|
||||
ss -tlnp | grep 8083
|
||||
curl -I https://shark.iamgmb.com
|
||||
curl http://127.0.0.1:8083/
|
||||
```
|
||||
|
||||
### Push Notifications
|
||||
```bash
|
||||
curl -X POST https://shark.iamgmb.com/api/push/test
|
||||
curl https://shark.iamgmb.com/api/push/vapid-public-key
|
||||
sqlite3 /root/shark-game/backend/game.db "SELECT COUNT(*) FROM push_subscriptions;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Complete Recovery Flow — If Everything Is Broken
|
||||
|
||||
Follow these steps in order if the entire game is down.
|
||||
|
||||
### Step 1: Check server is reachable
|
||||
```bash
|
||||
ping -c 2 shark.iamgmb.com
|
||||
```
|
||||
|
||||
### Step 2: Check web server (Caddy)
|
||||
```bash
|
||||
systemctl status caddy
|
||||
# If not running: systemctl restart caddy
|
||||
```
|
||||
|
||||
### Step 3: Check backend service
|
||||
```bash
|
||||
systemctl status shark-game
|
||||
# If not running: systemctl start shark-game
|
||||
```
|
||||
|
||||
### Step 4: Check port 8083
|
||||
```bash
|
||||
ss -tlnp | grep 8083
|
||||
# If nothing listening: systemctl restart shark-game
|
||||
# If wrong process: fuser -k 8083/tcp && systemctl restart shark-game
|
||||
```
|
||||
|
||||
### Step 5: Check the database
|
||||
```bash
|
||||
sqlite3 /root/shark-game/backend/game.db "PRAGMA integrity_check;"
|
||||
# Should return "ok"
|
||||
```
|
||||
|
||||
### Step 6: Check the scraper ran today
|
||||
```bash
|
||||
tail -20 /root/shark-game/data/scraper.log
|
||||
# If last run was yesterday, run it manually
|
||||
cd /root/shark-game && bash scraper/run.sh
|
||||
```
|
||||
|
||||
### Step 7: Test the site
|
||||
```bash
|
||||
curl -I https://shark.iamgmb.com
|
||||
# Should return HTTP/2 200
|
||||
```
|
||||
|
||||
### Step 8: Restore from backup (if DB is corrupted)
|
||||
```bash
|
||||
systemctl stop shark-game
|
||||
aws s3 cp s3://hermes-vps-backups/live-sync/state.db /root/shark-game/backend/game.db \
|
||||
--endpoint-url https://s3.us-east-1.wasabisys.com
|
||||
systemctl start shark-game
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. File Locations Map
|
||||
|
||||
| What | Path |
|
||||
|------|------|
|
||||
| Backend server | `/root/shark-game/backend/server.py` |
|
||||
| Database | `/root/shark-game/backend/game.db` |
|
||||
| Python virtual environment | `/root/shark-game/backend/venv/` |
|
||||
| Frontend files | `/var/www/shark-game/` |
|
||||
| Service worker (push) | `/var/www/shark-game/sw.js` |
|
||||
| Scraper script | `/root/shark-game/scraper/scrape.py` |
|
||||
| Scraper wrapper | `/root/shark-game/scraper/run.sh` |
|
||||
| Scraper dependencies | `/root/shark-game/scraper/requirements.txt` |
|
||||
| Scraper log | `/root/shark-game/data/scraper.log` |
|
||||
| Pending scores | `/root/shark-game/data/pending-scores.json` |
|
||||
| Systemd service | `/etc/systemd/system/shark-game.service` |
|
||||
| Caddy config | `/etc/caddy/Caddyfile` |
|
||||
|
||||
---
|
||||
|
||||
## 12. Database Tables Reference
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `users` | Players — email, display name, admin status, notification prefs |
|
||||
| `leagues` | Leagues — name, invite code, status (draft/active/closed), max players |
|
||||
| `league_members` | Which users belong to which leagues |
|
||||
| `league_excluded_regions` | Regions excluded from a league's draft |
|
||||
| `regions` | Shark attack regions (geo-areas players draft) |
|
||||
| `draft_picks` | Which player drafted which region in which league |
|
||||
| `scores` | Score events — what happened, how many points, which region |
|
||||
| `user_scores` | Running total scores per user |
|
||||
| `push_subscriptions` | Web push notification subscriptions |
|
||||
|
||||
---
|
||||
|
||||
## 13. Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `VAPID_PRIVATE_KEY` | Private key for Web Push notifications |
|
||||
| `VAPID_PUBLIC_KEY` | Public key for Web Push notifications |
|
||||
|
||||
To check if they're loaded:
|
||||
|
||||
```bash
|
||||
systemctl show shark-game -P Environment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Contact / Escalation
|
||||
|
||||
If you've tried everything in this manual and the game is still broken:
|
||||
|
||||
1. **Save the logs:**
|
||||
```bash
|
||||
journalctl -u shark-game -n 500 > /tmp/shark-game-logs.txt
|
||||
```
|
||||
|
||||
2. **Save the database:**
|
||||
```bash
|
||||
cp /root/shark-game/backend/game.db /root/shark-game/backend/game.db.crash
|
||||
```
|
||||
|
||||
3. **Send me the files.** I can work from logs alone if needed.
|
||||
|
||||
---
|
||||
|
||||
> **🦈 Remember:** The most common fix is simply `systemctl restart shark-game`. Try that first before doing anything complicated!
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Shark Attack Fantasy League — Trademark Research
|
||||
|
||||
Research conducted July 9, 2026. Checked USPTO, App Store, Google Play, Steam, BoardGameGeek, and general web.
|
||||
|
||||
## Names Checked
|
||||
|
||||
### "Feeding Frenzy" — ❌ NOT SAFE
|
||||
Electronic Arts owns **live USPTO Registration #2979806** (Class 9: game software, first use 2004). EA/PopCap has sold Feeding Frenzy games continuously since 2004; Feeding Frenzy 2 Deluxe is still on Steam with 97% positive ratings. **11 total USPTO filings found (5 live, 6 dead).** Live filings include:
|
||||
- EA (game software, Class 9) — active
|
||||
- Pennington (bird seed, Class 31)
|
||||
- Fish Tale (clothing, Class 25)
|
||||
- SF Bay Brand (aquarium accessories, Class 16)
|
||||
|
||||
Risk: **HIGH** — any USPTO filing would be rejected for likelihood of confusion, and EA would almost certainly oppose.
|
||||
|
||||
### "Shark Attack Fantasy League" — ✅ CLEAR
|
||||
No trademark registrations or existing games found in any database. The full phrase is distinctive enough for trademark protection.
|
||||
|
||||
Risk: **LOW**
|
||||
|
||||
### "Shark Attack League" — ✅ CLEAR
|
||||
No trademark registrations. Minor unrelated uses (esports tournament in India, golf societies) but nothing blocking a game software filing.
|
||||
|
||||
Risk: **LOW**
|
||||
|
||||
### Close names checked
|
||||
- "Shark Frenzy" — exists as bean bag toss game (Class 28) and TV show, not video game
|
||||
- "Fish Frenzy" — board game and slot machine, distinct mark
|
||||
- "Shark Attack" — amusement park ride, no trademark for video games found
|
||||
|
||||
## Recommendation
|
||||
Register "Shark Attack Fantasy League" in USPTO Class 9 (software) and Class 41 (entertainment services). A professional clearance search by a trademark attorney is advised before paying filing fees.
|
||||
|
||||
## Sources
|
||||
- USPTO: https://uspto.report/TM/2979806 (EA Feeding Frenzy)
|
||||
- Justia: https://trademarks.justia.com (search "Feeding Frenzy")
|
||||
- Steam: https://store.steampowered.com/app/3260/Feeding_Frenzy_2_Deluxe/
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Shark Game Trademark Research
|
||||
|
||||
Conducted July 9, 2026. The game was originally named "Feeding Frenzy" but that name conflicts with Electronic Arts' live trademark.
|
||||
|
||||
## "Feeding Frenzy" - CONFLICTED
|
||||
|
||||
EA/PopCap owns US Registration #2979806 (Class 9: game software) since 2005. Still active, renewed, enforced. Feeding Frenzy 2 Deluxe still on Steam with 97% positive ratings.
|
||||
|
||||
11 total USPTO filings found - 5 live, 6 dead. Live marks:
|
||||
- EA (game software, Class 9) - the blocking one
|
||||
- Pennington (bird seed, Class 31)
|
||||
- Fish Tale (clothing, Class 25)
|
||||
- SF Bay Brand (aquarium accessories, Class 16)
|
||||
|
||||
Risk: HIGH. Any USPTO filing would be rejected for likelihood of confusion.
|
||||
|
||||
## "Shark Attack Fantasy League" - CLEAR
|
||||
|
||||
No trademark registrations or existing games found in any database (USPTO, App Store, Steam, BoardGameGeek).
|
||||
|
||||
Risk: LOW. Recommended path for formal registration.
|
||||
|
||||
## "Shark Attack League" - CLEAR
|
||||
|
||||
No trademark registrations. Minor unrelated uses (esports tournaments in India, golf societies) but nothing blocking.
|
||||
|
||||
Risk: LOW.
|
||||
|
||||
## Domain Name Research
|
||||
|
||||
28 domains checked via WHOIS + DNS verification. Full list at /root/shark-game/domain-research.md.
|
||||
|
||||
### Top Picks
|
||||
|
||||
| Domain | Status | Price/yr | Why |
|
||||
|--------|--------|----------|-----|
|
||||
| sharkfantasy.com | Available | ~$10 | 12 chars, brandable |
|
||||
| sharkfantasyleague.com | Available | ~$10 | Exact game name |
|
||||
| sharkattackleague.com | Available | ~$10 | Clean, professional |
|
||||
| sharkdraft.com | Available | ~$10 | Short, action-packed |
|
||||
| jawsdraft.com | Available | ~$10 | 9 chars, iconic |
|
||||
| chumleague.com | Available | ~$10 | Wordplay (sharks + league) |
|
||||
|
||||
### Recommended Bundle
|
||||
sharkfantasy.com + sharkdraft.com = ~$20/yr. Register via Cloudflare for at-cost pricing.
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Unified Bottom Navigation (Fixed Tab Bar)
|
||||
|
||||
Added Jul 9, 2026. Every game page now has the **same** fixed bottom tab bar for consistent navigation.
|
||||
|
||||
## The tab bar (7 items, same order on every page)
|
||||
|
||||
```
|
||||
🏆 Scoring → draft-room.html
|
||||
📊 Standings → draft-room.html
|
||||
🦈 My Team → draft-room.html
|
||||
📋 League → league.html
|
||||
⚙️ Settings → settings.html
|
||||
❓ Help → help.html
|
||||
📖 Learn → how-to-play.html
|
||||
```
|
||||
|
||||
## Files with the tab bar
|
||||
|
||||
| Page | Marker | Notes |
|
||||
|------|--------|-------|
|
||||
| `index.html` | `id="bottomNav"`, class `hidden` | Hidden before login, shown via JS |
|
||||
| `draft-room.html` | End of `<body>` | Active on Scoring (first item) |
|
||||
| `league.html` | End of `<body>` | Active on League |
|
||||
| `settings.html` | End of `<body>` | Active on Settings |
|
||||
| `help.html` | End of `<body>` | Active on Help |
|
||||
| `how-to-play.html` | Replaced inline nav | Active on Learn |
|
||||
|
||||
## CSS (identical on every page)
|
||||
|
||||
```css
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
background: rgba(10, 22, 40, 0.95);
|
||||
border-top: 1px solid rgba(14, 165, 233, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 50;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.bottom-nav a {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
|
||||
.bottom-nav a .nav-icon { font-size: 18px; line-height: 1; }
|
||||
|
||||
.bottom-nav a.active {
|
||||
color: var(--amber);
|
||||
border-top-color: var(--amber);
|
||||
}
|
||||
```
|
||||
|
||||
## Body padding
|
||||
|
||||
Every page with a fixed tab bar needs `padding-bottom: 80px` on `<body>` to prevent content from being hidden behind the nav. On `index.html`, this is applied unconditionally even though the nav starts hidden — the auth form sits centered in the viewport and the extra bottom space doesn't interfere.
|
||||
|
||||
## Special case: index.html
|
||||
|
||||
- The tab bar has `class="hidden"` (display:none) in the static HTML
|
||||
- `onAuthSuccess()` calls `document.getElementById('bottomNav').classList.remove('hidden')`
|
||||
- `logout()` calls `document.getElementById('bottomNav').classList.add('hidden')`
|
||||
- This keeps the landing/auth screen clean while giving authenticated users the full tab bar
|
||||
|
||||
## Adding a new tab
|
||||
|
||||
If you add or rename a tab:
|
||||
|
||||
1. Update ALL 6 HTML files' tab bar HTML (use search_files to find all `<div class="bottom-nav">` blocks)
|
||||
2. Update the CSS if the number of items changes significantly (flex:1 handles even distribution)
|
||||
3. Update settings.html and help.html — they also have the tab bar
|
||||
4. Choose which page gets `class="active"` on each file
|
||||
|
||||
## Removing Feed tab
|
||||
|
||||
The original settings.html had a "Feed" tab (📡) pointing to draft-room.html. This was replaced by the unified 7-item bar above. The Feed tab was never implemented as a separate page — it was a placeholder.
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/bin/bash
|
||||
# shark-draft-reminder.sh — Send 24h and 1h draft reminder emails to league members
|
||||
# Runs every 15 minutes via cron
|
||||
set -euo pipefail
|
||||
|
||||
exec python3 - "$@" << 'PYTHON_SCRIPT'
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shark Draft Reminder — sends 24h and 1h email reminders to league members
|
||||
before their draft starts. Runs every 15 minutes via cron; idempotent.
|
||||
|
||||
DB: leagues table with draft_start_at (ISO 8601), status='draft'
|
||||
Tracking: /root/shark-game/data/draft-reminders.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────────
|
||||
DB_PATH = "/root/shark-game/backend/game.db"
|
||||
TRACKING_FILE = "/root/shark-game/data/draft-reminders.json"
|
||||
LOG_FILE = "/var/log/shark-reminder.log"
|
||||
PASS_FILE = "/root/.config/himalaya/shonuff.pass"
|
||||
TITLES_FILE = "/root/.hermes/references/shonuff-titles.py"
|
||||
CLOSINGS_FILE = "/root/.hermes/references/shonuff-closings.py"
|
||||
SIG_IMAGE_FILE = "/root/.hermes/references/shonuff-image-b64.txt"
|
||||
SIG_HTML_FILE = "/root/.hermes/references/shonuff-email-signature.html"
|
||||
|
||||
# ── SMTP Config ────────────────────────────────────────────────────────────
|
||||
SMTP_HOST = "mail.germainebrown.com"
|
||||
SMTP_PORT = 2525
|
||||
SMTP_USER = "shonuff@germainebrown.com"
|
||||
SMTP_FROM = "shonuff@germainebrown.com"
|
||||
SMTP_FROM_NAME = "Sho'Nuff Brown"
|
||||
BCC_ADDR = "g@germainebrown.com"
|
||||
|
||||
# ── Base URL for draft room links ─────────────────────────────────────────
|
||||
BASE_URL = "https://shark.itpropartner.com"
|
||||
|
||||
# ── Time windows (±5 minutes) ──────────────────────────────────────────────
|
||||
WINDOW_SECONDS = 5 * 60 # ±5 minutes
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("shark-draft-reminder")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_password():
|
||||
with open(PASS_FILE, "r") as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def load_closings():
|
||||
spec = spec_from_file_location("closings", CLOSINGS_FILE)
|
||||
mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return random.choice(mod.SHONUFF_CLOSINGS)
|
||||
|
||||
|
||||
def load_titles():
|
||||
spec = spec_from_file_location("titles", TITLES_FILE)
|
||||
mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return random.choice(mod.SHONUFF_TITLES)
|
||||
|
||||
|
||||
def load_signature_html(title):
|
||||
try:
|
||||
with open(SIG_IMAGE_FILE) as f:
|
||||
b64 = f.read().strip()
|
||||
with open(SIG_HTML_FILE) as f:
|
||||
sig = f.read()
|
||||
return sig.replace("%SHONUFF_IMAGE%", b64).replace("%SHONUFF_TITLE%", title)
|
||||
except FileNotFoundError:
|
||||
log.warning("Signature image/html files not found — using plain sig")
|
||||
return ""
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def load_tracking():
|
||||
if not os.path.exists(TRACKING_FILE):
|
||||
return {}
|
||||
with open(TRACKING_FILE, "r") as f:
|
||||
try:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Corrupt tracking file, resetting")
|
||||
return {}
|
||||
|
||||
|
||||
def save_tracking(tracking):
|
||||
os.makedirs(os.path.dirname(TRACKING_FILE), exist_ok=True)
|
||||
with open(TRACKING_FILE, "w") as f:
|
||||
json.dump(tracking, f, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def tracking_key(league_id, user_id, reminder_type):
|
||||
return f"league_{league_id}_user_{user_id}_{reminder_type}"
|
||||
|
||||
|
||||
def already_sent(tracking, league_id, user_id, reminder_type):
|
||||
return tracking_key(league_id, user_id, reminder_type) in tracking
|
||||
|
||||
|
||||
def mark_sent(tracking, league_id, user_id, reminder_type):
|
||||
tracking[tracking_key(league_id, user_id, reminder_type)] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def get_user_regions_preview(conn, league_id, user_id):
|
||||
rows = conn.execute(
|
||||
"""SELECT r.emoji, r.name
|
||||
FROM draft_picks dp
|
||||
JOIN regions r ON r.id = dp.region_id
|
||||
WHERE dp.league_id = ? AND dp.user_id = ?
|
||||
ORDER BY dp.round, dp.pick_number""",
|
||||
(league_id, user_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return " • No picks yet — get ready to draft!"
|
||||
return "\n".join(f" • {r['emoji']} {r['name']}" for r in rows)
|
||||
|
||||
|
||||
def build_email_body_24h(league_name, display_name, league_id, regions_preview, draft_start_at_str):
|
||||
draft_url = f"{BASE_URL}/draft-room.html?league_id={league_id}"
|
||||
dt = datetime.fromisoformat(draft_start_at_str)
|
||||
draft_time_str = dt.strftime("%A, %B %d at %I:%M %p %Z")
|
||||
return f"""Howdy {display_name},
|
||||
|
||||
🦈 SHARK ALERT — Your draft for {league_name} starts in 24 hours!
|
||||
|
||||
⏰ Draft Time: {draft_time_str}
|
||||
|
||||
Get ready to claim your regions and build the ultimate shark attack fantasy team. Here's your draft room link (bookmark it!):
|
||||
|
||||
🔗 {draft_url}
|
||||
|
||||
📋 Your Current Regions:
|
||||
{regions_preview}
|
||||
|
||||
Make sure you're logged in and ready to go when the draft starts. Each pick is timed, so don't be late — or the sharks will swim right past you!
|
||||
|
||||
Good luck, and may the biggest bites be yours."""
|
||||
|
||||
|
||||
def build_email_body_1h(league_name, display_name, league_id, draft_start_at_str):
|
||||
draft_url = f"{BASE_URL}/draft-room.html?league_id={league_id}"
|
||||
dt = datetime.fromisoformat(draft_start_at_str)
|
||||
draft_time_str = dt.strftime("%I:%M %p %Z")
|
||||
return f"""Yo {display_name},
|
||||
|
||||
🔥 The draft is T-1 HOUR away! {league_name} starts in just 60 minutes!
|
||||
|
||||
⏰ Draft Time: {draft_time_str}
|
||||
|
||||
Head to the draft room NOW and make sure everything is ready:
|
||||
|
||||
🔗 {draft_url}
|
||||
|
||||
📌 Pro tips:
|
||||
• Make sure your browser notifications are enabled
|
||||
• Have your region research ready
|
||||
• Don't step away — your picks are on a timer!
|
||||
|
||||
This is it — time to make your picks and show everyone who the real apex predator is.
|
||||
|
||||
Let's go!"""
|
||||
|
||||
|
||||
def send_email(to_addr, subject, body, closing, title):
|
||||
sig_html = load_signature_html(title)
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>"
|
||||
msg["To"] = to_addr
|
||||
msg["Bcc"] = BCC_ADDR
|
||||
msg["Subject"] = subject
|
||||
|
||||
plain_text = f"{body}\n\n{closing}\n\n-- \nSho'Nuff Brown\n{title}\nIT Pro Partner\n{SMTP_FROM}"
|
||||
msg.attach(MIMEText(plain_text, "plain"))
|
||||
|
||||
html_body = body.replace("\n", "<br>\n")
|
||||
if sig_html:
|
||||
html_content = f"<p>{html_body}</p><p><em>{closing}</em></p>{sig_html}"
|
||||
else:
|
||||
html_content = f"<p>{html_body}</p><p><em>{closing}</em></p><p>--<br>Sho'Nuff Brown<br>{title}<br>IT Pro Partner</p>"
|
||||
msg.attach(MIMEText(html_content, "html"))
|
||||
|
||||
password = load_password()
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as s:
|
||||
s.starttls()
|
||||
s.login(SMTP_USER, password)
|
||||
s.send_message(msg)
|
||||
log.info("Email sent to %s — subject: %s", to_addr, subject)
|
||||
return True
|
||||
except smtplib.SMTPException as e:
|
||||
log.error("SMTP error sending to %s: %s", to_addr, e)
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error("Unexpected error sending to %s: %s", to_addr, e)
|
||||
return False
|
||||
|
||||
|
||||
def process_draft_reminders():
|
||||
now = datetime.now(timezone.utc)
|
||||
tracking = load_tracking()
|
||||
conn = get_db()
|
||||
|
||||
try:
|
||||
leagues = conn.execute(
|
||||
"SELECT id, name, draft_start_at, status FROM leagues WHERE status = 'draft' AND draft_start_at IS NOT NULL"
|
||||
).fetchall()
|
||||
|
||||
if not leagues:
|
||||
log.info("No leagues with upcoming drafts found")
|
||||
return
|
||||
|
||||
log.info("Found %d league(s) with scheduled drafts", len(leagues))
|
||||
|
||||
for league in leagues:
|
||||
league_id = league["id"]
|
||||
league_name = league["name"]
|
||||
draft_start_str = league["draft_start_at"]
|
||||
|
||||
try:
|
||||
draft_start = datetime.fromisoformat(draft_start_str)
|
||||
if draft_start.tzinfo is None:
|
||||
draft_start = draft_start.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError) as e:
|
||||
log.warning("Invalid draft_start_at for league %d (%s): %s", league_id, league_name, e)
|
||||
continue
|
||||
|
||||
time_until = (draft_start - now).total_seconds()
|
||||
|
||||
members = conn.execute(
|
||||
"""SELECT u.id, u.email, u.display_name
|
||||
FROM league_members lm
|
||||
JOIN users u ON u.id = lm.user_id
|
||||
WHERE lm.league_id = ?
|
||||
ORDER BY u.display_name""",
|
||||
(league_id,),
|
||||
).fetchall()
|
||||
|
||||
if not members:
|
||||
log.info("No members in league %d (%s)", league_id, league_name)
|
||||
continue
|
||||
|
||||
for member in members:
|
||||
user_id = member["id"]
|
||||
email = member["email"]
|
||||
display_name = member["display_name"]
|
||||
|
||||
if abs(time_until - 24 * 3600) <= WINDOW_SECONDS:
|
||||
if already_sent(tracking, league_id, user_id, "24h"):
|
||||
log.debug("24h reminder already sent for %s in league %d", display_name, league_id)
|
||||
else:
|
||||
regions = get_user_regions_preview(conn, league_id, user_id)
|
||||
body = build_email_body_24h(league_name, display_name, league_id, regions, draft_start_str)
|
||||
closing = load_closings()
|
||||
title = load_titles()
|
||||
subject = f"🦈 [{league_name}] — Draft Starts in 24 Hours!"
|
||||
if send_email(email, subject, body, closing, title):
|
||||
mark_sent(tracking, league_id, user_id, "24h")
|
||||
save_tracking(tracking)
|
||||
log.info("Sent 24h reminder to %s (%s) for league '%s'", display_name, email, league_name)
|
||||
|
||||
elif abs(time_until - 3600) <= WINDOW_SECONDS:
|
||||
if already_sent(tracking, league_id, user_id, "1h"):
|
||||
log.debug("1h reminder already sent for %s in league %d", display_name, league_id)
|
||||
else:
|
||||
body = build_email_body_1h(league_name, display_name, league_id, draft_start_str)
|
||||
closing = load_closings()
|
||||
title = load_titles()
|
||||
subject = f"🦈 [{league_name}] — Draft Starts in 1 Hour!"
|
||||
if send_email(email, subject, body, closing, title):
|
||||
mark_sent(tracking, league_id, user_id, "1h")
|
||||
save_tracking(tracking)
|
||||
log.info("Sent 1h reminder to %s (%s) for league '%s'", display_name, email, league_name)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log.info("=== Shark Draft Reminder run started ===")
|
||||
try:
|
||||
process_draft_reminders()
|
||||
except Exception as e:
|
||||
log.exception("Unhandled error: %s", e)
|
||||
sys.exit(1)
|
||||
log.info("=== Shark Draft Reminder run complete ===")
|
||||
PYTHON_SCRIPT
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ad-hoc verification: quiet hours timezone logic in send_push_notification().
|
||||
|
||||
Run standalone — no DB, no push subscriptions, no browser needed.
|
||||
All 18 boundary/edge-case assertions must pass.
|
||||
|
||||
Usage: python3 verify-quiet-hours.py
|
||||
"""
|
||||
import sys
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def is_in_quiet_hours(start: str, end: str, now_str: str | None = None) -> bool:
|
||||
"""Replicates the exact logic from send_push_notification() in server.py."""
|
||||
if now_str:
|
||||
h, m = map(int, now_str.split(":"))
|
||||
now = datetime(2024, 1, 1, h, m, tzinfo=ZoneInfo("America/New_York"))
|
||||
else:
|
||||
now = datetime.now(ZoneInfo("America/New_York"))
|
||||
|
||||
s = start or "21:00"
|
||||
e = end or "06:00"
|
||||
try:
|
||||
sh, sm = map(int, s.split(":"))
|
||||
eh, em = map(int, e.split(":"))
|
||||
start_min = sh * 60 + sm
|
||||
end_min = eh * 60 + em
|
||||
now_min = now.hour * 60 + now.minute
|
||||
|
||||
if start_min > end_min: # midnight crossing
|
||||
return now_min >= start_min or now_min < end_min
|
||||
else: # same day
|
||||
return start_min <= now_min < end_min
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---- Midnight-crossing default (21:00 → 06:00) ----
|
||||
assert is_in_quiet_hours("21:00", "06:00", "22:00") is True
|
||||
assert is_in_quiet_hours("21:00", "06:00", "03:00") is True
|
||||
assert is_in_quiet_hours("21:00", "06:00", "05:59") is True
|
||||
assert is_in_quiet_hours("21:00", "06:00", "06:00") is False # boundary
|
||||
assert is_in_quiet_hours("21:00", "06:00", "06:01") is False
|
||||
assert is_in_quiet_hours("21:00", "06:00", "12:00") is False
|
||||
assert is_in_quiet_hours("21:00", "06:00", "20:59") is False
|
||||
assert is_in_quiet_hours("21:00", "06:00", "21:00") is True # boundary
|
||||
|
||||
# ---- Same-day window ----
|
||||
assert is_in_quiet_hours("14:00", "16:00", "14:00") is True
|
||||
assert is_in_quiet_hours("14:00", "16:00", "15:00") is True
|
||||
assert is_in_quiet_hours("14:00", "16:00", "16:00") is False # boundary
|
||||
assert is_in_quiet_hours("14:00", "16:00", "13:59") is False
|
||||
|
||||
# ---- Edge cases ----
|
||||
assert is_in_quiet_hours("00:00", "00:00", "00:00") is False # zero-length
|
||||
assert is_in_quiet_hours("23:59", "00:01", "23:59") is True # 1-min midnight
|
||||
assert is_in_quiet_hours("23:59", "00:01", "00:00") is True
|
||||
assert is_in_quiet_hours("23:59", "00:01", "00:02") is False
|
||||
|
||||
# Confirm ZoneInfo resolves
|
||||
assert ZoneInfo("America/New_York") is not None
|
||||
|
||||
print("✅ ALL 18 QUIET HOURS VERIFICATION CHECKS PASSED (ET timezone)")
|
||||
Reference in New Issue
Block a user