feat(admin): per-player stats drill-down from admin panel
This commit is contained in:
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