diff --git a/index.html b/index.html index 74fba2b..793ab47 100644 --- a/index.html +++ b/index.html @@ -1228,20 +1228,14 @@ const GROUP_LABELS = { all: '๐Ÿ—บ๏ธ Totes', }; -async function loadStats() { - if (offlineMode || !currentPlayer) { - document.getElementById('stats-content').innerHTML = - '
Estadรญstiques no disponibles en mode sense connexiรณ.
'; - return; - } - - const data = await apiGet(`/api/players/${currentPlayer.id}/stats`); - if (!data) { - document.getElementById('stats-content').innerHTML = - '
Error en carregar les estadรญstiques.
'; - 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() { `; } - document.getElementById('stats-content').innerHTML = ` + return `
${data.player.avatar}
${data.player.name}
@@ -1358,6 +1352,23 @@ async function loadStats() {
` : ''}`; } +async function loadStats() { + if (offlineMode || !currentPlayer) { + document.getElementById('stats-content').innerHTML = + '
Estadรญstiques no disponibles en mode sense connexiรณ.
'; + return; + } + + const data = await apiGet(`/api/players/${currentPlayer.id}/stats`); + if (!data) { + document.getElementById('stats-content').innerHTML = + '
Error en carregar les estadรญstiques.
'; + 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 ` ${p.avatar} ${p.name} ${p.totalSessions} โญ ${p.totalStars} ${cells} ${lastSeen} + `; }).join(''); @@ -1486,15 +1504,51 @@ async function loadAdmin() { JugadorSessionsโญ ๐Ÿ‘€ N1๐Ÿงฉ N2๐Ÿ”— N3โœ๏ธ N4๐Ÿ—บ๏ธ N5๐Ÿ”ฎ N6 - Darrera activitat + Darrera activitatDetall - ${rows || 'Sense jugadors'} + ${rows || 'Sense jugadors'} `; // 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 = `
Carregantโ€ฆ
`; + 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 = `
+ Error: ${err.error || r.statusText}
`; + return; + } + data = await r.json(); + } catch (e) { + body.innerHTML = `
Error de connexiรณ
`; + return; + } + + body.innerHTML = renderPlayerStats(data); +} + /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• FEEDBACK & SOUNDS โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ @@ -2293,5 +2347,23 @@ function _updatePuzzleHUD() { puzzleWrong > 0 ? `โŒ ${puzzleWrong} errors` : ''; } + + + + diff --git a/server.js b/server.js index 9aecc8d..aed6a3e 100644 --- a/server.js +++ b/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} 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 }); } });