A non-commercial World Cup 2026 prediction game for family & friends. See CLAUDE.md
and docs/PLAN.md / docs/SCHEMA.md for the full spec, build order, and current status.
docs/HISTORY.md has the detailed step-by-step build log.
v2 is complete and live at https://friendly-bets-rust.vercel.app.
2026-06-16 — model change: parimutuel pool → fixed-points scoring. Players no longer stake points. They predict each match's outcome (home win / draw / away win) and score fixed points at settlement: correct +10, +5 underdog bonus if the picked outcome got fewer than 33% of the match's bets (correct underdog = 15), wrong −5. Everyone starts at 0 points and balances may go negative. Draw is now a first-class pickable outcome. DB changes are in
supabase/migrations/20260616000000_accuracy_points_model.sql(apply it withsupabase db push— it resets existing balances to 0).
- Supabase schema, RPC (
settle_match),accuracyview, and RLS are live (seesupabase/migrations/). /api/sync(src/app/api/sync/route.ts) is deployed to Vercel, protected by a shared secret, and triggered every 5 minutes by cron-job.org (200 OK confirmed, reduced from every 2-3h on 2026-06-14) — pulls openfootball fixtures/results, upsertsmatches, and auto-settles viasettle_match. The Vercel project is connected to this GitHub repo for auto-deploys on push tomain.- Auth is built two ways: magic link (
/loginsends a sign-in email,/auth/confirmcompletes it) and Google OAuth (a "Sign in with Google" button on/login, same/auth/confirmreturn path). The home page shows the logged-in user's name + points balance with a sign-out button. Session cookies are kept fresh bysrc/proxy.ts.NEXT_PUBLIC_SITE_URLis set in Vercel and verified live. See "Auth" below. - The match list page (
/matches) splits all World Cup 2026 fixtures into Upcoming/Live/Past tabs (default Upcoming), each grouped by kickoff date under sticky headers with stage/group, kickoff time (Finnish time), status, and a per-tab match count. Readable by anyone (no login needed) and linked from the home page. - Place-pick flow is live: logged-in users predict one of three outcomes
(home win / draw / away win) on any open match from
/matches, alongside a crowd-split display (% of picks per outcome). The pick is inserted via a server action; theenforce_bet_windowtrigger and the one-pick-per-match UNIQUE constraint do the enforcement. Verified working end-to-end on the live site. - Leaderboard (
/leaderboard) shows a points podium (top 3) plus one sortable table covering every player (points, bets, correct, wrong, win %, streak). A segmented period selector sits above the podium: All time (fromprofiles+ theaccuracyview), Last 10 (each player's recent form), and one pill per tournament round that has settled bets — both the podium and the table re-scope to the selected period. Linked from the home page and/matches. - Stats tab (
/stats) "for the curious": a You section (login-gated — your accuracy by round, pick tendencies, contrarian record, ranking and best calls), a public The Crowd section (wisdom-of-the-crowd accuracy, biggest upset, most divisive/consensus matches, draw-shyness, fan favourite), and a public Records section (league-wide superlatives). All derived from existing data — no new DB objects. See "Stats" below. - Vercel Web Analytics is enabled (
@vercel/analytics). - Magic-link emails go through custom SMTP (Brevo) — Supabase's default
shared mailer caps at 2 emails/hour, which isn't enough for multiple
people signing in. See
CLAUDE.md("Email / SMTP") for setup details. Since the sending address has no domain reputation yet,/loginshows a reminder to check spam/junk for the magic-link email. - Team flags are shown next to team names on
/matches(see "Team flags" below). - Daily login bonus — disabled 2026-06-16 (it inflated the prediction score under the new fixed-points model). The DB RPC/columns remain dormant; all app wiring was removed. See "Daily login bonus (disabled)" below.
/matcheshas a mobile-first redesign: sticky header with points total + a dark/light toggle, a dismissible "How to play" card, tap-an-outcome predicting (home / draw / away), and a bottom Matches/Leaderboard/Stats tab bar (see "Place a prediction" and "Theme" below). The dark/light toggle is app-wide./leaderboardhas the matching redesign: same sticky header + bottom nav, a podium for the top 3 players (gold/silver/bronze avatars and bases), and below it a single sortable table covering every player (see "Leaderboard" below)./loginhas the matching redesign: centered logo, a "Sign in" card with email input + magic-link button, the spam/timing/double-submit warnings as a "Heads up" card, and a labeled dark/light toggle pill (see "Theme" below).
No known open bugs. All three Claude Design pages (Matches, Leaderboard,
Login) are implemented, plus the /stats tab (the former "Analysis tab" v3
idea, shipped 2026-06-29). Anything else further is a v3 idea — see
docs/PLAN.md.
npm install
npm run devOpen http://localhost:3000.
Copy .env.local.example to .env.local and fill in your Supabase project values
(see that file for where to find each one — .env.local is gitignored).
The Supabase schema lives in supabase/migrations/ — apply it with the Supabase CLI:
supabase login
supabase link --project-ref <your-project-ref>
supabase db pushWith npm run dev running, in another terminal:
curl http://localhost:3000/api/sync -H "Authorization: Bearer <your-SYNC_SECRET>"Expect {"synced": <count>, "settled": [...]}. The matches table should populate
with World Cup 2026 fixtures.
/login offers two sign-in options, separated by an "or" divider:
- Magic link — enter an email, get a sign-in link.
- Sign in with Google — OAuth via the Google provider.
Both end at the same /auth/confirm route, which exchanges Supabase's PKCE
?code=... for a session (the flow is identical for both). The new-user
profiles trigger fires the same way regardless of which method created the
account, so a Google signup gets a profile just like a magic-link signup.
/login also has a "View matches as guest" button (a full-width bordered
button directly under the sign-in options) — /matches and /leaderboard are
public (read-only without login), so visitors can browse before signing in;
they just can't place predictions until they do. Guests aren't stranded on
those pages either: the shared sticky header (src/components/app-header.tsx)
shows a green "Log in" link when no one is signed in (handy for someone
linked straight to /matches), and swaps it for a "Sign out" button
(src/components/sign-out-button.tsx) once signed in.
One-time setup in the Supabase dashboard — Authentication -> URL Configuration — DONE:
- Site URL: your deployed app URL (e.g.
https://friendly-bets-rust.vercel.app) - Redirect URLs: add that same URL plus
http://localhost:3000/**
No email template edits are needed — the default "Magic Link" email works as-is.
Visit /login, enter an email, and click the link from the email to sign in.
Magic-link emails are sent via custom SMTP (Brevo) — see CLAUDE.md ("Email
/ SMTP") for why and how it's configured. First-time recipients should check
their spam folder, since the sending address has no reputation yet — /login
itself now reminds users of this, so you shouldn't need to repeat it when
sharing the link.
Google OAuth needs the Google provider enabled in Supabase plus an OAuth client
in Google Cloud Console (with Supabase's callback URL as the authorized redirect
URI) and account linking turned on — all done, full step-by-step in CLAUDE.md
("Google OAuth"). The redirect URL is environment-aware (built from the
browser's window.location.origin), so the same code works in dev and prod.
NEXT_PUBLIC_SITE_URL is set in both .env.local (local dev) and the Vercel
project's Environment Variables (production), so emailRedirectTo resolves
correctly in both environments.
/matches is a read-only Server Component — matches is readable by anyone
via RLS, so no login is required to view it. Fixtures are split into three
tabs, each showing a match count:
- Upcoming (default) — not yet settled, kickoff still in the future (these are the bettable ones). Soonest first.
- Live — not yet settled, kickoff already passed (sync hasn't recorded a result yet). Soonest-started first.
- Past — settled. Most recent result first.
Within each tab, matches are grouped under a sticky date header per kickoff
day, styled as a bold green "washi tape" banner (clipped/angled corners) with
the date and that day's match count. Kickoff times are shown in Finnish time
(Europe/Helsinki, handles the EET/EEST daylight-saving switch automatically)
— kickoff_at itself is still stored in UTC. A tab with no matches shows a
short message (e.g. "No live matches right now") instead of a blank area.
On /matches, tap one of the three outcomes (Home win / Draw / Away win) in a
match that's still scheduled and hasn't kicked off yet — a confirm panel
slides open below the match card showing the scoring for that pick (correct
+10, +15 if it's an underdog under 33% of picks, wrong −5). There's no stake
to choose. Tap "Place pick →" to submit, or "Cancel" to close the panel.
Submitting inserts a row into bets; the enforce_bet_window trigger (from
supabase/migrations/20260609000000_initial_schema.sql) enforces the bet
window and the UNIQUE (user_id, match_id) constraint blocks a second pick on
the same match. Once you've predicted a match, the card shows a read-only
"Predicted — Outcome" row instead of the panel (no editing), and the result
(Correct +N / Wrong −5) once settle_match runs. A settled prediction is only
ever Correct or Wrong — there's no refund, so picking home/away on a match that
ends in a draw is just a wrong pick (−5); only a correct draw pick wins.
Scoring lives in the rewritten settle_match RPC
(20260616000000_accuracy_points_model.sql).
/leaderboard is a read-only Server Component (anyone can view, no login
required). At the top, a segmented period selector
(src/components/leaderboard-view.tsx, LeaderboardView) lets you switch
which period the podium + table show:
- All time (default) — points from
profiles.points_balance, stats from theaccuracyview; lists every registered player (zeros for those with no settled bets yet). This is the authoritative all-time standing. - Last 10 — each player's recent form, aggregated over only their 10 most recent settled predictions (newest first, across all rounds).
- One pill per tournament round (
Group stage,Round of 32, …,Final) — appears only once that round has settled bets, in tournament order. Shows only the players who predicted that round.
The round and Last-10 periods are aggregated in JS, server-side from a
single settled-bets fetch (joined to each match's stage) using the same
formulas as the accuracy view — no new DB view/RPC. Every period's standings
are precomputed on the server and handed to LeaderboardView, so switching
pills is instant (no refetch).
For the selected period: if it has at least 3 players, the top 3 by points are
shown as a podium (gold/silver/bronze circular avatars with initials, over
medal-colored bases). Below it, LeaderboardTable
(src/components/leaderboard-table.tsx) shows that period's players in one
sortable table: rank, player, points, bets, correct, wrong, win rate %, and 🔥
streak.
Tap any column header to sort by it; tapping the active column again toggles
ascending/descending (an arrow shows the direction). All six numeric columns
are sortable. Sorting is entirely client-side (useState/useMemo, no
refetch), defaults to points descending on load, and the rank column always
reflects the current sort order.
/stats is a third read-only tab "for the curious" (anyone can view; the
personal section is gated behind login). Like /leaderboard, it's a Server
Component that computes everything up front and hands precomputed sections to a
client switcher (src/components/stats-view.tsx, StatsView) — switching is
instant, no refetch. All aggregation lives in src/lib/stats.ts (pure
functions; same formulas as the accuracy view), and it needs no new DB
view/RPC — everything derives from the existing bets, matches,
match_bet_counts, profiles, and accuracy objects.
Three sections:
- You (login-gated; guests see a "log in to unlock" card) — overview tiles (points, rank, win %, current/best streak, predictions), accuracy by stage, your home/draw/away pick tendencies, your record with vs. against the crowd plus underdog calls landed, where you rank vs. the field, and your best/ toughest single calls.
- The Crowd (public) — wisdom-of-the-crowd accuracy %, biggest upset, most divisive and strongest-consensus matches (as 3-way split bars), whether we're draw-shy, and the most-backed team.
- Records (public) — longest win streak, biggest single-round haul, best underdog hunter, most accurate, most predictions, sharpest contrarian (rate-based records need at least 5 settled predictions to qualify).
/matches shows a small flag next to each team name. src/lib/flags.ts maps
the 48 real WC2026 country names (as they appear in openfootball's
team1/team2) to flag-icons codes; the SVGs
themselves live in public/flags/ (copied from the flag-icons npm package
by scripts/copy-flags.mjs, which is also how to add a flag later). The
Flag component (src/components/flag.tsx) renders nothing for team names
not in the map — that covered the knockout-bracket placeholders ("1A",
"W74", etc.), which get real country names as the bracket plays out. As of
2026-07-18 the bracket is fully resolved, so every match row now carries real
country names and no placeholders remain. If a placeholder ever resolves to a
country not yet in TEAM_FLAG_CODES, add it there and re-run the copy script.
Disabled as of 2026-06-16. It originally awarded a streak-based 100-400 point bonus on the first app load each UTC day, but under the new fixed-points scoring model that just inflated everyone's prediction score, so it was turned off.
What was removed: the <DailyBonusToast /> mount in src/app/layout.tsx, the
DailyBonusToast component, the claimDailyBonus() server action
(src/app/actions.ts), and the home page's streak display. What remains
dormant in the database (never called now): the claim_daily_bonus() RPC and
the profiles.last_bonus_date / streak_count columns from migration
20260613000000_daily_bonus.sql. To bring it back, restore the toast + action
wiring (and decide how a bonus should fit the points model).
The whole app has a manual dark/light toggle, defaulting to dark and
persisted in localStorage (fb-dark) as a .dark class on <html> — all
dark: Tailwind styles across the app follow this toggle rather than your
OS theme. The ☀/🌙 toggle button (src/components/theme-toggle.tsx) lives
in /matches' and /leaderboard's sticky headers. /login has no header,
so it shows its own labeled toggle pill instead
(src/app/login/theme-toggle-pill.tsx). The home page (/) doesn't have a
toggle control yet but still renders in whichever mode is active. See
CLAUDE.md ("Theme: dark/light toggle") for the implementation.