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:
season:* Matches Far More Than SeasonsSCAN 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:
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:
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:
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:
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:
WATCH transaction that checks both teams and claims both, or neither:
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.
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.
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.
| Stack | Where the selector's lists come from |
|---|---|
| Relational siblings | A 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
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.
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 solutionSelect 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 solutionChapter 10 Quick Reference
- Verified:
SCAN season:*is not a season list — it returns counters and sets too; keepseasonsandseason:{sid}:gameweeksas 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— useJSONResponseorHTTPException - 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