Initial skills documentation — 25 categories, all SKILL.md + references + scripts

This commit is contained in:
root
2026-07-15 17:42:20 -04:00
commit 1b4fcd4427
976 changed files with 188344 additions and 0 deletions
@@ -0,0 +1,98 @@
# Fantasy Game Core Patterns — Backend (FastAPI + SQLite)
## Database schema
### users
- id (int PK), email (unique), phone, display_name, password_hash, created_at
### leagues
- id (int PK), name, invite_code (unique 8-char), status (draft/active/closed), season_start (date), season_end (date/null), created_by (FK users), created_at
### league_members
- id (int PK), league_id (FK), user_id (FK), draft_order (int), is_commissioner (bool)
- Unique: (league_id, user_id)
### regions
- id (int PK), name, emoji, description, prev_year_sightings (int), prev_year_bites (int), prev_year_fatalities (int)
### draft_picks
- id (int PK), league_id (FK), user_id (FK), region_id (FK), round (int), pick_number (int), created_at
- Unique: (league_id, region_id) — no duplicates
### scores
- id (int PK), region_id (FK), event_type (sighting/bite/fatality), description, source_url, points (int: 2/5/10), event_date (date), verified (bool default false), created_at
### user_scores (materialized)
- user_id (FK), league_id (FK), total_points (int)
### league_excluded_regions
- id (int PK), league_id (FK), region_id (FK)
- Unique: (league_id, region_id) — a region can only be excluded once per league
- Used when 32 % member_count > 0 at draft start: lowest-performing regions are removed so remaining pool is evenly divisible
## Points mapping
sighting=2, bite=5, fatality=10
## Snake draft implementation
```python
members = sorted(league_members, key=lambda m: m['draft_order'])
for round_num in range(1, total_rounds + 1):
if round_num % 2 == 0:
order = reversed(members) # Reverse order on even rounds
else:
order = members
for member in order:
# member picks
```
## Turn enforcement
```python
current_pick_count = len(draft_picks)
current_turn_order = (current_pick_count % member_count) + 1
current_picker = member with draft_order == current_turn_order
```
## Score recalculation (on event)
```python
INSERT OR REPLACE INTO user_scores (user_id, league_id, total_points)
SELECT dp.user_id, dp.league_id, COALESCE(SUM(s.points), 0)
FROM draft_picks dp
LEFT JOIN scores s ON s.region_id = dp.region_id AND s.verified = 1
WHERE dp.league_id = ?
GROUP BY dp.user_id
```
## Excluded region filtering
When querying available regions for the draft board, filter against BOTH draft_picks AND league_excluded_regions:
```python
excluded_rows = conn.execute(
"SELECT region_id FROM league_excluded_regions WHERE league_id = ?", (league_id,)
).fetchall()
excluded_ids = {r["region_id"] for r in excluded_rows}
forbidden = drafted_ids | excluded_ids
```
## Exclusion pick rejection
```python
excluded = conn.execute(
"SELECT id FROM league_excluded_regions WHERE league_id = ? AND region_id = ?",
(league_id, req.region_id),
).fetchone()
if excluded:
raise HTTPException(status_code=400, detail="This region has been removed from the draft pool")
```
## Performance score for exclusion ordering
```sql
ORDER BY (prev_year_sightings + prev_year_bites * 5 + prev_year_fatalities * 10) ASC, id ASC
```
Lower scores = worse performing regions = first to be removed.
## League invite code generation
```python
def generate_invite_code():
return secrets.token_hex(4).upper()[:8]
```
## Region seeding
Use `INSERT OR IGNORE` so repeated runs don't error. Seed at least 20+ regions for meaningful draft depth.
@@ -0,0 +1,83 @@
# PWA Frontend Integration for Fantasy Draft Games
## Auth flow (index.html)
1. **Dual-mode form:** Registration + Login toggled by `isLoginMode` boolean
2. **On success:** Store token in `localStorage.getItem('shark_token')`, user in `localStorage.setItem('shark_user')`
3. **Post-auth:** `GET /api/leagues` — branch by count:
- 0 leagues → show create/join UI
- 1 league → auto-redirect `draft-room.html?league_id=X`
- Multiple → show league picker
4. **Auth guard** on every protected page: `if (!getToken()) window.location.href = 'index.html'`
5. **Register response:** `{user_id, token, display_name}` — backend must pass `display_name` from request
## League management (index.html)
- **Create:** `POST /api/leagues {name}` → returns `{id, invite_code}`
- **Join:** `POST /api/leagues/join {invite_code}` → returns `{league_id, league_name}`
- **Redirect after create/join:** `window.location.href = 'draft-room.html?league_id=' + result.id`
## Draft room (draft-room.html)
### League picker overlay
- GET `/api/leagues` on load
- If no `league_id` URL param, show overlay with league cards
- On select: `history.replaceState(null, '', '?league_id=' + id)` + `loadAll()`
### Data loading
```javascript
async function loadAll() {
const [league, board, scores, regions] = await Promise.all([
apiCall('/api/leagues/' + leagueId),
apiCall('/api/leagues/' + leagueId + '/draft'),
apiCall('/api/leagues/' + leagueId + '/scores'),
apiCall('/api/regions'),
]);
renderAll();
}
```
### Draft board tabs
- **Draft tab:** Region cards grid + status bar (round/turn info)
- **Team tab:** User's drafted regions + cumulative points
- **Leaderboard tab:** Ranked user list with scores
- **League info tab:** Invite code (copyable), member list, start draft (commissioner only)
### Card states
- Available: teal border, "DRAFT" button
- Your pick: amber glow border + "⭐ YOUR PICK"
- Taken: grayed out, "Drafted by [player]"
- WAITING (draft not started): dimmed, "WAITING" label
### Pitfalls
- **`league_id` from URL vs API:** After creating a league, the API returns `result.id`, not `result.league_id`. Use `result.id` in redirect URL.
- **`.hidden` CSS class:** Every standalone page MUST define `.hidden { display: none !important; }` in its own `<style>` block. Sibling pages don't inherit it.
- **Overlay staying visible:** Missing `.hidden` class causes the league picker overlay to remain on screen after selecting a league — the JS adds the class but the browser has no matching CSS rule.
- **API calls that work from curl** may fail from the browser if CORS headers are missing on the backend. FastAPI's `CORSMiddleware` with `allow_origins=["*"]` fixes this during development.
- **Token expiry:** JWT tokens set for 30 days. The frontend doesn't handle token refresh — if the token expires, the user gets "Invalid token" error and must re-login.
## HTML table templates (score charts, stats)
When rendering leaderboard/team data as HTML inside JS template literals, use inline `<table>` elements matching the game's dark theme:
```javascript
function renderLeaderboard(data) {
return `<table style="width:100%;border-collapse:collapse;font-size:13px;">
<tr style="background:rgba(15,23,42,0.8);">
<th style="padding:8px;text-align:left;font-weight:600;">Player</th>
<th style="padding:8px;text-align:center;font-weight:600;">Points</th>
</tr>
${data.map(p => `<tr>
<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);">${p.display_name}</td>
<td style="padding:8px 10px;text-align:center;border-bottom:1px solid rgba(255,255,255,0.05);color:var(--amber);">${p.total_points}</td>
</tr>`).join('')}
</table>`;
}
```
## Common pitfalls
- **Registration displays name as null** — backend must include `display_name=req.display_name` in AuthResponse (fix at `/api/auth/register` endpoint)
- **Mobile Safari caching** — after updating frontend files, users may see cached versions. Hard refresh or clear Safari cache is needed.
- **League creation redirect** — API returns `id`, not `league_id`. Use `result.id`.
- **API call errors** — Use try/catch + user-facing error messages. The `apiCall()` helper throws on non-ok status codes.
@@ -0,0 +1,222 @@
# FastAPI Backend Patterns — Auth, Leagues, Snake Draft, Scoring
Built Jul 8, 2026 for the Shark Attack Fantasy Game. Monolithic `server.py` (no routers/blueprints), SQLite, bcrypt+JWT, league system with snake draft, and event-based scoring with auto-recalculation.
## Architecture Pattern
A single-file FastAPI app (`server.py`) containing everything:
```
server.py ← All models, routes, DB setup, auth, helpers
seed.py ← Standalone region/schema seeder (optional, built-in init also works)
requirements.txt ← fastapi, uvicorn, bcrypt, pyjwt
venv/ ← Python virtualenv
game.db ← SQLite database (created on first startup)
```
**Rationale for single-file:** For services with < 20 endpoints, a monolithic file avoids import overhead, is easier to scroll/debug, and can be split later when it crosses ~800 lines.
## Database Pattern (SQLite + sqlite3)
Raw `sqlite3` — no ORM. Schema created via `executescript()` at startup:
```python
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
```
**Key choices:**
- `sqlite3.Row` row factory — dict-compatible access, `dict(row)` for serialization
- WAL mode for concurrent reads
- Foreign keys enforced at DB level
- `executescript()` for multi-statement DDL at init
- New `get_db()` connection per request (cheap with SQLite + WAL)
- Always `conn.close()` after each request in sync handlers
## Auth Pattern (bcrypt + PyJWT)
```python
import bcrypt, jwt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
def create_token(user_id: int) -> str:
payload = {
"user_id": user_id,
"exp": datetime.utcnow() + timedelta(days=30),
"iat": datetime.utcnow(),
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
async def get_current_user(authorization: Optional[str] = Header(None)) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
token = authorization.split(" ", 1)[1]
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
user = get_db().execute("SELECT id, email, display_name FROM users WHERE id = ?", (payload["user_id"],)).fetchone()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return dict(user)
```
**Pattern notes:**
- `Depends(get_current_user)` on any protected route — returns a `dict` of db row
- Token expiry set at 30 days, tracked as `exp` claim
- JWT_SECRET should come from env var; dev fallback is fine
- No session refresh endpoint (can add later if needed)
## League + Membership Pattern (join tables)
Three tables for the league system:
```sql
leagues (id, name, invite_code, status, season_start, season_end, created_by, created_at)
league_members (id, league_id, user_id, draft_order, is_commissioner)
draft_picks (id, league_id, user_id, region_id, round, pick_number)
```
**Invite code generation:**
```python
import secrets
def generate_invite_code() -> str:
return secrets.token_hex(4).upper() # 8-char hex, retry on collision
```
**Commissioner check pattern:**
```python
def get_commissioner_membership(conn, league_id, user_id):
member = conn.execute(
"SELECT * FROM league_members WHERE league_id = ? AND user_id = ? AND is_commissioner = 1",
(league_id, user_id),
).fetchone()
if not member:
raise HTTPException(status_code=403, detail="Only the commissioner can perform this action")
```
## Snake Draft Logic
```python
def get_current_round_and_pick(conn, league_id, member_count):
picks_made = conn.execute(
"SELECT COUNT(*) FROM draft_picks WHERE league_id = ?", (league_id,)
).fetchone()[0]
if picks_made >= member_count * 12: # max rounds
return 13, None, True # draft complete
current_round = (picks_made // member_count) + 1
pick_in_round = picks_made % member_count
members = conn.execute(
"""SELECT lm.user_id, u.display_name
FROM league_members lm JOIN users u ON u.id = lm.user_id
WHERE lm.league_id = ?
ORDER BY lm.draft_order ASC""",
(league_id,),
).fetchall()
if current_round % 2 == 1:
next_user = members[pick_in_round] # forward order (odd rounds)
else:
next_user = members[member_count - 1 - pick_in_round] # reverse (even rounds)
return current_round, dict(next_user) if next_user else None, False
```
**Snake reversal:** Odd rounds = natural `draft_order ASC`, even rounds = iterate backwards. Member count must be known to compute the reverse index.
**Turn enforcement:** Before allowing a pick, compare `next_pick["user_id"]` against the requesting user's ID. Return 403 if mismatch.
**Duplicate protection:** `UNIQUE(league_id, region_id)` constraint on `draft_picks` table, with a 409 check before insertion.
**Draft status flow:** `draft → active → closed`. Commissioner-only start transition. Draft start requires ≥2 members.
## Score Event System
Events table plus materialized user scores:
```sql
scores (id, region_id, event_type, description, source_url, points, event_date, verified, created_at)
user_scores (user_id, league_id, total_points) -- PRIMARY KEY (user_id, league_id)
```
**Point values:** sighting=2 (changed from 1), bite=5, fatality=10
**Auto-recalculation:** Every time a score event is ingested, recalculate for all leagues that drafted that region:
```python
def recalculate_user_scores(conn, league_id):
conn.execute("DELETE FROM user_scores WHERE league_id = ?", (league_id,))
users = conn.execute("SELECT DISTINCT user_id FROM draft_picks WHERE league_id = ?", (league_id,)).fetchall()
for user in users:
total = conn.execute(
"""SELECT COALESCE(SUM(s.points), 0)
FROM scores s JOIN draft_picks dp ON s.region_id = dp.region_id
WHERE dp.league_id = ? AND dp.user_id = ?""",
(league_id, user["user_id"]),
).fetchone()[0]
conn.execute("INSERT INTO user_scores (...) VALUES (?, ?, ?)", (user_id, league_id, total))
```
**Important:** Recalculation happens on every score ingestion for every affected league. With small datasets (dozens of users, hundreds of events) this is fine — the full table DELETE + INSERT loop is sub-100ms. For larger scale, switch to incremental updates or a background task.
## Verification / Testing Pattern
Standalone test scripts (no pytest) are preferred for fast iteration:
```python
def api(method, path, data=None, token=None):
url = f"{BASE}{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
if token: req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
return {"error": e.code, "detail": json.loads(e.read())}
```
This avoids pytest dependency and works with any Python. The `error` / `detail` pattern in the return value mirrors the FastAPI error shape and lets checks be uniform: `check(result.get("error") == 403, "non-commissioner blocked")`.
## Startup Pattern
```python
@app.on_event("startup")
def on_startup():
init_db()
seed_regions()
```
Deprecated in newer FastAPI but works fine. Use `lifespan` context manager for new projects:
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
seed_regions()
yield
app = FastAPI(lifespan=lifespan)
```
## Pitfalls
- **`sqlite3` doesn't autocommit DML** — always call `conn.commit()` after INSERT/UPDATE/DELETE.
- **`PRAGMA foreign_keys=ON` must be set per connection**, not just at schema creation.
- **JWT secret in code is a dev pattern** — always override via `JWT_SECRET` env var in production.
- **Auto-recalculation costs scale with number of leagues × drafted regions** — for 100+ simultaneous leagues, move to an event queue + batch recalculation.
- **FastAPI sync vs async** — all handlers above use sync DB calls. FastAPI runs sync `def` handlers in a thread pool, which is fine for SQLite. Use `async def` only when the endpoint does IO (HTTP calls, SSE streaming).
- **`sqlite3.Row` objects are not JSON-serializable** — call `dict(row)` before returning.
- **`Pydantic v1 vs v2`** — modern FastAPI uses Pydantic v2. Use `BaseModel` from `pydantic` (not `pydantic.v1`). String `Field(min_length=...)` works in both.
@@ -0,0 +1,54 @@
# Hermes Assistant — admin-ai SSE Proxy Reference
Deployed Jul 8, 2026. FastAPI SSE proxy that streams tokens from admin-ai.itpropartner.com (OpenAI-compatible API) to a PWA frontend.
## Architecture
```
Browser (PWA) → Caddy (app.itpropartner.com) → FastAPI (:8082) → admin-ai.itpropartner.com/v1
```
- Frontend: `/var/www/assistant/` — served directly by Caddy
- API: reverse-proxied to `127.0.0.1:8082`
- Backend: `/root/hermes-assistant/server.py` — FastAPI app
- Service: `hermes-assistant.service` — systemd-managed
- Sessions: JSON files in `/root/hermes-assistant/sessions/`
## API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/health` | Health check |
| GET | `/api/sessions` | List recent sessions |
| DELETE | `/api/sessions/{id}` | Delete session |
| POST | `/api/chat` | SSE streaming chat |
## Configuration
**Environment variables (in systemd unit):**
- `ADMIN_AI_API_KEY` — OpenAI-compatible API key
- `ADMIN_AI_BASE_URL``https://admin-ai.itpropartner.com/v1`
- `HERMES_MODEL``deepseek-chat`
- `PORT``8082`
**Caddyfile mapping:**
```
@api path /api/*
handle @api { reverse_proxy 127.0.0.1:8082 }
handle { root * /var/www/assistant; try_files {path} /index.html; file_server }
```
## Code locations
- Backend: `/root/hermes-assistant/server.py`
- Frontend: `/var/www/assistant/index.html`
- PWA manifest: `/var/www/assistant/manifest.json`
- Service worker: `/var/www/assistant/sw.js`
- Icon: `/var/www/assistant/icon.svg`
- Systemd unit: `/etc/systemd/system/hermes-assistant.service`
- Caddy config: `/etc/caddy/Caddyfile`
- Sessions: `/root/hermes-assistant/sessions/`
## Session persistence
Sessions are stored as JSON arrays of messages in `/root/hermes-assistant/sessions/<session_id>.json`. The backend appends each user + assistant response, so the session grows unbounded. For production, add a session rotation or truncation policy (e.g. keep last 50 messages).
@@ -0,0 +1,54 @@
# IMAP-to-IMAP Email Migration
Migrate all emails from one provider to another for a domain. Used for moving boy's emails (greyson@, garrison@) from SiteGround to MXroute.
## Process
1. **DNS check** — Verify MX records point to the destination provider:
```bash
dig +short MX iamgmb.com
```
2. **Verify old account access**:
```bash
python3 -c "
import imaplib, ssl
ctx = ssl.create_default_context()
s = imaplib.IMAP4_SSL('old_host', 993, ssl_context=ctx, timeout=10)
s.login('email@domain.com', 'password')
r = s.select('INBOX')
print(f'{r[1][0].decode()} messages in INBOX')
s.logout()
"
```
3. **List all folders** — SiteGround uses `INBOX.` prefix for subfolders; MXroute uses flat top-level folder names.
4. **Migrate each folder**:
- Map old folder names to new folder names (e.g. `INBOX.Deleted Messages` → `Trash`)
- Create destination folders on new server
- Copy messages preserving dates via IMAP APPEND
5. **Verify** — Count messages on new server match old server per folder.
## Folder Mapping (SiteGround → MXroute)
| SiteGround | MXroute |
|-----------|---------|
| INBOX | INBOX |
| INBOX.Trash | Trash |
| INBOX.Deleted Messages | Trash |
| INBOX.Sent | Sent |
| INBOX.Sent Messages | Sent |
| INBOX.Drafts | Drafts |
| INBOX.spam | INBOX.spam |
| INBOX.Junk | Junk |
| INBOX.Archive | Archive |
## Pitfalls
- **Folder listing format differs** — SiteGround uses `INBOX.` subfolder notation and returns `"."` entries from LIST (ignore those). MXroute returns flat format `(flags) "." folder_name`.
- **IMAP responses are bytes** — Decode with `decode()`, not `.decode('ascii')`. UTF-8 is safe.
- **Rate limits** — If migrating many messages (300+ per folder), batch in groups of 50 and add `time.sleep(0.5)` between batches.
- **Deduplication** — Messages ARE appended. If the script runs twice, duplicates happen. Track by `(folder, UID)` composite key.
- **Password visibility** — SMTP/IMAP passwords are passed on the command line in heredocs. Use `ps aux | grep` awareness. For production, read from a file.
@@ -0,0 +1,38 @@
# Leaflet Map Integration for PWA Draft Games
## When to add a map view
When a fantasy game's draft room needs to show geographic regions, a map view is more intuitive than a card grid. Use Leaflet.js + OpenStreetMap tiles for free, no-API-key map rendering.
## CDN
```html
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
```
## Implementation pattern
1. **Map container:** Single `<div id="leafletMap">` in the HTML
2. **On view switch:** Destroy old instance (`window._leafletMap.remove()`), create new `L.map()` centered on `[20, 0]` with zoom 2, minZoom 2, maxZoom 5
3. **Tiles:** `L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')`
4. **Markers:** `L.circleMarker([lat, lng], {radius, color, fillColor, fillOpacity})` for each region
5. **Popups:** `marker.bindPopup(content)` with region stats, name, and action button
6. **Color coding:** Available=teal/#0ea5e9, Your pick=amber/#f59e0b, Taken=red/#dc2626, Waiting=gray
7. **Toggle view:** Map | List toggle button that calls `renderMapView()` or shows card grid
## Pitfalls
- **Destroy before recreating:** Always remove the previous Leaflet instance (`window._leafletMap.remove()`) before initializing a new one. Multiple instances on the same div cause visual glitches.
- **Coordinate format:** Leaflet uses `[lat, lng]` in that order (not `[lng, lat]` like GeoJSON).
- **Dark theme:** Override `leaflet-container`, `.leaflet-popup-content-wrapper`, and `.leaflet-control-zoom` CSS to match dark theme.
- **Popup content:** Leaflet's `bindPopup()` accepts raw HTML — use it for region stats, action buttons, etc. Don't use custom overlay modals for map popups.
- **Mobile responsiveness:** Leaflet handles viewport resizing automatically, but set `height: 500px` on the container and wrap in a responsive parent.
- **Prevent scrolling off-world:** Without bounds locking, users can pan left/right until all markers disappear. Fix with:
```javascript
const map = L.map('leafletMap', {
maxBounds: [[-60, -180], [80, 180]],
maxBoundsViscosity: 0.8, // 0 = hard wall, 1 = bounce
});
```
Viscosity of 0.8 lets it feel natural (not a hard wall) while keeping markers in view.
@@ -0,0 +1,70 @@
# Ops Dashboard Pattern
## When to use
Build an infrastructure operations dashboard when you need a single pane of glass showing cron health, service status, backup freshness, API reachability, server inventory, and system resource usage -- all updating automatically.
## Architecture
```
Python data collector (every 5m via no_agent cron)
|
v writes JSON to
/var/www/<app>/data/ops-status.json
|
v served by Caddy
<domain>/data/ops-status.json
|
v fetched by
index.html (dark theme dashboard)
|
v auto-refresh every 30s
dashboard stays live
```
## Key pieces
### 1. Data collector script
- Self-contained (stdlib only: subprocess, urllib, json, pathlib, os, datetime, socket)
- Each data source wrapped in safe() -- one failure never crashes the whole cycle
- Sources: Hermes cron jobs, systemd services, disk/memory, S3 backups (aws CLI), API health checks, versions, Hetzner server list, netcup server info
- Output: /var/www/ops/data/ops-status.json (~6KB JSON)
- Error log: /var/log/ops-collector.log
- Runs via cron: --schedule "*/5 * * * *" --no_agent --script ops-data-collector.py
### 2. Dashboard HTML
- Dark theme: bg #0f172a, cards #1e293b, amber accents #f59e0b
- NO external dependencies (no Tailwind, no CDN, no frameworks) -- inline CSS + JS
- Mobile-first responsive, PWA meta tags for iPhone Safari
- Sections: Overall Health Bar, Scheduled Jobs, Services, S3 Backups, API Health, Server Inventory, Routers (placeholder), Versions
- Fetch /data/ops-status.json on load, auto-refresh every 30s
- If fetch fails: red banner "Dashboard data not available -- collector may be offline"
### 3. Caddy serving
```
ops.itpropartner.com {
root * /var/www/ops
encode gzip
file_server
log { output file /var/log/caddy/ops.log }
}
```
### 4. CORS for the data file
```
header /data/* {
Access-Control-Allow-Origin "*"
}
```
## Pitfalls
- S3 auth requires endpoint-url for Wasabi: aws s3 ls --recursive --endpoint-url https://s3.us-east-1.wasabisys.com
- Datetime timezone mismatch: S3 ls returns naive datetimes. The collector must .replace(tzinfo=timezone.utc) before subtracting.
- Python 3.13 deprecation: datetime.utcnow() is deprecated. Use datetime.now(timezone.utc) instead.
- AWS CLI path: must activate /opt/awscli-venv/bin/activate before calling aws commands
- Placeholder sections: Show amber cards with "Coming soon" for empty sections
@@ -0,0 +1,16 @@
Shark Game skill updates from this session (2026-07-08):
## New learning captured in existing skills
### delegation-pattern (patched)
- Model chain updated: Opus 4.8 is now PRIMARY (was 4.7)
### python-web-service-deployment
- Added: `references/shark-incident-10yr-reference.md` — ISAF-sourced 10-year data
- Existing `references/shark-game-backend-pattern.md` already covers the scoring system
- The `leaflet-map-integration.md` reference already covers the map integration
### What was NOT captured as a new skill (correctly)
- The fish info popup and toggle views are detailed enough in the existing draft-room.html
- AI scraper pattern is already in the skill tree under python-web-service-deployment references
- The sign-up display_name fix, badge CSS fix, and DB seeding patterns are documented as pitfalls in the deployment skill
@@ -0,0 +1,36 @@
# Shark Attack Fantasy Game — Technical Reference
## Game Overview
Fantasy league where players draft coastal regions and score points from real shark activity (sightings, bites, fatalities) reported globally. AI scraper searches for incidents daily.
## Architecture
- **Frontend:** Mobile-first PWA at `/var/www/shark-game/` (index.html, draft-room.html, league.html, how-to-play.html)
- **Backend:** FastAPI at `/root/shark-game/backend/server.py` on port 8083
- **Database:** SQLite at `/root/shark-game/backend/game.db`
- **AI Scraper:** Firecrawl + admin-ai classification pipeline at `/root/shark-game/scraper/scrape.py`
- **Domain:** `shark.iamgmb.com` (Cloudflare A record → 152.53.192.33)
- **Proxy:** Caddy reverse_proxy `/api/*` → 127.0.0.1:8083, static files from /var/www/shark-game/
## Scoring
| Event | Points |
|-------|--------|
| Sighting | 2 |
| Bite | 5 |
| Fatality | 10 |
## Regions
32 coastal regions with real 10-year ISAF (International Shark Attack File) data. Data source: `/root/shark-game/references/shark-incident-data-10yr.md`. Database pre-populates on startup.
## Draft Balancing
When draft starts with N players, the lowest-performing regions are removed so 32 % N = 0. Removed regions show as "🚫 Removed" on map and card grid.
## Map View
Uses Leaflet.js (CDN) with OpenStreetMap tiles. Dark-themed controls. Region markers are color-coded: teal=available, amber=your pick, red=taken, gray=excluded.
## API Endpoints
All under `/api/`:
- Auth: register, login, me
- Leagues: create, join, list, detail, settings
- Draft: board, pick, start
- Scores: leaderboard, daily, events
- Regions: list, events
@@ -0,0 +1,29 @@
# Shark Game Backend — FastAPI + SQLite + Auth + Draft + Scoring
## Database tables
- users (id, email, phone, display_name, password_hash, created_at)
- leagues (id, name, invite_code, status [draft/active/closed], season_start/end, created_by)
- league_members (id, league_id, user_id, draft_order, is_commissioner)
- regions (id, name, emoji, description, prev_year stats)
- draft_picks (id, league_id, user_id, region_id, round, pick_number) — unique on (league_id, region_id)
- scores (id, region_id, event_type, description, source_url, points 2/5/10, event_date, verified)
- user_scores (user_id, league_id, total_points) — materialized, recalculated on new score events
## Seed data: 32 coastal regions
The game ships with 32 pre-seeded regions with real-world lat/lng and historical stats (prev_year_sightings, prev_year_bites, prev_year_fatalities):
- **IDs 112** (original): Florida East Coast, Florida Gulf Coast, Caribbean, California South, California North, Hawaii, Carolina Coast, Texas Gulf, Australia East/West, South Africa, Brazil Coast
- **IDs 1332** (expanded): New England Coast, New York Bight, Mid-Atlantic, Georgia Coast, Louisiana Gulf, Mexico Pacific, Mexico Gulf, Central America, South America Pacific, Mediterranean, West/East Africa, Middle East, India West/East, Southeast Asia, Japan, New Zealand, Pacific Islands, UK & Ireland
**Seed strategy:** `INSERT OR IGNORE` — the regions table is populated once on first startup via `seed_regions()`. The `REGIONS_SEED` list in server.py is the single source of truth. When adding new regions, append tuples to the list and increment the total. The seed guard (`if existing == 0`) ensures existing databases are not touched.
The frontend `MAP_REGIONS` array in `draft-room.html` mirrors the backend list with matching IDs, lat/lng for Leaflet markers, and a `stats:{s,b,f}` field on new regions (IDs 13+). Original regions (112) intentionally lack the `stats` field to avoid breaking the existing code path.
## Key patterns
- **Snake draft:** Round 1 order 1→N, Round 2 N→1, Round 3 1→N
- **Turn enforcement:** Check draft_order matches next_pick_number
- **Commissioner-only:** Draft start endpoint validates is_commissioner
- **Scoring:** sighting=2, bite=5, fatality=10. Recalculate all user_scores on new event
- **Region assignment:** Region IDs 1-32 are pre-seeded. Event endpoint expects region_id, validates it exists.
- **Frontend serving:** FastAPI catch-all route at the end serves static files + falls back to index.html
@@ -0,0 +1,200 @@
# Shark Game Frontend Patterns — API-Connected HTML
Created Jul 8, 2026. Three frontend pages (`index.html`, `draft-room.html`, `league.html`) converted from static mockups to real API consumers against the FastAPI backend at port 8083. All served through the backend's catch-all route at `shark.iamgmb.com`.
## Architecture
```
Browser → shak.iamgmb.com → Caddy reverse_proxy → FastAPI (port 8083)
├── /api/* endpoints
└── serve /var/www/shark-game/*.html
```
Frontend makes relative fetch calls (`/api/auth/login`, `/api/leagues/1/draft`, etc.) — no hardcoded API base URL. Same-origin because Caddy proxies the whole domain through the backend.
## API Helper Pattern (all pages)
```javascript
const API_BASE = ''; // same-origin
function getToken() { return localStorage.getItem('shark_token'); }
function getUser() { try { return JSON.parse(localStorage.getItem('shark_user') || '{}'); } catch { return {}; } }
async function apiCall(path, options = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(API_BASE + path, { ...options, headers });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || data.message || 'Request failed');
return data;
}
```
**Key decisions:**
- `localStorage` for token storage (not sessionStorage — survives tab close)
- Token stored as `shark_token`, user object as `shark_user` (JSON serialized)
- Error extraction favors `data.detail` (FastAPI's default) then `data.message`
- `API_BASE = ''` for same-origin — works with both Caddy split-routing and full-proxy patterns
- No `fetch` wrapper retries — caller handles errors and shows them to user
## Auth Flow (index.html)
### Registration → Post-Auth Redirect
```
User fills form → handleSignUp()
→ POST /api/auth/register { email, password, display_name, phone? }
→ Store token + user in localStorage
→ GET /api/leagues (list user's leagues)
→ 0 leagues: show league creation + join UI
→ 1 league: window.location.href = 'draft-room.html?league_id=...'
→ 2+ leagues: show league picker cards
```
Login follows the same post-auth flow. The `onAuthSuccess()` function is shared between registration and login.
**Mode toggle:** Sign Up / Log In share a submit button. Toggling swaps visibility of `#registerFields` vs `#loginFields` and changes the button text / toggle link text.
### League Management on Landing Page
- **Create league:** `POST /api/leagues { name }` → redirects to draft-room.html
- **Join league:** `POST /api/leagues/join { invite_code }` → redirects to draft-room.html
- **Invite codes** are 8-char uppercase hex; input auto-uppercases, requires 8+ chars
- **League list cards** show name, member count, status, invite code; clicking enters that league
### Session persistence
`DOMContentLoaded` checks `getToken()`. If present, skips auth form and goes straight to `onAuthSuccess()`. This means returning users don't re-login unless they explicitly log out (which clears both `shark_token` and `shark_user`).
## Draft Room (draft-room.html)
### URL-driven league selection
```
?league_id=123 → load that league
no ?league_id → show league picker overlay (GET /api/leagues, user clicks one)
```
The overlay shows a list of league cards with member counts and status. If a user navigates to draft-room.html without a league, they pick one here.
### Data loading pattern
```javascript
async function loadAll() {
const [league, board, scores, regions] = await Promise.all([
apiCall('/api/leagues/' + leagueId),
apiCall('/api/leagues/' + leagueId + '/draft'),
apiCall('/api/leagues/' + leagueId + '/scores'),
apiCall('/api/regions'),
]);
// Store state, render all tabs
}
```
**Four parallel API calls at load time.** All tabs share this state — switching tabs doesn't re-fetch unless leaderboard hasn't loaded yet (lazy load on first tab switch).
### Tab Structure
| Tab | Contents | Data Source |
|-----|----------|-------------|
| **Draft** | Grid of 12 region cards, draft order bar, status | `draftBoard.available_regions`, `draftBoard.picks`, `draftBoard.current_pick` |
| **My Team** | 2-column grid of your drafted regions | `draftBoard.picks` filtered by `user_id` |
| **Leaderboard** | Sorted score entries with medals | `leaderboardData` (loaded lazily on first tab switch) |
| **League** | Name, status, invite code, members, draft start | `leagueData` |
### Draft Board Rendering
- Combines `available_regions` (undrafted) with regions from `draftBoard.picks` (already drafted)
- Sorts: undrafted first (alphabetical by name), then drafted
- Each card shows: emoji, name, sighting/bite/fatality stats from `regionsMap`
- **Your pick:** amber glow border + shadow, gold "Draft" button, only clickable when it's your turn
- **Drafted by others:** grayed out, shows "Taken by {name}", disabled button
- **Your drafted regions:** amber glow, "Drafted ✓", own label, disabled
### Draft Action
```javascript
async function makePick(regionId) {
const result = await apiCall('/api/leagues/' + leagueId + '/draft/pick', {
method: 'POST',
body: JSON.stringify({ region_id: regionId }),
});
await loadAll(); // Full reload after pick
}
```
**After every pick the entire page state reloads** (`loadAll()`). This is simple and correct for a turn-based game with 2-8 players. The API enforces turn order and duplicate protection server-side.
### Status Bar Messages
| Condition | Display |
|-----------|---------|
| Draft complete | "🏆 Draft Complete!" + green complete badge |
| Status = 'draft' (not started) | "⏳ Waiting for commissioner to start draft" + pending badge |
| Your turn | "🎯 YOUR PICK!" in amber |
| Someone else's turn | "⏳ Waiting for {name}" in teal |
### Draft Order Bar
Horizontal scroll of all members showing who has picked (dimmed), who's currently picking (amber border), and who is "you" (teal border). Order picks per iteration capacity (snake logic handled server-side).
### Turn Enforcement
The button is only clickable when `currentDraftingUserId === myUserId` AND `draftBoard.status === 'active'` AND `!draftBoard.draft_complete`. Server also enforces this — the POST returns 403 if it's not your turn.
### Bottom Bar (Your Team)
Horizontal scroll of picks chips showing emoji + region name + round. Remaining picks indicator ("+ 3 picks remaining"). Total score in top-right score box updated from leaderboard data.
### Commissioner Actions (League Info Tab)
- **Start draft button** shown only when: user is commissioner AND status is 'draft' AND ≥2 members
- **Need more members message** shown when commissioner but <2 members
- **"Waiting for commissioner" message** shown when non-commissioner and status is 'draft'
- **"Draft is live"** shown when status is 'active'
### Invite Code Copy
```javascript
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(code);
// Show "Copied!" for 2 seconds
} else {
// Fallback: create temporary textarea, select, execCommand('copy')
}
```
## League Settings Page (league.html)
Standalone page at `/web/league.html?league_id=...`. If no `league_id` in URL, auto-loads the user's first league (redirect fallback). Contains:
- League details card (name, status badge, season start, member count)
- Invite code with copy-to-clipboard
- Full member list with draft order
- Commissioner actions (same as draft room's League tab)
- Navigation links back to draft room and home
## CSS Theme
All pages share a consistent ocean theme:
- `--deep-ocean: #0a1628` (body background)
- `--amber: #f59e0b` (accent color, buttons)
- `--teal-accent: #0ea5e9` (secondary accent, borders)
- `--sonar-green: #22c55e` (score display)
- `--alert-red: #dc2626` (danger/warning elements)
- Wave overlay with `radial-gradient` and `waveShift` animation
- Sonar ping rings with `sonarPing` animation
- Card backgrounds with `backdrop-filter: blur(8px)` and top gradient border
- Responsive grid: 3 columns default, 2 columns below 360px
## Pitfalls
- **Token expiry is 30 days with no refresh endpoint** — the user gets 401 on any API call after expiry. Handle by catching 401 and redirecting to login. Currently no global 401 handler — individual `apiCall` callers show the error in the inline error box.
- **Data staleness after draft pick** — after `makePick`, `loadAll()` re-fetches everything. This is a full page refresh pattern. For a live draft with polling, add a `setInterval` that calls `GET /api/leagues/{id}/draft` every 5 seconds.
- **`history.replaceState`** used to set URL params silently — used in league picker overlay to set `?league_id=` without a page reload.
- **Same-origin fetch**: `API_BASE = ''` works only because frontend and API are served from the same domain (Caddy reverse proxy or full-proxy pattern). If frontend were on a different origin, CORS would need `Access-Control-Allow-Origin` on the backend.
- **Invite code expiry:** Backend rejects join attempts when league status is not 'draft'. The frontend shows the code even after draft starts — the user gets an API error when trying to join. Consider hiding the code after draft starts.
- **Button text swap pattern** — the auth submit button changes text from "Join the Frenzy" to "Registering..." and back. Use `innerHTML` restore pattern (save original in a `<span>` wrapper) rather than re-assigning `textContent`.
- **No CSRF protection** — token-in-header pattern is sufficient for this scale but CSP headers would be a good add.
@@ -0,0 +1,90 @@
# Shark Attack Fantasy Game — Full-Stack Pattern
## Architecture
```
iOS Safari → Cloudflare → Caddy (shark.iamgmb.com) → FastAPI (port 8083)
├── /api/* (auth, leagues, draft, scores)
└── /* (static frontend via FileResponse catch-all)
```
## Domain / DNS setup
- Domain: `shark.iamgmb.com` (Cloudflare zone f1fb2d357b8ff0fab54c5856130ec9ed)
- Points to Core's IP (152.53.192.33) via A record (not CNAME — CNAME flattens fail for cross-account zones)
- DNS-only initially, switch to proxied after TLS cert provisions
- Caddy full-proxy pattern: EVERYTHING routes through the backend on 8083
## Backend features learned
- **FastAPI monolithic server.py** — ~980 lines, no routers, all endpoints in one file
- **SQLite** with WAL mode + foreign keys
- **Auth:** bcrypt password hashing, PyJWT tokens, 30-day expiry
- **JWT gotcha:** `datetime.utcnow()` vs `datetime.now(timezone.utc)` — PyJWT expects naive UTC
- **Registration response must include `display_name`** — frontend uses it immediately
- **Snake draft logic:** round 1: 1→N, round 2: N→1, round 3: 1→N. Track via round number: odd rounds are forward, even are reverse
- **Scoring engine:** events table + materialized user_scores table recalculated on each insert
- **Point values:** sighting=2, bite=5, fatality=10
## Draft regulation
- **Minimum 2 members** to start a draft (server enforces)
- **Commissioner-only** start draft endpoint
- **Turn enforcement by draft_order** — server rejects out-of-turn picks with 403
- **Region locking** — duplicate picks return 409
## 32 Regions with ISAF-based data
Top 5 regions by 10-year sighting count:
| Region | Sightings | Bites | Fatalities |
|--------|-----------|-------|-----------|
| Australia East | 87 | 28 | 6 |
| Florida East Coast | 85 | 25 | 1 |
| Hawaii | 47 | 12 | 2 |
| Australia West | 42 | 15 | 8 |
| Caribbean | 40 | 10 | 2 |
Full data saved to `/root/shark-game/references/shark-incident-data-10yr.md` sourced from ISAF year-by-year reports.
## Caddy full-proxy pattern
When the backend serves BOTH API and static files, use:
```
shark.iamgmb.com {
reverse_proxy 127.0.0.1:8083
}
```
Backend catch-all route:
```python
@app.api_route("/{path_name:path}", methods=["GET"])
async def serve_frontend(path_name: str):
if path_name.startswith("api/"):
return {"error": "not found"}
file_path = os.path.join(STATIC_DIR, path_name)
if os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
```
## Frontend-JWT integration pattern
- Token stored in `localStorage` as `shark_token`, user as `shark_user`
- Auth guard on each page: `if (!getToken()) window.location.href = 'index.html';`
- API base is empty string (same-origin)
- Registration sends: `{email, password, display_name, phone?}`
- After auth, checks `/api/leagues` to determine next screen
- Pages: index.html (landing/register/login/league-mgmt), draft-room.html (draft/leaderboard/shark-feed/learn), league.html (settings/invite)
## Multi-tab draft room pattern
The draft room has 6 tabs:
1. DRAFT — Map view (Leaflet/OSM) | List view toggle
2. MY TEAM — User's drafted regions + cumulative score
3. STANDINGS — Leaderboard (all players ranked) + daily breakdown
4. 🦈 LEARN — Species guide, shark facts, safety tips, conservation
5. LEAGUE — Invite code, member list, start draft button (commissioner)
6. 🦈 SHARK FEED — Upcoming shark media (Shark Week, movies) + general shark news
## AI scraper pattern
- Daily cron: Firecrawl search → admin-ai classification → pending-scores.json
- Region matching via keyword/fuzzy matching of article text/location
- Dedup via processed-urls.json
## Common pitfalls
- **Missing `.hidden` CSS class** — New standalone pages often define `.hidden` via JS but forget the CSS rule `.hidden { display: none !important; }` in `<style>`. Index pages get it; sibling pages don't. Always add it to every new page.
- **Registration returns `display_name: null`** — The `/api/auth/register` endpoint must pass `display_name=req.display_name` in the response, not just write it to the DB. Login endpoint does this correctly (reads from DB); register endpoint must be patched to match.
- **Cross-account DNS** — A records work where CNAMEs silently fail for Cloudflare cross-account setups.
- **Serve both frontend and API from one backend** — Full-proxy through FastAPI + FileResponse catch-all avoids Caddy routing conflicts.
@@ -0,0 +1,32 @@
# Shark Attack Fantasy Game (Feeding Frenzy) — Session Notes
## Overview
Built Jul 8, 2026. A fantasy sports-style game where players draft coastal regions and score points from real shark incidents classified by AI.
## Current State — Frontend Connected to API
### Built
- Three frontend pages at `/var/www/shark-game/` connected to real API:
- `index.html` — Registration/login with JWT storage, league creation/join, auto-redirect
- `draft-room.html` — Full draft board with turn enforcement, 4 tabs (draft, team, leaderboard, league info), commissioner start-draft, invite copy, live scores
- `league.html` — Standalone league settings page
- Caddy config: `shark.iamgmb.com` → serves static files + proxies `/api/*` to :8083
- DNS: A record `shark.iamgmb.com``152.53.192.33` (Cloudflare, proxied)
- FastAPI backend at `/root/shark-game/backend/server.py` (SQLite, auth, draft, snake scoring)
- All 30+ API endpoints verified — auth, leagues, join, draft snake-order, score events, leaderboard
### Building (subagents in progress)
- AI scraper at `/root/shark-game/scraper/scrape.py` (Firecrawl + admin-ai classification)
### Still Needed
- Daily summary + scoring notifications
- Postseason wrap-up
- Live draft polling (auto-refresh every 5s during active draft)
## Design Notes
- 12 regions, pre-populated with prior-year stats
- Scoring: sighting=1pt, bite=5pts, fatality=10pts
- Snake draft: 2-3 rounds, order reverses each round
- Regions lock on selection — no duplicate picks
- Post-draft AI analysis with letter grades
- Mobile-first PWA
@@ -0,0 +1,35 @@
# 10-Year Global Shark Incident Data (2015-2025)
Source: ISAF (International Shark Attack File) at Florida Museum of Natural History
Based on 10-year annual reports: 2015 (98 incidents peak) through 2025 (65 incidents, 9 fatalities)
## USA & Territories 10-year totals
- Florida: ~176 (avg 17.6/yr) — 6 fatalities. Volusia County alone: ~80-100 incidents
- Hawaii: ~47 (avg 4.7/yr) — 2 fatalities
- California: ~34 (avg 3.4/yr) — 3 fatalities
- South Carolina: ~16 (avg 1.6/yr)
- North Carolina: ~12 (avg 1.2/yr)
- Texas: ~4 (avg 0.4/yr)
- New York: ~3 (avg 0.3/yr) — recent uptick
## Australia 10-year totals
- Total: ~157 (avg 15.7/yr) — ~28 fatalities
- NSW (East): ~52 (avg 5.2/yr)
- Western Australia: ~42 (avg 4.2/yr)
- Queensland (East): ~35 (avg 3.5/yr)
- South Australia: ~15 (avg 1.5/yr)
## Other prominent regions
- South Africa: ~38 incidents, ~6 fatalities (mostly Western/Eastern Cape)
- Brazil (Recife): ~28 incidents, ~8 fatalities (high fatality rate ~37%)
- Bahamas/Caribbean: ~40 incidents, ~2-3 fatalities
- Southeast Asia: ~40 incidents, ~4 fatalities (Indonesia highest)
- New Zealand: ~14 incidents, ~2 fatalities
- Pacific Islands: ~18 incidents, ~2 fatalities
- Mexico: ~12 incidents total (both coasts combined)
- Japan: ~7 incidents (Okinawa area)
- Mediterranean: ~10 incidents (rare, often unreported)
- Middle East (Red Sea/Oman): ~12 incidents, ~2 fatalities
## Applied to game regions
When building the REGIONS_SEED tuples, divide 10-year totals roughly by 10 for annual averages.
@@ -0,0 +1,300 @@
# Web Push Notifications — Full Implementation
Adding web push notifications (VAPID-based) to a FastAPI + PWA game. Covers VAPID key generation, backend subscription management, scoring-triggered push, service worker, and frontend opt-in UI.
## Architecture
```
Browser (SW) ← push event → Push Service (Chrome/Firefox) ← POST → FastAPI backend
└─ registers SW + subscribe() ─────────────────────────→ POST /api/push/subscribe
└─ receives notification → showNotification() ← scoring event triggers send_push_notification()
```
## VAPID Key Generation
`pywebpush` no longer exports `generate_vapid_keys()`. Generate keys using `cryptography` directly:
```python
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
# Public key in X962 uncompressed point format (65 bytes)
public_bytes = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
public_b64 = base64.urlsafe_b64encode(public_bytes).decode().rstrip('=')
# Private key in PKCS8 DER format
private_bytes = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
private_b64 = base64.urlsafe_b64encode(private_bytes).decode().rstrip('=')
```
**PITFALL:** The public key MUST be in uncompressed X962 format (65 bytes), NOT SubjectPublicKeyInfo (SPKI/91 bytes). The browser's `PushManager.subscribe()` expects the raw point. If you get a `DOMException: Registration failed - invalid key` on the client, the key format is wrong.
## Backend: Database
Add a `push_subscriptions` table:
```sql
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, endpoint)
);
```
Add a `notifications_enabled` column to `users`:
```sql
ALTER TABLE users ADD COLUMN notifications_enabled INTEGER DEFAULT 1;
```
## Backend: VAPID Config
```python
from pywebpush import webpush
VAPID_PRIVATE_KEY = os.environ.get("VAPID_PRIVATE_KEY", "<base64-private>")
VAPID_PUBLIC_KEY = os.environ.get("VAPID_PUBLIC_KEY", "<base64-public>")
VAPID_CLAIMS = {"sub": "mailto:admin@example.com"}
```
## Backend: Send Function
```python
def send_push_notification(user_id, title, body, icon="/icon.png"):
conn = get_db()
try:
subs = conn.execute(
"SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?",
(user_id,)
).fetchall()
# Check user preference
user = conn.execute(
"SELECT notifications_enabled FROM users WHERE id = ?", (user_id,)
).fetchone()
if user and not user["notifications_enabled"]:
return
for sub in subs:
try:
webpush(
subscription_info={
"endpoint": sub["endpoint"],
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]}
},
data=json.dumps({
"title": title,
"body": body,
"icon": icon,
}),
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims=VAPID_CLAIMS,
)
except Exception as e:
print(f"Push failed for user {user_id}: {e}")
finally:
conn.close()
```
**PITFALL:** `webpush()` from pywebpush raises `pywebpush.WebPushException` on HTTP errors (410 Gone = subscription expired). Catch broadly and log — expired subscriptions should be cleaned up but the function should not crash.
## Backend: API Endpoints
### GET /api/push/vapid-public-key
Returns `{"public_key": "<base64>"}` — no auth needed (called before registering SW).
### POST /api/push/subscribe (auth required)
```json
{"endpoint": "https://fcm.googleapis.com/...", "p256dh": "...", "auth": "..."}
```
Uses `INSERT OR REPLACE INTO push_subscriptions`. **PITFALL:** The `endpoint` is unique per registration (not user) — a user with multiple devices will have multiple rows.
### POST /api/push/unsubscribe (auth required)
Same body shape. Deletes by `user_id + endpoint`.
### GET /api/push/preferences (auth required)
Returns `{"notifications_enabled": bool, "subscription_count": int}`.
### PATCH /api/push/preferences (auth required)
```json
{"notifications_enabled": false}
```
Updates the `users.notifications_enabled` column.
## Backend: Triggering Notifications
After a scoring event, find all users who drafted the affected region and notify them:
```python
region_name = conn.execute("SELECT name, emoji FROM regions WHERE id = ?", (req.region_id,)).fetchone()
if region_name:
event_labels = {"sighting": "Sighting!", "bite": "Bite!", "fatality": "FATALITY!"}
title = f"🦈 {event_labels[req.event_type]}"
body = f"+{points} pts — {region_name['emoji']} {region_name['name']}"
affected = conn.execute(
"SELECT DISTINCT dp.user_id FROM draft_picks dp WHERE dp.region_id = ?",
(req.region_id,),
).fetchall()
for au in affected:
send_push_notification(au["user_id"], title, body)
```
**PITFALL:** `send_push_notification()` opens its own DB connection — call it AFTER closing the scoring transaction's connection, or use a separate connection. Don't keep a conn open across push HTTP calls.
## Frontend: Service Worker (sw.js)
```javascript
self.addEventListener('push', (event) => {
let data = { title: 'Default', body: '', icon: '/icon.png' };
if (event.data) {
try { data = event.data.json(); } catch { data.body = event.data.text(); }
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: '/badge.png',
vibrate: [200, 100, 200],
data: { url: '/app.html' },
requireInteraction: true,
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clients) => {
for (const c of clients) {
if (c.url.includes(url) && 'focus' in c) return c.focus();
}
if (clients.openWindow) return clients.openWindow(url);
})
);
});
```
## Frontend: Registration + Subscription
```javascript
// Register SW
const swReg = await navigator.serviceWorker.register('/sw.js');
// Get VAPID public key from backend
const keyRes = await apiCall('/api/push/vapid-public-key');
const vapidKey = urlBase64ToUint8Array(keyRes.public_key);
// Get permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// Subscribe
const sub = await swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidKey,
});
// Send to server
await apiCall('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
auth: arrayBufferToBase64(sub.getKey('auth')),
}),
});
```
### Base64 ↔ Uint8Array Utility Functions
```javascript
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i)
outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
```
**PITFALL:** The VAPID public key from the server is URL-safe base64 (no padding). `atob()` requires standard base64. Always convert `-``+` and `_``/` before decoding. Add padding with `'='.repeat((4 - len % 4) % 4)`.
## Frontend: UX Patterns
### Opt-in Banner
Show a subtle in-page banner after login (not a dialog — don't be pushy):
```html
<div id="pushBanner" style="display:none;">
<div>🔔 Get scoring alerts — Enable push notifications</div>
<button onclick="requestNotificationPermission()">Enable</button>
<button onclick="hideNotificationBanner()"></button>
</div>
```
**PITFALL:** Call `Notification.requestPermission()` only on explicit user action (click/tap). Browsers silently ignore requests not triggered by user gesture. The banner's "Enable" button is the gesture.
### State UI
Show current notification state in settings:
- **Unsubscribed**: show "Enable" button
- **Subscribed**: show "✅ Notifications enabled"
- **Blocked**: show "🔕 Notifications blocked — enable in browser settings"
- **Unsupported**: silently hide the entire block
### Preference Toggle (settings page)
A toggle button in user/league settings to globally disable notifications without unsubscribing devices:
```
PATCH /api/push/preferences {"notifications_enabled": false}
```
When disabling, also call `sub.unsubscribe()` and `POST /api/push/unsubscribe` to clean up.
## Verification
1. Open the app, log in — banner should show "Get scoring alerts"
2. Click "Enable" — browser prompts for notification permission
3. After granting, `POST /api/push/subscribe` is called with the subscription
4. Trigger a score event via the scoring endpoint
5. Browser shows a push notification even if the tab is minimized
6. Clicking the notification opens (or focuses) the app tab
**Test with curl:**
```bash
curl -s -X POST http://localhost:8083/api/scores/events \
-H "Content-Type: application/json" \
-d '{"region_id": 1, "event_type": "sighting", "description": "Test push"}'
```
## References
- [Web Push API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
- [VAPID Spec (RFC 8292)](https://datatracker.ietf.org/doc/html/rfc8292)
- [pywebpush on PyPI](https://pypi.org/project/pywebpush/)
- [PushManager.subscribe() (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)