Styling & the Gameweek/Season Selector UI

Premier League Predictor: Django & MySQL

Chapter 10 · Styling & the Gameweek/Season Selector UI

Every page this course has built since Chapter 4 has been visited against a specific, hardcoded gameweek_id or season_id baked straight into the URL. This chapter replaces that with a real, working selector — and gives the app the dark-theme styling it's been missing since Chapter 1. The selector itself, though, ends up looking genuinely different from the FastAPI sibling's own version, for a real, structural reason worth walking through rather than skipping past.

A Server-Rendered Selector, Not a Client-Side One

plpredict-fastapi1's own fixture_entry.js fetches its team list from a JSON route on every page load, because FastAPI's vanilla-JS frontend has nothing else to hand it that data — so its selector, quite reasonably, broadcasts a CustomEvent and lets JavaScript re-fetch and re-render whatever changed. Chapter 4's own fixture_entry view is built completely differently: it already renders the team grid, the gameweek, and everything else server-side, via {% for team in teams %}, before the page is ever sent to the browser. A season/gameweek selector for a page built this way doesn't need to swap data into an already-loaded page at all — the far simpler, more idiomatic answer is real navigation: picking a different gameweek just loads a different, freshly server-rendered page.

A Context Processor: Every Page Gets the Selector's Own Data, Automatically

The FastAPI sibling's own real problem — letting fixtures.js, predictions.js, and both table scripts each react to a selection change without any of them needing to know about the others — still exists here, just one layer down the stack. Every page this course has built needs the same season/gameweek dropdown data available in its own template context, without every single view having to remember to fetch and pass it in by hand. Django's own answer to exactly that problem is a context processor:

# predictor/context_processors.py from .models import Season, Gameweek def selector_context(request): seasons = Season.objects.order_by('-start_date') gameweek_id = request.resolver_match.kwargs.get('gameweek_id') if request.resolver_match else None if gameweek_id: current_gameweek = Gameweek.objects.filter(pk=gameweek_id).select_related('season').first() current_season = current_gameweek.season if current_gameweek else None else: current_season = seasons.filter(is_current=True).first() or seasons.first() gameweeks = ( Gameweek.objects.filter(season=current_season).order_by('number') if current_season else Gameweek.objects.none() ) return { 'all_seasons': seasons, 'all_gameweeks': gameweeks, 'current_season': current_season, 'current_gameweek_id': int(gameweek_id) if gameweek_id else None, }
# settings.py (TEMPLATES, additions) TEMPLATES = [ { ... 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.request', # ...the usual defaults... 'predictor.context_processors.selector_context', ], }, }, ]
Django's own idiomatic answer to the same decoupling problem
request.resolver_match.kwargs is what makes this genuinely automatic: Django sets resolver_match on the request the moment its own URL routing has matched a view, well before any template ever renders — so selector_context() can look at whichever URL the current request actually hit, pull gameweek_id straight out of it if present, and resolve the right season from there. Any template rendered anywhere in the app — the fixture-entry page, a predictions page, either league table — automatically receives all_seasons/all_gameweeks/current_season without its own view ever mentioning the selector at all. That's the same real goal selection-changed serves for the FastAPI sibling — no page needs to know the selector's own internals — solved at the template-context layer instead of with a browser event, because Django's own rendering pipeline already has a real, built-in mechanism for exactly this.

The Selector Partial & Real Navigation

<!-- predictor/templates/predictor/_selector.html --> <div class="selector-bar"> <select onchange="location.href = '/seasons/' + this.value + '/default-gameweek/'"> {% for season in all_seasons %} <option value="{{ season.id }}" {% if season == current_season %}selected{% endif %}> {{ season.name }} </option> {% endfor %} </select> <select onchange="location.href = '/gameweeks/' + this.value + '/fixtures/'"> {% for gw in all_gameweeks %} <option value="{{ gw.id }}" {% if gw.id == current_gameweek_id %}selected{% endif %}> Gameweek {{ gw.number }} </option> {% endfor %} </select> </div>

base.html includes it once, with {% include 'predictor/_selector.html' %} near the top of the page, so every template that extends base.html gets the same live selector for free. Switching the gameweek dropdown navigates directly to that gameweek's own fixture-entry page. Switching the season dropdown is one step more involved — there's no single "the" gameweek to jump to until one is picked — so it goes through one small, real redirect view instead:

# predictor/views.py (additions) from django.shortcuts import redirect def season_default_gameweek(request, season_id): season = get_object_or_404(Season, pk=season_id) first_gameweek = Gameweek.objects.filter(season=season).order_by('number').first() if not first_gameweek: return JsonResponse({'error': 'This season has no gameweeks yet'}, status=404) return redirect(f'/gameweeks/{first_gameweek.id}/fixtures/')
Two JSON routes this course never needs — another "one fetch call skipped" moment
plpredict-fastapi1's own Chapter 10 has to add GET /api/seasons and GET /api/seasons/{id}/gameweeks specifically to feed its own selector's two dropdowns. This chapter needs neither: all_seasons/all_gameweeks already arrive in every template's own context via selector_context(), rendered directly with {% for %} the same way Chapter 4's own team grid was — the exact same "server-side rendering skips a whole client-side fetch" pattern Chapter 4's own finding-box already established, showing up here a second time.

