Files
mtg-league/CLAUDE.md
T
derekandClaude Opus 5.5 985c85bcef Import Melee matches exports, add tests and local podman stack
- 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>
2026-10-28 16:58:24 +00:00

4.6 KiB
Raw Blame History

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.