Styling & the Gameweek/Season Selector UI

Premier League Predictor: FastAPI & PostgreSQL

Chapter 10 · Styling & the Gameweek/Season Selector UI

Every real route this course has built since Chapter 4 has been exercised against a hardcoded seasonId/gameweekId pair, deliberately left that way to keep each chapter's own example focused. This chapter replaces every one of those constants with a real, working dropdown selector — and gives the whole app the dark-theme styling it's been missing since Chapter 1.

Two Small List Routes This Course Never Quite Needed Until Now

# routers/seasons.py (additions) @router.get("/seasons", response_model=list[schemas.SeasonResponse]) def list_seasons(db: Session = Depends(get_db)): return db.query(models.Season).order_by(models.Season.start_date.desc()).all()
# routers/gameweeks.py (additions) @router.get("/seasons/{season_id}/gameweeks", response_model=list[schemas.GameweekResponse]) def list_gameweeks(season_id: int, db: Session = Depends(get_db)): season = db.get(models.Season, season_id) if not season: raise HTTPException(status_code=404, detail="Season not found") return ( db.query(models.Gameweek) .filter(models.Gameweek.season_id == season_id) .order_by(models.Gameweek.number) .all() )

A Shared Selector, Broadcasting One Event

Every page built since Chapter 4 — fixtures, predictions, both league tables — needs to know the currently selected season and gameweek. Rather than having each of those pages reach directly into a selector's own internal variables, the selector broadcasts a single CustomEvent whenever the selection changes, and every page independently listens for it:

// static/selector.js let selectedSeasonId = null; let selectedGameweekId = null; async function initSelectors() { const seasonSelect = document.getElementById('season-select'); const gameweekSelect = document.getElementById('gameweek-select'); const res = await fetch('/api/seasons'); const seasons = await res.json(); seasonSelect.innerHTML = ''; seasons.forEach(season => { const option = document.createElement('option'); option.value = season.id; option.textContent = season.name; if (season.is_current) option.selected = true; seasonSelect.appendChild(option); }); seasonSelect.addEventListener('change', () => loadGameweeks(Number(seasonSelect.value))); gameweekSelect.addEventListener('change', () => selectGameweek(Number(gameweekSelect.value))); await loadGameweeks(Number(seasonSelect.value)); } async function loadGameweeks(seasonId) { selectedSeasonId = seasonId; const gameweekSelect = document.getElementById('gameweek-select'); const res = await fetch(`/api/seasons/${seasonId}/gameweeks`); const gameweeks = await res.json(); gameweekSelect.innerHTML = ''; gameweeks.forEach(gw => { const option = document.createElement('option'); option.value = gw.id; option.textContent = `Gameweek ${gw.number}`; gameweekSelect.appendChild(option); }); if (gameweeks.length > 0) selectGameweek(gameweeks[0].id); } function selectGameweek(gameweekId) { selectedGameweekId = gameweekId; window.dispatchEvent(new CustomEvent('selection-changed', { detail: { seasonId: selectedSeasonId, gameweekId: selectedGameweekId }, })); } initSelectors();
Why an event, not just exported functions other scripts call directly
selector.js could have exported getSelectedSeasonId()/getSelectedGameweekId() for every other script to call whenever it needs the current selection — but that would mean fixtures.js, predictions.js, and both table scripts would each need to know exactly when to call them (on page load? on some other event?), coupling every page's own logic to selector.js's own timing. Broadcasting one selection-changed event instead means selector.js doesn't need to know or care what else is listening, and every other page only needs to know one event name — the same decoupling this course's own backend has leaned on repeatedly: Chapter 3's SeasonTeam as the one shared table two later chapters both read and write, Chapter 7's compute_league_table() reused directly rather than re-derived. This is that same idea, applied to the frontend.

Resolving Chapter 4's Own Flagged Gap, For Real

