A mobile-first web app for managing substitutions at children's football matches in New Zealand. Built for FC Twenty11, aligned to Mainland Football junior rules.
The app is mobile-only — designed to be used on a coach's phone on the sideline. There is no desktop layout. Light and dark modes follow the device's system preference.
- Each team has a 6-digit PIN. The coach enters it on their phone to unlock that team. Provisioning is server-side; PINs are scrypt-hashed.
- One-time team setup: roster, primary/secondary position preference per player (Forward / Midfield / Defence / Goalkeeper), and the team's grade (which sets per-side count and half length).
- Per-match config: tick who's present, set goalkeeper(s) per half, set opponent and (if needed) override half length / sub interval.
- Lineup screen with a touch-friendly drag-and-drop pitch (dnd-kit). Pick a formation, auto-fill from preferences, drag to swap.
- A rotation plan is generated when the lineup is locked in — it places block-style substitutions at every sub interval to keep playing time even (within 5–10 min spread) while honouring position preferences.
- During the game: a prominent mm:ss clock with one-tap pause/resume, the next planned swap previewed on screen (highlighted when due), live score with a scorer picker, and an End match action.
- After the game: a summary screen with the final score, scorers, and per-player on-field minutes (sorted, with a fairness-spread badge).
Goalkeepers play the full game: one half in goal, the other half outfield without being subbed. If two players volunteer, they split — one each half. If one volunteers, they stay in goal both halves. If nobody volunteers, GK time can rotate across the squad (the algorithm just won't lock anyone in).
- Frontend: Vite + React + TypeScript + Tailwind,
@dnd-kitfor touch drag-and-drop,vite-plugin-pwafor installable PWA. CSS variables drive the light/dark theme tokens. - Backend: Node + Hono +
better-sqlite3. Single process serves the built frontend and the/apiroutes. Block-sub rotation algorithm and lineup auto-fill are pure TypeScript modules. - Auth: 6-digit team PIN, server-side
scrypthash, per-IP + global rate limits, constant-time response, audit log. No OAuth, no email — designed for the operator to provision PINs and hand them out. - Hosting: runs as a Docker container on a NUC; eventually exposed via an existing Cloudflare Tunnel. No Cloudflare Access policy on this hostname — the app's own PIN is the gate.
npm install
# Two-process dev (Vite UI + API server, with /api proxied):
npm run dev:server # API on :8787, binds 0.0.0.0
npm run dev # UI on :5183, binds 0.0.0.0
# Or single-process (build then serve, like production):
npm run build
npm run start # serves UI + /api on :8787To test from your phone on the same WiFi, open http://<host-lan-ip>:5183 (dev) or :8787 (built).
While NODE_ENV !== 'production' the login screen offers a "Skip PIN (dev only)" link that creates a session for the most-recently-created team — useful during LAN testing. The endpoint disappears in production.
PINs are provisioned server-side. Run the CLI from the project root:
npm run admin -- create-team --name "FC Twenty11 U11" --grade U11
npm run admin -- list-teams
npm run admin -- rotate-pin <team-id>
npm run admin -- delete-team <team-id>
npm run admin -- recent-auth --limit 50create-team prints the generated PIN once — it's hashed before storage and is not retrievable later. Use rotate-pin to issue a new one (which also invalidates active sessions for that team).
npm run test:rotation # rotation algorithm sanity tests (vitest-free, tsx)
npm run test:e2e # Playwright happy-path: login → match → summaryThe e2e suite requires the dev servers (npm run dev and npm run dev:server) running. It uses a globalSetup that resets matches/sessions and reseeds 12 dummy players against the most-recent team.
docker compose build
docker compose up -dThe container exposes :8787 and persists the SQLite database at ./data/fc-subs.sqlite via a host bind mount. It uses node:20-bookworm-slim with a multi-stage build that prunes devDependencies. Health check pings /api/health.
The SQLite database lives at data/fc-subs.sqlite (override with FC_SUBS_DB). The directory is created automatically on first run. data/ is gitignored.
Working end-to-end: PIN auth, team setup, new match config, drag-and-drop lineup, block-sub rotation plan, live game with clock and scoring, end-of-match summary, history list. Not yet built:
- Manual sub override and injury button (bench a player for the rest of the game and re-run the rotation for the remaining time).
- Auto-pause at the half-time / full-time boundary.
- Cross-season fairness biasing — track halves played in goal and accumulate per-player minutes across matches.
- Cloudflare Tunnel ingress + DNS for
subs.peters.net.nz(paused until the user has finished LAN testing).