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

194
server.js
View File

@@ -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 18, 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 18, 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 });
}
});