diff --git a/CLAUDE.md b/CLAUDE.md
index e7301ef..949eaa7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,8 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
- `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.
+- `test/` `npm test` (node:test) with anonymised fixtures. `npm run check` type-checks. `test/e2e.sh [sampledata]`
+ runs end-to-end checks (imports, decks, hand import, league rename/delete) against `scripts/local.sh up`.
## Rules and decisions
- Scoring: match win 3, draw 1, loss 0, bye = 2-0 win. Session score = sum of that night's match points.
@@ -27,7 +28,8 @@ add `ALTER TABLE ... IF NOT EXISTS` statements to `init` or introduce a migratio
- 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
+- Decks are free text, one per player per session (Melee exports contain no deck names), edited in one form and kept
+ identical on the player's match rows and standings row; every import fills blanks from the decks already entered. `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.
diff --git a/README.md b/README.md
index 1a2c5e1..a41e270 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ Sub-path hosting: set `BASE_PATH=/mtg` and proxy with Caddy `handle_path /mtg/*`
npm install
npm test # parser + standings tests
npm run check # type-check
+scripts/local.sh up && test/e2e.sh [sampledata] # end-to-end checks against the local podman stack
DATABASE_URL=postgres://... SECRET_KEY=... ADMIN_PASSWORD=... npm run dev
```
Whole stack locally with podman (own `.env.local` with generated secrets, own project/volume, port 8889):
diff --git a/src/server.ts b/src/server.ts
index 7902fc3..652e55b 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -82,6 +82,16 @@ async function standingsFor(l: { id: number; best_n: number }) {
return computeStandings(ms, rs, new Map(ps.map((p) => [p.id, p.name])), l.best_n);
}
+// Everyone in a session (from its matches and imported standings) with their deck. A deck is per player per night
+// and is kept identical on their match rows and their standings row.
+const SESSION_PLAYERS = `SELECT p.id, p.name, MAX(x.deck) AS deck FROM (
+ SELECT p1_id AS pid, p1_deck AS deck FROM matches WHERE session_id=$1
+ UNION ALL SELECT p2_id, p2_deck FROM matches WHERE session_id=$1 AND p2_id IS NOT NULL
+ UNION ALL SELECT player_id, deck FROM session_results WHERE session_id=$1) x
+ JOIN players p ON p.id=x.pid GROUP BY p.id, p.name ORDER BY p.name`;
+const sessionDecks = async (c: Q, sid: number) =>
+ new Map No results yet. If this session also has match rows, those are used for scoring instead. One match per line: e2e renamed
' "$T/out")" 1
+check "rename unknown league" "$(req -d name=x "$B/admin/league/2147483647/rename")" 404
+
+echo "== session A: standings, then a deck, then matches"
+req -d date=2026-10-14 "$B/admin/league/$LG/session" >/dev/null; SA=$(db "SELECT max(id) FROM sessions")
+check "import standings" "$(req "${STANDINGS[@]}" "$B/admin/session/$SA/import-melee")" 303
+P1=$(db "SELECT player_id FROM session_results r JOIN players p ON p.id=r.player_id WHERE session_id=$SA ORDER BY p.name LIMIT 1")
+check "deck form saves to standings" "$(req -d "deck_$P1=Burn" "$B/admin/session/$SA/decks")" 303
+check "standings deck" "$(decks_of "$SA" "$P1")" Burn
+check "import all matches at once" "$(req "${MATCHES[@]}" "$B/admin/session/$SA/import-melee")" 303
+check "matches picked up the standings deck" "$(decks_of "$SA" "$P1")" Burn
+req "$B/session/$SA" >/dev/null
+check "one deck form on the page" "$(grep -c '/decks"' "$T/out")" 1
+check "one deck input per session player" "$(grep -o 'name="deck_' "$T/out" | wc -l)" "$(db "SELECT count(*) FROM ($(sed 's/\$1/'"$SA"'/g' <<<"SELECT pid FROM (SELECT p1_id pid FROM matches WHERE session_id=\$1 UNION SELECT p2_id FROM matches WHERE session_id=\$1 AND p2_id IS NOT NULL UNION SELECT player_id FROM session_results WHERE session_id=\$1) x")) y")"
+check "deck form saves to matches and standings" "$(req -d "deck_$P1=Control" "$B/admin/session/$SA/decks")" 303
+check "both updated" "$(decks_of "$SA" "$P1")" Control
+check "clearing a deck" "$(req -d "deck_$P1=" "$B/admin/session/$SA/decks")" 303
+check "cleared everywhere" "$(decks_of "$SA" "$P1")" -
+req -d "deck_$P1=Control" "$B/admin/session/$SA/decks" >/dev/null
+
+echo "== session A: hand import"
+csv() { db "COPY (SELECT m.round, a.name, CASE WHEN m.p1_id=$P1 THEN '$1' ELSE '' END, coalesce(b.name, ''),
+ CASE WHEN m.p2_id=$P1 THEN '$1' ELSE '' END, m.p1_wins, m.p2_wins, m.draws FROM matches m JOIN players a ON a.id=m.p1_id
+ LEFT JOIN players b ON b.id=m.p2_id WHERE m.session_id=$SA ORDER BY m.round, m.id) TO STDOUT WITH CSV"; }
+before=$(db "SELECT count(*) FROM matches WHERE session_id=$SA")
+check "hand import with blank decks" "$(req --data-urlencode "data=$(csv '')" "$B/admin/session/$SA/import")" 303
+check "match count unchanged" "$(db "SELECT count(*) FROM matches WHERE session_id=$SA")" "$before"
+check "blank keeps the entered deck" "$(decks_of "$SA" "$P1")" Control
+check "hand import with a new deck" "$(req --data-urlencode "data=$(csv Elves)" "$B/admin/session/$SA/import")" 303
+check "new deck reaches standings too" "$(decks_of "$SA" "$P1")" Elves
+
+echo "== session B: matches, then a deck, then standings"
+req -d date=2026-10-15 "$B/admin/league/$LG/session" >/dev/null; SB=$(db "SELECT max(id) FROM sessions")
+check "import matches" "$(req "${MATCHES[@]}" "$B/admin/session/$SB/import-melee")" 303
+P2=$(db "SELECT p1_id FROM matches WHERE session_id=$SB AND p2_id IS NOT NULL ORDER BY id LIMIT 1")
+check "deck on matches" "$(req -d "deck_$P2=Elves" "$B/admin/session/$SB/decks")" 303
+check "import standings" "$(req "${STANDINGS[@]}" "$B/admin/session/$SB/import-melee")" 303
+if [[ -n "$(db "SELECT 1 FROM session_results WHERE session_id=$SB AND player_id=$P2")" ]]; then
+ check "standings picked up the match deck" "$(decks_of "$SB" "$P2")" Elves
+else echo "skip standings deck check: player not in the standings export"; fi
+
+echo "== scores"
+# only meaningful when the matches exports cover every round of the standings export
+python3 -I - "$DIR" "$(curl -s "$B/api/league/$LG/leaderboard")" "$(db "SELECT json_agg(json_build_object('name', p.name, 'points', r.points))
+ FROM session_results r JOIN players p ON p.id=r.player_id WHERE r.session_id=$SA")" <<'EOF' || fails=$((fails + 1))
+import glob, json, sys
+d, board, final = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3])
+rounds = {m["RoundNumber"] for f in glob.glob(d + "/*atches*.json") for m in json.load(open(f))}
+last = max(r["RoundNumber"] for f in glob.glob(d + "/*tandings*.json") for r in json.load(open(f)))
+if rounds != set(range(1, last + 1)):
+ print("skip leaderboard check: matches exports don't cover rounds 1 to", last); sys.exit(0)
+got = {r["name"]: r["total"] / 2 for r in board} # sessions A and B hold the same night
+bad = [f"{r['name']} {got.get(r['name'])} != {r['points']}" for r in final if got.get(r["name"]) != r["points"]]
+if bad: print("FAIL leaderboard vs Melee standings:", bad); sys.exit(1)
+print("ok leaderboard matches Melee's points for", len(final), "players")
+EOF
+
+echo "== delete league"
+check "delete league" "$(req -X POST "$B/admin/league/$LG/delete")" 303
+check "league gone" "$(db "SELECT count(*) FROM leagues WHERE id=$LG")" 0
+check "its sessions gone" "$(db "SELECT count(*) FROM sessions WHERE id IN ($SA, $SB)")" 0
+check "its matches gone" "$(db "SELECT count(*) FROM matches WHERE session_id IN ($SA, $SB)")" 0
+
+echo; [[ $fails == 0 ]] && echo "all e2e checks passed" || { echo "$fails e2e check(s) failed"; exit 1; }
diff --git a/views/league.ejs b/views/league.ejs
index fea0e03..5e0a2d9 100644
--- a/views/league.ejs
+++ b/views/league.ejs
@@ -1,5 +1,6 @@
<%- include('head') %>
<%= lg.name %>
+<% if (admin) { %><% } %>
Leaderboard (best <%= lg.best_n %> sessions)
<% }) %>
<% if (!rounds.length && !results.length) { %>
<% const pct = (r, k) => r.tb ? (r.tb[k] * 100).toFixed(2) : '–' %>
diff --git a/views/session.ejs b/views/session.ejs
index dff341b..06929ba 100644
--- a/views/session.ejs
+++ b/views/session.ejs
@@ -9,17 +9,13 @@
# Player Counted All sessions Played W-D-L OMW% GW% OGW% <%= m.p1_wins %>–<%= m.p2_wins %><%= m.draws ? '–' + m.draws : '' %>
<% }) %>Decks played
-<% } %>
<% if (results.length) { %>Standings (imported)
-
+<% results.forEach(r => { %> <% }) %>
<%= r.name %> <%= r.points %> <%= r.rec %> <%= r.deck || '' %> Import Melee export
@@ -31,7 +27,7 @@ If anything in the batch is invalid, nothing is imported. If a session has match
Import matches by hand
round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws. Leave player 2 blank for a bye.
-Comma- or tab-separated. Saving replaces this session's matches.