feat(admin): per-player stats drill-down from admin panel
All checks were successful
CI · Test & Deploy / Playwright tests (push) Successful in 1m50s
CI · Test & Deploy / Deploy to VPS (push) Successful in 21s
Security · npm audit / npm audit (push) Successful in 9m28s

This commit is contained in:
Jaume Garriga Maestre
2026-05-15 09:26:09 +02:00
parent 288a000953
commit 9e6cc91a16
2 changed files with 200 additions and 100 deletions

View File

@@ -1228,20 +1228,14 @@ const GROUP_LABELS = {
all: '🗺️ Totes',
};
async function loadStats() {
if (offlineMode || !currentPlayer) {
document.getElementById('stats-content').innerHTML =
'<div class="card" style="text-align:center;color:#aaa;">Estadístiques no disponibles en mode sense connexió.</div>';
return;
}
const data = await apiGet(`/api/players/${currentPlayer.id}/stats`);
if (!data) {
document.getElementById('stats-content').innerHTML =
'<div class="card" style="text-align:center;color:var(--red);">Error en carregar les estadístiques.</div>';
return;
}
/**
* Pure renderer: builds and returns the full stats HTML for a player.
* Receives the API payload directly so it can be reused by loadStats()
* and showAdminPlayerDetail() without coupling to fetch logic.
* @param {Object} data Response from /api/players/:id/stats or /api/admin/player/:id/stats
* @returns {string} HTML string ready to be injected into a container
*/
function renderPlayerStats(data) {
const ls = data.levelStats || {};
const streak = data.currentStreak || 0;
const totalS = data.totalSeconds || 0;
@@ -1320,7 +1314,7 @@ async function loadStats() {
</div>`;
}
document.getElementById('stats-content').innerHTML = `
return `
<div class="stats-hero">
<span class="stats-avatar">${data.player.avatar}</span>
<div class="stats-name">${data.player.name}</div>
@@ -1358,6 +1352,23 @@ async function loadStats() {
</div>` : ''}`;
}
async function loadStats() {
if (offlineMode || !currentPlayer) {
document.getElementById('stats-content').innerHTML =
'<div class="card" style="text-align:center;color:#aaa;">Estadístiques no disponibles en mode sense connexió.</div>';
return;
}
const data = await apiGet(`/api/players/${currentPlayer.id}/stats`);
if (!data) {
document.getElementById('stats-content').innerHTML =
'<div class="card" style="text-align:center;color:var(--red);">Error en carregar les estadístiques.</div>';
return;
}
document.getElementById('stats-content').innerHTML = renderPlayerStats(data);
}
/* ══════════════════════════════════════════════════════
ADMIN — GROUP FILTER CONFIG
══════════════════════════════════════════════════════ */
@@ -1470,12 +1481,19 @@ async function loadAdmin() {
const lastSeen = p.lastActivity
? new Date(p.lastActivity).toLocaleDateString('ca-ES')
: '—';
// Escape single quotes in name/avatar for safe inline onclick attribute
const safeName = p.name.replace(/'/g, "\\'");
const safeAvatar = p.avatar.replace(/'/g, "\\'");
return `<tr>
<td><span style="font-size:1.2rem">${p.avatar}</span> <strong>${p.name}</strong></td>
<td>${p.totalSessions}</td>
<td>⭐ ${p.totalStars}</td>
${cells}
<td style="font-size:.75rem;color:#aaa;">${lastSeen}</td>
<td><button onclick="showAdminPlayerDetail(${p.id}, '${safeName}', '${safeAvatar}')"
style="background:var(--orange);color:#fff;border:none;border-radius:8px;padding:4px 10px;cursor:pointer;font-size:.8rem;">
📊 Detall
</button></td>
</tr>`;
}).join('');
@@ -1486,15 +1504,51 @@ async function loadAdmin() {
<thead><tr>
<th>Jugador</th><th>Sessions</th><th>⭐</th>
<th>👀 N1</th><th>🧩 N2</th><th>🔗 N3</th><th>✏️ N4</th><th>🗺️ N5</th><th>🔮 N6</th>
<th>Darrera activitat</th>
<th>Darrera activitat</th><th>Detall</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="10" style="color:#aaa;text-align:center;padding:20px;">Sense jugadors</td></tr>'}</tbody>
<tbody>${rows || '<tr><td colspan="11" style="color:#aaa;text-align:center;padding:20px;">Sense jugadors</td></tr>'}</tbody>
</table>
</div>`;
// Restore chip active state after render
syncChips();
}
/**
* Load and display full stats for a specific player inside the admin detail overlay.
* Reuses renderPlayerStats() so the layout is identical to the player-facing stats screen.
* @param {number} id Player id
* @param {string} name Player display name (for fallback if API fails)
* @param {string} avatar Player avatar emoji
*/
async function showAdminPlayerDetail(id, name, avatar) {
const pin = document.getElementById('admin-pin').value;
const overlay = document.getElementById('admin-detail-overlay');
const body = document.getElementById('admin-detail-body');
// Show overlay immediately with a loading indicator
body.innerHTML = `<div style="text-align:center;padding:40px;color:#aaa;">Carregant…</div>`;
overlay.style.display = 'flex';
let data;
try {
const r = await fetch(`/api/admin/player/${id}/stats`, {
headers: { 'x-admin-pin': pin },
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
body.innerHTML = `<div style="color:var(--red);padding:24px;text-align:center;">
Error: ${err.error || r.statusText}</div>`;
return;
}
data = await r.json();
} catch (e) {
body.innerHTML = `<div style="color:var(--red);padding:24px;text-align:center;">Error de connexió</div>`;
return;
}
body.innerHTML = renderPlayerStats(data);
}
/* ══════════════════════════════════════════════════════
FEEDBACK & SOUNDS
══════════════════════════════════════════════════════ */
@@ -2293,5 +2347,23 @@ function _updatePuzzleHUD() {
puzzleWrong > 0 ? `${puzzleWrong} errors` : '';
}
</script>
<!-- ── Admin player detail overlay ──────────────────────────────────────── -->
<div id="admin-detail-overlay"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);
z-index:9999;align-items:flex-start;justify-content:center;
padding:16px;overflow-y:auto;">
<div style="background:#fff;border-radius:16px;max-width:700px;width:100%;
margin:auto;position:relative;padding:24px 20px 28px;">
<!-- Close button -->
<button onclick="document.getElementById('admin-detail-overlay').style.display='none'"
aria-label="Tanca"
style="position:absolute;top:12px;right:12px;background:#eee;border:none;
border-radius:50%;width:32px;height:32px;font-size:1.1rem;
cursor:pointer;line-height:32px;text-align:center;"></button>
<div id="admin-detail-body"></div>
</div>
</div>
</body>
</html>

View File

@@ -174,15 +174,22 @@ app.post('/api/players', async (req, res) => {
}
});
// GET /api/players/:id/stats — full stats including streak, hardComarques, groupStats
app.get('/api/players/:id/stats', async (req, res) => {
try {
/**
* Fetch full stats for a player by id.
* Shared between the player-facing and admin endpoints to avoid code duplication.
* @param {number|string} playerId
* @returns {Promise<Object>} Stats payload (player, levelStats, hardComarques, groupStats, …)
* @throws {Error} with .status = 404 if player not found
*/
async function getPlayerFullStats(playerId) {
const playerRes = await pool.query(
'SELECT id, name, avatar, created_at FROM players WHERE id = $1',
[req.params.id]
[playerId]
);
if (!playerRes.rows.length) {
return res.status(404).json({ error: 'Jugador no trobat' });
const err = new Error('Jugador no trobat');
err.status = 404;
throw err;
}
const player = playerRes.rows[0];
@@ -190,7 +197,7 @@ app.get('/api/players/:id/stats', async (req, res) => {
const sessionRes = await pool.query(
`SELECT level, score, total, stars, duration_seconds, created_at
FROM sessions WHERE player_id = $1 ORDER BY created_at DESC`,
[req.params.id]
[playerId]
);
const sessions = sessionRes.rows;
@@ -221,7 +228,7 @@ app.get('/api/players/:id/stats', async (req, res) => {
HAVING COUNT(*) >= 3
ORDER BY (SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::float / COUNT(*)) DESC
LIMIT 5`,
[req.params.id]
[playerId]
);
const hardComarques = hardRes.rows.map(r => ({
comarca: r.comarca_name,
@@ -241,14 +248,14 @@ app.get('/api/players/:id/stats', async (req, res) => {
AND filter_group != 'all'
AND total > 0
GROUP BY filter_group`,
[req.params.id]
[playerId]
);
const groupStats = {};
for (const r of groupRes.rows) {
groupStats[r.filter_group] = { sessions: r.sessions, avgPct: r.avg_pct };
}
res.json({
return {
player,
totalStars,
totalSessions,
@@ -258,9 +265,30 @@ app.get('/api/players/:id/stats', async (req, res) => {
hardComarques,
groupStats,
recent,
});
};
}
// GET /api/players/:id/stats — full stats including streak, hardComarques, groupStats
app.get('/api/players/:id/stats', async (req, res) => {
try {
const data = await getPlayerFullStats(req.params.id);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
res.status(err.status || 500).json({ error: err.message });
}
});
// GET /api/admin/player/:id/stats Header: x-admin-pin
// Returns the same full stats payload as /api/players/:id/stats but requires admin PIN.
app.get('/api/admin/player/:id/stats', async (req, res) => {
if (req.headers['x-admin-pin'] !== ADMIN_PIN) {
return res.status(401).json({ error: 'PIN incorrecte' });
}
try {
const data = await getPlayerFullStats(req.params.id);
res.json(data);
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
});