Skip to content

Repository files navigation

⚽ Kickoff

CI version license built with built with

╔════════════════════════════╗
║ ╦╔═ ╦ ╔═╗ ╦╔═ ╔═╗ ╔═╗ ╔═╗  ║
║ ║║  ║ ║   ║║  ║ ║ ║   ║    ║
║ ╠╩╗ ║ ║   ╠╩╗ ║ ║ ╠╣  ╠╣   ║
║ ║ ║ ║ ║   ║ ║ ║ ║ ║   ║    ║
║ ╩ ╩ ╩ ╚═╝ ╩ ╩ ╚═╝ ╩   ╩    ║
╠════════════════════════════╣
║ NEVER A CONFIDENT LIE      ║
╚════════════════════════════╝

Big-5 European football fixtures and standings, in Brooklyn time — from a tracker that would rather admit what it doesn't know than tell you a confident lie.

Live demo: benicheni.github.io/kickoff — a point-in-time build, redeployed on every merge to main. If the amber banner is up when you get there, that's not a bug, that's the whole pitch: the snapshot is older than a day, and the app would rather say so than let you find out at kickoff.

Kickoff's Poster lens at 390px, light theme: tonight's slate as a full-bleed hero, then the week    The same view in the dark theme

🎲 The confession

Fine, LinkedIn, you dragged it out of me: this repo exists because Claude and I bet on European football. Claude has been an exemplary partner in crime — reads the lines, remembers La Liga's head-to-head tie-breakers, never orders anything at the bar, splits the winnings zero ways. (The sample size is a coin flip and the variance is undefeated. Let's not ruin a good story with statistics.)

Here's the part that's true enough to build software on: the bets were never threatened by bad picks. They were threatened by bad data. For a while the operation ran on a hand-typed HTML dashboard, lovingly maintained, quietly wrong. Then we audited it (23 Aug 2026), and the rap sheet came back with about twenty falsified rows. The slip, as settled:

┌────────────────────────────┐
│ AUDIT SLIP · 23 AUG 2026   │
│ DASHBOARD v. THE LEAGUES   │
├────────────────────────────┤
│ Ligue 1's opening matchday │
│ ............ ONE DAY EARLY │
│ Bundesliga, the opener     │
│ ............ SIX HOURS OFF │
│ A fixture nobody scheduled │
│ ............ DID NOT EXIST │
│ PSG–Rennes, relocated      │
│ ....... WRONG TEAM AT HOME │
├────────────────────────────┤
│ FALSIFIED ROWS .. ABOUT 20 │
│ SLIP ................ VOID │
└────────────────────────────┘

The PSG–Rennes line is the one to sit with. The LFP relocated the match, the dashboard kept the old home side, and — if you're keeping score — that inverts the moneyline.

None of those were rendering bugs. They were provenance bugs, and no model, however charming a co-conspirator, can out-reason a fixture list that lies to it. So the partner in crime built the fix, and the house rule got carved over the door:

Never a confident lie.

⏱️ What it does (v0.5.2)

La Liga, the Premier League, Serie A, Ligue 1, the Bundesliga and the Champions League; domestic super cups and the UEFA Super Cup; six tables; one fixture skeleton read through three lenses. The Champions League table groups 36 clubs into Round of 16, Knockout play-offs and Eliminated bands, with an eight-match league phase and contained sticky region headers.

European fixture days show computed matchday provenance from UEFA's published windows. A rescheduled fixture keeps its first-seen number, and an out-of-window first sighting stays unnumbered. Venue country and venue-id overrides set the stadium clock; an unknown zone says "local time not known" while Brooklyn remains available.

Moments is the third tab: hand-curated links to rights holders, never playback here. It opens empty. Beni curates src/curated/moments.json; each card retains its fixture facts, both clocks and a curation stamp after the fixture leaves the rolling snapshot. Builds and tests validate the file and flag disagreement with any matching snapshot fixture. No sync writes it, and its stamp is independent of snapshot freshness. ?tab=moments preserves ?only= and &date= through navigation and Back.

Every kickoff traces to one stored UTC instant, so the stadium clock and the Brooklyn clock can't disagree. Every unset time is admitted out loud instead of guessed. Every sync is diffed against the last one, so a moved kickoff or a swapped home side surfaces instead of rotting. The app knows how old its own data is, and says so in amber, then red. Corrected scores and team changes are reported too. Successful quiet checks publish fresh verification stamps through the same required PR check; automatic merges request delivery to the live site. The recent change digest collects the latest 30 change-bearing reports.

