Styling & the Gameweek/Season Selector UI

Premier League Predictor: FastAPI & Redis

Chapter 10 · Styling & the Gameweek/Season Selector UI

Nine chapters of backend, and the app still has no way to say which gameweek you are looking at. This chapter adds the selector — a season menu and a gameweek menu that drive the fixture-entry page — and gives the whole page a proper dark theme. It also closes a gap Chapter 4 left open, and finds two bugs in Chapter 4's own route while doing it. Everything here was run: the routes against a real Redis with real HTTP requests, the JavaScript in Node, and the finished page rendered in headless Chrome.

Redis Doesn't Keep a List of Seasons

The selector needs two lists: every season, and every gameweek in a season. A relational database answers both with a SELECT. In Redis the seasons exist only as scattered keys, and the obvious way to find them is SCAN with a pattern:

Verified: season:* Matches Far More Than Seasons
await [k async for k in r.scan_iter(match="season:*")] # ['season:1', 'season:1:gameweeks', 'season:1:teams', 'season:2', 'season:2:gameweeks', # 'season:2:teams', 'season:current_id', 'season:next_id']
One pattern returns season records, their sets, and two unrelated counters, all mixed together and in no particular order. The same problem hits gameweeks: SCAN over gameweek:1:*:fixtures found the right keys, but pulling the numbers out and sorting them as strings gives ['1', '10', '2', '3'] — Chapter 7's "10 sorts before 2" trap again.

The idiomatic answer is to stop searching the keyspace and keep the list yourself, in a sorted set whose score is the number. A sorted set is ordered numerically, so the string-order trap disappears:

seasons # sorted set: member = season id, score = season id season:{sid}:gameweeks # sorted set: member = gameweek number, score = gameweek number await r.zrange("season:1:gameweeks", 0, -1) # ['1', '2', '3', '10'] -- numeric order

The cost is the one this course keeps meeting: a second structure that has to be written to at the right moments. Two write paths change. The fixture-creation route adds the gameweek (ZADD season:{sid}:gameweeks n n, which does nothing if it is already there). And the rollover script from Chapter 9 must add the new season:

Verified: Chapter 9's Rollover Leaves the New Season Off the List
Running Chapter 9's script as published, then asking the API for the seasons:
rolled 17 clubs -> /api/seasons: [2024/25, 2025/26 (no longer current)] season:current_id = 3, and season 3 exists -- but is not listed
The new current season exists and isn't in the menu, and no listed season is marked current. Adding one line to the script, redis.call('ZADD', KEYS[6], ARGV[1], ARGV[1]), with "seasons" passed as the sixth key, fixed it: the next rollover appeared, marked current.

The Read Routes

Three routes feed the page. The third returns everything the fixture-entry screen needs in one response — teams, fixtures and the already-used team IDs — so the client never has to stitch several requests together:

@app.get("/api/seasons") async def list_seasons(): r = app.state.redis ids = await r.zrange("seasons", 0, -1) async with r.pipeline(transaction=False) as p: for i in ids: p.hget(f"season:{i}", "name") names = await p.execute() current = await r.get("season:current_id") return [{"id": int(i), "name": n, "current": i == current} for i, n in zip(ids, names)] @app.get("/api/seasons/{sid}/gameweeks") async def list_gameweeks(sid: int): r = app.state.redis if await r.zscore("seasons", sid) is None: return JSONResponse({"error": "no such season"}, status_code=404) return [int(n) for n in await r.zrange(f"season:{sid}:gameweeks", 0, -1)] @app.get("/api/gameweeks/{sid}/{n}") async def gameweek_state(sid: int, n: int): # same 404 guard, then: SMEMBERS the fixtures, the season's teams and the used teams, # and one pipeline of HGETALL/HGET for the fixture hashes and team names ...

Fixture and team lookups are pipelined, as in Chapters 5 and 7. The season guard matters because of Chapter 2's missing referential integrity: without it, asking for a season that doesn't exist returns an empty list with a 200, indistinguishable from a real season with no gameweeks yet. With it, the call gets a 404 (verified). A real season with a gameweek nobody has entered still returns 200 with empty lists, which is correct.

Two Corrections to Chapter 4

Building the page meant driving Chapter 4's create-fixture route from a real client, and it turned out to have two problems.

1. FastAPI ignores a status code returned in a tuple

Chapter 4's route ended with lines like return {"error": ...}, 400. That is Flask's convention. In FastAPI:

Verified: The 400 Never Happens
@app.get("/x") async def x(): return {"error": "team used"}, 400 GET /x -> 200 [{'error': 'team used'}, 400] # a two-element JSON array
The client receives success and an array. A page checking res.ok would never see a refusal. Use JSONResponse(..., status_code=...) or raise HTTPException.

2. A rejected pairing still consumed the home team

Chapter 4 claimed the home team, then separately the away team. If the second claim is refused, the first has already happened:

Verified: A Team Burned With No Fixture
team 4 already used this gameweek; try to pair team 3 (home) with team 4 (away): home: ACCEPTED away: REJECTED -> used_teams = ['3', '4'] # team 3 is now unusable, no fixture exists
Each claim was individually race-safe, but the two together weren't atomic. The fix is one WATCH transaction that checks both teams and claims both, or neither:
async def claim_both(r, key, a, b): async with r.pipeline(transaction=True) as pipe: while True: try: await pipe.watch(key) if await pipe.sismember(key, a) or await pipe.sismember(key, b): await pipe.unwatch() return False pipe.multi() pipe.sadd(key, a, b) # both members in one command await pipe.execute() return True except WatchError: continue # re-check, as in Chapter 4
Verified Through the Real Route
team 3 v team 4 (4 already used): 409 used_teams still ['4'] # nothing burned two simultaneous POSTs, same pair: [200 {'fixture_id': 15}, 409 ...] fixtures created: 1
The route also refuses a team playing itself (400) and, via the guard, a nonexistent season (404). After a successful claim it creates the fixture hash, adds it to the gameweek's fixture set, and ZADDs the gameweek to the season's list, all in one MULTI/EXEC.

