diff --git a/index.html b/index.html
index 18ef29c..74fba2b 100644
--- a/index.html
+++ b/index.html
@@ -79,6 +79,8 @@
.stats-avatar{font-size:4rem;display:block;margin-bottom:6px;}
.stats-name{font-size:1.5rem;font-weight:800;}
.stats-stars{font-size:1.1rem;margin-top:4px;opacity:.9;}
+ .stats-streak{font-size:1.3rem;font-weight:800;margin-top:8px;background:rgba(0,0,0,.15);
+ border-radius:50px;display:inline-block;padding:4px 16px;}
.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px;}
.stat-box{background:#fff;border-radius:14px;padding:16px;text-align:center;box-shadow:var(--shadow);}
.stat-box .stat-val{font-size:2rem;font-weight:800;color:var(--dark-orange);}
@@ -100,6 +102,24 @@
.ri-score{flex:1;font-size:.9rem;font-weight:600;}
.ri-stars{font-size:.9rem;}
.ri-date{font-size:.72rem;color:#bbb;}
+ /* Hard comarques section */
+ .hard-comarques-section{background:#fff;border-radius:14px;padding:18px;
+ box-shadow:var(--shadow);margin-bottom:18px;}
+ .hc-row{display:flex;align-items:center;gap:10px;margin-bottom:10px;}
+ .hc-row:last-child{margin-bottom:0;}
+ .hc-name{font-size:.85rem;font-weight:700;width:120px;flex-shrink:0;color:#444;}
+ .hc-track{flex:1;background:#eee;border-radius:50px;height:12px;overflow:hidden;}
+ .hc-fill{height:100%;border-radius:50px;background:var(--red);transition:width .6s ease;}
+ .hc-pct{font-size:.78rem;font-weight:700;width:44px;text-align:right;flex-shrink:0;color:var(--red);}
+ /* Group stats section */
+ .group-stats-section{background:#fff;border-radius:14px;padding:18px;
+ box-shadow:var(--shadow);margin-bottom:18px;}
+ .gs-row{display:flex;align-items:center;gap:10px;margin-bottom:10px;}
+ .gs-row:last-child{margin-bottom:0;}
+ .gs-name{font-size:.85rem;font-weight:700;width:100px;flex-shrink:0;color:#444;}
+ .gs-track{flex:1;background:#eee;border-radius:50px;height:12px;overflow:hidden;}
+ .gs-fill{height:100%;border-radius:50px;transition:width .6s ease;}
+ .gs-pct{font-size:.78rem;font-weight:700;width:36px;text-align:right;flex-shrink:0;}
/* ── ADMIN ── */
.admin-pin-wrap{max-width:320px;margin:0 auto 24px;}
@@ -936,10 +956,14 @@ const LEVEL_NAMES = {1:'Descobreix',2:'Tria',3:'Uneix',4:'Escriu',5:'El Mapa',6:
/* ══════════════════════════════════════════════════════
ESTAT
══════════════════════════════════════════════════════ */
-let currentPlayer = null;
-let offlineMode = false;
-let totalStars = 0;
-let sessionStart = null;
+let currentPlayer = null;
+let offlineMode = false;
+let totalStars = 0;
+let sessionStart = null;
+// Per-question answer tracking — reset at startLevel(), pushed on each answer
+let sessionAnswers = [];
+// Timestamp for the current question start (set when a new question is displayed)
+let questionStartTime = null;
let currentLevel=1, currentQ=0, score=0, questions=[];
let fcFlipped=false, fcIndex=0;
@@ -1006,8 +1030,17 @@ function addStars(n) {
async function saveSession(level, score, total, stars) {
if (offlineMode || !currentPlayer) return;
- const duration = sessionStart ? Math.round((Date.now()-sessionStart)/1000) : 0;
- await apiPost('/api/sessions', { player_id: currentPlayer.id, level, score, total, stars, duration_seconds: duration });
+ const duration = sessionStart ? Math.round((Date.now() - sessionStart) / 1000) : 0;
+ const filter_group = [...activeGroups].join(',') || 'all';
+ await apiPost('/api/sessions', {
+ player_id: currentPlayer.id,
+ level, score, total, stars,
+ duration_seconds: duration,
+ filter_group,
+ answers: sessionAnswers,
+ });
+ // Reset answers for the next session
+ sessionAnswers = [];
}
/* ══════════════════════════════════════════════════════
@@ -1147,6 +1180,54 @@ function goToStats() {
loadStats();
}
+/**
+ * Render an inline SVG sparkline for the last N sessions' percentages.
+ * @param {number[]} evolution Array of 0-100 values (chronological order).
+ * @returns {string} SVG markup or empty string if not enough data.
+ */
+function sparkline(evolution) {
+ if (!evolution || evolution.length < 2) return '';
+ const w = 80, h = 24;
+ const pts = evolution.map((v, i) => {
+ const x = (i / (evolution.length - 1)) * w;
+ const y = h - (v / 100) * h;
+ return `${x},${y}`;
+ }).join(' ');
+ const last = evolution[evolution.length - 1];
+ const lastColor = last >= 80 ? '#4DBD6E' : last >= 50 ? '#F4A535' : '#F05C5C';
+ const lx = w; // last point x = w (always)
+ const ly = h - (last / 100) * h;
+ return ``;
+}
+
+/**
+ * Format seconds as "Xh Ym" or "Ym" if under an hour.
+ * @param {number} secs
+ * @returns {string}
+ */
+function formatTime(secs) {
+ if (!secs) return '0m';
+ const h = Math.floor(secs / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
+/** Group label mapping for display */
+const GROUP_LABELS = {
+ mountain: '⛰️ Muntanya',
+ interior: '🌄 Interior',
+ coastal: '🏖️ Litoral',
+ B: '🏙️ Barcelona',
+ G: '🌊 Girona',
+ L: '🏔️ Lleida',
+ T: '☀️ Tarragona',
+ all: '🗺️ Totes',
+};
+
async function loadStats() {
if (offlineMode || !currentPlayer) {
document.getElementById('stats-content').innerHTML =
@@ -1161,11 +1242,20 @@ async function loadStats() {
return;
}
- const ls = data.levelStats || {};
+ const ls = data.levelStats || {};
+ const streak = data.currentStreak || 0;
+ const totalS = data.totalSeconds || 0;
+
+ // ── Hero streak badge ──────────────────────────────────────────────────────
+ const streakBadge = streak > 0
+ ? `
ÚLTIMES PARTIDES
@@ -1416,6 +1558,8 @@ function goHome(){ showScreen('screen-home'); }
function startLevel(n){
currentLevel=n; currentQ=0; score=0;
+ sessionAnswers = []; // reset per-question tracking for the new session
+ questionStartTime = null;
COMARQUES = getActiveComarques(); // refresh from current filter
// For map levels (5 & 6), exclude comarques without SVG path (noMap:true)
const mapComarques = COMARQUES.filter(c => !c.noMap && COMARCA_PATHS[c.name]);
@@ -1472,13 +1616,19 @@ function renderL2(){
const grid=document.getElementById('l2-opts');grid.innerHTML='';
opts.forEach(opt=>{
const btn=document.createElement('button');btn.className='opt-btn';btn.textContent=opt.capital;
- btn.onclick=()=>handleL2(btn,opt.capital===q.capital,q.capital);grid.appendChild(btn);});
+ btn.onclick=()=>handleL2(btn,opt.capital===q.capital,q.capital,q.name);grid.appendChild(btn);});
+ // Start timing for this question
+ questionStartTime = Date.now();
}
-function handleL2(btn,ok,right){
+function handleL2(btn,ok,right,comarcaName){
+ const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
document.querySelectorAll('.opt-btn').forEach(b=>b.disabled=true);
btn.classList.add(ok?'correct':'wrong');
if(!ok)document.querySelectorAll('.opt-btn').forEach(b=>{if(b.textContent===right)b.classList.add('correct');});
- if(ok)score++;showFeedback(ok);
+ if(ok)score++;
+ // Record this answer
+ sessionAnswers.push({ comarca: comarcaName, correct: ok, response_ms });
+ showFeedback(ok);
setTimeout(()=>{currentQ++;renderL2();},1400);
}
@@ -1522,13 +1672,18 @@ function renderL4(){
document.getElementById('l4-input').style.borderColor='#ddd';
document.getElementById('l4-fb').textContent='';
document.getElementById('l4-input').focus();
+ // Start timing for this question
+ questionStartTime = Date.now();
}
function checkL4(){
const q=questions[currentQ],val=document.getElementById('l4-input').value;
const ok=normalize(val)===normalize(q.capital)||normalize(val)===normalize(q.capital.replace(/^(el |la |l')/i,''));
const inp=document.getElementById('l4-input'),fb=document.getElementById('l4-fb');
+ if(!val.trim()){fb.style.color='#aaa';fb.textContent='Escriu alguna cosa!';return;}
+ const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
+ // Record this answer regardless of correctness
+ sessionAnswers.push({ comarca: q.name, correct: ok, response_ms });
if(ok){inp.style.borderColor='#5BC97A';fb.style.color='#1a7a3c';fb.textContent='✅ Correcte! La capital és '+q.capital;score++;showFeedback(true);setTimeout(()=>{currentQ++;renderL4();},1400);}
- else if(!val.trim()){fb.style.color='#aaa';fb.textContent='Escriu alguna cosa!';}
else{inp.style.borderColor='#F05C5C';fb.style.color='#a02020';fb.textContent='❌ La capital és '+q.capital;showFeedback(false);setTimeout(()=>{currentQ++;renderL4();},2000);}
}
function skipL4(){const q=questions[currentQ];document.getElementById('l4-fb').style.color='#999';document.getElementById('l4-fb').textContent='➡ '+q.name+' → '+q.capital;setTimeout(()=>{currentQ++;renderL4();},1900);}
@@ -1679,7 +1834,7 @@ function mapZoomReset(){
if(svg) svg.setAttribute('viewBox',`0 0 ${MAP_W} ${MAP_H}`);
}
function nextMapQ(){
- if(currentQ>=questions.length){showResult(5);return;}
+ if(currentQ>=questions.length){showResult(currentLevel===6?6:5);return;}
mapAnswered=false;mapTarget=questions[currentQ];
document.getElementById('map-prog').style.width=(currentQ/questions.length*100)+'%';
document.getElementById('map-ctr').textContent=`Pregunta ${currentQ+1} de ${questions.length}`;
@@ -1687,10 +1842,15 @@ function nextMapQ(){
if(mapMode==='name'){document.getElementById('map-qlabel').textContent='TOCA LA COMARCA...';document.getElementById('map-qtext').textContent=mapTarget.emoji+' '+mapTarget.name;}
else{document.getElementById('map-qlabel').textContent='QUINA COMARCA TÉ LA CAPITAL...';document.getElementById('map-qtext').textContent='📍 '+mapTarget.capital;}
document.getElementById('map-qhint').textContent='';resetMapPaths();
+ // Start timing for this question
+ questionStartTime = Date.now();
}
function handleMapClick(path){
if(mapAnswered)return;
const clicked=path.getAttribute('data-name');const ok=clicked===mapTarget.name;mapAnswered=true;
+ const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
+ // Record this answer
+ sessionAnswers.push({ comarca: mapTarget.name, correct: ok, response_ms });
if(ok){
path.style.fill='#4DBD6E';path.style.filter='drop-shadow(0 0 12px #4DBD6E)';
score++;showFeedback(true);document.getElementById('map-qhint').textContent='✅ Capital: '+mapTarget.capital;
@@ -1795,12 +1955,15 @@ function renderMill() {
// Hide the Next button until an answer is chosen
document.getElementById('mill-next').style.display = 'none';
+ // Start timing for this question
+ questionStartTime = Date.now();
}
/** Handle option click. Disables all options, colours correct/wrong, shows Next. */
function millAnswer(btn) {
const chosen = btn.dataset.opt;
const correct = btn.dataset.correct;
+ const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
// Disable all option buttons
document.querySelectorAll('.mill-opt').forEach(b => {
@@ -1808,7 +1971,11 @@ function millAnswer(btn) {
b.onclick = null;
});
- if (chosen === correct) {
+ const ok = chosen === correct;
+ // Record this answer (comarca is the current millionari question target)
+ sessionAnswers.push({ comarca: millQuestions[millQ].name, correct: ok, response_ms });
+
+ if (ok) {
btn.classList.add('correct');
millScore++;
score++; // global score used by showResult()
@@ -1985,6 +2152,8 @@ function _buildPuzzleTray() {
el.addEventListener('pointerdown', _puzzleDragStart, {passive:false});
tray.appendChild(el);
}
+ // Set initial question time for the first piece
+ questionStartTime = Date.now();
}
/** Comença el drag en prémer una peça de la safata. */
@@ -2047,11 +2216,17 @@ function _puzzleDragEnd(e) {
hit = Math.hypot(svgX - info.cx, svgY - info.cy) < 65;
}
if (hit) {
+ const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
+ sessionAnswers.push({ comarca: name, correct: true, response_ms });
+ questionStartTime = Date.now(); // reset for the next piece
_puzzlePlace(name);
trayEl.remove();
} else {
// Deixada incorrecta — torna a la safata
puzzleWrong++;
+ const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
+ sessionAnswers.push({ comarca: name, correct: false, response_ms });
+ questionStartTime = Date.now(); // reset timer even on wrong drop
trayEl.style.opacity = '1';
trayEl.style.pointerEvents = '';
trayEl.style.animation = 'wShake .4s ease';
diff --git a/server.js b/server.js
index f6fe52c..9aecc8d 100644
--- a/server.js
+++ b/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 });
}