Chapter 4 was honest that usedTeamIds only lived in the page's own memory, resetting to empty on every reload and never reflecting fixtures already entered in an earlier visit. Wiring fixtures.js into the new selection-changed event is exactly the natural place to fix that — not by trusting a stale in-memory set, but by re-seeding it from the real server state every time the gameweek actually changes:

// static/fixtures.js (updated) let seasonId = null; let gameweekId = null; window.addEventListener('selection-changed', async (event) => { seasonId = event.detail.seasonId; gameweekId = event.detail.gameweekId; await loadTeams(); await seedUsedTeams(); }); async function seedUsedTeams() { const res = await fetch(`/api/gameweeks/${gameweekId}/fixtures`); const existingFixtures = await res.json(); usedTeamIds = new Set(); existingFixtures.forEach(fx => { usedTeamIds.add(fx.home_team_id); usedTeamIds.add(fx.away_team_id); }); disableUsedButtons(); // the exact function Chapter 4 already defined }

Reloading the page — or switching to a different gameweek and back — now reliably shows the real, correct set of already-used teams, fetched fresh from GET /api/gameweeks/{id}/fixtures (Chapter 4's own listing route) every time, rather than an empty set that happened to be correct only by coincidence of a fresh page load.

Real Styling

/* static/style.css */ body { background: #0d1117; color: #c9d1d9; font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 1.5rem; } select, button { background: #161b22; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 0.5rem 0.8rem; font-size: 0.9rem; cursor: pointer; } button:hover:not(:disabled) { background: #30363d; } button:disabled { opacity: 0.4; cursor: not-allowed; } .team-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; margin: 1rem 0; } .fixture-slots { display: flex; align-items: center; gap: 1rem; margin: 1rem 0; } .slot { background: #161b22; border: 1px dashed #30363d; border-radius: 6px; padding: 0.6rem 1.2rem; min-width: 120px; text-align: center; } table { width: 100%; border-collapse: collapse; margin: 1rem 0; } th, td { padding: 0.5rem 0.7rem; text-align: left; border-bottom: 1px solid #21262d; } th { background: #062120; color: #5eead4; text-transform: uppercase; font-size: 0.7rem; letter-spacing: 0.07em; }

The same teal accent (#00C7B7/#5EEAD4) and dark surfaces (#0d1117/#161b22) used across every chapter of this course's own documentation — the finished app looks like a genuine continuation of the material teaching it, not a visually disconnected afterthought.

Not a design system, on purpose
This is real, working CSS — enough to make the app pleasant and legible — not a component library or a design system with reusable tokens beyond a couple of shared colors. A larger app would want that; a personal prediction tracker genuinely doesn't need it yet.

Where This Course Is Headed

Deployment — getting this app running somewhere real, not just localhost (Chapter 11); and a capstone integrating the finished predictor into the existing Astro-based site (Chapter 12).

Hands-On Exercises

Exercise 1

Explain why selector.js broadcasts a single CustomEvent rather than exporting getSelectedSeasonId()/getSelectedGameweekId() functions for other scripts to call, and name one other place in this course where a similar decoupling choice was already made.

📄 View solution
Exercise 2

Explain exactly how seedUsedTeams resolves the gap Chapter 4 flagged about usedTeamIds resetting on page reload, and what real request it makes to do so.

📄 View solution
Exercise 3

Build the selector into a real page with at least two seasons and two gameweeks to choose from, enter a fixture in one gameweek, switch to the other gameweek and back, and confirm the previously-entered fixture's own two teams are still shown as disabled after switching back — without reloading the page.

📄 View solution

Chapter 10 Quick Reference

  • GET /api/seasons and GET /api/seasons/{id}/gameweeks — the two small list routes this course never quite needed until the selector required them
  • selection-changed — one CustomEvent broadcast whenever season/gameweek changes, decoupling selector.js from every page that reacts to it
  • seedUsedTeams() — genuinely resolves Chapter 4's own flagged gap by re-fetching real fixture data on every gameweek switch
  • Styling — real, working dark-theme CSS reusing this course's own documentation accent colors, deliberately not a full design system
  • Next chapter: Deployment