The Fast Fixture-Entry UI: Click-to-Pair Teams Into a Gameweek

Premier League Predictor: FastAPI & PostgreSQL

Chapter 4 · The Fast Fixture-Entry UI: Click-to-Pair Teams Into a Gameweek

Ten fixtures, every single gameweek, for 38 gameweeks — filling that in through two dropdowns per fixture would be genuinely tedious. This chapter builds the real alternative promised back in Chapter 1: a plain vanilla-JS/HTML/CSS frontend (served as static files, no build step — the same choice this site's own FastAPI courses already make when there's no React sibling forcing a different pick) with 20 always-visible team buttons, clicked directly into place.

Creating a Gameweek

# schemas.py (additions) class GameweekCreate(BaseModel): number: int class GameweekResponse(BaseModel): id: int season_id: int number: int class Config: from_attributes = True
# routers/gameweeks.py from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from database import get_db import models, schemas router = APIRouter(prefix="/api", tags=["gameweeks"]) @router.post("/seasons/{season_id}/gameweeks", response_model=schemas.GameweekResponse) def create_gameweek(season_id: int, payload: schemas.GameweekCreate, db: Session = Depends(get_db)): season = db.get(models.Season, season_id) if not season: raise HTTPException(status_code=404, detail="Season not found") if not (1 <= payload.number <= 38): raise HTTPException(status_code=400, detail="Gameweek number must be between 1 and 38") gameweek = models.Gameweek(season_id=season_id, number=payload.number) db.add(gameweek) try: db.commit() except IntegrityError: db.rollback() raise HTTPException( status_code=409, detail=f"Gameweek {payload.number} already exists for this season" ) db.refresh(gameweek) return gameweek
Turning Chapter 2's UniqueConstraint into a friendly error, not a crash
Chapter 2's own UniqueConstraint("season_id", "number") already stops a genuinely duplicate gameweek from ever being stored. Without the try/except above, PostgreSQL would still reject the duplicate — but as a raw IntegrityError bubbling straight up through FastAPI as an unhandled 500, with none of the useful "gameweek 12 already exists" detail this route actually returns. Catching it and calling db.rollback() — required, since a session that hit a failed commit() can't be reused until it's rolled back — turns a genuine database-level guarantee into a genuinely useful 409 response instead.

Creating a Fixture: The Real Server-Side Guard

# routers/fixtures.py from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from database import get_db import models, schemas router = APIRouter(prefix="/api", tags=["fixtures"]) @router.post("/gameweeks/{gameweek_id}/fixtures", response_model=schemas.FixtureResponse) def create_fixture(gameweek_id: int, payload: schemas.FixtureCreate, db: Session = Depends(get_db)): gameweek = db.get(models.Gameweek, gameweek_id) if not gameweek: raise HTTPException(status_code=404, detail="Gameweek not found") if payload.home_team_id == payload.away_team_id: raise HTTPException(status_code=400, detail="A team cannot play itself") # The real authority: check every fixture already entered this gameweek existing = db.query(models.Fixture).filter(models.Fixture.gameweek_id == gameweek_id).all() used_team_ids = set() for fx in existing: used_team_ids.add(fx.home_team_id) used_team_ids.add(fx.away_team_id) if payload.home_team_id in used_team_ids or payload.away_team_id in used_team_ids: raise HTTPException( status_code=409, detail="One of these teams is already fixtured this gameweek" ) fixture = models.Fixture( gameweek_id=gameweek_id, home_team_id=payload.home_team_id, away_team_id=payload.away_team_id, kickoff_time=payload.kickoff_time, ) db.add(fixture) db.commit() db.refresh(fixture) return fixture @router.get("/gameweeks/{gameweek_id}/fixtures", response_model=list[schemas.FixtureResponse]) def list_fixtures(gameweek_id: int, db: Session = Depends(get_db)): return ( db.query(models.Fixture) .filter(models.Fixture.gameweek_id == gameweek_id) .all() )

schemas.FixtureCreate, added alongside the others:

class FixtureCreate(BaseModel): home_team_id: int away_team_id: int kickoff_time: Optional[datetime] = None
This is the route Chapter 2's own warn-box was pointing at
Back in Chapter 2: "a team appearing twice in the same gameweek... this schema leaves it to the application layer instead." create_fixture above is that application layer — a real, explicit query across every existing fixture in the gameweek, not a database constraint. Checking home_team_id == away_team_id here as well, even though Chapter 2's CheckConstraint already blocks it at the database level, means a bad request gets a clean 400 with a real message instead of an unhandled IntegrityError — the exact same "friendly error over a raw crash" reasoning as the gameweek route above.

The Click-to-Pair Interface

The whole point: every one of the season's 20 teams is a button, always visible, always one click away — no scrolling through a dropdown to find a name.

