feat(admin): per-player stats drill-down from admin panel
This commit is contained in:
106
index.html
106
index.html
@@ -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>
|
||||
|
||||
194
server.js
194
server.js
@@ -174,93 +174,121 @@ app.post('/api/players', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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',
|
||||
[playerId]
|
||||
);
|
||||
if (!playerRes.rows.length) {
|
||||
const err = new Error('Jugador no trobat');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
const player = playerRes.rows[0];
|
||||
|
||||
// All sessions for this player (include created_at for streak + evolution)
|
||||
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`,
|
||||
[playerId]
|
||||
);
|
||||
const sessions = sessionRes.rows;
|
||||
|
||||
// Aggregate scalars
|
||||
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
|
||||
const totalSessions = sessions.length;
|
||||
const totalSeconds = sessions.reduce((s, r) => s + (r.duration_seconds || 0), 0);
|
||||
const currentStreak = calcStreak(sessions);
|
||||
|
||||
// Level breakdown (levels 1–8, best + avg + evolution)
|
||||
const levelStats = buildLevelStats(sessions);
|
||||
|
||||
// Recent 10 sessions
|
||||
const recent = sessions.slice(0, 10).map(s => ({
|
||||
level: s.level, score: s.score, total: s.total,
|
||||
stars: s.stars, date: s.created_at,
|
||||
}));
|
||||
|
||||
// Hard comarques: top 5 by error rate (min 3 attempts)
|
||||
const hardRes = await pool.query(
|
||||
`SELECT comarca_name,
|
||||
COUNT(*)::int AS attempts,
|
||||
SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::int AS wrong
|
||||
FROM session_answers
|
||||
JOIN sessions ON sessions.id = session_answers.session_id
|
||||
WHERE sessions.player_id = $1
|
||||
GROUP BY comarca_name
|
||||
HAVING COUNT(*) >= 3
|
||||
ORDER BY (SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::float / COUNT(*)) DESC
|
||||
LIMIT 5`,
|
||||
[playerId]
|
||||
);
|
||||
const hardComarques = hardRes.rows.map(r => ({
|
||||
comarca: r.comarca_name,
|
||||
attempts: r.attempts,
|
||||
wrong: r.wrong,
|
||||
errorRate: Math.round(r.wrong / r.attempts * 100),
|
||||
}));
|
||||
|
||||
// Group performance (exclude 'all' entries, exclude null)
|
||||
const groupRes = await pool.query(
|
||||
`SELECT filter_group,
|
||||
COUNT(*)::int AS sessions,
|
||||
ROUND(AVG(score::float / total * 100))::int AS avg_pct
|
||||
FROM sessions
|
||||
WHERE player_id = $1
|
||||
AND filter_group IS NOT NULL
|
||||
AND filter_group != 'all'
|
||||
AND total > 0
|
||||
GROUP BY filter_group`,
|
||||
[playerId]
|
||||
);
|
||||
const groupStats = {};
|
||||
for (const r of groupRes.rows) {
|
||||
groupStats[r.filter_group] = { sessions: r.sessions, avgPct: r.avg_pct };
|
||||
}
|
||||
|
||||
return {
|
||||
player,
|
||||
totalStars,
|
||||
totalSessions,
|
||||
totalSeconds,
|
||||
currentStreak,
|
||||
levelStats,
|
||||
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 playerRes = await pool.query(
|
||||
'SELECT id, name, avatar, created_at FROM players WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (!playerRes.rows.length) {
|
||||
return res.status(404).json({ error: 'Jugador no trobat' });
|
||||
}
|
||||
const player = playerRes.rows[0];
|
||||
|
||||
// All sessions for this player (include created_at for streak + evolution)
|
||||
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]
|
||||
);
|
||||
const sessions = sessionRes.rows;
|
||||
|
||||
// Aggregate scalars
|
||||
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
|
||||
const totalSessions = sessions.length;
|
||||
const totalSeconds = sessions.reduce((s, r) => s + (r.duration_seconds || 0), 0);
|
||||
const currentStreak = calcStreak(sessions);
|
||||
|
||||
// Level breakdown (levels 1–8, best + avg + evolution)
|
||||
const levelStats = buildLevelStats(sessions);
|
||||
|
||||
// Recent 10 sessions
|
||||
const recent = sessions.slice(0, 10).map(s => ({
|
||||
level: s.level, score: s.score, total: s.total,
|
||||
stars: s.stars, date: s.created_at,
|
||||
}));
|
||||
|
||||
// Hard comarques: top 5 by error rate (min 3 attempts)
|
||||
const hardRes = await pool.query(
|
||||
`SELECT comarca_name,
|
||||
COUNT(*)::int AS attempts,
|
||||
SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::int AS wrong
|
||||
FROM session_answers
|
||||
JOIN sessions ON sessions.id = session_answers.session_id
|
||||
WHERE sessions.player_id = $1
|
||||
GROUP BY comarca_name
|
||||
HAVING COUNT(*) >= 3
|
||||
ORDER BY (SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::float / COUNT(*)) DESC
|
||||
LIMIT 5`,
|
||||
[req.params.id]
|
||||
);
|
||||
const hardComarques = hardRes.rows.map(r => ({
|
||||
comarca: r.comarca_name,
|
||||
attempts: r.attempts,
|
||||
wrong: r.wrong,
|
||||
errorRate: Math.round(r.wrong / r.attempts * 100),
|
||||
}));
|
||||
|
||||
// Group performance (exclude 'all' entries, exclude null)
|
||||
const groupRes = await pool.query(
|
||||
`SELECT filter_group,
|
||||
COUNT(*)::int AS sessions,
|
||||
ROUND(AVG(score::float / total * 100))::int AS avg_pct
|
||||
FROM sessions
|
||||
WHERE player_id = $1
|
||||
AND filter_group IS NOT NULL
|
||||
AND filter_group != 'all'
|
||||
AND total > 0
|
||||
GROUP BY filter_group`,
|
||||
[req.params.id]
|
||||
);
|
||||
const groupStats = {};
|
||||
for (const r of groupRes.rows) {
|
||||
groupStats[r.filter_group] = { sessions: r.sessions, avgPct: r.avg_pct };
|
||||
}
|
||||
|
||||
res.json({
|
||||
player,
|
||||
totalStars,
|
||||
totalSessions,
|
||||
totalSeconds,
|
||||
currentStreak,
|
||||
levelStats,
|
||||
hardComarques,
|
||||
groupStats,
|
||||
recent,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user