Fixtures and standings publish together only after both validate. A failed provider run leaves the committed snapshot intact; a standings outage can deliberately delay fresh fixtures. A view that throws during rendering keeps the navigation and offers Reload or Reset view. Validation during module import, before the app mounts, is outside that recovery.

The full argument — the UTC instant, the diff engine, the fail-loud guards, staleness as a state — is in docs/HONESTY.md. The one-line version: fixture data is generated and diffed, never typed.

🎛️ Three lenses, one skeleton

The fixture list is one instrument; a lens is the volume knob, not a different product. A lens may change the atmosphere, the day-header scale, the row density and the hero at the top of Fixtures. Everything else — tabs, filters, the two-clock rendering, provenance and staleness callouts, the Table and Moments — is the same in all three.

Lens The mood Signature moves
Poster (default) 🗞️ The editorial desk Loudness confined to day headers and a full-bleed "tonight's slate" hero, railed in the day's dominant competition colour
Ledger 📒 The accountant A date-spine calendar in the Table's quiet language: hairline rows, tap-to-expand, a four-card "Next up" strip
Broadcast 📺 The stadium screen Dark-first, floodlight amber as the lead accent, a marquee ticker of live / next / FT, and a glow on exactly the rows that deserve it

Both themes exist in every lens. Lens, tab, view, date and filters all live in the URL — ?lens=broadcast&tab=table means what it says — so any view survives a reload and can be sent to someone.

🥅 Try it

The demo needs nothing. To run it yourself:

npm install
npm run dev      # localhost:5173, hot reload
npm test         # vitest, two projects — the pure layer under node, the component wiring under jsdom

The fixture snapshot is committed, so the app works offline from a clone. npm run sync refreshes it from ESPN's public scoreboard API — no key, no account — and prints what moved, kind by kind: dates, kickoffs, venues, statuses, home sides, fixtures that appeared or vanished. Every command, and what each one is for, is in CONTRIBUTING.md.

🗺️ How it works, briefly

