Live sports league platform

Badminton League 2026

A live scores, fixtures and standings site for a 15-pair doubles badminton league, built to replace the Excel workbook its organiser had been updating by hand.

Live platformRealtime scoresOrganiser-only writesServer-validated writesFree-tier hosting
My role
Sole developer
Status
Live platform
Group A and Group B standings tables side by side, each with columns for played, won, lost, points for and against, score difference, recent form and total points
Both group tables are recomputed from the match results on every read, including the form guide and the score-difference tiebreak.
60League fixtures
15Competing pairs
72Automated tests

Context

The system behind the screenshot.

The league is a doubles badminton competition between 15 pairs, split into two groups and scheduled across 60 fixtures. The people in it want three things: where their pair sits in the group, what they are playing next, and what happened in last night's matches. None of that is complicated on its own. All of it changes every time a match finishes.

It had been running on an Excel workbook. One sheet held a row per fixture with the raw scores; two more sheets derived a dashboard and the group tables from formulas over it. That worked, but the file lived with the organiser, so the standings were only as current as the last time someone was told them. The workbook is still in the repository and is still the reference the app is tested against.

Reality first

The problem

A league table is not data you store, it is data you derive. Played, won, lost, points, score difference and recent form all fall out of the match results underneath them. Keep a copy of the table as well and there are two sources of truth, which will disagree the first time a score is corrected. Getting that wrong is the difference between a site that is trusted and one people check against the spreadsheet anyway.

The other half is when and where the data arrives. Results come in during play, from one person who is usually also playing, holding a phone at the side of a court. Everyone else is watching on their own phone somewhere else. So the write path has to be usable one-handed and cheap enough that a 21-point rally does not cost 21 database writes, the read path has to update without anyone refreshing, and the public URL has to be open to people with no account while still being closed to anyone trying to change a score.

01

Constraints

  • Firebase Spark (free) plan — no Cloud Functions and no server to run
  • No backend of my own; the browser talks to Firestore directly
  • One non-technical organiser enters every result
  • Mobile-first — nearly every viewer opens it on a phone
  • Shared as a link in WhatsApp, so readers need no account and no login
  • Must reproduce the existing workbook exactly, row for row
02

My role

I specified, built and verified the whole system: the Firestore model, the security rules, the standings calculation and the standard for what counted as finished. The result was checked against the deployed site and against the league's original workbook rather than against the code that produced it, so the workbook's own numbers are the test.

  • Modelled the league in Firestore: groups, teams, matches, settings and an organiser roster.
  • Wrote the Firestore security rules, including server-side shape validation for every match and team write.
  • Implemented the standings calculation and its tiebreak chain, derived from results rather than stored.
  • Built the public pages and the organiser console, mobile-first, with a courtside score entry screen.
  • Wrote the Excel importer and the regression test that proves the app still reproduces the original workbook.
  • Deployed to Firebase Hosting and wrote scripts that check the live database against the workbook.

System architecture

One database, two audiences

The browser talks to Firestore directly and there is no server in between. Reads are open to everyone, while writes are gated on an organiser record and shape-checked by the security rules, so the same database safely serves an anonymous phone viewer and a signed-in organiser.

  1. 01Organiser signs in
  2. 02Write to Firestore
  3. 03Security rules check role and shape
  4. 04Root onSnapshot listeners
  5. 05Standings recomputed from results
  6. 06Public pages update in place

Implementation

What was built.

Feature groups are kept specific to the system instead of repeating a generic services list.

01

Public experience

  • Home page with the live match, league statistics, next fixtures and recent results
  • Group standings with points, score difference and a recent-form guide
  • Fixtures filterable by status and group, searchable by team
  • A page per pair and per match, each showing the table that result produced
02

Organiser tools

  • Email sign-in for organisers, with the session kept across reloads
  • Courtside score console with large steppers and an explicit save
  • Start, complete and reopen a match; create, edit and archive teams
  • Organiser roster management, so a second organiser does not need the Firebase console
03

Data and rules

  • Firestore model for groups, teams, matches, settings and organisers
  • Security rules that validate the shape of every match and team write
  • Standings derived from results with a deterministic tiebreak chain
  • Excel importer that cross-checks its own output against the workbook's dashboard
04

Delivery

  • Firebase Hosting with SPA rewrites and a year of caching on hashed assets
  • Installable PWA that precaches the shell and never caches league data
  • React and Firebase split into separate vendor chunks so app changes do not invalidate them
  • Scripts that verify the live database and the deployed rules rather than trusting them
Progressive disclosureTechnical details

Standings are derived on every read, never stored

No table is written to the database. computeStandings takes the raw matches and rebuilds each group table on render, so a corrected score cannot leave a stale table behind it. The ranking is points, then score difference — the workbook's own sort — followed by wins, points scored and team name, which exist only to make the order deterministic rather than dependent on insertion order. A match counts once it is completed and has two numeric scores, so a game in progress stays out of the table until it finishes; that mirrors the COUNTIFS the spreadsheet used. The league is a few dozen documents, so recomputing costs nothing and the class of bug where the table disagrees with the results simply does not exist.

Four listeners at the root, and one write per rally

