As sports lovers, we love the NBA, but we also love to watch all kinda of sports. Soccer, Football, Baseball, F1, etc. If its a sport, someone from our group probably watches it and so I wanted something that can easily scale to various sports.
Leagues have various structures and various zones, promotion and relegation schemes, and it should be able to handle any case without requiring a total refactor every time I want to support a new league. This brought me to LeagueConfig , a way to represent any league’s structure in a structured, type safe way so a new league or sport is just a new config file, not new components and a whole refactor.
It started with words, not code. Before building any UI I wrote GLOSSARY.md so the code would speak "sport" and not "NBA":
| Term |
Meaning |
Instead of |
| Entry |
Anything that can be ranked: a team today, a driver or player later |
Team |
| Table |
One ranked list. The NBA has two (East and West), the Premier League has one |
Conference, Group |
| Position |
A rank slot. Each sport names it, and the NBA calls it "Seed" |
Seed |
| Zone |
A labeled range of Positions: Playoffs, Play-in, Lottery (or Relegation) |
|
| Prediction |
A person's guess about how a Season ends |
Pick |
| Standings |
The real results. Never a person's guess |
|
💡 Key finding: early on, "Standings" meant both the real results and a person's guess. Settling that (Standings means real results only) is what makes the planned "score your Prediction against the Standings" feature possible to talk about at all.
The schema uses those words directly, and "Seed" is data, not a hard-coded label:
// src/lib/league-config/schema.ts
export const leagueConfigSchema = z
.object({
league: z.string().min(1),
season: z.string().min(1),
title: z.string().min(1),
/** What this sport calls a position in a table, e.g. "Seed". */
positionLabel: z.string().min(1),
entries: z.array(entrySchema).min(1),
tables: z.array(tableSchema).min(1),
})
.superRefine(checkLeagueConfig);
Zod checks the shape. checkLeagueConfig checks what a shape can't: every Entry sits in exactly one Table, and each Table's Zones cover every Position once, with no gaps and no overlaps. A broken config fails the tests instead of breaking the page.
Configs change, old predictions don't. A team gets renamed or a Zone moves, but a prediction saved last month must still read the way it did. Every Saved Prediction stores a config_version, a 12-character SHA-256 hash of the config it was made on (leagueConfigVersion in src/lib/league-config/version.ts). Replaced configs are archived as data/<league>/history/<season>/<version>.json, and a test checks that every archived file is named by its own hash.