Split The code is public; the app answers only to the crew.
The leaderboard runs off a phone: seven columns in 360px, and `wins` is the one that goes #145
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/141-the-leaderboard-runs-off-a-phone-seven-c → main 1 commit fork
2 files
changed +34
−3
src/pages/game.test.ts +14
| @@ -140,6 +140,20 @@ test("a recent match's row names the map it was played on", () => { | ||
| 140 | 140 | expect(html).toContain("de_forge"); |
| 141 | 141 | }); |
| 142 | 142 | |
| 143 | test("the leaderboard table carries the hook and labels a phone-width card layout needs", () => { | |
| 144 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 145 | const writer = { identity: "user:u_4", name: "sable", guest: false, session: "e".repeat(32) }; | |
| 146 | saveMatch(stats, writer, matchResult()); | |
| 147 | const html = statsHtml(stats, player); | |
| 148 | // table.board is what the 480px breakpoint selects; lose the class and the | |
| 149 | // seven columns go back to running off a 360px screen unstyled. | |
| 150 | expect(html).toContain(`<table class="board">`); | |
| 151 | // Each stat cell repeats its header as data-label, so a narrow layout that | |
| 152 | // drops the table's columns down to one per line still says what each | |
| 153 | // number means instead of leaving it a bare digit. | |
| 154 | for (const label of ["matches", "kills", "K/D", "HS", "wins"]) expect(html).toContain(`data-label="${label}"`); | |
| 155 | }); | |
| 156 | ||
| 143 | 157 | test("a crosshair field can only offer the numbers the parser would keep", () => { |
| 144 | 158 | const panel = element(gamePage(db, options), "menu"); |
| 145 | 159 | for (const [field, limit] of Object.entries(CROSSHAIR_LIMITS)) { |
src/pages/game.ts +20 −3
| @@ -28,8 +28,10 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 28 | 28 | const tag = (identity: string): string => (isGuest(identity) ? ` <span class="tag">guest</span>` : ""); |
| 29 | 29 | const rows = table |
| 30 | 30 | .map( |
| 31 | (s, i) => `<tr><td>${i + 1}</td><td>${escape(s.name)}${tag(s.identity)}</td><td>${s.matches}</td><td>${s.kills}</td>` + | |
| 32 | `<td>${ratio(s.kills, s.deaths)}</td><td>${headshotShare(s.headshots, s.kills)}</td><td>${s.wins}</td></tr>`, | |
| 31 | (s, i) => `<tr><td>${i + 1}</td><td>${escape(s.name)}${tag(s.identity)}</td>` + | |
| 32 | `<td data-label="matches">${s.matches}</td><td data-label="kills">${s.kills}</td>` + | |
| 33 | `<td data-label="K/D">${ratio(s.kills, s.deaths)}</td><td data-label="HS">${headshotShare(s.headshots, s.kills)}</td>` + | |
| 34 | `<td data-label="wins">${s.wins}</td></tr>`, | |
| 33 | 35 | ) |
| 34 | 36 | .join(""); |
| 35 | 37 | const status = (m: MatchRow): string => |
| @@ -48,7 +50,7 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 48 | 50 | : `Signed in as <b>${escape(player.name)}</b> — your matches land under that name.`}</p> |
| 49 | 51 | ${table.length === 0 |
| 50 | 52 | ? `<p class="empty">No matches yet. Play one and it lands here.</p>` |
| 51 | : `<table><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody></table>`} | |
| 53 | : `<table class="board"><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody></table>`} | |
| 52 | 54 | <h3>Recent matches</h3> |
| 53 | 55 | <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`; |
| 54 | 56 | } |
| @@ -224,6 +226,21 @@ kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; pa | ||
| 224 | 226 | footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; } |
| 225 | 227 | code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } |
| 226 | 228 | @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } |
| 229 | /* Seven columns of numbers do not fit a phone's width at any font size that | |
| 230 | stays readable, so under ~480px each row becomes a card: rank and name on | |
| 231 | their own line, the five stats below as wrapped "label: value" pairs from | |
| 232 | data-label, which the desktop table ignores entirely. */ | |
| 233 | @media (max-width: 480px) { | |
| 234 | table.board, table.board tbody { display:block; width:100%; } | |
| 235 | table.board thead { display:none; } | |
| 236 | table.board tr { display:flex; flex-wrap:wrap; align-items:baseline; column-gap:.6rem; | |
| 237 | padding:.5rem 0; border-bottom:1px solid var(--line); } | |
| 238 | table.board td { border:none; padding:0; } | |
| 239 | table.board td:nth-child(1) { color:var(--dim); font-variant-numeric:tabular-nums; } | |
| 240 | table.board td:nth-child(2) { flex-basis:100%; font-weight:600; } | |
| 241 | table.board td[data-label] { font-size:.82rem; } | |
| 242 | table.board td[data-label]::before { content: attr(data-label) ": "; color:var(--dim); } | |
| 243 | } | |
| 227 | 244 | `; |
| 228 | 245 | |
| 229 | 246 | export type PageOptions = { |