The Client: Resolving Chapter 4's Open Gap

Chapter 4's page kept a JavaScript usedTeamIds set in memory and flagged that it would go stale on a gameweek switch. Two separate failures are hiding there, and both were reproduced in Node with a stubbed fetch.

// selector.js -- holds the selection and announces changes const state = { seasonId: null, gameweek: null }; export function getSelection() { return { ...state }; } export function select(seasonId, gameweek) { state.seasonId = seasonId; state.gameweek = gameweek; window.dispatchEvent(new CustomEvent("selectionchange", { detail: { seasonId, gameweek } })); }
Verified: Failure One — Merging Instead of Replacing
A set that only ever has IDs added to it, shown after switching from gameweek 1 (teams 1–6 used) to gameweek 2 (teams 7 and 8 used):
client shows used: [1, 2, 3, 4, 5, 6, 7, 8] server says: [7, 8]
Six teams are disabled that are perfectly free. The state has to be replaced from the server's answer on every switch, never merged into.
Verified: Failure Two — A Slow Response Arriving Last
Selecting gameweek 1 and then gameweek 2 in quick succession, where gameweek 1's response is slower:
naive: showing gameweek 1, used [1, 2, 3, 4, 5, 6] # the menu says gameweek 2
Whichever response arrives last wins, and it isn't necessarily the one for the current selection. The fix is a ticket: each request takes a number, and only the response holding the latest number is allowed to render.
// loader.js export function createLoader(fetchState, onData) { let latest = 0; return async function load(selection) { const ticket = ++latest; const data = await fetchState(selection); if (ticket !== latest) return; // a newer selection superseded this one onData(data); // replaces what was shown }; }
Verified: The Ticketed Loader
ticketed loader, same two clicks: showing gameweek 2, used [7, 8]
Chapter 4's "usedTeamIds" gap is closed: the used teams come from the server's used_team_ids on every load, and the same server-side guard that decides whether a pairing is allowed also produces the list the buttons are drawn from. The page never decides anything.

The superseded request is still sent — the ticket only stops its answer being shown. For two menus and a one-person tool that is fine.

Styling

The page uses the same dark palette as the rest of the course, with Redis's red as the accent. The parts that carry meaning are small: a used team is dimmed, struck through and disabled, and the picked home team gets the accent fill.

:root { --bg:#0d1117; --card:#161b22; --line:#30363d; --text:#c9d1d9; --accent:#dc382d; --accent2:#ff6b5c; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:.5rem; } .team-btn { background:var(--card); color:var(--text); border:1px solid var(--line); border-radius:6px; } .team-btn:hover:not(:disabled) { border-color:var(--accent2); background:#24100e; } .team-btn.picked { border-color:var(--accent); background:#5c1a15; color:#fff; } .team-btn.used { opacity:.4; text-decoration:line-through; cursor:not-allowed; }

auto-fill with a 140 px minimum lets the twenty buttons reflow to whatever width the page has, and a small media query makes the selectors full-width on narrow screens. Rendered in headless Chrome at 1,000 px wide against the seeded data, the page showed the selector bar, twenty team buttons in a five-column grid with the four already-used teams struck through, and the two entered fixtures listed underneath. The narrow-screen rule wasn't rendered.

StackWhere the selector's lists come from
Relational siblingsA SELECT over the seasons and gameweeks tables
This course (Redis)Two sorted sets maintained by the routes that create seasons and fixtures; a SCAN over the keyspace was tried and rejected

Hands-On Exercises

Exercise 1

List season 1's gameweeks using SCAN over gameweek:1:*:fixtures, extract the numbers and sort them as strings and as numbers, then compare with the sorted-set answer. Explain what the string order would do to the gameweek menu.

📄 View solution
Exercise 2

Call the create-fixture route with a team playing itself, with a nonexistent season, and request an empty but valid gameweek's state. Report the status code and body for each, and whether anything was left behind in Redis.

📄 View solution
Exercise 3

Select gameweeks 1, 2 and 3 in quick succession where the responses take 80, 40 and 10 ms respectively. Report which gameweek a naive handler and the ticketed loader end up showing, and how many requests were still sent.

📄 View solution

Chapter 10 Quick Reference

  • Verified: SCAN season:* is not a season list — it returns counters and sets too; keep seasons and season:{sid}:gameweeks as sorted sets
  • Sorted sets give numeric order — [1, 2, 3, 10], not the string order ['1', '10', '2', '3']
  • Verified: Chapter 9's rollover must also ZADD seasons — otherwise the new current season isn't listed
  • Guard reads with the season check — an unknown season is a 404, not an empty 200
  • Verified correction: FastAPI ignores return body, 400 — use JSONResponse or HTTPException
  • Verified correction: claim both teams in one WATCH transaction — a refused away team no longer burns the home team
  • Replace, don't merge, on every switch — verified: a merged set showed 8 used teams where the server said 2
  • Ticket each request — verified: without it a slow gameweek 1 response overwrote gameweek 2