From 288a0009530828ae86d02f1520957c2464301f6f Mon Sep 17 00:00:00 2001 From: Jaume Garriga Maestre Date: Fri, 15 May 2026 09:06:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20enhanced=20stats=20=E2=80=94=20streak,?= =?UTF-8?q?=20hard=20comarques,=20evolution,=20group=20comparison,=20per-q?= =?UTF-8?q?uestion=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- index.html | 213 ++++++++++++++++++++++++++++++++++++++++++++++++----- server.js | 181 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 359 insertions(+), 35 deletions(-) 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 + ? `
🔥 ${streak} ${streak === 1 ? 'dia seguit' : 'dies seguits'}!
` + : ''; + + // ── Recent rows ──────────────────────────────────────────────────────────── const recentRows = (data.recent || []).map(s => { const d = new Date(s.date); const dateStr = `${d.getDate()}/${d.getMonth()+1} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; - const pct = Math.round(s.score/s.total*100); + const pct = Math.round(s.score / s.total * 100); return `
${LEVEL_ICONS[s.level]} ${LEVEL_NAMES[s.level]} · ${pct}% @@ -1174,26 +1264,68 @@ async function loadStats() {
`; }).join('') || '
Cap sessió registrada
'; - const barRows = [1,2,3,4,5,6,7].map(lvl => { - const s = ls[lvl]; + // ── Level bars with sparkline ────────────────────────────────────────────── + const barRows = [1,2,3,4,5,6,7,8].map(lvl => { + const s = ls[lvl]; const pct = s?.bestPct ?? null; - const color = pct===null ? '#ddd' : pct>=80 ? '#4DBD6E' : pct>=50 ? '#F4A535' : '#F05C5C'; - const pctStr = pct===null ? '—' : pct+'%'; + const color = pct === null ? '#ddd' : pct >= 80 ? '#4DBD6E' : pct >= 50 ? '#F4A535' : '#F05C5C'; + const pctStr = pct === null ? '—' : pct + '%'; + const spark = s?.evolution ? sparkline(s.evolution) : ''; return `
${LEVEL_ICONS[lvl]} ${LEVEL_NAMES[lvl]}
-
+
${pctStr} + ${spark}
`; }).join(''); + // ── Hard comarques ───────────────────────────────────────────────────────── + let hardSection = ''; + if (data.hardComarques && data.hardComarques.length > 0) { + const hardRows = data.hardComarques.map(hc => ` +
+ ${hc.comarca} +
+ ${hc.errorRate}% error +
`).join(''); + hardSection = ` +
+
LES TEVES COMARQUES MÉS DIFÍCILS
+ ${hardRows} +
`; + } + + // ── Group stats ──────────────────────────────────────────────────────────── + let groupSection = ''; + const gs = data.groupStats || {}; + const gsKeys = Object.keys(gs); + if (gsKeys.length > 0) { + const gsRows = gsKeys.map(key => { + const g = gs[key]; + const color = g.avgPct >= 80 ? '#4DBD6E' : g.avgPct >= 50 ? '#F4A535' : '#F05C5C'; + const label = GROUP_LABELS[key] || key; + return `
+ ${label} +
+ ${g.avgPct}% +
`; + }).join(''); + groupSection = ` +
+
RENDIMENT PER GRUP
+ ${gsRows} +
`; + } + document.getElementById('stats-content').innerHTML = `
${data.player.avatar}
${data.player.name}
⭐ ${data.totalStars} estreles acumulades
+ ${streakBadge}
@@ -1204,11 +1336,21 @@ async function loadStats() {
${data.totalStars}
Estreles totals
+
+
${streak > 0 ? streak + '🔥' : '0'}
+
Dies de ratxa
+
+
+
⏱️ ${formatTime(totalS)}
+
Temps total
+
MILLOR RESULTAT PER NIVELL
${barRows}
+ ${hardSection} + ${groupSection} ${data.recent?.length ? `
Ú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 }); }