ESPN fantasy football

League updates in Discord. The season on a dashboard.

One Docker container posts ESPN reports on a schedule, answers slash commands, and serves a web dashboard of league history.

Channel

Scheduled posts

Reports land in the Discord channel that owns the webhook. Jobs run only between START_DATE and END_DATE.

On demand

Slash commands

If DISCORD_BOT_TOKEN is set, anyone in the server can ask for the current week. The bot syncs its command tree when it connects.

The season

Web dashboard

A Tuesday snapshot writes the finished week to SQLite. The dashboard charts every season stored in that file.

A typical week

The scheduler follows the NFL game clock for some jobs. Those times stay on Eastern. All other times follow TIMEZONE.

Mon

9:00 AM

Score update

6:30 PM ET

Close scores

Tue

6:00 AM ET

Dashboard snapshot

9:00 AM

Finals and trophies

6:30 PM

Power rankings

Wed

9:00 AM

Standings

9:01 AM

Waiver report

Thu

7:30 PM ET

Matchups

7:31 PM ET

Matchup polls

Fri

9:00 AM

Score update

Sun

9:00 AM

Injury monitor

4:00 / 8:00 PM ET

Score updates

Set DAILY_WAIVER to True to post the waiver report every day at 9:01 AM, not only Wednesday. The matchup polls are native Discord polls, one per matchup, open for MATCHUP_POLL_HOURS (65 by default, closing before Sunday’s early games); set MATCHUP_POLLS to False to skip them, or MATCHUP_POLL_WEBHOOK_URL to send them to one webhook when DISCORD_WEBHOOK_URL lists several. The Tuesday snapshot waits until Monday night football is final. A trade check also runs every hour, every day: each trade is announced once, within the hour it clears.

Slash commands

Slash commands need DISCORD_BOT_TOKEN. If ESPN returns an error, the bot explains that the season may not have started yet.

Command Returns
/matchups The current week’s matchups.
/scoreboard The current scoreboard.
/standings The current standings.
/power-rankings The current power rankings.
/monitor The injury and player monitor report.
/trophies This week’s trophies.
/waiver-report Recent waiver moves. Private leagues only.
/dashboard A link to the web dashboard.

Dashboard

The dashboard reads data/fantasy.db. It does not call ESPN on its own. It opens on This week, the latest collected week for the season. League and Records carry a scope segment — Regular, Playoffs, or Full — that sets which weeks are in play. The regular-season boundary comes from the data. A team’s wins plus losses plus ties is how many games it has played.

This week

Results ordered closest-first, who moved in the standings, the week’s bests, and every team against its own average. Below them, the week’s recap: a few hundred words written by the league recap agent on Tuesday morning, game by game, with a note on the standings and the week’s moves. It appears on its own once the agent has written it. Nothing to configure.

League

The standings board — record, streak, last-five form, points for/against, and a sparkline per row — sortable by seed, points, or recent form. Below it: luck (all-play record and expected wins, with a points-for against points-against quadrant), playoff odds from a simulation of the remaining schedule, projection accuracy per team, the head-to-head grid, the trade and moves ledgers, the week’s power rankings written by the agent, and “the race”: rank by week for every team on one chart.

Teams

One page per team: average, ceiling, floor, projection delta, and points for and against with their league rank; a week-by-week bar chart against the league average; where the points come from by position; bench regrets (the optimal lineup each week against the one started, and the losses that were left on the bench); a game log; range vs the league; and every head-to-head matchup for that team, sorted by average margin. The agent’s own team page also carries a manager’s log of the moves it made and why. The full head-to-head grid stays on the League page.

Players

Leaderboards from every played week’s lineups: points as a starter, starts, average, best week, and boom and bust rates, filterable by position. Every rostered player counts, bench weeks included.

Records

The record book. Twelve season awards, each a row with its scoreline and week, that links straight to the team it belongs to, the all-time book keyed on (year, week), and the champions once a season’s bracket is played out.

Chat

A data chat for league members, behind a shared passphrase. It answers from the site’s own tables (scores, standings, head to head, records, lineups, the draft, trades, moves) with a small model and per-viewer daily caps. It does not give start, sit, or trade advice; that is what /ask in Discord is for, and it says so.

