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

Premier League Predictor: Astro

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 real Astro page with 20 always-visible team buttons, clicked directly into place, backed by two new API routes for gameweeks and fixtures.

Creating a Gameweek

// src/pages/api/seasons/[seasonId]/gameweeks/index.ts import type { APIRoute } from 'astro'; import { db } from '../../../../../lib/db'; import { json } from '../../../../../lib/http'; export const POST: APIRoute = async ({ params, request }) => { const seasonId = Number(params.seasonId); const { number } = await request.json(); const season = db.prepare('SELECT * FROM seasons WHERE id = ?').get(seasonId); if (!season) { return json({ error: 'Season not found' }, 404); } if (!(number >= 1 && number <= 38)) { return json({ error: 'Gameweek number must be between 1 and 38' }, 400); } try { const result = db.prepare( 'INSERT INTO gameweeks (season_id, number) VALUES (?, ?)' ).run(seasonId, number); const gameweek = db.prepare('SELECT * FROM gameweeks WHERE id = ?').get(result.lastInsertRowid); return json(gameweek, 201); } catch (err) { if ((err as any).code === 'SQLITE_CONSTRAINT_UNIQUE') { return json({ error: `Gameweek ${number} already exists for this season` }, 409); } throw err; } };
SqliteError.code tells you exactly which constraint failed — and there's no rollback to worry about
Chapter 2's own UNIQUE (season_id, number) already stops a genuinely duplicate gameweek from ever being stored. better-sqlite3 reports the violation as a real SqliteError instance with a .code property matching SQLite's own extended result codes — 'SQLITE_CONSTRAINT_UNIQUE' here, and 'SQLITE_CONSTRAINT_CHECK' for the fixture chapter's own team-can't-play-itself rule below. Checking that exact string is enough to turn a real database guarantee into a genuinely useful 409 response. There's no equivalent to SQLAlchemy's own required db.rollback() step here either — a single .run() call that throws simply never took effect at all, so there's no half-finished session state left behind that needs explicitly clearing before the database can be used again.

Creating a Fixture: The Real Server-Side Guard

// src/pages/api/gameweeks/[gameweekId]/fixtures/index.ts import type { APIRoute } from 'astro'; import { db } from '../../../../../lib/db'; import { json } from '../../../../../lib/http'; export const POST: APIRoute = async ({ params, request }) => { const gameweekId = Number(params.gameweekId); const { home_team_id, away_team_id, kickoff_time = null } = await request.json(); const gameweek = db.prepare('SELECT * FROM gameweeks WHERE id = ?').get(gameweekId); if (!gameweek) { return json({ error: 'Gameweek not found' }, 404); } if (home_team_id === away_team_id) { return json({ error: 'A team cannot play itself' }, 400); } // The real authority: check every fixture already entered this gameweek const existing = db.prepare( 'SELECT home_team_id, away_team_id FROM fixtures WHERE gameweek_id = ?' ).all(gameweekId) as { home_team_id: number; away_team_id: number }[]; const usedTeamIds = new Set<number>(); for (const fx of existing) { usedTeamIds.add(fx.home_team_id); usedTeamIds.add(fx.away_team_id); } if (usedTeamIds.has(home_team_id) || usedTeamIds.has(away_team_id)) { return json({ error: 'One of these teams is already fixtured this gameweek' }, 409); } const result = db.prepare( 'INSERT INTO fixtures (gameweek_id, home_team_id, away_team_id, kickoff_time) VALUES (?, ?, ?, ?)' ).run(gameweekId, home_team_id, away_team_id, kickoff_time); const fixture = db.prepare('SELECT * FROM fixtures WHERE id = ?').get(result.lastInsertRowid); return json(fixture, 201); }; export const GET: APIRoute = async ({ params }) => { const gameweekId = Number(params.gameweekId); const fixtures = db.prepare('SELECT * FROM fixtures WHERE gameweek_id = ?').all(gameweekId); return json(fixtures); };
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." The POST handler above is that application layer — a real, explicit query across every existing fixture in the gameweek, not a database constraint, run in plain JavaScript exactly the same way the FastAPI sibling runs it in Python. Checking home_team_id === away_team_id here as well, even though Chapter 2's own CHECK constraint already blocks it at the database level, means a bad request gets a clean 400 with a real message instead of an unhandled SqliteError — 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. A minimal Astro page for now; Chapter 10 replaces the hardcoded seasonId/gameweekId below with a real selector.

--- // src/pages/admin/fixtures.astro --- <html lang="en"> <body> <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 id="clear-btn">Clear</button> <button id="add-fixture-btn" disabled>Add Fixture</button> <script> // 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: { id: number; short_name: string } | null = null; let selectedAway: { id: number; short_name: string } | null = null; const usedTeamIds = new Set<number>(); 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: any) => { 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: { id: number; short_name: string }) { 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') as HTMLButtonElement).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') as HTMLButtonElement).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.error); // e.g. "One of these teams is already fixtured this gameweek" return; } usedTeamIds.add(selectedHome!.id); usedTeamIds.add(selectedAway!.id); document.querySelectorAll<HTMLButtonElement>('#team-grid button').forEach((btn) => { if (usedTeamIds.has(Number(btn.dataset.teamId))) btn.disabled = true; }); clearSelection(); } // Real event listeners, not inline onclick — see the finding-box below for why document.getElementById('clear-btn')!.addEventListener('click', clearSelection); document.getElementById('add-fixture-btn')!.addEventListener('click', addFixture); loadTeams(); </script> </body> </html>
A genuine Astro-specific gotcha: inline onclick attributes silently fail here
A plain <script> tag written directly inside an Astro page's own template, with no extra attributes, is automatically processed by Astro: bundled, converted to a real ES module, and deduplicated. That processing has a real, non-obvious consequence — a module's own top-level functions are scoped to that module, not attached to the global window object the way an ordinary, unprocessed script's functions would be. Writing <button onclick="clearSelection()"> the way the FastAPI sibling's own plain, unbundled static file safely can would silently fail here, since the browser's own inline event-handler attribute looks for clearSelection on window and finds nothing there. Every button interaction above is wired up with a real addEventListener call instead — Astro's own documented, recommended fix — which works regardless of whether the function it's calling was ever exposed globally.
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 the fixture route'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 Astro islands replacing this chapter's own hardcoded seasonId/gameweekId with a real gameweek/season selector (Chapter 10).

Hands-On Exercises

Exercise 1

Explain why the gameweek and fixture routes both check their own rule in JavaScript (the 1-38 range, home_team_id === away_team_id) even though a database-level guarantee (a CHECK or UNIQUE constraint) 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, then explain what would actually happen if the Clear and Add Fixture buttons used inline onclick attributes instead of addEventListener, given how Astro processes this script by default.

📄 View solution

Chapter 4 Quick Reference

  • POST /api/seasons/{id}/gameweeks — creates a gameweek (1-38); catches better-sqlite3's own SqliteError with code 'SQLITE_CONSTRAINT_UNIQUE' as a friendly 409, no rollback step needed
  • 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 — a real Astro page with an inline, Astro-processed script; 20 always-visible team buttons instead of dropdowns
  • Genuine Astro gotcha — a processed script runs as an ES module, so its functions aren't on window; inline onclick attributes silently fail, and addEventListener is the real, documented fix
  • 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 the fixture route is what actually prevents a bad duplicate
  • Next chapter: Recording the user, expert, guest(s) & AI predictions per fixture