Private 12-manager fantasy football — live draft, live scoring, no ads.
Translation pending
Sports & fitnessTypeScript · PLpgSQLGitHub @anthonyhaapOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Fantasy Football
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Private 12-manager fantasy football league. Next.js 16 + Supabase (Postgres,
Realtime, Auth), deployed on Vercel.
The league clubhouse now includes private league chat and head-to-head social
challenges. Accepted challenge terms are immutable and audited. Optional dollar
amounts use an external settlement assistant: both managers opt in, the loser
pays separately, and both sides confirm completion. The app never stores payment
credentials, holds funds, initiates transfers, or automatically debits Venmo.Read the full README ↗Private 12-manager fantasy football league. Next.js 16 + Supabase (Postgres, Realtime, Auth), deployed on Vercel.
The league clubhouse now includes private league chat and head-to-head social challenges. Accepted challenge terms are immutable and audited. Optional dollar amounts use an external settlement assistant: both managers opt in, the loser pays separately, and both sides confirm completion. The app never stores payment credentials, holds funds, initiates transfers, or automatically debits Venmo.
Weekly-matchup challenges resolve automatically from official fantasy scores. Custom challenges use commissioner verification. Settlement deadlines, payment references, receipt confirmation, and disputes remain in the audit trail.
The database is the application. All business logic lives in Postgres as
ff_* SECURITY DEFINER functions; RLS grants authenticated SELECT only, and
there are no write policies anywhere. The web app never writes a row directly —
it calls RPCs. That means a browser with the publishable key cannot corrupt a
draft even if someone reverse-engineers the client.
Scheduled pg_cron jobs do the work nobody is watching:
| job | schedule | does |
|---|---|---|
draft-tick | every 5s | ff_tick_drafts() — autopicks when a clock expires, or for a team on auto draft |
live-stats | every 2 min | ff_poll_live() — pulls Sleeper stats during game windows |
wire-refresh | every 15 min | ff_refresh_wire() — ESPN news and the league injury report |
projections | every 6 h | ff_refresh_projections() — this week and next, then rebuild season totals |
projections-season | daily 09:10 | ff_load_season_projections() — every unplayed week of the season |
player-pool | daily 08:40 | ff_load_sleeper_players() — bio, depth chart, injury designation |
stats-settle | daily 09:17 | ff_settle_recent_weeks() — re-pulls for stat corrections |
The web app's request boundary is src/proxy.ts (Next 16 renamed middleware
to proxy). Everything is behind the session except /login, /join, /auth,
/share, /splash, /preview and the manifest.
src/lib/live.ts is the contract: whenever you open the app you see current
state. It fetches on mount, refetches on realtime change, refetches on
reconnect, and refetches on tab focus with a polling safety net. Postgres change
events are treated only as a signal to refetch, never as the data — which makes
dropped, duplicated, and out-of-order events harmless.
Draft clocks are rendered in server time. useServerClock measures the
offset against ff_now() so a manager with a skewed laptop still sees the true
countdown.
/team is a desk, not a list. One RPC — ff_team_hub(team_id, week) — returns
the roster with each player's season form, per-game usage rates, depth-chart
position and this week's real NFL game, plus the team's record, league rank,
matchup and per-position splits. Historical stat lines are re-scored with this
league's rules, so a 2025 game log reads as what it would have been worth to us.
Beside the lineup sits the wire. ESPN's public news and league-wide injury
feeds are loaded into nfl_news and nfl_injuries by pg_cron, which means
the app reads them as ordinary tables under the same live contract as
everything else — and an injury row carries the players.id it refers to, so
"is one of my guys hurt" is a foreign key rather than a string match. Club
crests and player headshots are hotlinked from ESPN's CDN by ids we already
hold (player_id_map), with a monogram fallback — no image pipeline and
nothing of the NFL's stored here.
The one picture the league does store is a manager's own. Edit team in the
hero renames the team and uploads a crest, which then replaces the monogram on
every seal in the app — top bar, standings, scoreboard, draft clock. Two
things keep that safe. teams.logo_path holds an object key, never a URL, so
the column can only ever address our own bucket rather than becoming a stored
redirect printed on every screen. And the key IS the authorization: an object
lives at <team_id>/<file>, the team-logos storage policy lets a manager
write only inside the folder named for the team he owns, and
ff_update_my_team takes no team id at all — it edits whatever team the caller
owns, so there is nothing to spoof. The commissioner keeps ff_update_team for
draft slots, manager names and any team in the league. Uploads are downscaled
to 512px in the browser before they are sent; an animated GIF is left alone,
because a canvas would keep only its first frame.
A team keeps its manager and may seat up to three co-owners beside him — brothers on one team, a father drafting with his son, a friend holding the queue while the manager is on a plane. A co-owner has his own login and the seat's full standing: he drafts, sets the lineup, works the wire, trades, talks in the house and edits the crest as the team. The only thing he cannot do is seat further co-owners; the seat is the manager's to share, not to sublet. The commissioner can seat one on any team from the Managers list.
The invite is the same secret link a manager gets (ff_invite_co_owner
mints it, /api/invite/co-owner mails it, /join claims it through the same
ff_claim_invite), and a person holds one seat per league — the unique index
on team_co_owners says so, and the claim refuses in English first.
Underneath, "is this my team" is answered in one place, ff_seat_team, and
every function that used to ask owner_id = auth.uid() inline was rewritten
to ask it there. supabase/tests/co_owners.sql scans the catalogue on every
replay for a function that has gone back to the old spelling, because that is
the failure nobody would see: a co-owner refused by one screen, silently.
The two are joined by src/lib/nfl/insights.ts, which is the point of the page:
a national injury report is noise until it is read against your roster. The back
ahead of yours is out, so his carries are yours; the quarterback throwing to your
receiver is out, so that is a downgrade; your own starter is questionable, so act
on it. Pure function, roster in and ranked notes out.
Every name in the app is a PlayerBadge: a headshot on the club's colour with
the crest in the corner, linking to /player/[id]. ff_player_card assembles
that page in one call — bio, club, injury and expected return, this season and
last (both scored under our rules), the projected week and rest of season, the
club's own depth chart, and the wire filtered to him.
Three feeds keep it current, all free and keyless:
| source | gives |
|---|---|
Sleeper /players/nfl | age, height, weight, college, jersey, experience, depth chart, injury designation |
Sleeper /projections/nfl/… | weekly projections, in the same stat shape as the actuals |
| ESPN news + injuries | headlines with photographs, and the league-wide report |
Because projections arrive in the same shape as real stat lines, ff_score
prices them with this league's rules: the number on the card is what the
projection is worth here, not somebody else's PPR setting.
Every row in the pool is a badge with a face, and carries a season
projection — all eighteen weeks of Sleeper's numbers summed through
ff_score, so it is what the player is worth under our rules rather than the
market's. player_season_projections holds the totals so the board joins
instead of aggregating eighteen weeks per keystroke; cron rebuilds them.
The board can be ordered by ADP or by projection, and the two disagree in useful places — a quarterback projecting 345 points can sit at ADP 34 because our passing rules are stingier than the market's. That gap is the point of showing both.
A name opens the card in the room. Leaving the page mid-draft loses the
clock and your place in the list, so in the draft room a PlayerBadge takes an
onOpen and a plain click raises PlayerSheet over the board instead: the
same ff_player_card data and the same projection and season cards as
/player/[id], with Draft and Queue on the footer. A cmd-click or middle
click still opens the full page, and the board's filled cells open the same
card. Borrowed from ESPN's room alongside it: a "show drafted" toggle that
greys out taken players with who took them, a roster tab laid out slot by slot
the way the lineup will seed, and "your pick in N" on the clock.
/preview/team, /preview/player and /preview/draft render these from fixtures — the team desk
from real players with an invented wire, the player card from a verbatim
snapshot of one real response, the draft pool from ten real rows — so any of
them can be inspected without a session.
A record waits for the week. A matchup becomes a result when its NFL week
is over — every game final, or four hours past the last kickoff
(ff_week_final) — and not when the first starter scores. Until then the
scoreboard shows the live score and the table shows last week's records.
Standings carry playoff odds. ff_playoff_outlook returns the table, the
whole schedule with a played flag per matchup, and each lineup's projected
points per game (the starters' remaining season projections, spread over the
weeks left). src/lib/playoffs.ts then plays the rest of the season out 4,000
times in the browser: each team's weekly score is normal around a blend of what
it has scored and what its lineup projects, with the projection worth about
four games of evidence. The top N by wins-then-points are in. Clinches and
eliminations are worked out exactly, not read off the simulation, so a
"Clinched" badge is a mathematical claim and ">99%" is not.
The race says what this week is worth. The table says where everybody
stands; it cannot say what a manager on the bubble wants to know on a Tuesday.
playoffRace is the same simulation, same seed and same draws, so its odds
agree with the table to the decimal (projectPlayoffs is now a view of it). It
also keeps two things the table throws away: which side won each of this
week's games in every simulated season, and how many more games each team won.
Conditioning on those gives the card under the standings:
A clinched or eliminated seat is told so in a line. A visitor sees only the
games. The front page carries the race in one line (RaceSoon) while it is
still a race for you.
Verified by tests/e2e/race.spec.ts on /preview/standings, whose seat switch
(In, Bubble, Out, Signed out) picks whose race is told. One test reads the
table's odds for the bubble team and asserts the card says the same number.
The old and new simulation were compared on the fixture before the switch: the
projections came out identical.
The commissioner edits rules all year. /admin has the league rules
(season length, playoff teams and byes, trade deadline, waivers, keepers,
roster slots) and every scoring value. Scoring changes are versioned by
effective week through ff_set_scoring_rules; roster_points already prices
each week with the rules in force for it, and ff_rescore_weeks rewrites the
matchup totals on the spot so the standings agree immediately rather than after
the next cron run.
Managers have names. teams.manager_name is what the league sees under a
team everywhere; the email is only shown on the invite row in /admin.
/ is no longer a dashboard. It is one card, typeset like a reservation, that
answers the three questions a manager has when the app opens: who am I
playing, am I winning, what do I do now. Everything else on the screen — the
six tables as a swipe carousel, the standings as a place card — is below it.
One RPC feeds it. ff_briefing(league_id) returns my matchup with live and
projected totals and each starter's game state, my record and seed, the
all-time head-to-head against tonight's opponent, last week's result, the
week's slate, the lineup's problems, and the state of the NFL calendar. The
page is server-rendered from that call on the session cookie, so the HTML
that arrives already says "Week 3 · You vs. Dave"; useLive then takes over
with the server payload as its initial state and refetches under the usual
contract. The skeleton has the card's exact silhouette, so nothing shifts.
src/lib/briefing.ts turns the facts into words, and is pure:
| function | decides |
|---|---|
phaseOf | the personality: draft, preseason, lineup, waivers (Wednesday), recap (Tuesday), live, monday, settled |
headline | "Week 3 · You vs. Dave", "You beat Mike." |
narrative | the one true line: "You've dropped three straight to Dave. Sunday's the rematch.", "You need 11.4 from McBride. He's projected 12.4." |
action | the one thing to do, never two: fill the empty slot, check on the questionable starter, watch it live, send the recap |
recapText | the Tuesday recap in the house voice, for the group chat |
Under it: the six tables as a swipe carousel, Overheard (below), and the standings as a place card.
The day of the week is the league's (LEAGUE_TZ), not the phone's. The NFL
slate decides the rest: any game in progress is Sunday whatever the calendar
says, and a Monday with only your tight end left to play is written as a
number he has to reach. /preview/tonight renders every day from one fixture.
Two things the card does on a phone that the old dashboard did not. On the
first visit of a session — the doors below take the place of it on the way in
from sign-in — an ink curtain with a gold monogram etches in and the card's
rules draw before the text fades up — a little over a second, set by an inline
script before first paint so it never flashes, pointer-events: none so it
never blocks a tap, and skipped entirely under prefers-reduced-motion. And on
a Sunday the projected numbers become live numbers: a score that ticks up
counts rather than snaps, flashes gold when it is yours and goes dim when it is
his, and taps an Android wrist through navigator.vibrate.
It is an app on the phone. manifest.ts declares standalone display,
/splash/<w>x<h>.png draws the ink launch screen at whatever size an iPhone
asks for (Satori, no PNGs in the repo), the layout lists them per device, and
InstallNudge shows the two taps once on a first mobile visit. The tab bar
carries Tonight, Matchups, Game Center and Chat, with everything else behind
More (see Game Day 2.0 below); pull to refresh works in the installed app only, where the
browser's own is absent. Fraunces and Inter arrive through next/font, so the
headline has an optical-size axis and every score sits in tabular figures.
There is no app store, on purpose. Installing from the browser is the only
route that costs nothing, and for twelve managers a store listing buys nothing
but the fee: Google Play is a one-off $25 developer registration to wrap the
same site as a Trusted Web Activity (PWABuilder does it), and the App Store is
Apple's $99-a-year program with no way around it — TestFlight needs it too.
So the site makes the browser install feel like one. /install, behind More
and linked from the first-visit nudge, has the two taps for each phone and
calls Android's own prompt when Chrome offers it; the manifest carries two
captures of the fixtures (scripts/screenshots.mjs) so the install sheet
shows the app rather than an icon and a name. If the league ever wants the
stores, that paragraph is the whole plan.
"Est. 2016" is on the crest, and /history is the room that proves it: a
plaque of champions, a 12×12 all-time head-to-head grid painted as a heat map,
rivalries the numbers found on their own, the longest runs and the worst
beatings, and a card for every manager with a title the record earned
("Three-time finalist. Never won.").
league_history holds the seasons before this app, keyed by manager name
rather than team row, because teams are re-created every year and people are
not. The current season joins in from matchups through teams.manager_name,
so the wall and the briefing's "all-time against Dave" line are live from week
two of 2026 whether or not the old seasons are in yet. They go in once, from
/admin: one CSV line per game, season,week,round,home_manager,…, through
ff_import_history, which replaces any season it is handed so a corrected
sheet can be pasted again. ff_history assembles the wall; /preview/history
renders ten invented seasons through it.
/share/matchup/[id] is the one public page in the league. It reads through
ff_share_card, the only function anon may call, which returns two names,
two managers, two scores, the week and the top scorer for a matchup addressed
by an unguessable id — and nothing else. Its opengraph-image is drawn per
matchup in ink and gold with Fraunces (public/fonts/), so the link unfurls
in the group chat as a card. The recap button on Tuesday's briefing opens the
phone's share sheet with the week's results written in the house voice and
that link at the bottom; where there is no share sheet, it copies.
/matchups is the screen the league actually watches on a Sunday, and for a
long time it was a schedule: two names, two numbers and a caret. True, and
nearly useless — the numbers a manager watches are the ones that say where a
game is going.
One RPC feeds it now. ff_scoreboard(league_id, week) returns every table in
the week with both lineups on it: each starter's points, his projection, his
real game's state and kickoff, plus each side's projected total, how much of it
is still to come, how many men are in action, and that side's best day so far.
It also returns when the numbers were last written, which the page prints.
src/lib/scoreboard.ts turns those facts into the screen, and is pure:
| function | decides |
|---|---|
cardState | pre, live, between, settled — the card's personality |
projectedFinal | points plus what is left, counting a man already playing for the part of his projection he has not reached |
winOdds | the live probability: the projected margin over the spread still to come, through a normal CDF |
cardLine | the one sentence — "You need 5.9 from Robinson. He's projected 8.0." |
slateLine | "6 games on now, 2 still to kick." |
freshness | "Scores 1 min ago · projections 5h ago" |
The odds model is deliberately simple and the card says so: each starter still to play is an independent swing with a standard deviation of 65% of his projection, floored at five points, halved for a man whose game is already on. It is not a simulation of the NFL; it is an honest reading of how much can still change, and it collapses to a certainty when the last game ends. The split is always printed as two percentages as well as drawn as a bar, so the card reads the same to someone who cannot separate wine from gold.
Hierarchy is the other half of it: your game is one card at the top, three times the size, and the other five are a list. Changing week never blanks the screen — the board you were looking at stays, dimmed, until the next lands.
/preview/matchups runs a whole invented Sunday through the real component:
before the draft, nothing kicked, the one o'clock games on, the late window,
and Monday with one man left. tests/e2e/scoreboard.spec.ts asserts the
sentences and the odds off that fixture.
league_messages.matchup_id had been on the table since the clubhouse was
built and nothing had ever written to it. That column is the whole feature: a
comment belongs to the game it is about, so the argument lives on the
scoreboard card instead of in a room people have to remember to visit.
Every card carries a thread. Closed, it is one line — the count and the last
thing said, both of which ff_scoreboard already returns, so a quiet table and
a loud one do not look the same. Opened, ff_matchup_thread fetches it with
each author resolved to their team and to which side of the game they sit on,
and ff_send_matchup_message posts, behind the same RPC boundary as every
other write. The membership check is the league, not the two managers playing:
heckling somebody else's table is the point.
The thread is not fetched until somebody opens it — six threads polled every
fifteen seconds through a Sunday would be six times the traffic for a screen
nobody is reading. league_messages is on the board's realtime watch list, so
a line said about any game lights up its count without a reload.
It reads in both directions. A matchup comment still appears in the clubhouse, captioned with the game it was said about and linking back to that week's board — moving the argument onto the scoreboard would be no improvement if it then vanished from the room everyone reads.
TalkThread is pure and MatchupTalk is the live one around it, which is what
lets /preview/matchups render a real argument from a fixture with no session:
an onSend that isn't there is the read-only thread a signed-out reader gets.
One thing this found. ff_scoreboard had shipped without a grant line, so
it kept Postgres' default of EXECUTE to PUBLIC — which here means anon, and
the league id ships inside the client bundle. Every sibling RPC is
authenticated-only. 20260904020439 corrects the grant and, so a future
mistake cannot re-open it, makes the function refuse a caller with no
auth.uid() outright rather than leaning on the grant the way its siblings do.
/sunday is the game center: NFL RedZone on the television, this on the
phone. RedZone says what is happening around the NFL; the game center says
why it matters to this league. On a phone or tablet it is a button in the
top bar on every screen — red and pulsing on a Sunday — and on a Sunday it
also takes the Matchups slot in the nav. On a laptop it is in the League menu
the rest of the week, and linked from the board.
It opens before its migration is applied: src/lib/sunday-load.ts falls back
to ff_scoreboard plus the week's nfl_games rows when ff_sunday is not
there yet, and only possession and the red zone wait for the migration.
It reads one call. ff_sunday(league_id, week) is ff_scoreboard with the
week's NFL games beside it: score, state, and — new — who has the ball and
whether that drive is in the red zone. ff_load_nfl_week already read ESPN's
scoreboard every two minutes during a game window; the same payload carries a
situation, and three more fields off it (possession, red_zone,
down_distance) land on nfl_games in the same upsert. No browser talks to
ESPN or Sleeper: cron writes once, realtime tells every open phone to refetch,
and the page is server-rendered from the same call so it arrives painted.
src/lib/sunday.ts turns the payload into the page, and is pure:
| function | decides |
|---|---|
sundayPhase | pre, live (including the gap between windows), final |
excitement | a score per table from configurable weights (EXCITEMENT): within a point, within five, fourth quarter, upset, the week's high, standings, and — when the server can supply them — lead changes, touchdowns, rivalry and playoff stakes |
featured | the Game of the Week before kickoff, the Game to Watch during, the finish of the day after |
moodOf | ROLLING, SWEATING, COOKED — read off the live odds; a caption, not a claim |
needs | the points it would take to pass the other side's projected finish — never "score this and you win" |
inTheRedZone | our starters whose club has the ball inside the twenty (not kickers, not defenses) |
alerts | the Fantasy RedZone, levels 1–4, stable ids so a refetch never repeats a row |
closeGames | tables within five, late — lifted above the board for everyone |
tickerItems, recap, delayed | the bottom rail, Tuesday's tiles, and whether to say the stats are behind |
Everything there is read from the state of the board. "Allen scored and it put Ray ahead" needs the board before and after, stored once and deduplicated — the event engine, which is the next phase and a server job, not a browser's. Nothing on the page invents a history it cannot see.
Desktop is a broadcast dashboard (featured table and board left, RedZone rail
right, chat as a drawer, ticker along the bottom); a phone is one column with
the featured strip over sticky tabs, opening on the RedZone feed. Dark only
here, scoped to .sun. Watch NFL RedZone links out to the NFL (REDZONE_URL)
and never embeds it. When the provider goes quiet the page keeps every score,
the chat and the feed on screen and says the stats are delayed.
Phase 2: the event engine. Phase 1 could only say what the board looked
like; "Allen scored and it put Ray ahead" is a difference between two boards.
ff_sunday_detect, on the sunday-events cron every minute, keeps the other
board — every starter's line and every table's leader in
sunday_player_state / sunday_matchup_state / sunday_game_state — and
writes what changed to sunday_events: touchdowns (a TD count that went up,
"40+ yard" only when Sleeper's own *_td_40p bucket did), big plays, giveaways,
lead changes, close games, upsets, red-zone drives and finals. Each carries a
level (1–4), a priority from ff_sunday_weights (overridable per league in
settings.sunday_weights) and a dedupe_key that names the thing, not the
moment it was seen — td:<week>:<team>:<player>:<n>, lead:<matchup>:<n> —
so a line seen in three polls is one touchdown and a corrected-then-restored
one is still one. The first look at a week seeds silently. A touchdown that
takes the lead is one card, not two. supabase/tests/sunday_events.sql plays
a fourth quarter through it look by look.
The page reads events from ff_sunday, refetches on a sunday_events
row, shows each play's Fantasy Impact (both sides before and after), lets a
level-4 moment hold the featured slot for ten minutes, and leads the ticker
with the last half hour.
/preview/sunday runs an invented week (@/lib/fixtures/gamecenter) —
sixteen real fixtures, twelve lineups of real players — from Sunday morning
to Tuesday, with a switch that takes the provider down.
tests/e2e/sunday.spec.ts holds it.
Phases 1–5 built the game center as a broadcast: the game to watch, the red zone, the close games. Game Day 2.0 turns it to face the reader. The question every screen answers on a game day is "what matters to me right now", and the order down the page is the order that question is asked in.
My matchup first. /sunday opens on the reader's own table, the widest and
loudest thing on the page: both managers, both scores at hero size, Winning or
Losing with the board's own win probability (winOdds, the model the
scoreboard already documents — no new one, and none at all with no lineups or
no projections), each side's projection, players remaining and live. Under it
sits the matchup moment — the one thing worth saying right now, or nothing:
a lead that changed hands in the last quarter hour (from the event, and only
while that side still leads), a side down to its last one or two men ("Needs
13.9 · Ray has one player left · Justin Jefferson — MIN"), one man each, a
deficit cut close, or a game inside two. The featured "game to watch" is now
chosen from the other tables, so no game is on screen twice. A manager with
no game that week is told so and gets the league.
What just happened is the last 45 minutes of sunday_events, told
fantasy-first: the NFL fact is the title, the points and who they went to are
the loudest line, and what it did to the game is the sentence under it
("Marcus trails Anthony 127.3–130.6."). Anything that moved the reader's own
score qualifies, however small.
League Pulse is every other table in a line each, ranked the way the brief ranks them: lead changes and comebacks, games within five, big scoring plays, upsets (with the table's own ranks when there are results: "12th-place Lou is leading 5th-place Gus"), monster days, playoff consequences, bench pain, and the week's high — two lines a table at most. A line about something that happened is only written from an event; a line about something that is comes from the board in the present tense. "As it stands" re-ranks the table by the standings' own rule (wins, ties, points for) with every current leader winning, and speaks only when someone crosses the playoff line or moves three places. Bench pain waits until both the reserve's and the starter's games are over, and needs a ten-point swing at a slot the reserve could have filled.
All of it is src/lib/gameday.ts, pure, over the same ff_sunday payload — the
front page and the matchup page use the same functions, so the three screens
cannot disagree about one game.
The ticker crawls at a fixed reading speed (48px a second, so a long Sunday
is not a faster one), pauses under a pointer or keyboard focus, and under
prefers-reduced-motion stands still as a swipeable strip. The copy that
makes the loop seamless is aria-hidden and out of the tab order. It now
carries the pulse's monster days, bench pain and playoff lines too.
Two new events. 20261002023710_sunday_swings adds ff_sunday_swings,
run after the detector and the moments pass: monster_game (a starter past
monster_points, 30 by default — in this week's lineup, so a benched 33
counts for nobody) and tightening (a side once down tightening_from, 15,
now within close_margin: "Toby cuts Mike's lead to 4.7 / Was down 18.2",
from the worst deficits the moments pass already keeps). Both thresholds are
commissioner dials. Applied 2026-10-02. supabase/tests/sunday_swings.sql
holds it.
tightening was first told once per lead, keyed by the leader and the count
of lead changes. In a game that changed hands every few minutes it re-armed
after every flip, each time with the same "Was down" (the week's worst), so
week 6 told one comeback six times. Once game-day push sent it, each of those
would buzz a phone. 20261027002416_tightening_once_per_side keys it by the
matchup and the side that was behind, so a side's climb back is told once a
week. The other side's climb is still its own story. The swings test now
plays a seesaw and expects silence.
NFL games are ordered live-first by how many Steakhouse starters are in them, each card says how many, and when the slate cannot be read the panel says the NFL feed is down while every fantasy score stays on screen. Before kickoff the page names the real next game off the slate and when the board starts moving.
The front page on a game day. Under Tonight's Table: who plays today, mine
and his by name (Thursday night's receiver, Monday's tight end); and once
football is on, the latest event in my game, its moment, the league pulse and
the door to the game center. That half is ff_sunday as a second call, made
only on a day it can say something, and refetched on sunday_events.
The matchup page carries the same moment under its scoreboard and this
game's own events below it, read from sunday_events in the same refetch as
the board.
The tab bar on a phone is Tonight, Matchups, My Team, Chat and More.
While an NFL game is live, Game Center takes My Team's slot. This is
phoneTabs in Shell.tsx, which asks the same question as the red dot
(useNflLive: one head-only count of in-progress nfl_games, shared by every
page, every two minutes while visible). The calendar is not the trigger:
Sunday morning before kickoff is not live, and Thursday night is. The lineup
is set by then, and the afternoon is spent on the game center.
Chat carries its unread count, and Standings is on Tonight and in More. The desktop nav keeps every destination where it was.
/preview/sunday now has a seat switch (cruising, getting crushed, a
tight one, a fresh lead change, no matchup), a Monday night stage, and an
NFL feed down switch; tests/e2e/gameday.spec.ts walks the brief's
scenarios through them and checks nothing scrolls sideways at 375, 390, 430,
820 and 1280.
Sunday is read on a phone held in one hand. On a touch screen
(pointer: coarse) every game-day control gets a target near the 44px the
tab bar already takes, in two ways:
::after, so nothing around them moves.The mouse keeps the tighter sizes (sunday.css, "thumbs", and the
coarse-pointer block in globals.css). Two of those reaches are cut short so
they never take a tap from a neighbour:
tests/e2e/thumbs.spec.ts measures what a finger meets: the run of points
through each control that land on it. It also checks that no control's reach
covers another's drawn box.
On a tablet, or a desktop with the chat open, the two lineups sit side by side in about 280px each. "@ MIN Mon 8:15 PM" used to leave a name eight letters: "Rome O…". In a panel 521–760px wide the name now takes the whole line, and the game's status moves to the line under it, beside the position.
Push was built for what a manager cannot look up in time — an offer, a claim, a bet, his name in the chat. A Sunday is the other thing: the game center says what every play meant to your matchup, but only while you are looking at it, and on a Sunday you are looking at the television.
20261021001736_gameday_push puts a trigger on sunday_events, so the
server's own record of what happened also writes to the outbox — inside the
detector's transaction, drained by the existing /api/push/drain like every
other kind. Two kinds, each with its own switch on the account:
(In production it was applied in parts, because the Supabase connector holds
any statement containing drop for a confirmation that never arrives. So the
old six-argument ff_set_notification_prefs was renamed to
ff_set_notification_prefs_retired with every grant revoked, not dropped.
Drop it from the SQL editor whenever convenient; nothing calls it.)
| kind | for | default |
|---|---|---|
gameday | your own matchup: a lead changing hands (either way, with the play that did it), a comeback, a deficit cut close, a monster game on either roster, a season high, a game inside a point late, the final | on |
moment | the league's Steakhouse moments (level 4) in other people's games | off |
ff_gameday_words writes each event from the reader's side of the table —
"You take the lead over Ada · You 110.8, Ada 109.0. Push Star +6.8." to one
manager, "Bo takes the lead · Bo 110.8, you 109.0." to the other — and every
seat at the team hears it, co-owners included, each by their own switch.
Restraint. An ordinary touchdown is not a push; one that takes the lead is, as the lead change it is. A lead change and a final always go out; a squeeze, a monster game or a comeback (which is written straight after its lead change) waits if this manager was pushed about game day in the last two minutes. Moments are at most one every ten minutes. The service worker tags by kind, so a new game-day push replaces the last on the lock screen instead of stacking.
Safety. The trigger swallows its own failures into ingest_log
(push / gameday_failed): ff_sunday_detect_all runs a league's whole pass
in one block, and a push problem must never cost the league its events.
Asking. One device in the league had push on, so the game center now asks
— once, under your own matchup, on a game day, while push is off on that
device: "Get buzzed when your matchup swings. Lead changes, comebacks and the
final — not every catch." Not now is remembered per device. The settings
card gains both switches, and the @mentions switch that had been missing from
it since mentions shipped; a switch the database has not reported yet is
neither shown nor sent, so the page works ahead of the migration.
supabase/tests/gameday_push.sql holds the restraint.
Who could be asked. After the first Sunday with push live (week 7), the league still had one device on push: a Mac, not a phone. Thirteen accounts had been active in the fortnight. The asking had three gaps:
usePush reports ios-needs-install, and the game
center's offer only spoke to off. Now the offer says what to do instead:
"On an iPhone, alerts start once the app is on your home screen", with
Show me how linking to /install.off): "Turn on alerts on this device." Not now is remembered
per device, apart from the game center's own offer (useOfferDismissed in
src/lib/push.ts).A device already on, blocked, or unable is not asked; the settings card on
My Team says why. /preview/sunday has an iPhone in Safari switch, and
/preview/tonight shows the front page's offer.
Tonight's Table answers three questions in the first second. The clubhouse is not one of them — it is the thing that makes a manager open the app a fourth time on a Tuesday — so Overheard sits under the card: the last four lines said anywhere in the league, and, above them, the thread on your own table with the last thing said in it.
A line said on a matchup card arrives with the game it was said about and links
to that week's board; a line said in the room carries nothing. The feed is one
call, ff_clubhouse_feed, and it is deliberately a second call made in the
browser after the card is painted, for three reasons in order of weight: it is
a secondary feed and should arrive after the card, not with it; it refetches on
league_messages alone, where folding it into the briefing would re-run the
head-to-head history and the playoff seeding every time somebody typed a
sentence; and restating a four-hundred-line function to add a footnote to it is
how a working function gets broken.
RoomBoard is pure and Room is the live one around it, so /preview/tonight
renders the feed from a fixture. The section is .club, not .room: the
carousel of six tables already wears that class.
Two things this found. The desktop nav had eleven destinations in uppercase
at 0.13em tracking, and after Scores → Matchups and Chat → Clubhouse it no
longer fit a 1200px laptop — the header scrolled sideways. The items are now
sentence case at normal tracking, which is both a third narrower and the
legibility fix the review asked for, and the breakpoint where the tab bar hands
over moved from 1080px to 1180px. tests/e2e/tonight.spec.ts now asserts that
the page never scrolls sideways at either viewport.
The recap existed twice and neither one was a post. recapText writes the week
for a share sheet, which needs somebody to press share; Tuesday's briefing tells
you what happened to you. Neither leaves anything behind for the league to
argue with on Wednesday. So the house writes it, once a week, into the clubhouse.
| function | does |
|---|---|
ff_week_recap | the facts: every result, the high and the low, the widest margin, the closest game, the week's best player, and the bench decision that cost somebody the game |
ff_recap_body | those facts as prose, in the house voice |
ff_publish_recap | writes the recap and posts it — idempotent on (league, week) |
ff_post_weekly_recaps | the weekly-recap cron, daily at 13:00 UTC |
The Weekly Special · Week 11
Tom 130.1 — Nate 83.9
Dave 142.6 — Marcus 118.2
Sam 104.2 — Kai 103.4
Tonight's Specials: Dave, 142.6.
Sent back to the kitchen: Nate, 83.9.
The Bill: Tom by 46.2 over Nate.
Last Call: Sam edged Kai by 0.8.
Left on the pass: Priya sat Trey McBride (22.4) and lost by 5.1.
Player of the week: Puka Nacua (LAR), 34.2, for Dave.
The bench line is the one with a test in it. Naming the highest-scoring reserve of the week is trivia; the line only earns its place when sitting him actually lost the game, so it names a loser whose best reserve beat their worst starter by more than the margin of defeat, and stays silent otherwise.
Two decisions worth knowing. The prose is a separate immutable function from the
facts, so the words can be tested without a played week — which is the only way
this got tested at all, the league having not kicked off yet. And the cron runs
daily, not on Tuesdays: the guard decides when a week is actually over, so a
flexed game, a holiday or a missed run still gets its recap the day the week
finishes. /admin has the same button for the week the scheduler missed.
Verified end to end on a throwaway branch, 2026-09-04. A seeded week 5 —
twelve teams, 168 players, six games — was run through ff_post_weekly_recaps()
exactly as the scheduler calls it, and every claim in the posted body was then
recomputed independently and compared:
| claim | check |
|---|---|
| six result lines | each matches a real game, ordered by descending margin |
| high / low | equal to the max / min team score of the week |
| The Bill / Last Call | equal to the max / min margin |
| player of the week | the top starter (22.8) — not the 26.0 on a bench |
| left on the pass | the only one of twelve teams that qualifies, swing 19.1 against a 5.1 defeat; the manager who lost by 0.9 is correctly passed over |
| idempotency | a second cron run does nothing, a second publish says "already written", an unfinished week says "nothing played"; one recap row, one post |
kind / author_id | a house post with an author, a manager post without one, and a third kind are all rejected by the check constraints |
The mine fix was confirmed to be a real bug and not a theoretical one: against
a house post, author_id = auth.uid() evaluates to NULL, and only the
coalesce(..., false) turns it into the boolean the browser expects.
A note if you try this yourself. When this was run, supabase/migrations/
still started at 2026-08-26 — the core schema had been applied directly and
never checked in — so a fresh Supabase branch could not build itself from this
repo and came up with zero tables. The verification above therefore installed
the slice the recap touches, copied from production via pg_get_functiondef
and pg_get_viewdef, and confirmed the five recap functions matched production
byte-for-byte once comments were normalised away. That workaround is no longer
necessary: the directory is complete and replays, and npm run check:replay
will build the whole schema on a local Postgres in about a minute. The one deliberate
substitution was ff_score, stubbed to read {"pts": n} so the harness could
set every player's score exactly; the recap never looks at how a player earned
his points, only at which player has the most.
A house post is a message with no author: league_messages.author_id lost
its NOT NULL and gained a kind, with a check constraint that keeps the two in
step ((kind = 'house') = (author_id is null)) — a manager's line must still be
signed. The alternative was posting as the commissioner, which is a lie about
who wrote it. It renders in Overheard and in the clubhouse as a column: a gold
rule, the serif, and the line breaks it was composed with.
The column is written from final scores. A final score says nothing about how
the game went: 120.7–112.8 reads the same whether it was never close or
changed hands four times. The detector stored the rest in sunday_events as
it happened. /recap/N now reads it back under the column, in a card called
How it happened:
/sunday?week=N).All of it is weekStory in src/lib/recap.ts, over loadWeekStory
(src/lib/sunday-load.ts). The loader asks for the week's finals, comebacks,
monster games and season highs by kind, plus every play that changed a lead.
It does not take the newest few hundred rows, because a busy Sunday writes
hundreds of scoring rows and the oldest lead changes are the ones it would
lose. Its rules:
eventStory, the same words the Game Center used on the day.No migration. /preview/recap tells it from the Game Center test data's
Tuesday; its second switch shows a week from before the feed.
The wall's Sunday lines (ff_sunday_history) count what a manager did. The
same stored events also say what was done to him, and which afternoons two
managers shared. Both now appear where people look for them.
/history?manager=…) has an On Sundays block:
late
flag);Both are sundayLine and rivalrySundays (src/lib/sundayline.ts), over one
fetch, loadCareerEvents (src/lib/sunday-load.ts). That fetch asks for every
lead change, comeback and monster game by kind, all seasons. "Changed the lead"
is the recap's own changesLead, so a comeback is never counted twice and the
three screens agree.
A manager with nothing stored gets no block at all, not a row of zeros. Nothing is inferred from final scores.
No migration. /preview/history?manager=Anthony and /preview/matchups show
them from a short script of Sundays (src/lib/fixtures/career.ts).
The playoff simulation on /standings did exactly what it was asked before the
draft — drew twelve identical teams from one distribution and reported that all
of them had about a coin flip's chance, with a projected 7–7 apiece. Every
number correct, and the screen a lie: analytical theatre about a league that
has not happened yet.
oddsCanSeparate is the gate. Until a result exists or a drafted roster can be
projected from, the board withholds the odds, drops the columns that would be
zeros, lists the teams alphabetically so the order claims nothing, and says
that odds unlock after the draft. A league that has drafted but not kicked off
still gets its odds, with the note that they lean entirely on projected
lineups. The same rule applies on the Sunday board: no rosters, no odds bar.
/preview/standings and /preview/admin render the standings board and the
rule editors from fixtures; the standings preview switches between the
preseason state and week 11. The commissioner's old dashboard — readiness
checklist, roll call, automation health — lives on at /league.
Two names were doing two jobs each. "Scores" is the week's matchups, and calling it Scores made it read as a results page rather than the screen you watch: the tab is Matchups. And the room is the Clubhouse everywhere in the app except the tab that opened it, which said Chat; a product with two names for one place has neither. Commish tools keep their place in the bar and lose their equality — a rule before them and a crown on them — because sitting them at the same weight as My Team told eleven managers to read past that whole end of the bar.
And four tabs were one thing. Players, Waivers, Trades and Ledger are four
views of a single question — how a roster changes. Every move starts from the
player pool, all three systems write the same rows to transactions, and the
ledger is the account of all three. So they are one destination, Transactions,
with the four as tabs inside it, and the bar is three entries shorter.
/transactions?tab=players|waivers|trades|ledger is the address of each, and
the old /players, /waivers, /trades and /ledger still resolve — they
redirect to their tab, because they are in bookmarks, in the url a push
notification carries, and in the next= a sign-in hands back. Only the open
tab is mounted: each panel opens its own realtime channel and its own poll, and
running four of those behind three hidden tabs is four times the traffic for
one screen anybody is reading.
The /preview routes are public. They read nothing from the database; every
one is an invented league rendered through the real components, which is what
lets tests/e2e/tonight.spec.ts assert the card's sentences without a session.
The transaction log already knew how to say this. transaction_items carries a
from-team and a to-team on every row, which add/drop and waivers only ever
used one half of; a trade is the case both halves were designed for. So an
executed trade is one kind = 'trade' header with an item per player, and
ff_owner_at needed no changes — the last word about a player is now sometimes
"he went that way".
What is new is the negotiation, which is not a transaction at all until somebody
says yes. trades is the offer on the table; nothing reaches the log until it
is accepted, and a declined offer leaves no mark on any roster. A counter is
a new offer that closes the one it answers and links back to it, so a
negotiation reads as one thread rather than four unrelated rows. The block
is a notice board: players their manager will listen about, so a manager with a
surplus at running back need not message eleven people to find who needs one.
The deadline is the league's own — settings.trade_deadline_week has said 12
since the league was configured.
Everything is validated twice, when the offer is made and again when it is accepted. That is not belt and braces: an offer sits on the table for days and a roster does not, so what was legal on Sunday can be nonsense by Wednesday. Both sides must still own what they are offering, neither man may have kicked off, and neither roster may end over the limit — uneven trades are fine, over-full ones are not. Accepting also invalidates every other live offer naming a player who just moved, with a sentence saying why, rather than leaving the next manager to discover it by pressing accept on something that cannot happen.
A live offer is between the two managers in it. The league sees it once it
is settled. That is an RLS claim rather than a function check, so
supabase/tests/trades.sql sets role authenticated and real JWT claims and
asserts a third manager sees neither the offer nor its contents — the one place
in the suite where the policies themselves are exercised rather than the
SECURITY DEFINER functions in front of them. Every other check in these files
runs as the database owner, which bypasses RLS entirely.
Verified by 24 SQL checks and 5 e2e checks against /preview/trades, which
has an open desk and one after the deadline. The e2e caught a real split: the
"Make an offer" button lived in the page while the deadline that disables it
lived in the desk component, so the two could drift. It is one component now.
Until the run-up the deadline was a number — "deadline: week 12" on the desk — and the moment itself happened silently at a quarter past midnight on a Tuesday. Nobody was told it was coming, and an offer still on the table when it shut sat there as "proposed" for the rest of the season.
The moment is exact. ff_current_week() moves on four hours after the
week's last kickoff, and ff_validate_trade refuses once it has, so trades
close at last_kick + 4 hours of the deadline week. ff_trade_deadline_at
computes exactly that from the same view, so the countdown and the rule cannot
disagree. In 2026 that is Tue Dec 1, 12:15 AM ET, after Monday night's game.
ff_trade_deadline(league, team) is the read: when it closes, whether the
market is open, and what is in play — offers on the table, players on the block,
trades done, and the offers waiting on the caller's own team (only for a caller
who sits there).
ff_trade_deadline_tick, every fifteen minutes (trade-deadline), does
three things once each per league per deadline. A week out, it pins a house
line on the League Feed, posts a deadline activity event and pushes every
manager. On the last day it pushes again, naming the offers waiting on each
manager. At the deadline it expires every offer still proposed ("expired at
the trade deadline"), tells both sides of each, unpins the week's line and
says the market is shut. Each push is kind trade, so the trades switch
silences it.Bookkeeping is one row per (league, deadline instant, stage) in
trade_deadline_posts, so re-running is a no-op and a moved deadline gets a
fresh run-up. Each stage has a window: a job that first runs inside the last day
skips the week post rather than sending both at once, and nothing is announced
for a deadline more than two days gone. A league still drafting hears nothing.
Verified by 24 SQL checks (supabase/tests/trade_deadline.sql, driving
the job with an explicit clock) and e2e on /preview/trades (Open, Deadline
week, Last day, Deadline gone) and /preview/tonight (the deadline toggle),
both on fixed clocks so they hydrate the same whenever they are opened.
The Waivers tab is the screen the waiver backend was built for. It says when the next settlement is and that claims are blind until then; it lists a manager's own claims in the order they will be answered, because the run gives a team at most one claim per pass and which one is first is therefore the whole decision; it lists who is on the wire and when each clears; and it shows the league's running order, which is the only place a manager can see that winning a claim costs him his place.
A claim may name who makes way, or not. That is not a shortcut — it is the difference between a claim and a signing. A claim settles on Wednesday against the roster the manager has then, so naming a drop says "take him anyway" and leaving it blank says "only if I have room". The run enforces exactly that and marks the second kind invalid, in those words, when there is no room.
The Players tab had to learn about waivers too. A dropped player is unowned, so without this it would have offered a Sign button that the database refuses every time; on-waivers players now get a Claim button instead, and the free-agent count stops counting them.
Verified by tests/e2e/waivers.spec.ts — six checks against
/preview/waivers, a fixture that reads no database, because the real screen
needs a session, a drafted league and somebody to have dropped a player. It has
a Busy and a Quiet state, since an empty wire is what most Tuesdays look like
and "explains itself rather than showing nothing" is a claim worth testing. One
of the six caught a real bug: a disabled button reading Claimed still
announced itself to a screen reader as Claim Jaylen Wright, which is the
opposite of what the screen says.
A roster used to exist only as rows in rosters, one set per week, written by
ff_seed_rosters from draft_picks and called by hand for week 1. Nothing
carried it into week 2 — ff_team_hub reads rosters where week = ? with no
fallback, so week 2 rendered an empty team. That was survivable before kickoff
and unbuildable-on the moment anyone wanted to change their side.
So ownership is now derived, not stored: a player belongs to the team his
last transaction gave him to, or to the team that drafted him if he has none.
transactions is the log, ff_owner_at(league, week) is the derivation, and
rosters is demoted to a cache that ff_materialize_roster rebuilds from it.
Every existing reader — the team hub, roster_points, scoring, standings, the
recaps — keeps reading rosters and did not change.
The log is a header plus items rather than one flat row, because the next three asks are waivers, trades and a transaction history. A trade is one header with four items; a waiver claim is one header with a status; an add/drop is one header with two. None of those needs a new shape.
What the database refuses, all of it in ff_add_drop and none of it in the
browser: a signing before the draft is complete, a week other than the current
one, a player you do not own, a player already on someone's roster, either side
of a move once his game has kicked off, an empty move, the same man on both
sides, and a bare add onto a full roster. The last one is what opens the drop
picker — the browser does not pre-empt it, because the cap is the league's, the
count is derived, and a page that greyed the button out in advance would be
wrong exactly when two managers want the same man. An add and its drop go up as
one call, so nobody releases a player for a signing that then fails.
Three of those guards were missing from the first cut and found by review
before the league had drafted. Each was reachable by any manager with a team.
A signing before the draft writes a transaction that ff_make_pick cannot
see — it rejects duplicates through a unique constraint on draft_picks alone —
so the player stays draftable, and ff_owner_at then hands him to whoever
signed him, stripping the team that spent a pick. A week chosen by the
caller sits invisible against the current week and then outranks every
legitimate move made in between, because the derivation orders by week before
ord. And two managers claiming the same free agent both read a null owner
and both succeed, because a log cannot express "current owner" as a unique
constraint; the serialization is a per-league advisory lock held for the
transaction, which closes the same race on the roster cap for free. Resetting a
draft now clears the league's moves and roster cache as well as its picks, since
clearing one without the other leaves rosters disagreeing with the draft board.
Two things carry the model. transactions.ord is a sequence, not a timestamp:
inside one database transaction now() is frozen, so two moves tie and the
derivation picks the wrong one — a bug the test suite caught rather than a
theory. And ff_materialize_roster refuses to act on an empty derivation: a
league with no picks and no moves has not drafted, which is not the same as
"every roster is empty", and treating it that way would let the daily roll
delete rosters it cannot rebuild. That is the state this project sits in all
preseason, so the guard is the normal case.
ff_roll_rosters runs daily at 09:20 UTC and materializes the current week for
every league — daily rather than weekly for the same reason the recaps are: a
flexed game or a missed run should not cost a league its rosters, and
materializing a week that is already correct is a no-op.
Verified by supabase/tests/add_drop.sql — 27 checks against a seeded
two-team league, run by npm run check:replay (and by CI) on a database built
by replaying every migration, and rolled back at the end so it leaves nothing
behind. It covers the cap, each
refusal, ownership moving both ways, the cache agreeing with the derivation, a
re-signing of a player dropped earlier (the case a naive "has he ever been
dropped" rule gets wrong), a week-5 move not leaking back into week 4, and the
week-6 roll inheriting week 5's lineup. The authenticated paths are exercised
through real JWT claims rather than the service-role hatch, so the ownership and
current-week checks are tested as a manager meets them. The empty-derivation
guard, the pre-draft guard and the advisory lock each have a test confirmed to
fail without the code that satisfies it — the pre-draft one is a cap-neutral
swap, so nothing but the draft check can refuse it.
npm install && npm run devsrc/lib/config.ts):
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYsupabase/migrations/ are already applied to project
ojhjrxolrsppircyrcff.Release verification: npm run build and npm run test:e2e. For anything
touching supabase/migrations/, also npm run check:migrations (filenames and
bodies against the recorded history) and npm run check:replay (the directory
actually builds an empty database). CI runs both.
ff_link_me binds their account to their team
automatically. The name is what the league sees; the email stays on /admin.A full 12-team, 15-round mock draft was run on a throwaway league and torn down. Verified: snake order across round turns, off-clock rejection, duplicate-player rejection at the DB constraint, pause banks remaining time / resume restores it, undo rolls the board back and frees the player, cron autopick honours the team's queue first then falls back to ADP, and the endgame forces required slots (every team finished with exactly one K and one DST).
Result: 180 picks, 180 distinct players, 180 distinct pick numbers, 15 per team. Post-draft seeding produced a complete legal lineup for all 12 teams with zero illegal slot assignments, and the schedule generated 84 matchups over 14 weeks with no duplicate pairings.
drafts records started_at — when the commissioner actually pressed the
button — and nothing for when she said she would, so the room could tell you it
had not started and could not tell you when it would. That answer lived in a
group chat.
DRAFT_STARTS_AT in src/lib/config.ts is the instant, in UTC, and
DRAFT_START_LABEL is the time as the league was told it. The draft room counts
down to it in the clock card's own face, and Tonight's Table carries the same
count on the front page — both in server time, like every other clock here, so a
phone that is forty seconds fast does not get its own answer.
It is a constant rather than a column on purpose: it is one evening, once, and a
schema change cannot be applied until it has been reviewed and merged, which is
longer than a draft is usually away. Note that the value is an instant, not a
wall clock — the league was told "7:30 Central", and in September Central is
CDT, so the stored value is 00:30Z the following day. Set it to null after
the draft and the countdown disappears everywhere it appears.
A manager who cannot be at their phone can hand the whole draft to autopick
rather than burning ninety seconds on each of fifteen picks while eleven other
people wait. The switch is at the head of the Queue tab in the draft room —
on the queue, because the queue is the list it drafts from — and the clock
carries an auto draft chip while it is on, since the point of the setting is
that you are not watching the screen when it fires.
It sets teams.auto_draft, through ff_set_auto_draft(team, on) — owner or
commissioner only, the same guard as ff_set_queue. ff_tick_drafts then
treats that team as due the moment it is on the clock instead of waiting for
pick_deadline, and picks through exactly the path a timeout takes: the queue
first, then ADP under the position caps, then the endgame need rules. A row of
managers all on auto resolves inside one 5-second tick, and the run stops at the
first manager who is playing.
The same migration also pins cron.schedule('draft-tick', ...). The job had
existed since the first draft but only in this project's dashboard — no
migration created it, so a database rebuilt from these files came back with
every draft function present and nothing calling them. Clocks would have
expired and simply sat there, which is the one failure that looks like nothing
being wrong.
SMTP was removed from the critical path. The commissioner's list of team emails
is the invite list; a manager sets their own password at /join, and
ff_email_invited gates it. Nothing is ever sent by mail.
Requires Confirm email OFF in Supabase → Authentication → Sign In / Providers → Email. With it on, signup tries to send a confirmation and fails.
Because signup is open, reads are gated by ff_is_member() rather than by
merely holding a session: a stranger who signs up sees an empty app. Draft
queues are visible only to their owner and the commissioner.
Supabase URL and publishable key are hardcoded in src/lib/config.ts — see the
comment there for why env vars were removed.
Signing in is the one moment the app can afford some theatre, so it takes it.
On the way out of /login and /join two black doors close over the form,
lean on the latch, and swing open towards you; the house mark is drawn once
across both leaves in brass, so it splits down the seam as they part, and the
light from inside catches their leading edges as they go.
Behind them is a room, not a light box: near black, thick with smoke, cut by five hard shafts off a lamp at the far end. Haze comes up first, because a beam is only a beam once there is something in the air for it to land on; the smoke is nine plumes on nine clocks, screened over the dark so the shafts brighten it rather than paint over it. You walk at the lamp, it blooms, and the screen washes white — the page changes under that, and the last of the smoke drifts off the top as the overlay dissolves.
DoorsHost lives in the root layout rather than on the two auth screens
because the whole point is that it outlasts them — a layout is the only thing
in an App Router tree that survives the router.push it is covering. The
screens themselves only call enterThroughDoors(go); that hands go back
2.6s in, under full white, and takes the overlay off the screen at 3.5s whether
or not the route ever arrived, so nothing can be left sitting on top of the
app. Under prefers-reduced-motion it navigates immediately and mounts
nothing. Going through the doors also marks the ink curtain seen for the
session, because the two of them back to back is the same beat played twice.
A phone gets four of the plumes and one of the shafts taken out and the fan closed up, because a shaft at 38 degrees leaves a 390px frame before it reaches the floor — and because blur that wide, that many times over, is what costs a phone its frame rate.
/logo-full.png is ink on paper and would be invisible on a black door, so the
crest is redrawn as inline SVG in cream and gold at the logo's own proportions,
its ring cut as the two arcs the wordmark leaves behind. /preview/doors plays
the whole thing on demand against a stand-in for the page behind it.
The filenames in supabase/migrations/ deliberately match the version values
already recorded in supabase_migrations.schema_migrations on the live project.
Every migration in this directory has ALREADY been applied.
That matters because the repo is connected to Supabase: on push, the integration applies any migration whose version is not in the remote history. Matching the recorded versions makes these a no-op instead of a re-run against production.
A recorded version with no file is harmless. A file whose version is not recorded is the dangerous direction, because that is the one the integration would run.
The gate above had a hole in the shape of its own instruction. A file whose version was not recorded got rejected with "apply it first, then name the file after the version that comes back" — so the only way to get a new migration through CI was to run it against production before a pull request existed. The check built to stop production being written to by accident was forcing it to be written to before review.
On 2026-09-05 that cost three manager-reachable holes in ff_add_drop — a
signing before the draft, a caller-chosen effective week, and two managers
claiming the same free agent. All three were live before anyone read the SQL,
and all three were found by a review bot reading the migration afterwards.
supabase/pending_migrations.txt is the missing third state: checked in, not
yet applied. A migration named there may have an unrecorded version while it
is reviewed, and is applied after it merges.
1. write supabase/migrations/<timestamp>_<name>.sql
2. add <name> to supabase/pending_migrations.txt
3. open the PR — CI accepts it, and prints what it would arm
4. merge
5. apply it; rename the file to the version the database assigns
6. move <name> into applied_versions.txt
The original protection is untouched. A mis-stamped file for something already applied is not listed as pending, so it still fails exactly as before — and the list cannot rot, because a name with no file and a name whose file is recorded are both errors.
And CI now says what a pull request would arm. The three holes were
reachable because the migration that created ff_add_drop granted it to
authenticated in the same breath — live, callable and unreviewed together. So
for every pending migration the check prints the functions it would make
callable:
1 migration is checked in and NOT applied:
pending 29990101000000_brand_new.sql
what they would make callable once applied:
29990101000000_brand_new.sql
authenticated: ff_demo
That is a reminder rather than a rule — a grant has to happen somewhere. But
landing a new RPC revoked and arming it in a follow-up migration, once the
feature is verified, keeps the blast radius of an unreviewed mistake at zero,
and the line above is the one a reviewer is being asked to vouch for. A grant to
anon warns on its own: 20260824034323 revoked anon from every function in
public deliberately, and ff_share_card is the league's one exception.
Until 2026-09-04 the directory started at 2026-08-26: everything before it had
been applied by hand and existed only in the remote history. That was tolerable
for production — the integration only pushes forward — and fatal for anything
that has to build the schema from nothing. A fresh Supabase preview branch
replays this directory against an empty database, so it came up with zero
tables, which is why the main preview branch sat in MIGRATIONS_FAILED.
All 29 missing migrations are now checked in, recovered verbatim from
supabase_migrations.schema_migrations.statements — the database keeps the SQL
of everything it has run — and each file was verified byte-for-byte against its
recorded statement by md5 before being committed.
One file is different, and deliberately so.
20260809014900_enable_extensions.sql is the only migration here whose version
was chosen rather than assigned. http (the loaders' fetch) and pg_cron
(every scheduled job) were enabled by hand before the first migration ever ran,
so they appear nowhere in the history — and a fresh database therefore died at
the first cron.schedule. The file is back-dated to sort ahead of
core_schema, because a prerequisite that runs after the thing needing it is
not a prerequisite. Its statements are create extension if not exists, so on
the live project it is a strict no-op; it was executed there, changed nothing,
and was then recorded so the version and the file agree.
What was verified, and what was not. On a throwaway branch, an empty
database was confirmed to lack both extensions, cron.schedule was confirmed to
fail on it, and that migration was confirmed to fix both. A complete 66-file
replay was not executed at the time, which left the real claim — that this
directory builds a database — untested. It has since been executed. See
The directory builds, and that is now a test below.
The files were then compared against what actually ran, and two had drifted.
Only 29 files are byte-identical to their recorded statement. The rest differ,
and almost all of it is nothing: 17 differ only in line endings or a trailing
newline, 5 only in comments, and the rest only because the SQL editor reflowed
the statement onto one line or spelled a dollar-quote $fn$ instead of $$.
Normalize those away and 62 of 66 files say exactly what was run.
Two did not, and both would have made a rebuilt database quietly wrong:
20260829120826_player_card had lost a nulls last from the depth-chart
ordering. desc alone sorts nulls first in Postgres, so a rebuilt
ff_player_card would have ordered the depth chart differently from the live
one. Production has the nulls last; the file now does too.20260826023254_harden_commissioner_authorization was missing the two
revoke statements it ends with. The live project has ff_current_week
revoked from public and anon; a database built from this directory would
have left it callable by anyone. That is a security migration that had been
checked in with its security half removed.Four files still differ for reasons that are argued rather than accidental, and
they are listed in CONTENT_EXCEPTIONS in the check script: two where the file
adds something harmless (an idempotent revoke, a comment on function), and
two applied through the Supabase SQL editor, which recorded no usable
statements at all — historical_final_standings recorded the sentence
"Applied from the checked-in migration through the Supabase SQL editor."
instead of SQL, and team_hub_projections recorded nothing. For those two the
file is the only record of the migration, and the live schema was checked by
hand to confirm it matches.
This is now checked, not remembered. See the content check below.
So the invariant is one-way, and CI enforces it. npm run check:migrations —
run on every pull request touching supabase/migrations/ by
.github/workflows/migrations.yml — checks each filename against
supabase/applied_versions.txt, the committed ledger of recorded versions. It
takes no database credentials and has no dependencies, so nothing can break it
but the thing it is looking for.
It rejects a file whose version is not recorded, a filename whose name half
disagrees with the recorded one, a repeated version, and anything not shaped
like a migration. It warns, without failing, when two files share a name —
legal (20260829020645 and 20260829021500 are both team_hub) but also what
a migration checked in twice looks like.
@vercel/analytics (src/components/WebAnalytics.tsx, mounted in the root
layout) counts page views by route, device, OS and browser. It is cookieless
and knows no accounts. Before anything leaves the browser the query string is
dropped, because /history?manager=Ada names a person and the path alone says
which page it was.
It is mounted only on Vercel's own builds (VERCEL=1). Anywhere else, such as
next start in CI or a laptop, the collector script is not served, and asking
for it would put a 404 in every e2e run's console.
The data appears once Analytics is enabled for the project in the Vercel dashboard. That switch is not reachable through the API. It exists because "what phones does the league use?" had no answer when push adoption needed one.
Every /preview/* page is prerendered at build time and rendered again in the
browser. A fixture that takes a date from the clock and prints it as a day
writes different text on each side once midnight falls between the build and
the visit. React then refuses the page (#418), and the test asserting no page
errors fails. A CI run that built at 23:59 UTC and tested at 00:01 failed this
way on /preview/challenges. Two pages that no other test opens (/preview
and /preview/admin) had the same fault.
The rule for a fixture: print fixed instants, never Date.now() counted
forward or back. A page that also prints times relative to its own "now"
(the dashboard's "in 13d 21h") fixes that "now" too and counts every fixture
time from it. Relative times counted from each side's own clock, like "42m
ago", are safe. tests/e2e/hydration.spec.ts opens every preview with the
browser's clock a day and a half ahead of the build, so a fixture that breaks
the rule fails the next test run, not one in a hundred CI runs after midnight.
Checking in the missing 29 files made the history complete. It did not make it
replayable, and those are different claims — which is the whole reason the
main preview branch stayed in MIGRATIONS_FAILED after the history was
supposedly fixed. Every file can be a faithful copy of a statement production
ran and the directory can still fail to build, because these migrations were
applied by hand over a month in the order someone typed them, with drops and
fixups in between that were never recorded. Sorted by filename and replayed
cold, they are a different program.
npm run check:replay runs that program. It builds a throwaway Postgres, puts
in the objects a Supabase branch starts with — the anon and authenticated
roles, auth.uid(), the storage tables, the supabase_realtime publication,
supabase_migrations.schema_migrations — and feeds it all 66 files in filename
order with ON_ERROR_STOP, each in its own transaction, exactly as Supabase
applies them. http and pg_cron cannot be installed on a stock Postgres, so
stand-ins are installed under those names; that is what lets
20260809014900_enable_extensions.sql execute verbatim instead of being edited
before it is tested. The harness and its reasoning are in scripts/replay/.
It failed on the first run, twice, and both were real.
20260809022638_sleeper_canonical_player_pool renames all three OUT columns
of ff_load_sleeper_players from (mapped, def_mapped, unmatched) to
(upserted, defenses, still_unmapped), using create or replace. Postgres
will not change a function's OUT parameters that way — it wants a drop function first. On the live project someone typed the drop into the SQL
editor and it was never recorded, so the file as checked in cannot replay.
The drop is now in the file. Production's pg_proc was checked: it already
has the post-drop signature, so the file now matches where production
actually ended up.20260904003458_historical_final_standings seeds 97 rows of ESPN history
keyed to league 11111111-…, and no migration creates that league — it
was made through the app. On an empty database the foreign key fails on the
first row. The seed is now guarded by where exists (select 1 from leagues …),
so a rebuilt database gets the table, the policy and no rows. Seeding a
league to hang another league's nine-year history on would be fabricating
data; a branch legitimately has no league, no teams and no draft. On the live
project, which has the league, the guard is a no-op and all 97 rows still land.With those two fixed the replay is clean: 66/66 against an empty database, producing 27 tables, 132 functions, 5 views, 28 policies and 7 cron jobs.
The job runs in .github/workflows/migrations.yml on every pull request that
touches the directory, so the next migration that cannot rebuild the database is
caught before merge rather than by a preview branch after it.
What the two checks prove together, and separately. check:migrations
proves each file is what production ran; it cannot prove the directory builds.
check:replay proves the directory builds; it does not compare the result to
production. Neither is the claim on its own, and it took both to find these two
— the first because the file matched a recorded statement, and the second
because the statement itself was incomplete.
Still not proven here: that the rebuilt schema is equal to production's,
object for object. The per-file content check is the closest thing to that, and
the honest confirmation is the main preview branch rebuilding green once this
merges.
How much it proves depends on whether CI can reach the database. Set a
SUPABASE_DB_URL repository secret and the workflow reads the real history and
passes it in with --remote; the database then wins over the ledger, so a
stale ledger cannot fail a legitimate file and an invented one cannot pass a
bogus version. Without the secret the check still runs, but it compares the
migrations directory against a ledger sitting beside it in the same pull
request — which a single consistent mistake satisfies: pick a timestamp, name
the file with it, write the same timestamp into the ledger, and the two agree
while the database has never heard of it. The output says which mode ran.
The offline floor is kept deliberately, because a gate that needs a credential
stops working the day the credential expires.
With the database, it also compares contents. The dump carries each
migration's statements alongside its version and name, and every file is
compared against the SQL that ran under it. This is the failure that hides the
longest: the version is already in the history, so the file never runs against
production and nothing breaks — but a preview branch replays the file, and
builds a database that is not a copy of production. It is what had happened to
player_card and harden_commissioner_authorization above.
The comparison normalizes what a migration legitimately picks up on the way to
being checked in — line endings, statements reflowed onto one line, comments,
$fn$ versus $$ — so only a real difference in the SQL fails, and a file that
is the documented version of what ran still passes. Files that differ for a
known reason are listed in CONTENT_EXCEPTIONS in the script, each with the
argument for it; they warn instead of failing, so the list stays visible and
short. Offline the bodies are not checked at all, and the output says so.
The credential does not have to be the postgres superuser, and should not be:
that is full production access sitting in a CI secret to run one select
against one table. 20260902020943_ci_migrations_reader.sql creates a role
that can do exactly that and nothing else — no superuser, no BYPASSRLS, no
role memberships, a connection limit of four, and a single SELECT grant on
supabase_migrations.schema_migrations.
It is created without a password, so it cannot authenticate. The role existing grants nobody anything; it only becomes usable when someone sets one. To finish:
alter role ci_migrations_reader with password '…';SUPABASE_DB_URL repository secret under Settings → Secrets and
variables → Actions, using the session pooler host:
postgresql://ci_migrations_reader.ojhjrxolrsppircyrcff:[email protected]:5432/postgresUse the pooler rather than db.<ref>.supabase.co: the direct host is IPv6-only
and GitHub's hosted runners have no IPv6, so a direct string works from a
laptop and hangs in CI.
You can tell the stronger mode is live from the check's own output — it logs
rows: 54 and says recorded in the database instead of recorded in the
ledger, and drops the note about configuring the secret. To undo the role
entirely: drop owned by ci_migrations_reader; drop role ci_migrations_reader;
When adding a migration, declare it pending and let it be reviewed before it is applied — see A migration is reviewed before it is applied above, which replaced the apply-first instruction that used to live in this paragraph.
Once it is applied, by you or by the integration, name the file after the
version the database recorded for it — not a timestamp you picked — and add that
version to the ledger (the refresh query is in its header). The two differ
because apply_migration stamps its own. Getting this backwards is what left
20260902010000 and 20260902011500 on disk against 20260902005227 and
20260902005322 recorded, and days later checked a whole migration in a second
time as 20260902120000, which would have re-run a drop function against
production. Both are now CI failures rather than things somebody has to notice.
Two files carry content from a version adjacent to their name, both deliberate and both no-ops (see also the caveat above: many files are documented rather than byte-exact copies of what ran):
| file | note |
|---|---|
20260827034158_league_pulse_automation_health.sql | four comment lines the applied version lacks; the code is byte-identical to the live ff_league_pulse |
20260829021956_team_hub_documented.sql | byte-exact for this version; it was previously misfiled under 20260829021500, an empty recorded marker |
Never edit a file that has been applied — replace it with a new migration.