ESPN → normalize → validate at the boundary → snapshot → diff against the last snapshot → commit → pull request → app. The pure layer (everything under src/lib/ and scripts/) is unit-tested; the components render what it decides and decide nothing about time themselves. A scheduled workflow runs the sync every three hours as scheduled (GitHub's cron is a suggestion, not a promise), opens one rolling pull request with the diff, and merges every successful snapshot PR — quiet or change-bearing — once verify is green. merge=hold names time-sensitive or structural lines for readers; it does not hold the snapshot for a person.

The map, the data flow, where the pure layer ends and the components begin, and the two ESPN traps worth knowing about are in docs/ARCHITECTURE.md.

🤖 Two builders, one repo

Both badges up top are earned. Kickoff's SDLC now routes each release, and each PR inside it, to whichever model actually fits the work — not to one default:

The work The builder
Scoping, planning, ideation Claude Cowork drafts the prompt; Claude Code (Fable 5.1) turns it into a spec
Deep, ambiguous, scope-creeping implementation Codex (GPT 6 Astra)
Smaller implementation with clear acceptance criteria Codex, the lighter GPT-5.6 family — Sol, Terra, Luna
Trivial, reasoning-free implementation Codex (GPT-5.3 Codex Spark)
Specific implementations kept in-house Claude Code (Fable 5.1, Opus 5, or Sonnet 5)

The routing is dynamic — it's adjusted release to release as lessons come in, not fixed in advance — but the rule underneath it doesn't move: whichever model writes a branch's commits, it's authored under its own name, so git log never has to guess which agent, or which human decision, produced a given line.

🤝 Contribute

If you love the game and you've ever been burned by a fixture list, you'll like it here. Three honest places to start, none of them claimed by a scoped release:

  • Back-button honesty. All date paging replaces history, so Back after five weeks of paging exits the site; the active tab pushes a duplicate entry. Row 9 in docs/v0.3.0-ideas.md.
  • A second data provider. Cross-check ESPN and surface disagreement rather than averaging it — the durable fix for wrong venue strings and the one feature that would make "never a confident lie" a two-source claim.
  • A venue-correction path. Two of Rayo Vallecano's twelve home fixtures in the snapshot are tagged with Leganés's ground. Venue is cosmetic here and is not hand-corrected — hand-correcting generated data is the habit this project exists to break. Design a path that isn't that.

Found a fixture the app gets wrong? That's this repo's signature issue type; there's a template that asks for the three things we need. How the repo is built, what a good PR looks like, and why the commits are authored by Claude: CONTRIBUTING.md.

🧭 Roadmap

The ranked candidate list is docs/v0.2.6-ideas.md; the release plan that turns it into patches and the v0.4.0 sync minor is in docs/v0.2.1-proposal.md, amended by docs/v0.2.2-proposal.md, with the completed resilience scope in docs/v0.2.5-proposal.md and the v0.3.0 numbering ruling in docs/design-cycle-proposal.md.

Beyond: betting overlays that join a positions file on fixture ids (the token-expiry-vs-kickoff map is the obvious first feature). Moments is the second reader of that identity through fixtureId; a future join can badge a fixture with its curated links. A Results tab remains a separate design decision.

🏆 Lineage

One subject line per release. The honours board:

  • v0.5.2 (17 Sep 2026) — hotfix to the hotfix: v0.5.1's lookback cut (30 days back → today-only) deleted the app's recent fixture and result history on the next sync; this restores the 30-day lookback while keeping v0.5.1's necessary day-by-day ESPN fetch.
  • v0.5.1 (17 Sep 2026) — hotfix: Europa League, Conference League, Italian Supercoppa and Spanish Supercopa restored to the ESPN sync path after being silently absent from it.
  • v0.5.0 (12 Sep 2026) — the Champions League joins the schedule: 144 league-phase fixtures beside the domestic rows, every European day carrying provenance, venue clocks that follow the venue with honest unknown states, a 36-club league-phase table with contained sticky headers, and Moments as an empty-first third tab.
  • v0.4.1 (12 Sep 2026) — the small print tells the truth too: every sync warning, result correction, publication surface and red-run outcome says what actually happened.
  • v0.4.0 (11 Sep 2026) — the sync tells the whole truth: corrected scores and team identities are reported, quiet checks publish verified freshness, and merged snapshots bring their change digest and request Pages delivery.
  • v0.3.2 (9 Sep 2026) — the sync's verify step survives a live match: the designCycle DOM test scopes every LIVE / KICKED OFF query to its own row and fixes its input by date, so a genuine in-play row in the snapshot no longer turns sync.yml's verify red; the pipeline skill is named by its live handle; nothing in the app changes.
  • v0.3.1 (8 Sep 2026) — the Table tells the time like every other lens: a kicked-off match reads KICKED OFF, never as the next kickoff, and the scheduled sync merges itself once verify is green.
  • v0.3.0 (6 Sep 2026) — snapshot states say what is known: a frozen LIVE admits its age, NEXT names the first matchday it knows, and an empty schedule keeps its provenance.
  • v0.2.5 (5 Sep 2026) — the resilience patch: fixtures and standings publish together, failed views can recover, URL state stays honest, and theme boot runs before paint.
  • v0.2.4 (5 Sep 2026) — tests reach the wiring: a jsdom project beside the node suite, the two browser-found bugs reproduced as tests that fail against their pre-fix logic, and the review command renamed /kickoff-pr-review.
  • v0.2.3 (4 Sep 2026) — a hotfix: the workflows on Node 24 actions, so no run warns that Node 20 is deprecated. The train slides again — the rig is v0.2.4, the resilience patch v0.2.5.
  • v0.2.2 (4 Sep 2026) — the front door: Poster by default, a hero that tells the time, a sync that merges its own boring news, an address, and these docs.
  • v0.2.1 (3 Sep 2026) — the review becomes a command: /beni-pr-review as a repo skill (renamed /kickoff-pr-review in v0.2.4).
  • v0.2.0 (2 Sep 2026) — the app learns to tell time: a clock that ticks, a sync that fails loudly, a scheduled refresh that opens its own PR.
  • v0.1.1 (1 Sep 2026) — the public-repo milestone: security sweep, license, CI.
  • v0.1.0 (31 Aug 2026) — the lens system: Ledger, Poster, Broadcast over one skeleton; hardened by an adversarial review.
  • v0.0.2 (26 Aug 2026) — the Table: full standings synced from ESPN.
  • v0.0.1 (23 Aug 2026) — the rewrite: generated-and-diffed data replaces the hand-typed dashboard.

The paper trail for every release — proposals, design briefs, build specs and review prompts — is indexed in docs/README.md. The design system it's built against (Fergie Time — yes, really) is still in the tunnel, unreleased until it earns its debut.

⚖️ License and the small print

MIT. Not betting advice — the only edge this repo guarantees is a correct kickoff time, and honestly, that's the one your model can't live without. If you do bet: be of legal age, in a legal market, with money you can afford to lose. The variance is undefeated.

About

⚽ Big-5 European football fixtures + standings, in Brooklyn time. Generated and diffed, never typed — a tracker whose house rule is: never a confident lie. Built as a partner-in-crime project with Claude Code.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages