
SHL GM Dashboard
Personal project · In active development
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.

