diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cfbf26..419f8b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# 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
### Added
diff --git a/public/app.js b/public/app.js
index f14958d..f73d037 100644
--- a/public/app.js
+++ b/public/app.js
@@ -364,11 +364,11 @@ async function loadDashboard() {
}
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-rank-label', rankLabel || r.label);
+ setText('dash-rank-label', rankLabel || myRank.label);
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-rank', rank ? '#' + rank.position : 'Unranked');
@@ -389,14 +389,15 @@ function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent,
if (!recent?.length) {
tbody.innerHTML = '
| No sessions yet |
';
} else {
- tbody.innerHTML = recent.map(r =>
- `
- ${esc(r.stratagem)} |
+ tbody.innerHTML = recent.map(r => {
+ const icon = state.stratagems.find(s => s.name === r.stratagem)?.icon || '';
+ return `
+ ${esc(r.stratagem)} |
${esc(r.mode || 'timed')} |
${r.score} |
${(r.time_ms / 1000).toFixed(2)}s |
-
`
- ).join('');
+ `;
+ }).join('');
}
updateDashboardOnline(online);
@@ -412,23 +413,18 @@ function renderDailySequencePreview(sequence) {
function updateDashboardOnline(online) {
const el = document.getElementById('dash-online');
if (!el) return;
- const players = (online || []).filter(u => {
- const name = typeof u === 'object' ? u.name : u;
- return name !== state.user?.user;
- });
+ const players = (online || []).filter(u => u.name !== state.user?.user);
if (!players.length) {
el.innerHTML = 'No other Helldivers online';
} else {
- el.innerHTML = players.map(u => {
- const name = typeof u === 'object' ? u.name : u;
- const elo = typeof u === 'object' ? u.elo : '';
- return `
+ el.innerHTML = players.map(u =>
+ `
- ${esc(name)}
- ${elo ? `${elo}` : ''}
-
-
`;
- }).join('');
+
${esc(u.name)}
+ ${u.elo ? `
${u.elo}` : ''}
+
+
`
+ ).join('');
}
}
@@ -602,19 +598,13 @@ function nextStratagem() {
if (!p.speedrunPool.length) {
const totalMs = Date.now() - p.speedrunStart;
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`);
openSessionSummary();
return;
}
strat = p.speedrunPool[0];
} else {
+ // timed & endless: pick random from pool
const pool = getPool();
if (!pool.length) { showPracticeIdle(); return; }
strat = pool[Math.floor(Math.random() * pool.length)];
@@ -836,15 +826,13 @@ function handlePracticeInput(dir) {
// Score popup
showScorePopup('+' + pts);
- if (mode !== 'speedrun') {
- api('POST', '/scores/practice', {
- stratagem: p.current.name,
- category: p.current.category,
- time_ms: elapsed,
- score: pts,
- mode: mode,
- }).catch(() => {});
- }
+ api('POST', '/scores/practice', {
+ stratagem: p.current.name,
+ category: p.current.category,
+ time_ms: elapsed,
+ score: pts,
+ mode: mode,
+ }).catch(() => {});
if (mode === 'drill') {
p.drillPool.shift();
@@ -995,10 +983,7 @@ document.getElementById('btn-summary-restart')?.addEventListener('click', () =>
// ── Lobby ─────────────────────────────────────────────────────────────────────
function updateLobbyView() {
- const others = state.lobby.online.filter(u => {
- const name = typeof u === 'object' ? u.name : u;
- return name !== state.user?.user;
- });
+ const others = state.lobby.online.filter(u => u.name !== state.user?.user);
const el = document.getElementById('lobby-players');
if (!el) return;
@@ -1008,17 +993,14 @@ function updateLobbyView() {
No other Helldivers online.
Waiting for reinforcements...
`;
} else {
- el.innerHTML = others.map(u => {
- const name = typeof u === 'object' ? u.name : u;
- const elo = typeof u === 'object' ? u.elo : '';
- const rank = typeof u === 'object' ? u.rank : '';
- return `
+ el.innerHTML = others.map(u =>
+ `
- ${esc(name)}
- ${elo ? `${esc(rank)} · ${elo}` : ''}
-
-
`;
- }).join('');
+
${esc(u.name)}
+ ${u.elo ? `
${esc(u.rank)} · ${u.elo}` : ''}
+
+
`
+ ).join('');
}
const challEl = document.getElementById('lobby-challenges');
diff --git a/server.js b/server.js
index a1255cf..6ee3897 100644
--- a/server.js
+++ b/server.js
@@ -418,9 +418,15 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
const userRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(u);
const elo = userRow?.elo ?? 1000;
- const onlineWithElo = [...userSockets.keys()].map(name => {
- const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name);
- return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) };
+ const onlineNames = [...userSockets.keys()];
+ const eloRows = onlineNames.length > 0
+ ? 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({
@@ -562,9 +568,17 @@ async function main() {
}
function broadcastLobbyUpdate() {
- const online = [...userSockets.keys()].map(name => {
- const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name);
- return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) };
+ const onlineNames = [...userSockets.keys()];
+ const eloMap = onlineNames.length > 0
+ ? 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 => {
if (ws.readyState !== WebSocket.OPEN || !ws.userId) return;