Gaffr is built around a single mechanic — Blackjack scoring against a target — and a small, opinionated stack. Below is how data, rules and lifelines actually wire together.
┌────────────────────┐ OAuth ┌────────────────────┐
│ Player / Admin │ ─────────────▶ │ Supabase Auth │
└─────────┬──────────┘ └──────────┬──────────┘
│ Next.js App Router (RSC + Edge) │
▼ ▼
┌────────────────────┐ queries ┌────────────────────┐
│ Roster + Lock UI │ ───────────▶ │ Postgres (RLS) │
│ Dashboard │ ◀─────────── │ - rosters │
│ Leaderboard │ realtime │ - player_stats │
│ Monthly Challenge │ │ - gameweeks │
└─────────┬──────────┘ │ - scoring_rules │
│ │ - events_raw │
▼ └─────────┬──────────┘
┌────────────────────┐ │
│ Scoring Engine │ ◀───── pg_cron ─────────┘
│ (rules x stats) │
└─────────┬──────────┘
▼
┌────────────────────┐ cron ┌────────────────────┐
│ Edge Functions │ ───────────▶ │ FPL Public API │
│ /sync-bootstrap │ ◀─────────── │ /bootstrap-static │
│ /sync-live │ │ /event/{id}/live │
└────────────────────┘ └────────────────────┘
Points are not read from FPL. Raw events are ingested and re-scored against our own rule table. This means the same event can score differently in main vs monthly scope.
-- pseudo SELECT u.id, SUM(r.points * s.count) AS total FROM rosters u JOIN player_stats s ON s.player_id = ANY(u.roster) JOIN scoring_rules r ON r.key = s.metric AND r.scope = 'main' AND r.active GROUP BY u.id;
alive ──score>100 (pre-Feb)─▶ bust_recoverable alive ──score>100 (Feb+)──▶ bust_permanent bust_recoverable ──switch─▶ alive
/sync-bootstrapSeeds players, teams, gameweek calendar.
/sync-live?gw=XUpserts raw events into events_raw. Triggers re-score.
recompute_totals()Materialised view refresh. Realtime push to /leaderboard subscribers.
reconcile()Diff against FPL canonical totals. Logs anomalies for review.