fix: speedrun scores, dashboard icons, N+1 queries, typeof guards
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+33
-51
@@ -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,15 +826,13 @@ 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,
|
time_ms: elapsed,
|
||||||
time_ms: elapsed,
|
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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user