fix: speedrun scores, dashboard icons, N+1 queries, typeof guards

This commit is contained in:
Jeremy Brandenburger
2026-03-31 09:05:33 +02:00
parent 2d27d9fe4d
commit 8a5d08b586
3 changed files with 66 additions and 57 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog helldivers-trainer # Changelog helldivers-trainer
## [2.1.1] 2026-03-31
### Fixed
- **Speedrun scores never saved**: Individual scores were blocked by `if (mode !== 'speedrun')` guard; now saved per-stratagem like other modes. Final bulk-save with invalid `__speedrun__` stratagem name removed (was causing 400 errors).
- **Dashboard recent-sessions icons missing**: `r.icon` was always `undefined` since the server doesn't store icon paths in the DB. Now looks up icon from `state.stratagems` by name.
- **Variable shadowing in `renderDashboard`**: Outer `const r = eloRankFor(...)` was shadowed by `recent.map(r => ...)`. Renamed outer to `myRank`.
- **N+1 DB queries in `broadcastLobbyUpdate`**: Was running one `SELECT elo` per online user. Now batches into a single `WHERE username IN (...)` query.
- **N+1 DB queries in dashboard endpoint**: Same pattern fixed for the `/api/dashboard` online-users list.
- **Unnecessary `typeof` guards**: `typeof u === 'object'` checks in lobby/dashboard removed — `lobby-update` always sends objects, never plain strings.
- **Duplicate `else if (mode === 'endless')` branch in `nextStratagem`**: Was identical to the `else` branch below it; merged.
---
## [2.1.0] 2026-03-31 ## [2.1.0] 2026-03-31
### Added ### Added
+26 -44
View File
@@ -364,11 +364,11 @@ async function loadDashboard() {
} }
function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) { function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) {
const r = eloRankFor(elo || 1000); const myRank = eloRankFor(elo || 1000);
setText('dash-hero-name', state.user.user); setText('dash-hero-name', state.user.user);
setText('dash-rank-label', rankLabel || r.label); setText('dash-rank-label', rankLabel || myRank.label);
setText('dash-elo', elo || 1000); setText('dash-elo', elo || 1000);
setText('dash-rank-icon', r.icon); setText('dash-rank-icon', myRank.icon);
setText('dash-total-score', stats.totalScore || 0); setText('dash-total-score', stats.totalScore || 0);
setText('dash-rank', rank ? '#' + rank.position : 'Unranked'); setText('dash-rank', rank ? '#' + rank.position : 'Unranked');
@@ -389,14 +389,15 @@ function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent,
if (!recent?.length) { if (!recent?.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No sessions yet</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" class="muted">No sessions yet</td></tr>';
} else { } else {
tbody.innerHTML = recent.map(r => tbody.innerHTML = recent.map(r => {
`<tr> const icon = state.stratagems.find(s => s.name === r.stratagem)?.icon || '';
<td><img class="stratagem-icon-sm" src="${esc(r.icon || '')}" alt="" ${r.icon ? '' : 'style="display:none"'}>${esc(r.stratagem)}</td> return `<tr>
<td><img class="stratagem-icon-sm" src="${esc(icon)}" alt="" ${icon ? '' : 'style="display:none"'}>${esc(r.stratagem)}</td>
<td><span class="badge">${esc(r.mode || 'timed')}</span></td> <td><span class="badge">${esc(r.mode || 'timed')}</span></td>
<td>${r.score}</td> <td>${r.score}</td>
<td>${(r.time_ms / 1000).toFixed(2)}s</td> <td>${(r.time_ms / 1000).toFixed(2)}s</td>
</tr>` </tr>`;
).join(''); }).join('');
} }
updateDashboardOnline(online); updateDashboardOnline(online);
@@ -412,23 +413,18 @@ function renderDailySequencePreview(sequence) {
function updateDashboardOnline(online) { function updateDashboardOnline(online) {
const el = document.getElementById('dash-online'); const el = document.getElementById('dash-online');
if (!el) return; if (!el) return;
const players = (online || []).filter(u => { const players = (online || []).filter(u => u.name !== state.user?.user);
const name = typeof u === 'object' ? u.name : u;
return name !== state.user?.user;
});
if (!players.length) { if (!players.length) {
el.innerHTML = '<span class="muted">No other Helldivers online</span>'; el.innerHTML = '<span class="muted">No other Helldivers online</span>';
} else { } else {
el.innerHTML = players.map(u => { el.innerHTML = players.map(u =>
const name = typeof u === 'object' ? u.name : u; `<div class="online-user">
const elo = typeof u === 'object' ? u.elo : '';
return `<div class="online-user">
<span class="online-dot"></span> <span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(name)}</span> <span style="flex:1;font-family:var(--font-mono)">${esc(u.name)}</span>
${elo ? `<span class="player-elo">${elo}</span>` : ''} ${u.elo ? `<span class="player-elo">${u.elo}</span>` : ''}
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(name)}">⚔ Challenge</button> <button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.name)}">⚔ Challenge</button>
</div>`; </div>`
}).join(''); ).join('');
} }
} }
@@ -602,19 +598,13 @@ function nextStratagem() {
if (!p.speedrunPool.length) { if (!p.speedrunPool.length) {
const totalMs = Date.now() - p.speedrunStart; const totalMs = Date.now() - p.speedrunStart;
clearInterval(p.timerHandle); clearInterval(p.timerHandle);
api('POST', '/scores/practice', {
stratagem: '__speedrun__',
category: 'All',
time_ms: totalMs,
score: p.score,
mode: 'speedrun',
}).catch(() => {});
showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`); showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`);
openSessionSummary(); openSessionSummary();
return; return;
} }
strat = p.speedrunPool[0]; strat = p.speedrunPool[0];
} else { } else {
// timed & endless: pick random from pool
const pool = getPool(); const pool = getPool();
if (!pool.length) { showPracticeIdle(); return; } if (!pool.length) { showPracticeIdle(); return; }
strat = pool[Math.floor(Math.random() * pool.length)]; strat = pool[Math.floor(Math.random() * pool.length)];
@@ -836,7 +826,6 @@ function handlePracticeInput(dir) {
// Score popup // Score popup
showScorePopup('+' + pts); showScorePopup('+' + pts);
if (mode !== 'speedrun') {
api('POST', '/scores/practice', { api('POST', '/scores/practice', {
stratagem: p.current.name, stratagem: p.current.name,
category: p.current.category, category: p.current.category,
@@ -844,7 +833,6 @@ function handlePracticeInput(dir) {
score: pts, score: pts,
mode: mode, mode: mode,
}).catch(() => {}); }).catch(() => {});
}
if (mode === 'drill') { if (mode === 'drill') {
p.drillPool.shift(); p.drillPool.shift();
@@ -995,10 +983,7 @@ document.getElementById('btn-summary-restart')?.addEventListener('click', () =>
// ── Lobby ───────────────────────────────────────────────────────────────────── // ── Lobby ─────────────────────────────────────────────────────────────────────
function updateLobbyView() { function updateLobbyView() {
const others = state.lobby.online.filter(u => { const others = state.lobby.online.filter(u => u.name !== state.user?.user);
const name = typeof u === 'object' ? u.name : u;
return name !== state.user?.user;
});
const el = document.getElementById('lobby-players'); const el = document.getElementById('lobby-players');
if (!el) return; if (!el) return;
@@ -1008,17 +993,14 @@ function updateLobbyView() {
<p>No other Helldivers online.<br>Waiting for reinforcements...</p> <p>No other Helldivers online.<br>Waiting for reinforcements...</p>
</div>`; </div>`;
} else { } else {
el.innerHTML = others.map(u => { el.innerHTML = others.map(u =>
const name = typeof u === 'object' ? u.name : u; `<div class="lobby-player">
const elo = typeof u === 'object' ? u.elo : '';
const rank = typeof u === 'object' ? u.rank : '';
return `<div class="lobby-player">
<span class="online-dot"></span> <span class="online-dot"></span>
<span class="player-name">${esc(name)}</span> <span class="player-name">${esc(u.name)}</span>
${elo ? `<span class="player-elo">${esc(rank)} · ${elo}</span>` : ''} ${u.elo ? `<span class="player-elo">${esc(u.rank)} · ${u.elo}</span>` : ''}
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(name)}">⚔ Challenge</button> <button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.name)}">⚔ Challenge</button>
</div>`; </div>`
}).join(''); ).join('');
} }
const challEl = document.getElementById('lobby-challenges'); const challEl = document.getElementById('lobby-challenges');
+20 -6
View File
@@ -418,9 +418,15 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
const userRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(u); const userRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(u);
const elo = userRow?.elo ?? 1000; const elo = userRow?.elo ?? 1000;
const onlineWithElo = [...userSockets.keys()].map(name => { const onlineNames = [...userSockets.keys()];
const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name); const eloRows = onlineNames.length > 0
return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) }; ? db.prepare(`SELECT username, elo FROM users WHERE username IN (${onlineNames.map(() => '?').join(',')})`)
.all(...onlineNames)
: [];
const eloByUser = Object.fromEntries(eloRows.map(r => [r.username, r.elo]));
const onlineWithElo = onlineNames.map(name => {
const elo = eloByUser[name] ?? 1000;
return { name, elo, rank: eloRank(elo) };
}); });
res.json({ res.json({
@@ -562,9 +568,17 @@ async function main() {
} }
function broadcastLobbyUpdate() { function broadcastLobbyUpdate() {
const online = [...userSockets.keys()].map(name => { const onlineNames = [...userSockets.keys()];
const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name); const eloMap = onlineNames.length > 0
return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) }; ? Object.fromEntries(
db.prepare(`SELECT username, elo FROM users WHERE username IN (${onlineNames.map(() => '?').join(',')})`)
.all(...onlineNames)
.map(r => [r.username, r.elo])
)
: {};
const online = onlineNames.map(name => {
const elo = eloMap[name] ?? 1000;
return { name, elo, rank: eloRank(elo) };
}); });
wss.clients.forEach(ws => { wss.clients.forEach(ws => {
if (ws.readyState !== WebSocket.OPEN || !ws.userId) return; if (ws.readyState !== WebSocket.OPEN || !ws.userId) return;