Resolving Chapter 4's Own Flagged Gap — Server-Side, Not With a Re-Fetch

Chapter 4 was honest that usedTeamIds only lived in the page's own JavaScript memory, empty on every reload and reflecting only fixtures added during that exact visit. The FastAPI sibling fixes this with a live client-side re-fetch on every selection change. Django's own fixture_entry view already queries the database directly on every render — the far more direct fix is to compute the real, correct set of used teams there, and bake it straight into the HTML the server sends:

# predictor/views.py (fixture_entry, updated) @ensure_csrf_cookie def fixture_entry(request, gameweek_id): gameweek = get_object_or_404(Gameweek, pk=gameweek_id) season_teams = ( SeasonTeam.objects .filter(season=gameweek.season) .select_related('team') .order_by('team__name') ) teams = [st.team for st in season_teams] existing_fixtures = Fixture.objects.filter(gameweek=gameweek) used_team_ids = set() for fx in existing_fixtures: used_team_ids.add(fx.home_team_id) used_team_ids.add(fx.away_team_id) return render(request, 'predictor/fixture_entry.html', { 'gameweek': gameweek, 'teams': teams, 'used_team_ids': used_team_ids, })
<!-- predictor/templates/predictor/fixture_entry.html (updated) --> <div id="team-grid" class="team-grid"> {% for team in teams %} <button type="button" data-team-id="{{ team.id }}" {% if team.id in used_team_ids %}disabled{% endif %}>{{ team.short_name }}</button> {% endfor %} </div>

fixtures.js then only needs to seed its own JavaScript Set from what the DOM already correctly shows, instead of trusting a variable that started life empty:

// static/predictor/fixtures.js (updated) let usedTeamIds = new Set( Array.from(document.querySelectorAll('#team-grid button[disabled]')) .map(btn => Number(btn.dataset.teamId)) ); function disableUsedButtons() { document.querySelectorAll('#team-grid button').forEach(btn => { btn.disabled = usedTeamIds.has(Number(btn.dataset.teamId)); }); }

addFixture(), from Chapter 4, is updated to call disableUsedButtons() too, replacing its own original inline disabling loop — the same logic, now written once and shared, rather than kept as two near-identical copies.

The same real gap, fixed a genuinely different way
Reloading the page — or navigating to a different gameweek and back through the new selector — now reliably shows the real, correct disabled buttons every single time, because used_team_ids is recomputed fresh from Fixture.objects.filter(gameweek=gameweek) on every request, not carried over in memory from a previous page view at all. The FastAPI sibling achieves the identical real outcome with a dedicated client-side re-fetch call on every selection change; this course achieves it by never letting the client-side state be wrong in the first place, since the server-rendered HTML was already correct the moment it arrived.

Real Styling

/* static/predictor/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; } .selector-bar { display: flex; gap: 0.7rem; margin-bottom: 1.5rem; } .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: #0c1f18; color: #7fd6b4; text-transform: uppercase; font-size: 0.7rem; letter-spacing: 0.07em; }

The same green accent (#44b78b/#7fd6b4) 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 this chapter's selector uses real page navigation plus a Django context processor rather than a JS CustomEvent the way the FastAPI/PostgreSQL sibling does, naming the specific architectural fact about fixture_entry (and every other page in this course) that makes navigation the more natural choice here.

📄 View solution
Exercise 2

Trace through exactly what selector_context() does when a request hits /gameweeks/5/fixtures/, explaining how it correctly resolves gameweek 5's own season for the season dropdown even though the function's own signature never receives a season_id directly.

📄 View solution
Exercise 3

Build the selector into a real setup with at least two seasons and two gameweeks, enter a fixture in one gameweek, navigate to the other gameweek and back using only the selector dropdowns, and confirm the previously-entered fixture's own two teams are still shown disabled — then explain specifically why this works even though no client-side state was ever preserved across the navigation at all.

📄 View solution

Chapter 10 Quick Reference

  • Real navigation, not a CustomEvent — fixture_entry is already fully server-rendered per gameweek, so a different selection is just a different page
  • selector_context() — a Django context processor giving every template access to all_seasons/all_gameweeks/current_season automatically, the backend-layer analog to the sibling's own selection-changed event
  • request.resolver_match.kwargs — how the context processor knows which season "belongs" to the current URL with no view needing to say so explicitly
  • season_default_gameweek — a small redirect view handling the "season picked, no gameweek chosen yet" case
  • Two JSON routes skipped — no GET /seasons or GET /seasons/{id}/gameweeks needed at all, the same server-rendering payoff Chapter 4 already established for the team grid
  • usedTeamIds, fixed server-side — computed fresh in fixture_entry on every request and baked into the disabled attribute directly, rather than re-fetched by the client
  • Styling — real, working dark-theme CSS reusing this course's own documentation accent colors, deliberately not a full design system
  • Next chapter: Deployment