Every page, every table

The full route catalog and the live operational schema behind it, plus the warehouse vocabulary and the one SQL pattern that keeps a galaxy schema from silently multiplying rows. The warehouse's actual findings and the coach-row bug are on the main engineering page's data-model section - this page is everything underneath that.

12 pages
every player-facing route, real URL behind each one
18 tables
live MySQL - user, tournament, operational, and daily-AI tables
125,350 rows
the single largest warehouse fact table - regular-season player scoring
Galaxy schema
3 dimensions, 4 fact tables, sharing the same points on purpose

Every player-facing page, one route each

Twelve real routes, each doing one job - no page tries to be two things at once.

PageRouteWhat it's for
Home/Rules, scoring, payout table; your own entries if logged in
My Entries/my-entriesYour brackets, a live "how am I doing" rank card during the tournament
Submit / Edit Entry/enter, /edit/<id>Draft a bracket; locked once the tournament goes live
Standings/leaderboardFull ranked leaderboard, sortable, with a 30-second live-score widget
Details/report/entriesEvery entry's picks, round by round, filterable by player status
Compare/compareTwo brackets head to head - only-A, only-B, both-picked columns
Prediction/predictProjected final score per entry, from seed-based expected advancement
Today's Games/all-gamesLive and upcoming games, each linked to its ESPN box score
Upsets/upsetsEvery higher-seed-beats-lower-seed result, with affected-entry counts
Player Data/tourney-dataEvery draftable player/coach, season PPG, how many brackets picked them
Submitted/rosterPlain entry + payment-status list, for the organizer to verify who's paid
Daily Update/dailyAI-generated commentary; a code-gated link opens the live generator comparison

The operational schema, as it actually runs

Four tables carry the real weight; the rest are support tables around them.

TableKey columnsWhat it enforces
entriesuser_id FK, bracket_name (unique per year), token, paidOne bracket name per participant per year; a magic-link token for access without a full session
picksentry_id FK (cascade delete), espnPlayerId FK, groupId 1-5Every pick tied to exactly one entry and one seed group; deleting an entry cleans up its picks automatically
password_reset_tokenstoken (unique random hex), expires_at, usedA reset link that's single-use and time-boxed to 24 hours, enforced by two flags, not by trust
score_run_logrun_id (UUID), status (info/winner/playing/ok/error)Every ESPN fetch run's outcome, per game, so a bad run is diagnosable after the fact

Around those four: users and tournament_config hold identity and season state; teams, players, and player_pts are the ESPN-sourced tournament data, reloaded each year and never hand-edited; settings is a plain key-value store for everything from the active year to score-fetch cadence; and a games / player_game_pts / game_odds family holds regular-season data and betting lines - the same tables the warehouse's ETL reads from, never the tournament tables. Three more (daily_commentary, daily_scores_cache, commentary_jobs) exist only to support the AI daily-update feature.

One deliberate quirk, not a bug: a coach pick's row in players reuses that team's own ESPN ID as its player ID, because ESPN's roster API has no independent coach identity to key off. It's the same reuse that produced the 756-row inner-join bug on the warehouse side - see the main engineering page.

Grain, dimension, fact - the vocabulary

Three terms, each with a one-question test that tells them apart. Get the first one right and the other two mostly fall out of it.

Grain
Can you finish "one row of this table = ___" without using the word "and"? Decided first - everything else derives from it. fact_player_points's grain: one player's points, in one tournament game.
Dimension
Would you ever write WHERE or GROUP BY on this column? The nouns - who, what, when, where. dim_team.seed, dim_player.playerName.
Fact
Would you ever write SUM(), AVG(), or COUNT() on this column? The numbers a real process produced. fact_player_points.pts - meaningless without the dimensions saying whose points, in which game.
Degenerate dimension
dim_year has exactly one column, gameYear - nothing to look up. It behaves like a dimension but carries no attributes, so it could live as a plain column with no join at all; it only earns a real table if it grows one.

Drill-across, not a raw join

"Who scored the most in the tournament, then how did they do in the season" is exactly the move a galaxy schema is for - but joining fact_player_points straight to fact_player_points_season before aggregating is the wrong move. Both facts sit at the same grain (one row per player per game), so a raw join multiplies rows for anyone who played more than one game in either fact.

The fix is drill-across: collapse each fact to the grain actually being compared - player, per year - independently, then join those two already-summarized results through the dimension keys they share.

WITH tourney AS (
  SELECT espnPlayerId, gameYear, SUM(pts) AS tourney_pts
  FROM fact_player_points GROUP BY espnPlayerId, gameYear
),
season AS (
  SELECT espnPlayerId, gameYear, SUM(pts)/COUNT(*) AS season_ppg
  FROM fact_player_points_season GROUP BY espnPlayerId, gameYear
)
SELECT p.playerName, t.tourney_pts, s.season_ppg
FROM tourney t
JOIN season s ON s.espnPlayerId = t.espnPlayerId AND s.gameYear = t.gameYear
JOIN dim_player p ON p.espnPlayerId = t.espnPlayerId AND p.gameYear = t.gameYear
ORDER BY t.tourney_pts DESC

By the time the two CTEs meet, neither is a raw fact table anymore - each is already one row per player, so the join can't fan anything out. dim_player only gets pulled in at the end, purely to attach a name to an ID.

Reporting, then statistics, then AI - never the other order

The core question: out of season scoring average, seed, and opponent strength, what actually predicts tournament scoring? Answering it takes three distinct steps, each doing a different job - reporting (a chart, available the moment data loads), statistics/ML (testing every candidate factor against real results), and AI narrative (explaining a confirmed finding in plain English, never discovering one).

DuckDB the warehouse pandas in memory scikit-learn the model matplotlib / plotly explain the finding explore first Looker Studio - plain reporting
Visualization runs twice, not once at the end: an exploratory pass right after pandas, and an explanatory pass after the model. DuckDB feeds Looker Studio directly for reporting that needs no model at all.
An earlier, deliberately unrevived attempt: a standalone pipeline pulling nine years of ESPN box scores into a scikit-learn GradientBoostingRegressor, built on a similar feature set. It's kept as reference for feature ideas only - unregistered, no reachable routes - since this warehouse is the cleaner, more reproducible foundation to build the real answer on.