- Parse Melee matches exports (one round per file, byes, null GameWins); one import form detects matches vs standings exports. Re-importing a round replaces just that round and keeps entered decks. - Per-player deck entry for sessions scored from matches. - node:test suite with anonymised fixtures; lockfile. - scripts/local.sh runs the compose stack under podman with its own .env.local, project name and volume. - README and CLAUDE.md docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 lines
4.6 KiB
Markdown
61 lines
4.6 KiB
Markdown
# MTG league tracker
|
||
|
||
Small web app for a Magic: The Gathering league that runs its nights on melee.gg. Melee stays the source of truth
|
||
for pairings and results; this app is a dashboard (leaderboard, session results, deck stats) fed by Melee exports.
|
||
Hosted at home (Proxmox VM, Docker, Portainer stack deployed from Git), exposed through Caddy on a VPS over Tailscale.
|
||
|
||
## Stack
|
||
Node + TypeScript run directly with `tsx` (no build step), Express 5, server-rendered EJS, Postgres via `pg` with
|
||
plain parameterised SQL (no ORM). Tables are created on startup in `src/db.ts`. There is no migration tool yet:
|
||
add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migration tool when the schema changes.
|
||
|
||
## Layout
|
||
- `src/server.ts` routes, auth, security middleware, import handlers
|
||
- `src/tables.ts` pure parsers: Melee matches export, Melee standings export, generic CSV/JSON (no deps)
|
||
- `src/standings.ts` pure leaderboard + deck-stats logic
|
||
- `src/db.ts` pool, `tx()` helper, schema
|
||
- `views/*.ejs` five pages. Every link/form action is prefixed with `<%= base %>` (BASE_PATH support)
|
||
- `test/` `npm test` (node:test) with anonymised fixtures. `npm run check` type-checks.
|
||
|
||
## Rules and decisions
|
||
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
|
||
Leaderboard = sum of each player's best `league.best_n` session scores; tiebreak = total of all sessions, then name.
|
||
- A session with match rows is scored from them; otherwise from imported standings (`session_results`).
|
||
Standings imports have W-D-L but no opponents, so the leaderboard shows "–" W-D-L for players with such a session.
|
||
- Players are identified by real name (Melee `Team.Players[0].Name`, falling back to DisplayName, then Username),
|
||
matched case-insensitively and created on first sight. Spelling differences create duplicate players.
|
||
- No top cut will ever be used, so the standings importer takes the last round of the export.
|
||
- Decks are free text, stored per player per session (Melee exports contain no deck names). `known` decks feed a datalist
|
||
to avoid "Burn"/"burn" duplicates. Deck stats count whole-night results against the deck.
|
||
- 1-on-1 matches only. Unreported matches (`HasResult` false) are skipped.
|
||
|
||
## Melee export shapes (observed)
|
||
- Standings export: array of rows, one snapshot per round (`RoundNumber`), each with `Team.Players[]`, `Points`,
|
||
`MatchWins/MatchDraws/MatchLosses`. Use the final round only.
|
||
- Matches export: array of matches with `Competitors[]` (sorted by `SortOrder`), `GameWinsAndGameByes` (always
|
||
filled; `GameWins` is null for 0), `GameDraws`, `HasResult`, `RoundNumber`. One round per file; a bye is a single competitor.
|
||
- Melee only keeps organiser export data for ~60 days. The data API needs approval from contact@melee.gg, so it
|
||
was deliberately not used; a future automation could use it (credentials must stay server side).
|
||
- Real exports contain personal data (Discord names, DCI numbers) and are gitignored. Use `test/fixtures` instead.
|
||
|
||
## Security measures already in place
|
||
Parameterised SQL only; EJS escaping everywhere; admin-only routes behind `admin` middleware; strict CSP with a nonce
|
||
(no inline handlers: use `data-confirm`); same-origin check on non-GET requests; login rate limit (10 failures / 15 min / IP);
|
||
startup refuses weak `SECRET_KEY` (<32) or `ADMIN_PASSWORD` (<10); container runs as `node`; port bound to `BIND_ADDR`.
|
||
Admin cookie is a stateless signed cookie (logout does not revoke it; rotate `SECRET_KEY` to invalidate).
|
||
Leaderboard pages are public and show real names; protect with Caddy `basic_auth` if that is a concern.
|
||
|
||
## Config (env)
|
||
`DB_PASSWORD`, `SECRET_KEY`, `ADMIN_PASSWORD`, `COOKIE_SECURE`, `BIND_ADDR` (Tailscale IP of the VM), `HOST_PORT` (default 8888),
|
||
`BASE_PATH` (sub-path like `/mtg`, no trailing slash; Caddy must use `handle_path` so it strips the prefix).
|
||
|
||
## Status
|
||
Verified locally (2026-10-28): `npm run check`, `npm test`, and the compose stack under podman (`scripts/local.sh up`)
|
||
with real Melee exports: login, CSRF block, league/session creation, matches + standings import, decks, all pages, API.
|
||
Hosted at https://derekc.xyz/mtgleague (`BASE_PATH=/mtgleague`); the user confirmed `BASE_PATH` and `COOKIE_SECURE=true`
|
||
login work behind Caddy, and the public pages' security headers and prefixed links were checked through the proxy.
|
||
|
||
## Ideas for automation
|
||
Scripted import of a Melee export directory; a CLI/API-token route for imports instead of the admin form; nightly `pg_dump`;
|
||
GitOps redeploy in Portainer; per-round deck changes; merge/rename players; Melee API sync if access is ever granted.
|