Split The code is public; the app answers only to the crew.

The match you just played has no page: `GET /m/<id>`, linked from every row on the board and reachable from the terminal #242

Closed opened this
Closed without merging

scout/cs:scout/173-the-match-you-just-played-has-no-page-ge → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
12 files changed +294 −20
README.md +11 −3
@@ -171,7 +171,10 @@ wants one, but the running server never reads it.
171171 credited for the kill, the way blowing yourself up with your own grenade
172172 never is.
173173 A footstep only carries so far, but the bomb's beep and the blast that ends it
174 carry across the whole map. The leaderboard is a real SQLite table under
174 carry across the whole map. Every row on the board and the match-over card
175 both link to that one match's own page at `GET /m/<id>` — the same numbers
176 the board already shows, held at an address worth coming back to. The
177 leaderboard is a real SQLite table under
175178 `FORGE_APP_DATA`, also served as plain JSON from `GET /api/leaderboard` for
176179 the terminal or any other client — with a `guest` flag per row instead of the
177180 identity it comes from, because a guest's identity is the session cookie that
@@ -673,7 +676,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
673676 browser may claim, `src/who.ts` verifies the forge's signed identity
674677 headers, `src/player.ts` turns "who is asking" into who a match is credited
675678 to, `src/limit.ts` is the rate limit `POST /api/match` stands behind, and
676 `src/pages/game.ts` renders the page.
679 `src/pages/game.ts` renders the page, and `src/pages/match.ts` renders one
680 match's own page at `GET /m/<id>` — the id is a uuid, checked before it ever
681 reaches `src/db.ts`'s `matchById`, so a malformed one is a 404 that never
682 queries and an unknown one reads the same as a malformed one.
677683 - `src/bundle.ts` bundles the client; `src/server.ts` runs it at start-up and
678684 serves the result from memory as `/static/app.<hash>.js`, cached for a year.
679685 `scripts/build.ts` is a thin CLI over the same function, for a file on disk.
@@ -692,7 +698,9 @@ on a real pty over the same database: the same standings, the same matches, a
692698 `who` column that says `signed` or `guest` (a terminal has no markup, so the
693699 tag a name cannot fake is a column of its own), and `x` removes one.
694700 <kbd>Tab</kbd> walks the three tabs — the third is the policy card the page
695 shows, which the tab key could not reach until now.
701 shows, which the tab key could not reach until now. On the matches tab,
702 <kbd>Enter</kbd> opens the selected row full-screen — the same fields
703 `GET /m/<id>` renders — and <kbd>Esc</kbd> goes back.
696704
697705 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
698706 `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH`, `FORGE_APP_COMMIT` and
src/client/main.test.ts +4
@@ -92,3 +92,7 @@ test("every selector main.ts runs against the page it did not write matches some
9292 expect(html).toContain(`<${selector}`);
9393 }
9494 });
95
96 test("the match-over card is pointed at the match that just finished, not the board", () => {
97 expect(mainSource).toContain('$<HTMLAnchorElement>("summary-match").href = `/m/${matchId}`;');
98 });
src/client/main.ts +1
@@ -757,6 +757,7 @@ function boot(): void {
757757 $("summary-detail").textContent = summary.detail;
758758 $("summary-score").textContent = summary.score;
759759 $("summary-line").textContent = `${summary.name} — ${summary.line}`;
760 $<HTMLAnchorElement>("summary-match").href = `/m/${matchId}`;
760761 // Built once from board — already escaped — never patched frame by frame.
761762 $("summary-board").innerHTML = finalBoardHtml(board);
762763 show(summaryPanel, true);
src/db.test.ts +16 −1
@@ -8,7 +8,7 @@ import { Database } from "bun:sqlite";
88 import { mkdtempSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { openDatabase, recentMatches, saveMatch, standings } from "./db.ts";
11 import { matchById, openDatabase, recentMatches, saveMatch, standings } from "./db.ts";
1212 import type { Result } from "./result.ts";
1313
1414 const dir = (): string => mkdtempSync(join(tmpdir(), "db-"));
@@ -167,6 +167,21 @@ test("a match round-trips its state: finished, left, or still live", () => {
167167 expect(recentMatches(db).find((m) => m.id === live.matchId)?.state).toBe("live");
168168 });
169169
170 test("a known match is found by its id, with the same numbers recentMatches reads", () => {
171 const db = openDatabase(dir());
172 const match = result({ kills: 12, damage: 900 });
173 expect(saveMatch(db, writer("user:u_1", "ada"), match)).toBe(true);
174 const row = matchById(db, match.matchId);
175 expect(row?.kills).toBe(12);
176 expect(row?.damage).toBe(900);
177 expect(row?.map).toBe("de_forge");
178 });
179
180 test("an id nothing was ever saved under is not found", () => {
181 const db = openDatabase(dir());
182 expect(matchById(db, crypto.randomUUID())).toBe(null);
183 });
184
170185 test("a late periodic post cannot undo a leave that already landed", () => {
171186 const db = openDatabase(dir());
172187 const w = writer("user:u_1", "ada");
src/db.ts +17
@@ -222,6 +222,19 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
222222 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
223223 ).all(limit);
224224
225 /**
226 * One match, by the id the browser generated for it — the same row shape as
227 * `recentMatches`, so the page and the terminal's detail view read it the
228 * same way they read the board. A match with no round played yet is `null`
229 * here exactly as it is absent from the board: there is nothing to show.
230 */
231 export const matchById = (db: Database, id: string): MatchRow | null =>
232 db.query<MatchRow, [string]>(
233 `SELECT id, identity, session, name, map, kills, deaths, headshots, damage, rounds,
234 score_mine, score_theirs, finished, walked_out, seconds, updated_at, ${STATE_SQL}
235 FROM match WHERE id = ?1 AND rounds > 0`,
236 ).get(id);
237
225238 /**
226239 * The table people actually look at: one row an identity, best first. The name
227240 * shown is the one on that identity's most recent match, kept current by
@@ -253,3 +266,7 @@ export const ratio = (kills: number, deaths: number): string =>
253266
254267 export const headshotShare = (headshots: number, kills: number): string =>
255268 kills === 0 ? "—" : `${Math.round((headshots / kills) * 100)}%`;
269
270 /** How long a match took, as minutes:seconds — the one place seconds becomes a clock. */
271 export const duration = (seconds: number): string =>
272 `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
src/pages/game.test.ts +9
@@ -182,6 +182,15 @@ test("a recent match's row names the map it was played on", () => {
182182 expect(html).toContain("de_forge");
183183 });
184184
185 test("a recent match's row links to its own page", () => {
186 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
187 const writer = { identity: "user:u_link", name: "reyes", guest: false, session: "f".repeat(32) };
188 const played = matchResult();
189 saveMatch(stats, writer, played);
190 const html = statsHtml(stats, player);
191 expect(html).toContain(`<a href="/m/${played.matchId}">`);
192 });
193
185194 test("the leaderboard table carries the hook and labels a phone-width card layout needs", () => {
186195 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
187196 const writer = { identity: "user:u_4", name: "sable", guest: false, session: "e".repeat(32) };
src/pages/game.ts +6 −3
@@ -40,8 +40,8 @@ export function statsHtml(db: Database, player: Player): string {
4040 m.state === "finished" ? "" : m.state === "abandoned" ? " · abandoned" : " · in progress";
4141 const feed = recent
4242 .map(
43 (m) => `<li><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` +
44 `<span>${escape(m.map)} · ${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span> ` +
43 (m) => `<li><a href="/m/${escape(m.id)}"><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` +
44 `<span>${escape(m.map)} · ${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span></a> ` +
4545 `<time>${escape(m.updated_at)}</time></li>`,
4646 )
4747 .join("");
@@ -142,7 +142,7 @@ export function motionControl(id: "motion" | "pause-motion"): string {
142142 return `<label>Motion<select id="${id}">${options}</select></label>`;
143143 }
144144
145 const STYLE = `
145 export const STYLE = `
146146 :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; }
147147 * { box-sizing: border-box; }
148148 body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; }
@@ -267,6 +267,8 @@ th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--li
267267 th { color:var(--dim); font-weight:500; }
268268 ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; }
269269 ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; }
270 ul.recent a { color:inherit; text-decoration:none; display:flex; gap:.5rem; flex-wrap:wrap; align-items:baseline; }
271 ul.recent a:hover, ul.recent a:focus { text-decoration:underline; }
270272 ul.recent span, ul.recent time, .empty, .you { color:var(--dim); }
271273 .you { font-size:.86rem; margin:.2rem 0 .6rem; }
272274 .you b { color:var(--text); }
@@ -416,6 +418,7 @@ export function gamePage(db: Database, options: PageOptions): string {
416418 <p id="summary-detail"></p>
417419 <p class="score" id="summary-score"></p>
418420 <p id="summary-line"></p>
421 <p><a id="summary-match" href="/">View this match</a></p>
419422 <div id="summary-board"></div>
420423 <div class="row">
421424 <button type="button" class="primary" id="again">Play again</button>
src/pages/match.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * One match's own page: everything the board already shows about it, held
3 * still at an address you can come back to. Nothing here that is not already
4 * a column the board reads — no session, no identity string, one id in and
5 * one row out.
6 */
7
8 import { duration, headshotShare, ratio, type MatchRow } from "../db.ts";
9 import { isGuest } from "../player.ts";
10 import { escape, STYLE } from "./game.ts";
11
12 const STATE_WORD: Record<MatchRow["state"], string> = {
13 finished: "finished",
14 abandoned: "abandoned",
15 live: "in progress",
16 };
17
18 export function matchPage(match: MatchRow): string {
19 const tag = isGuest(match.identity) ? ` <span class="tag">guest</span>` : "";
20 const title = `${match.name}${isGuest(match.identity) ? " (guest)" : ""} on ${match.map}`;
21 const row = (k: string, v: string): string => `<tr><th>${escape(k)}</th><td>${v}</td></tr>`;
22 return `<!doctype html>
23 <html lang="en">
24 <meta charset="utf-8">
25 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
26 <title>cs — ${escape(title)}</title>
27 <style>${STYLE}</style>
28 <header>
29 <h1><a href="/">cs</a></h1>
30 </header>
31 <main>
32 <p><a href="/">&larr; Back to the board</a></p>
33 <h2>${escape(match.name)}${tag} on ${escape(match.map)}</h2>
34 <table>
35 <tbody>
36 ${row("Score", `${match.score_mine}–${match.score_theirs}`)}
37 ${row("State", escape(STATE_WORD[match.state]))}
38 ${row("Map", escape(match.map))}
39 ${row("Rounds", String(match.rounds))}
40 ${row("Kills", String(match.kills))}
41 ${row("Deaths", String(match.deaths))}
42 ${row("Headshots", `${match.headshots} (${headshotShare(match.headshots, match.kills)})`)}
43 ${row("Damage", String(match.damage))}
44 ${row("K/D", ratio(match.kills, match.deaths))}
45 ${row("Duration", duration(match.seconds))}
46 ${row("When", escape(match.updated_at))}
47 </tbody>
48 </table>
49 </main>
50 <footer>cs · <a href="/">back to the leaderboard</a></footer>
51 </html>
52 `;
53 }
src/server.test.ts +48
@@ -395,6 +395,54 @@ test("the endpoint is rate limited, and one player's flood is their own", async
395395 expect((await post(result(), "", forge("u_calm", "calm"))).status).toBe(200);
396396 });
397397
398 test("a known match has its own page, with the same numbers the board shows", async () => {
399 const id = crypto.randomUUID();
400 await post(result({ matchId: id, map: "de_forge", kills: 17, deaths: 6, headshots: 4, damage: 2100, rounds: 8, scoreMine: 5, scoreTheirs: 3, seconds: 700 }), "", forge("u_m1", "Solo"));
401 const response = await fetch(base + "/m/" + id);
402 expect(response.status).toBe(200);
403 const body = await response.text();
404 expect(body).toContain("Solo");
405 expect(body).toContain("de_forge");
406 expect(body).toContain("17");
407 expect(body).toContain("6");
408 expect(body).toContain("4");
409 expect(body).toContain("2100");
410 expect(body).toContain("5–3");
411 expect(body).toContain("finished");
412 });
413
414 test("a match page never shows a session id or an identity string", async () => {
415 const id = crypto.randomUUID();
416 const session = "5".repeat(32);
417 await post(result({ matchId: id }), "cs_session=" + session);
418 const body = await (await fetch(base + "/m/" + id)).text();
419 expect(body).not.toContain(session);
420 expect(body).not.toContain("guest:");
421 expect(body).toContain("guest");
422 });
423
424 test("an id nothing was ever posted under is a 404, and so is a malformed one", async () => {
425 const unknown = await fetch(base + "/m/" + crypto.randomUUID());
426 expect(unknown.status).toBe(404);
427 for (const bad of ["not-a-uuid", "12345678-1234-1234-1234-12345678901", "../etc/passwd", ""]) {
428 const response = await fetch(base + "/m/" + bad);
429 expect([bad, response.status]).toEqual([bad, 404]);
430 }
431 });
432
433 test("HEAD on a match page answers like GET, under the floor and out of shared caches", async () => {
434 const id = crypto.randomUUID();
435 await post(result({ matchId: id }), "", forge("u_m2", "Headcheck"));
436 const get = await server.fetch(new Request(base + "/m/" + id));
437 const head = await server.fetch(new Request(base + "/m/" + id, { method: "HEAD" }));
438 expect(head.status).toBe(200);
439 expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0));
440 expect(head.headers.get("content-length")).toBe(get.headers.get("content-length"));
441 expect(head.headers.get("cache-control")).toBe("private, no-cache");
442 expect(head.headers.get("content-security-policy")).toContain("default-src 'self'");
443 expect(head.headers.get("x-content-type-options")).toBe("nosniff");
444 });
445
398446 test("a guest is limited too, by the session they hold", async () => {
399447 const cookie = "cs_session=" + "e".repeat(32);
400448 let last = 200;
src/server.ts +25 −1
@@ -9,9 +9,10 @@
99 import { join } from "node:path";
1010 import { bundle } from "./bundle.ts";
1111 import { acceptsGzip } from "./compress.ts";
12 import { openDatabase, saveMatch } from "./db.ts";
12 import { matchById, openDatabase, saveMatch } from "./db.ts";
1313 import { createLimiter, type Decision } from "./limit.ts";
1414 import { escape, gamePage, leaderboardJson, statsHtml } from "./pages/game.ts";
15 import { matchPage } from "./pages/match.ts";
1516 import { credit } from "./player.ts";
1617 import { parseResult } from "./result.ts";
1718 import { identityWarning, whoIs, type Who } from "./who.ts";
@@ -120,6 +121,11 @@ async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array |
120121 return body;
121122 }
122123
124 // A match id is a uuid the browser generated with crypto.randomUUID(): the
125 // one shape /m/<id> is ever allowed to mean. Checked here, before the id
126 // reaches the database — a malformed one is a 404 that never queries.
127 const MATCH_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
128
123129 // A session is only ever a name for "the browser that started this match", so
124130 // one browser cannot overwrite another's result. It carries nothing else.
125131 const SESSION_COOKIE = "cs_session";
@@ -230,6 +236,24 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> {
230236 : new Response(JSON.stringify(leaderboardJson(db)), { headers });
231237 }
232238
239 // One match, at the address every row on the board and the match-over
240 // card link to. Not found and malformed read the same: a 404, and a
241 // malformed id never reaches matchById at all.
242 if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/m/")) {
243 const id = url.pathname.slice("/m/".length);
244 const match = MATCH_ID.test(id) ? matchById(db, id) : null;
245 if (match === null)
246 return html(`<h1>Not found</h1><p><a href="/">Back to ${escape("cs")}</a></p>`, 404, {
247 "cache-control": "private, no-cache",
248 });
249 const headers = {
250 "content-type": "text/html; charset=utf-8",
251 "cache-control": "private, no-cache",
252 ...SECURITY,
253 };
254 return encoded(request, encoder.encode(matchPage(match)), headers);
255 }
256
233257 // HEAD is GET without the body: a health check sees the app up.
234258 if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
235259 // A first visit is given its session here, and the page is rendered
src/tty.test.ts +48 −7
@@ -62,14 +62,14 @@ test("tab walks the three tabs in src/tabs.ts's order and comes back round", ()
6262
6363 test("the cursor is clamped when the list is shorter than the cursor, including across a tab switch", () => {
6464 const rows = data({ standings: [standing()], matches: [match(), match(), match()] });
65 let state: State = { tab: "matches", cursor: 2, note: "" };
65 let state: State = { tab: "matches", cursor: 2, note: "", detail: null };
6666 state = key(state, "j", rows, noRemove);
6767 expect(state.cursor).toBe(2); // three matches: index 2 is the last one, "j" cannot go past it
6868 state = key(state, "\t", rows, noRemove);
6969 expect(state.tab).toBe("policy");
7070 expect(state.cursor).toBe(0);
7171 // render() clamps too, independently of key(), for a state built by hand.
72 const wide: State = { tab: "standings", cursor: 5, note: "" };
72 const wide: State = { tab: "standings", cursor: 5, note: "", detail: null };
7373 expect(() => render(wide, rows)).not.toThrow();
7474 const lines = render(wide, rows);
7575 expect(lines.some((l) => l.includes("tester"))).toBe(true);
@@ -82,7 +82,7 @@ test("x removes the selected match and notes it; x on another tab does nothing",
8282 removed.push(id);
8383 return true;
8484 };
85 const state: State = { tab: "matches", cursor: 1, note: "" };
85 const state: State = { tab: "matches", cursor: 1, note: "", detail: null };
8686 const next = key(state, "x", rows, remove);
8787 expect(removed).toEqual(["b"]);
8888 expect(next.note).toBe("removed Bob's match");
@@ -90,7 +90,7 @@ test("x removes the selected match and notes it; x on another tab does nothing",
9090 const failing = key(state, "x", rows, () => false);
9191 expect(failing.note).toBe("nothing removed");
9292
93 const onStandings = key({ tab: "standings", cursor: 0, note: "" }, "x", rows, remove);
93 const onStandings = key({ tab: "standings", cursor: 0, note: "", detail: null }, "x", rows, remove);
9494 expect(removed).toEqual(["b"]); // unchanged: x only removes on the matches tab
9595 expect(onStandings.note).toBe("");
9696 });
@@ -98,16 +98,16 @@ test("x removes the selected match and notes it; x on another tab does nothing",
9898 test("x past the end of the list removes nothing", () => {
9999 const rows = data({ matches: [] });
100100 const remove = () => true;
101 const state: State = { tab: "matches", cursor: 0, note: "" };
101 const state: State = { tab: "matches", cursor: 0, note: "", detail: null };
102102 const next = key(state, "x", rows, remove);
103103 expect(next.note).toBe("");
104104 });
105105
106106 test("an unrecognised key, like q, leaves the tab and cursor exactly as they were", () => {
107107 const rows = data({ standings: [standing(), standing()] });
108 const state: State = { tab: "standings", cursor: 1, note: "stale" };
108 const state: State = { tab: "standings", cursor: 1, note: "stale", detail: null };
109109 const next = key(state, "q", rows, noRemove);
110 expect(next).toEqual({ tab: "standings", cursor: 1, note: "" });
110 expect(next).toEqual({ tab: "standings", cursor: 1, note: "", detail: null });
111111 });
112112
113113 test("the standings tab renders every row, and says so when there are none", () => {
@@ -181,6 +181,47 @@ test("a C1 control byte, like the 8-bit form of CSI, is stripped too", () => {
181181 expect(line).not.toContain("\x9b");
182182 });
183183
184 test("enter opens the selected match on the matches tab, and shows the same numbers the page does", () => {
185 const rows = data({
186 matches: [match({ id: "target", name: "Vesper", map: "de_dust", kills: 17, deaths: 6, headshots: 4, damage: 2100, rounds: 8, score_mine: 5, score_theirs: 3, seconds: 700 })],
187 });
188 const state: State = { tab: "matches", cursor: 0, note: "", detail: null };
189 const opened = key(state, "\r", rows, noRemove);
190 expect(opened.detail).toBe("target");
191 const lines = render(opened, rows).join("\n");
192 expect(lines).toContain("Vesper");
193 expect(lines).toContain("de_dust");
194 expect(lines).toContain("5–3");
195 expect(lines).toContain("17");
196 expect(lines).toContain("6");
197 expect(lines).toContain("4");
198 expect(lines).toContain("2100");
199 expect(lines).toContain("finished");
200 expect(lines).toContain("esc back");
201 });
202
203 test("enter does nothing on the standings tab", () => {
204 const rows = data();
205 const state: State = { tab: "standings", cursor: 0, note: "", detail: null };
206 expect(key(state, "\r", rows, noRemove).detail).toBe(null);
207 });
208
209 test("esc closes the detail view and returns to the list, and nothing else does", () => {
210 const rows = data({ matches: [match({ id: "target" })] });
211 const open: State = { tab: "matches", cursor: 0, note: "", detail: "target" };
212 expect(key(open, "j", rows, noRemove)).toEqual(open);
213 const closed = key(open, "\x1b", rows, noRemove);
214 expect(closed.detail).toBe(null);
215 expect(render(closed, rows).join("\n")).toContain("tester");
216 });
217
218 test("opening a match id that has since vanished says so instead of throwing", () => {
219 const rows = data({ matches: [] });
220 const open: State = { tab: "matches", cursor: 0, note: "", detail: "gone" };
221 expect(() => render(open, rows)).not.toThrow();
222 expect(render(open, rows).join("\n")).toContain("gone");
223 });
224
184225 test("standings and matches say who a row belongs to, guest or signed", () => {
185226 const rows = data({
186227 standings: [standing({ identity: "user:1", name: "guest aaaa" }), standing({ identity: "guest:aaaa", name: "guest aaaa" })],
src/tty.ts +56 −5
@@ -13,7 +13,7 @@
1313
1414 import type { Database } from "bun:sqlite";
1515 import {
16 deleteMatch, headshotShare, matchCount, openDatabase, ratio, recentMatches, standings,
16 deleteMatch, duration, headshotShare, matchCount, openDatabase, ratio, recentMatches, standings,
1717 type MatchRow, type Standing,
1818 } from "./db.ts";
1919 import { isGuest } from "./player.ts";
@@ -21,9 +21,14 @@ import { identityWarning } from "./who.ts";
2121 import { CARD } from "./client/weights.ts";
2222 import { nextTab, TABS, type Tab } from "./tabs.ts";
2323
24 export type State = { tab: Tab; cursor: number; note: string };
24 /**
25 * `detail` is the id of the match the matches tab is showing full-screen —
26 * the same view `/m/<id>` renders — or `null` for the ordinary tab list.
27 * Only Enter opens it, from a row on the matches tab, and only Esc closes it.
28 */
29 export type State = { tab: Tab; cursor: number; note: string; detail: string | null };
2530
26 export const initialState: State = { tab: "standings", cursor: 0, note: "" };
31 export const initialState: State = { tab: "standings", cursor: 0, note: "", detail: null };
2732
2833 /** Everything a frame needs to draw, read fresh from the database each time. */
2934 export type Data = {
@@ -63,9 +68,14 @@ const clampCursor = (cursor: number, rows: number): number => Math.max(0, Math.m
6368
6469 /** What one keypress does to the state. Reads `data` but never the database itself. */
6570 export function key(state: State, input: string, data: Data, remove: Remove): State {
71 // The detail view is a dead end but for Esc: no scrolling one row, no tab
72 // switch out from under it — back to the list is the only way out.
73 if (state.detail !== null)
74 return input === "\x1b" ? { tab: state.tab, cursor: state.cursor, note: "", detail: null } : state;
6675 let tab = state.tab;
6776 let cursor = state.cursor;
6877 let note = "";
78 let detail: string | null = null;
6979 if (input === "j" || input === "\x1b[B") cursor += 1;
7080 else if (input === "k" || input === "\x1b[A") cursor = Math.max(0, cursor - 1);
7181 else if (input === "\t") {
@@ -74,13 +84,54 @@ export function key(state: State, input: string, data: Data, remove: Remove): St
7484 } else if (input === "x" && tab === "matches") {
7585 const match = data.matches[cursor];
7686 if (match !== undefined) note = remove(match.id) ? `removed ${match.name}'s match` : "nothing removed";
87 } else if ((input === "\r" || input === "\n") && tab === "matches") {
88 const match = data.matches[cursor];
89 if (match !== undefined) detail = match.id;
7790 }
7891 cursor = clampCursor(cursor, rowCount(tab, data));
79 return { tab, cursor, note };
92 return { tab, cursor, note, detail };
93 }
94
95 // The word for a row's state, unchanged for the detail view — the page and
96 // the terminal say the same thing.
97 const stateWord = (m: MatchRow): string =>
98 m.state === "finished" ? "finished" : m.state === "abandoned" ? "abandoned" : "in progress";
99
100 /**
101 * One match, full-screen: the same fields the page at /m/<id> shows, read the
102 * same way — `data.matches` is where both this and the page's own query draw
103 * their numbers from a row.
104 */
105 function renderDetail(match: MatchRow | undefined): string[] {
106 const lines: string[] = [];
107 lines.push(`${esc}1mcs${esc}0m ${esc}2mmatch${esc}0m`);
108 lines.push("");
109 if (match === undefined) {
110 lines.push(` ${esc}2mthat match is gone${esc}0m`);
111 } else {
112 const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 12)}${esc}0m${v}`;
113 lines.push(` ${esc}1m${safe(match.name)}${esc}0m ${esc}2m(${who(match.identity)})${esc}0m`);
114 lines.push("");
115 lines.push(line("map", safe(match.map)));
116 lines.push(line("score", `${match.score_mine}–${match.score_theirs}`));
117 lines.push(line("state", stateWord(match)));
118 lines.push(line("rounds", String(match.rounds)));
119 lines.push(line("kills", String(match.kills)));
120 lines.push(line("deaths", String(match.deaths)));
121 lines.push(line("headshots", `${match.headshots} (${headshotShare(match.headshots, match.kills)})`));
122 lines.push(line("damage", String(match.damage)));
123 lines.push(line("k/d", ratio(match.kills, match.deaths)));
124 lines.push(line("duration", duration(match.seconds)));
125 lines.push(line("when", safe(match.updated_at)));
126 }
127 lines.push("");
128 lines.push(`${esc}2mesc back${esc}0m`);
129 return lines;
80130 }
81131
82132 /** The screen for a state and a frame of data, one line per row. */
83133 export function render(state: State, data: Data): string[] {
134 if (state.detail !== null) return renderDetail(data.matches.find((m) => m.id === state.detail));
84135 const cursor = clampCursor(state.cursor, rowCount(state.tab, data));
85136 const lines: string[] = [];
86137 lines.push(`${esc}1mcs${esc}0m ${esc}2m${safe(data.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`);
@@ -120,7 +171,7 @@ export function render(state: State, data: Data): string[] {
120171 });
121172 }
122173 lines.push("");
123 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
174 lines.push(`${esc}2mj/k move · tab switch · enter open a match · x remove a match · r refresh · q quit${esc}0m`);
124175 lines.push(data.ssh === undefined
125176 ? `${esc}2mthe app's terminal is off${esc}0m`
126177 : `${esc}2mreach this terminal with: ${data.ssh}${esc}0m`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs