- 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
363 lines
12 KiB
JavaScript
363 lines
12 KiB
JavaScript
const express = require('express');
|
||
const { Pool } = require('pg');
|
||
const path = require('path');
|
||
|
||
const app = express();
|
||
const PORT = parseInt(process.env.PORT || '3003');
|
||
const ADMIN_PIN = process.env.ADMIN_PIN || '1234';
|
||
|
||
// ── Database ──────────────────────────────────────────────────────────────────
|
||
// Requires env var DATABASE_URL with a valid PostgreSQL connection string.
|
||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||
|
||
/**
|
||
* Run schema migrations on startup.
|
||
* CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS — idempotent, safe every boot.
|
||
*/
|
||
async function initSchema() {
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS players (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
avatar TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
id SERIAL PRIMARY KEY,
|
||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||
level INTEGER NOT NULL,
|
||
score INTEGER NOT NULL,
|
||
total INTEGER NOT NULL,
|
||
stars INTEGER NOT NULL,
|
||
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
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');
|
||
}
|
||
|
||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||
app.use(express.json());
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 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 <= 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;
|
||
}
|
||
|
||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||
|
||
// GET /api/health — also verifies DB connectivity
|
||
app.get('/api/health', async (_req, res) => {
|
||
try {
|
||
await pool.query('SELECT 1');
|
||
res.json({ ok: true });
|
||
} catch {
|
||
res.status(503).json({ ok: false });
|
||
}
|
||
});
|
||
|
||
// GET /api/players
|
||
app.get('/api/players', async (_req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT id, name, avatar, created_at FROM players ORDER BY name'
|
||
);
|
||
res.json(rows);
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// POST /api/players { name, avatar }
|
||
app.post('/api/players', async (req, res) => {
|
||
const { name, avatar } = req.body ?? {};
|
||
if (!name?.trim() || !avatar) {
|
||
return res.status(400).json({ error: 'name i avatar són obligatoris' });
|
||
}
|
||
try {
|
||
// Case-insensitive duplicate check
|
||
const dup = await pool.query(
|
||
'SELECT id FROM players WHERE lower(name) = lower($1)',
|
||
[name.trim()]
|
||
);
|
||
if (dup.rows.length) {
|
||
return res.status(409).json({ error: 'Ja existeix un jugador amb aquest nom' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO players (name, avatar) VALUES ($1, $2) RETURNING id, name, avatar',
|
||
[name.trim(), avatar]
|
||
);
|
||
res.status(201).json(rows[0]);
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// 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,
|
||
});
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// 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,
|
||
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]
|
||
);
|
||
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, filter_group)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||
[player_id, level, score, total, stars, duration_seconds, filter_group]
|
||
);
|
||
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 });
|
||
}
|
||
});
|
||
|
||
// GET /api/admin/stats Header: x-admin-pin
|
||
app.get('/api/admin/stats', async (req, res) => {
|
||
if (req.headers['x-admin-pin'] !== ADMIN_PIN) {
|
||
return res.status(401).json({ error: 'PIN incorrecte' });
|
||
}
|
||
try {
|
||
const { rows: players } = await pool.query(
|
||
'SELECT id, name, avatar, created_at FROM players ORDER BY name'
|
||
);
|
||
|
||
// Fetch all sessions in one query to avoid N+1
|
||
const { rows: allSessions } = await pool.query(
|
||
`SELECT player_id, level, score, total, stars, created_at
|
||
FROM sessions ORDER BY created_at DESC`
|
||
);
|
||
|
||
const full = players.map(p => {
|
||
const sessions = allSessions.filter(s => s.player_id === p.id);
|
||
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
|
||
const levelStats = buildLevelStats(sessions);
|
||
const lastActivity = sessions[0]?.created_at ?? null;
|
||
return { ...p, totalSessions: sessions.length, totalStars, levelStats, lastActivity };
|
||
});
|
||
|
||
res.json(full);
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||
initSchema()
|
||
.then(() => app.listen(PORT, '0.0.0.0', () =>
|
||
console.log(`🏔️ Comarques API · port ${PORT} · PostgreSQL`)
|
||
))
|
||
.catch(err => {
|
||
console.error('❌ DB init failed:', err.message);
|
||
process.exit(1);
|
||
});
|