feat: enhanced stats — streak, hard comarques, evolution, group comparison, per-question tracking
- BD: ADD COLUMN filter_group on sessions; CREATE TABLE session_answers (per-question detail) - server.js: POST /api/sessions accepts filter_group + answers array; GET stats returns totalSeconds, currentStreak, avgPct+evolution per level (1-8), hardComarques, groupStats - index.html: sessionAnswers[] tracking in L2/L4/L5/L6/L7/L8; saveSession sends new fields; loadStats rewritten with streak badge, 4-box stats grid, sparklines on level bars, hard comarques section and group performance section
This commit is contained in:
181
server.js
181
server.js
@@ -12,7 +12,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
/**
|
||||
* Run schema migrations on startup.
|
||||
* CREATE TABLE IF NOT EXISTS is idempotent — safe to run every boot.
|
||||
* CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS — idempotent, safe every boot.
|
||||
*/
|
||||
async function initSchema() {
|
||||
await pool.query(`
|
||||
@@ -37,6 +37,25 @@ async function initSchema() {
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_player ON sessions(player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_level ON sessions(player_id, level);
|
||||
`);
|
||||
|
||||
// Idempotent column additions (PostgreSQL ≥9.6 supports IF NOT EXISTS on ALTER)
|
||||
await pool.query(`
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS filter_group TEXT DEFAULT 'all';
|
||||
`);
|
||||
|
||||
// Per-question answer tracking
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS session_answers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
comarca_name TEXT NOT NULL,
|
||||
correct BOOLEAN NOT NULL,
|
||||
response_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sa_session ON session_answers(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sa_comarca ON session_answers(comarca_name);
|
||||
`);
|
||||
|
||||
console.log('✅ Schema ready');
|
||||
}
|
||||
|
||||
@@ -45,22 +64,62 @@ app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build per-level best-score stats from a flat sessions array.
|
||||
* @param {Array} sessions
|
||||
* @returns {Object} keyed by level 1–5
|
||||
* Compute consecutive-day streak up to today (UTC).
|
||||
* A streak starts if the most recent session was today or yesterday.
|
||||
* @param {Array<{created_at: string|Date}>} sessions Ordered DESC by created_at.
|
||||
* @returns {number}
|
||||
*/
|
||||
function calcStreak(sessions) {
|
||||
if (!sessions.length) return 0;
|
||||
|
||||
// Unique UTC date strings, sorted descending
|
||||
const dates = [...new Set(
|
||||
sessions.map(s => new Date(s.created_at).toISOString().slice(0, 10))
|
||||
)].sort().reverse();
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
|
||||
// Streak only counts if last session was today or yesterday
|
||||
if (dates[0] !== today && dates[0] !== yesterday) return 0;
|
||||
|
||||
let streak = 1;
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const prev = new Date(dates[i - 1]);
|
||||
const curr = new Date(dates[i]);
|
||||
const diffDays = Math.round((prev - curr) / 86400000);
|
||||
if (diffDays === 1) streak++;
|
||||
else break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-level stats (levels 1–8) from a flat sessions array.
|
||||
* Returns bestScore, bestTotal, bestPct, avgPct, played, evolution (last 10 %).
|
||||
* @param {Array} sessions Must include created_at.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function buildLevelStats(sessions) {
|
||||
const stats = {};
|
||||
for (let lvl = 1; lvl <= 5; lvl++) {
|
||||
const ls = sessions.filter(s => s.level === lvl);
|
||||
for (let lvl = 1; lvl <= 8; lvl++) {
|
||||
// Sort chronologically for evolution calculation
|
||||
const ls = sessions
|
||||
.filter(s => s.level === lvl)
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
if (!ls.length) continue;
|
||||
const best = ls.reduce((b, s) => (s.score / s.total > b.score / b.total ? s : b));
|
||||
const evolution = ls.slice(-10).map(s => Math.round(s.score / s.total * 100));
|
||||
const avgPct = Math.round(ls.reduce((sum, s) => sum + s.score / s.total * 100, 0) / ls.length);
|
||||
stats[lvl] = {
|
||||
played: ls.length,
|
||||
bestScore: best.score,
|
||||
bestTotal: best.total,
|
||||
bestPct: Math.round(best.score / best.total * 100),
|
||||
avgPct,
|
||||
evolution,
|
||||
};
|
||||
}
|
||||
return stats;
|
||||
@@ -115,7 +174,7 @@ app.post('/api/players', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/players/:id/stats
|
||||
// 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(
|
||||
@@ -127,6 +186,7 @@ app.get('/api/players/:id/stats', async (req, res) => {
|
||||
}
|
||||
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`,
|
||||
@@ -134,26 +194,91 @@ app.get('/api/players/:id/stats', async (req, res) => {
|
||||
);
|
||||
const sessions = sessionRes.rows;
|
||||
|
||||
// Aggregate scalars
|
||||
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
|
||||
const totalSessions = sessions.length;
|
||||
const levelStats = buildLevelStats(sessions);
|
||||
const recent = sessions.slice(0, 10).map(s => ({
|
||||
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,
|
||||
}));
|
||||
|
||||
res.json({ player, totalStars, totalSessions, levelStats, recent });
|
||||
// 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) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/sessions { player_id, level, score, total, stars, duration_seconds? }
|
||||
// POST /api/sessions
|
||||
// Body: { player_id, level, score, total, stars, duration_seconds?, filter_group?, answers? }
|
||||
// answers: [{ comarca, correct, response_ms }]
|
||||
app.post('/api/sessions', async (req, res) => {
|
||||
const { player_id, level, score, total, stars, duration_seconds = 0 } = req.body ?? {};
|
||||
const {
|
||||
player_id, level, score, total, stars,
|
||||
duration_seconds = 0,
|
||||
filter_group = 'all',
|
||||
answers = [],
|
||||
} = req.body ?? {};
|
||||
|
||||
if ([player_id, level, score, total, stars].some(v => v == null)) {
|
||||
return res.status(400).json({ error: 'Falten camps obligatoris' });
|
||||
}
|
||||
|
||||
try {
|
||||
const playerRes = await pool.query(
|
||||
'SELECT id FROM players WHERE id = $1', [player_id]
|
||||
@@ -161,12 +286,36 @@ app.post('/api/sessions', async (req, res) => {
|
||||
if (!playerRes.rows.length) {
|
||||
return res.status(404).json({ error: 'Jugador no trobat' });
|
||||
}
|
||||
|
||||
// Insert session
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO sessions (player_id, level, score, total, stars, duration_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[player_id, level, score, total, stars, duration_seconds]
|
||||
`INSERT INTO sessions (player_id, level, score, total, stars, duration_seconds, filter_group)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[player_id, level, score, total, stars, duration_seconds, filter_group]
|
||||
);
|
||||
res.status(201).json({ id: rows[0].id });
|
||||
const sessionId = rows[0].id;
|
||||
|
||||
// Insert per-question answers (optional)
|
||||
if (Array.isArray(answers) && answers.length > 0) {
|
||||
const values = answers.map((_, i) => {
|
||||
const base = i * 4;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4})`;
|
||||
}).join(', ');
|
||||
|
||||
const params = answers.flatMap(a => [
|
||||
sessionId,
|
||||
String(a.comarca),
|
||||
Boolean(a.correct),
|
||||
parseInt(a.response_ms, 10) || 0,
|
||||
]);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO session_answers (session_id, comarca_name, correct, response_ms) VALUES ${values}`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json({ id: sessionId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user