The app subscribes to groups, teams, matches and settings once, at the root, and every page reads from that cache. Four listeners in total: no per-page queries, no composite indexes, and a score typed by the organiser reaches every open device through the matches listener. The indexes file is deliberately empty, with a comment explaining that single-field automatic indexes already cover every query the app issues. On the write side, the plus and minus buttons only change local state; Save score writes once. A 21-point rally costs one Firestore write instead of twenty, and the organiser can correct a mis-tap before anyone else sees it.

The rules are the validation; the form is only a hint

Firestore rules check more than who is asking. Every match write must have two different teams, a status from a known set, integer scores between 0 and 99, and — if it is being completed — both scores present and not equal. A signed-in user gains nothing on its own: write access needs a document at /admins/{uid}, and the existence of that document is the entire role check. The client-side validation is richer, covering rally scoring at 21 with a two-point margin and a cap at 30, but it is advisory by design: those are warnings rather than errors, so historical spreadsheet rows that never followed the convention stay editable. One detail that matters more than it looks: an organiser can remove another organiser but not themselves, so the league cannot end up with nobody who can get in.

A green test suite that was testing nothing

The first deployment shipped with no Firebase configuration at all. PowerShell had written the .env file with a byte-order mark, so the build read the first key as an invisible-prefixed name and never found it. The end-to-end suite passed against that deployment anyway, because a site with no database renders perfectly — every page loads, every heading is present, and every number is zero. The suite was asserting shape, not substance. The fix was to make a deployed environment prove it is connected and has fixtures in it before anything else is asserted. Two more specs turned out to have been skipping in silence, because Playwright's count() and all() do not auto-wait and had been resolving before the league loaded; two others were matching the wrong element entirely. Four real gaps, all hidden behind a green run.

Quality system

Testing is part of the build.

Checks focus on real failure modes, evidence boundaries and the public experience after deployment.

Standings calculation

20 unit tests covering points, score difference, winner detection, the full tiebreak chain, positions, recent form, custom points schemes, and which matches count towards a table.

Score validation

19 unit tests covering rally scoring at 21 with a two-point margin and a cap at 30, a team against itself, level completed matches, negative and fractional scores, and duplicate fixture detection.

Workbook fidelity

13 unit tests parse the real Excel file and assert the import reproduces it: 60 fixtures, 13 played, 15 teams across 2 groups, every winner and points cell, and both group tables in the order the workbook's own dashboard shows them.

Public site in a browser

13 Playwright specs run at 1440px, 768px and on a Pixel 7: page loads, statistics, standings, fixture filtering, team search, deep links surviving a hard refresh, the 404 page, horizontal overflow on every page, and the admin area being closed to signed-out visitors.

Organiser flow and realtime

7 Playwright specs sign in, drive a fixture through start, score, complete and reopen, refuse a result that ends level, and prove a score change reaches a separate already-open browser without a refresh. They only ever touch an unplayed fixture and put it back as they found it.

The deployed project, not just the code

One script checks every match, total and standings row in the live database against the workbook; another proves public reads work and every anonymous write is refused. Offline behaviour was checked by hand: the shell loads from the service worker cache, but league data is network-only and reads as empty.

Problem → system

What changed in the operating model.

Problem

Store the league table and update it whenever a result comes in.

System response

Derive the table from the matches on every read. There is one source of truth, so correcting a score cannot leave a stale table behind it.

Problem

Write to the database on every plus and minus tap so the score is always current.

System response

Hold the running score in local state and write once on an explicit save. A 21-point rally costs one write instead of twenty, and a mis-tap can be fixed before it is public.

Problem

Validate the score in the form and trust whatever the client sends.

System response

Validate the shape in the security rules as well: two different teams, a known status, integer scores in range, and no completed match that ends level. The form's rally-scoring checks stay advisory so older spreadsheet rows remain editable.

Evidence-backed

Outcomes

The league now has a public URL that anyone can open on a phone, with group tables that recompute the moment a score is saved at the court.

  1. 01

    The league is live with all 60 fixtures imported and results recorded against them.

  2. 02

    Anyone can open the standings on a phone from a shared link, with no account and no sign-in.

  3. 03

    The organiser enters a result at the court and every open page updates without a refresh.

  4. 04

    It runs entirely on Firebase's free tier with no server to maintain, and a unit test parsing the original workbook proves the app still reproduces it exactly.

Honest reflection

Lessons carried forward.

01

A passing end-to-end suite can be worth nothing. Mine went green against a deployment that had no database configured at all, because an empty league renders perfectly. Any suite that runs against a deployed environment should first prove that environment has data in it.

02

Storing a timestamp is not the same as storing a date. Fixtures that only carry a day are saved as UTC midnight and then display a start time of 04:00 to anyone east of Greenwich. A date-only fixture should be stored as a plain date string, and that is the first thing I would change.

03

A utility that sets a CSS property outright will quietly undo a class that set it too. The safe-area padding helper replaced the footer's bottom padding rather than adding to it, and the fixed mobile navigation ended up covering the content by 65px. Worth hit-testing a layout, not just looking at it.

Next system

Need something similar?

Bring the business problem. We can map the workflow, architecture, build and verification plan from there.

Start a Project Explore More Work