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',
|
all: '🗺️ Totes',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadStats() {
|
/**
|
||||||
if (offlineMode || !currentPlayer) {
|
* Pure renderer: builds and returns the full stats HTML for a player.
|
||||||
document.getElementById('stats-content').innerHTML =
|
* Receives the API payload directly so it can be reused by loadStats()
|
||||||
'<div class="card" style="text-align:center;color:#aaa;">Estadístiques no disponibles en mode sense connexió.</div>';
|
* and showAdminPlayerDetail() without coupling to fetch logic.
|
||||||
return;
|
* @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
|
||||||
|
*/
|
||||||
const data = await apiGet(`/api/players/${currentPlayer.id}/stats`);
|
function renderPlayerStats(data) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ls = data.levelStats || {};
|
const ls = data.levelStats || {};
|
||||||
const streak = data.currentStreak || 0;
|
const streak = data.currentStreak || 0;
|
||||||
const totalS = data.totalSeconds || 0;
|
const totalS = data.totalSeconds || 0;
|
||||||
@@ -1320,7 +1314,7 @@ async function loadStats() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('stats-content').innerHTML = `
|
return `
|
||||||
<div class="stats-hero">
|
<div class="stats-hero">
|
||||||
<span class="stats-avatar">${data.player.avatar}</span>
|
<span class="stats-avatar">${data.player.avatar}</span>
|
||||||
<div class="stats-name">${data.player.name}</div>
|
<div class="stats-name">${data.player.name}</div>
|
||||||
@@ -1358,6 +1352,23 @@ async function loadStats() {
|
|||||||
</div>` : ''}`;
|
</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
|
ADMIN — GROUP FILTER CONFIG
|
||||||
══════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════ */
|
||||||
@@ -1470,12 +1481,19 @@ async function loadAdmin() {
|
|||||||
const lastSeen = p.lastActivity
|
const lastSeen = p.lastActivity
|
||||||
? new Date(p.lastActivity).toLocaleDateString('ca-ES')
|
? 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>
|
return `<tr>
|
||||||
<td><span style="font-size:1.2rem">${p.avatar}</span> <strong>${p.name}</strong></td>
|
<td><span style="font-size:1.2rem">${p.avatar}</span> <strong>${p.name}</strong></td>
|
||||||
<td>${p.totalSessions}</td>
|
<td>${p.totalSessions}</td>
|
||||||
<td>⭐ ${p.totalStars}</td>
|
<td>⭐ ${p.totalStars}</td>
|
||||||
${cells}
|
${cells}
|
||||||
<td style="font-size:.75rem;color:#aaa;">${lastSeen}</td>
|
<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>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -1486,15 +1504,51 @@ async function loadAdmin() {
|
|||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Jugador</th><th>Sessions</th><th>⭐</th>
|
<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>👀 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>
|
</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>
|
</table>
|
||||||
</div>`;
|
</div>`;
|
||||||
// Restore chip active state after render
|
// Restore chip active state after render
|
||||||
syncChips();
|
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
|
FEEDBACK & SOUNDS
|
||||||
══════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════ */
|
||||||
@@ -2293,5 +2347,23 @@ function _updatePuzzleHUD() {
|
|||||||
puzzleWrong > 0 ? `❌ ${puzzleWrong} errors` : '';
|
puzzleWrong > 0 ? `❌ ${puzzleWrong} errors` : '';
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</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
|
// GET /api/players/:id/stats — full stats including streak, hardComarques, groupStats
|
||||||
app.get('/api/players/:id/stats', async (req, res) => {
|
app.get('/api/players/:id/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const playerRes = await pool.query(
|
const data = await getPlayerFullStats(req.params.id);
|
||||||
'SELECT id, name, avatar, created_at FROM players WHERE id = $1',
|
res.json(data);
|
||||||
[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,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} 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