<div id="team-grid" class="team-grid"></div> <div class="fixture-slots"> <div id="home-slot" class="slot">Home</div> <span>vs</span> <div id="away-slot" class="slot">Away</div> </div> <button onclick="clearSelection()">Clear</button> <button id="add-fixture-btn" onclick="addFixture()" disabled>Add Fixture</button>
// static/fixtures.js // In the real app, seasonId and gameweekId come from Chapter 10's own // gameweek/season selector — hardcoded here to keep this example focused. const seasonId = 1; const gameweekId = 1; let selectedHome = null; let selectedAway = null; let usedTeamIds = new Set(); async function loadTeams() { const res = await fetch(`/api/seasons/${seasonId}/teams`); const teams = await res.json(); const grid = document.getElementById('team-grid'); grid.innerHTML = ''; teams.forEach(team => { const btn = document.createElement('button'); btn.textContent = team.short_name; btn.dataset.teamId = team.id; btn.addEventListener('click', () => selectTeam(team)); grid.appendChild(btn); }); } function selectTeam(team) { if (usedTeamIds.has(team.id)) return; if (!selectedHome) { selectedHome = team; document.getElementById('home-slot').textContent = team.short_name; } else if (!selectedAway && team.id !== selectedHome.id) { selectedAway = team; document.getElementById('away-slot').textContent = team.short_name; } document.getElementById('add-fixture-btn').disabled = !(selectedHome && selectedAway); } function clearSelection() { selectedHome = null; selectedAway = null; document.getElementById('home-slot').textContent = 'Home'; document.getElementById('away-slot').textContent = 'Away'; document.getElementById('add-fixture-btn').disabled = true; } async function addFixture() { const res = await fetch(`/api/gameweeks/${gameweekId}/fixtures`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ home_team_id: selectedHome.id, away_team_id: selectedAway.id, }), }); if (!res.ok) { const error = await res.json(); alert(error.detail); // e.g. "One of these teams is already fixtured this gameweek" return; } usedTeamIds.add(selectedHome.id); usedTeamIds.add(selectedAway.id); document.querySelectorAll('#team-grid button').forEach(btn => { if (usedTeamIds.has(Number(btn.dataset.teamId))) btn.disabled = true; }); clearSelection(); } loadTeams();

Clicking a team fills home-slot first, then away-slot — a two-click fixture, every time. Once both slots hold a team, Add Fixture enables; submitting it grays out both teams' own buttons for the rest of this gameweek, and resets the slots for the next pair.

Client-side disabling is convenience, not the real rule
usedTeamIds above lives only in the page's own memory — it starts empty every time the page loads, and only grows as fixtures are actually added during that visit. Reload the page halfway through entering a gameweek and every team button re-enables, even ones already fixtured. That's fine, because create_fixture's own server-side check (querying every existing fixture in the gameweek) is the real authority — a stale client would simply get a real 409 back rather than silently creating a bad duplicate. A more complete version would call GET /api/gameweeks/{id}/fixtures on page load and seed usedTeamIds from the real, current state, rather than assuming a fresh visit means a fresh gameweek — left out here to keep the example focused on the click-to-pair interaction itself.

Where This Course Is Headed

Recording all four prediction sources against each fixture this chapter creates (Chapter 5); entering results — the real UPDATE Chapter 2 set up, filling in the home_score/away_score this chapter's own fixtures still leave null (Chapter 6); the real league table (Chapter 7); the prediction league table (Chapter 8); promotion and relegation (Chapter 9); and a real gameweek/season selector to replace this chapter's own hardcoded seasonId/gameweekId (Chapter 10).

Hands-On Exercises

Exercise 1

Explain why create_gameweek and create_fixture both check their own rule in Python (the 1-38 range, home_team_id == away_team_id) even though a database-level guarantee (a CheckConstraint or a UniqueConstraint) already exists for each one.

📄 View solution
Exercise 2

Explain why usedTeamIds is described as "convenience, not the real rule," and describe exactly what happens — on both the client and the server — if a stale page somehow submits a fixture for a team that's already been used in that gameweek.

📄 View solution
Exercise 3

Build the full click-to-pair page yourself against a real season and gameweek, enter all 10 fixtures for a gameweek, and then write one sentence explaining what happens when you try to click an 11th team pairing that reuses an already-fixtured team.

📄 View solution

Chapter 4 Quick Reference

  • POST /api/seasons/{id}/gameweeks — creates a gameweek (1-38); catches the Chapter 2 UniqueConstraint's IntegrityError as a friendly 409
  • POST /api/gameweeks/{id}/fixtures — the real authority for "no team twice in a gameweek," checked by querying every existing fixture in that gameweek
  • Frontend — plain vanilla JS/HTML/CSS, no framework, no build step; 20 always-visible team buttons instead of dropdowns
  • Click-to-pair flow — first click fills Home, second (different team) fills Away, then Add Fixture
  • Real limit — client-side usedTeamIds resets on page reload; the server-side check in create_fixture is what actually prevents a bad duplicate
  • Next chapter: Recording the user, expert, guest(s) & AI predictions per fixture