SHL GM Dashboard overview: Edmonton Blizzard team card with a cap usage bar, beside a standings widget, the schedule and quick actions

SHL GM Dashboard

Personal project · In active development

July to September 2026Solo project
Architecture diagram: the portal API and Google Sheets feed four sync routes into Postgres, which a Next.js App Router app reads from, alongside live reads from the league's simulation index, serving GM sign-in
Two paths into the app: four manually triggered sync routes load the portal API and Google Sheets into Postgres, while live standings and leaderboards query the simulation index directly.

What it was

The Simulation Hockey League is a community-run fantasy league where general managers run teams: signing contracts, staying under the cap, trading picks. What they need to know is split across a forum portal API, the league's simulation index and shared Google Sheets, so answering "can I afford this player?" means opening four tabs and doing the arithmetic by hand. The dashboard answers it behind one sign-in: rosters, contracts with a four-season cap projection, live standings, draft picks, trades and GM messaging. It is a Next.js App Router app on PostgreSQL through Prisma.

What I owned

Solo project, so all of it: the Postgres schema, sign-in with an admin approval queue and per-team roles, four sync routes from the portal API and spreadsheet exports, the team pages, the cap projection, trade proposals with counteroffers, the contract review queue and the audit log underneath.

The hard part

The hard part is that the app does not own its data yet. Contracts are still written in a spreadsheet and synced in, but the goal is for GMs to sign players in the app, so an approved contract has to survive the next sync instead of being overwritten by an older sheet row. Each approval is stored as a fingerprint, and every sync classifies the sheet row against it as converged, diverged or unreadable, because a row the parser cannot read is a different problem from one that disagrees.

Each rule on the write paths exists because breaking it caused a real bug. Role and team are re-read inside the transaction, not trusted from the session, since an admin may have changed them. Preconditions live in the WHERE clause of the update and zero rows updated counts as a conflict, because a check in a separate statement is one two callers can both pass. Transactions are serializable, and a conflict returns a 409 rather than retrying: a retry with stale authorisation would turn a refused action into an accepted one.

What I would do differently

Syncing is still manual: an admin presses a button and nothing is scheduled. Stats are matched to players on a normalised name, so a duplicate or an in-game rename would mismatch silently; a stable ID would need the league to expose one. Next is contract signing in the app, which is the whole reason contracts live in Postgres.

League-wide cap sheet: 24 teams with players signed, cap used, cap room and three seasons of projections
The league-wide cap sheet, sorted by room left. Cap used comes from the budget spreadsheet; the S90 to S92 columns are projected from contract seasons, so a dash means nothing is signed that far out.
Contracts page listing each rostered player with position, contract type, term and salary, each row with a Propose button
Every rostered player with term and salary. Propose sends a contract to the admin review queue, the path meant to replace editing the sheet by hand.
← All projects