Selecting a team — a standings row, a matchup, a record-book row, or the team picker itself — takes you to that team’s own page rather than filtering a shared chart. The dashboard has no reset button because there is nothing left to un-filter: each destination shows the whole league, or one team, never a muted version of either.

A dropdown in the masthead selects the season. Only seasons in the database appear there. The page polls the database every 30 seconds, so a new snapshot reaches an open tab on its own, advancing This week’s default view along with it.

Reach the dashboard at http://localhost:8000 locally, or at https://fantasy.ethandbard.com/.

Before and after

The dashboard has gone through three shapes. The first version proved the idea — one SQLite file, four stat boxes, four tabs, Bootstrap defaults. It grew into a five-tab dark build with a shared week-range slider. The redesign below restructures that build into four destinations, without changing a single number underneath it.

The old Trend tab: ten teams’ weekly scores on one line chart.

Before — Trend

This week: results, movers, and the week’s bests, open by default.

After — This week

The 10×10 head-to-head heatmap, unreadable under about 900px.

Before — Head to head

A team’s own page: game log, range vs the league, and a nine-row head-to-head strip.

After — Teams

The old Standings table.

Before — Standings

The new standings board, with a sparkline per row and “the race” below it.

After — League

The Trophies tab as a twelve-card grid.

Before — Trophies

The record book: every award a row, every row a link to its team.

After — Records

The very first version, for scale:

The first working build — light theme, a sidebar, four stat boxes, four tabs. Same underlying data as every version since.

How it runs

The entrypoint gamedaybot/run.py starts three parts in one process:

flowchart LR
  ESPN["ESPN API"] --> Bot["fantasy-bot container"]
  Bot --> Hook["Discord webhook"]
  Bot --> Slash["Slash-command bot"]
  Bot --> DB["SQLite in data/"]
  DB --> Dash["Shiny dashboard :8000"]
  Dash --> Tunnel["Cloudflare Tunnel"]

  • A scheduler posts recurring reports to a Discord webhook.
  • A Discord gateway bot answers slash commands, if DISCORD_BOT_TOKEN is set.
  • A Shiny dashboard on port 8000, backed by SQLite in data/.

A Cloudflare tunnel publishes the dashboard. On the VPS that is the shared connector on the edge network. Off the VPS it is an optional cloudflared sidecar with its own tunnel credentials. DASHBOARD_URL in config.env is the link /dashboard returns.

Secrets stay in config.env. Docker Compose loads them through env_file, so no secret is baked into the image.

Where the code lives

Path Role
gamedaybot/run.py Container entrypoint.
gamedaybot/espn/ ESPN access, report text, and the scheduler.
gamedaybot/discord_bot/ Slash commands, webhook client, and embeds.
gamedaybot/storage/db.py SQLite schema and queries.
gamedaybot/web/ Dashboard layout, season math, and charts.
dev/ API health check and season backfill.
tests/ Tests for web/stats.py.
docs/ This overview, the slide deck, and the docs Worker.

The README covers configuration, Docker setup, tunnel setup, and troubleshooting. This page is the public overview. The slide deck is a shorter walkthrough of the same three parts.

Preview and publish this site

The overview and slides are a Quarto website in docs/.

Preview:

quarto preview docs

Render:

quarto render docs

Output lands in docs/_site. Two public URLs serve that folder:

URL How it is served
https://ethandbard.github.io/fantasy-football/ GitHub Pages, from the gh-pages branch
https://fantasy-docs.ethandbard.com/ A Cloudflare Worker that fetches the GitHub Pages copy

Push to main to publish content. The workflow in .github/workflows/quarto-publish.yml renders docs/ and deploys docs/_site to gh-pages. GitHub Pages serves that branch. The Worker at the custom hostname fetches the same GitHub Pages site. Both URLs then show the new render.

The Worker script is proxy-worker.js in this directory. Redeploy it only when that file changes:

cd docs
npx wrangler deploy

A change to the Quarto pages does not need wrangler deploy. Push to main is enough.

Do not run a second copy of the bot stack on another host at the same time. Two schedulers post every report twice. The docs site has no such limit: it is static files only.