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:
213
index.html
213
index.html
@@ -79,6 +79,8 @@
|
|||||||
.stats-avatar{font-size:4rem;display:block;margin-bottom:6px;}
|
.stats-avatar{font-size:4rem;display:block;margin-bottom:6px;}
|
||||||
.stats-name{font-size:1.5rem;font-weight:800;}
|
.stats-name{font-size:1.5rem;font-weight:800;}
|
||||||
.stats-stars{font-size:1.1rem;margin-top:4px;opacity:.9;}
|
.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;}
|
.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{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);}
|
.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-score{flex:1;font-size:.9rem;font-weight:600;}
|
||||||
.ri-stars{font-size:.9rem;}
|
.ri-stars{font-size:.9rem;}
|
||||||
.ri-date{font-size:.72rem;color:#bbb;}
|
.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 ── */
|
||||||
.admin-pin-wrap{max-width:320px;margin:0 auto 24px;}
|
.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
|
ESTAT
|
||||||
══════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════ */
|
||||||
let currentPlayer = null;
|
let currentPlayer = null;
|
||||||
let offlineMode = false;
|
let offlineMode = false;
|
||||||
let totalStars = 0;
|
let totalStars = 0;
|
||||||
let sessionStart = null;
|
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 currentLevel=1, currentQ=0, score=0, questions=[];
|
||||||
let fcFlipped=false, fcIndex=0;
|
let fcFlipped=false, fcIndex=0;
|
||||||
@@ -1006,8 +1030,17 @@ function addStars(n) {
|
|||||||
|
|
||||||
async function saveSession(level, score, total, stars) {
|
async function saveSession(level, score, total, stars) {
|
||||||
if (offlineMode || !currentPlayer) return;
|
if (offlineMode || !currentPlayer) return;
|
||||||
const duration = sessionStart ? Math.round((Date.now()-sessionStart)/1000) : 0;
|
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 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();
|
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 `<svg width="${w}" height="${h}" style="display:inline-block;vertical-align:middle;opacity:.85;flex-shrink:0;">
|
||||||
|
<polyline points="${pts}" fill="none" stroke="${lastColor}" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<circle cx="${lx}" cy="${ly}" r="3" fill="${lastColor}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
async function loadStats() {
|
||||||
if (offlineMode || !currentPlayer) {
|
if (offlineMode || !currentPlayer) {
|
||||||
document.getElementById('stats-content').innerHTML =
|
document.getElementById('stats-content').innerHTML =
|
||||||
@@ -1161,11 +1242,20 @@ async function loadStats() {
|
|||||||
return;
|
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
|
||||||
|
? `<div class="stats-streak">🔥 ${streak} ${streak === 1 ? 'dia seguit' : 'dies seguits'}!</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// ── Recent rows ────────────────────────────────────────────────────────────
|
||||||
const recentRows = (data.recent || []).map(s => {
|
const recentRows = (data.recent || []).map(s => {
|
||||||
const d = new Date(s.date);
|
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 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 `<div class="recent-item">
|
return `<div class="recent-item">
|
||||||
<span class="ri-lvl">${LEVEL_ICONS[s.level]}</span>
|
<span class="ri-lvl">${LEVEL_ICONS[s.level]}</span>
|
||||||
<span class="ri-score">${LEVEL_NAMES[s.level]} · ${pct}%</span>
|
<span class="ri-score">${LEVEL_NAMES[s.level]} · ${pct}%</span>
|
||||||
@@ -1174,26 +1264,68 @@ async function loadStats() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('') || '<div style="color:#aaa;font-size:.85rem;text-align:center;padding:12px;">Cap sessió registrada</div>';
|
}).join('') || '<div style="color:#aaa;font-size:.85rem;text-align:center;padding:12px;">Cap sessió registrada</div>';
|
||||||
|
|
||||||
const barRows = [1,2,3,4,5,6,7].map(lvl => {
|
// ── Level bars with sparkline ──────────────────────────────────────────────
|
||||||
const s = ls[lvl];
|
const barRows = [1,2,3,4,5,6,7,8].map(lvl => {
|
||||||
|
const s = ls[lvl];
|
||||||
const pct = s?.bestPct ?? null;
|
const pct = s?.bestPct ?? null;
|
||||||
const color = pct===null ? '#ddd' : pct>=80 ? '#4DBD6E' : pct>=50 ? '#F4A535' : '#F05C5C';
|
const color = pct === null ? '#ddd' : pct >= 80 ? '#4DBD6E' : pct >= 50 ? '#F4A535' : '#F05C5C';
|
||||||
const pctStr = pct===null ? '—' : pct+'%';
|
const pctStr = pct === null ? '—' : pct + '%';
|
||||||
|
const spark = s?.evolution ? sparkline(s.evolution) : '';
|
||||||
return `<div class="level-bar-row">
|
return `<div class="level-bar-row">
|
||||||
<span class="lbr-icon">${LEVEL_ICONS[lvl]}</span>
|
<span class="lbr-icon">${LEVEL_ICONS[lvl]}</span>
|
||||||
<span class="lbr-name">${LEVEL_NAMES[lvl]}</span>
|
<span class="lbr-name">${LEVEL_NAMES[lvl]}</span>
|
||||||
<div class="lbr-track">
|
<div class="lbr-track">
|
||||||
<div class="lbr-fill" style="width:${pct??0}%;background:${color};"></div>
|
<div class="lbr-fill" style="width:${pct ?? 0}%;background:${color};"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="lbr-pct" style="color:${color};">${pctStr}</span>
|
<span class="lbr-pct" style="color:${color};">${pctStr}</span>
|
||||||
|
${spark}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// ── Hard comarques ─────────────────────────────────────────────────────────
|
||||||
|
let hardSection = '';
|
||||||
|
if (data.hardComarques && data.hardComarques.length > 0) {
|
||||||
|
const hardRows = data.hardComarques.map(hc => `
|
||||||
|
<div class="hc-row">
|
||||||
|
<span class="hc-name">${hc.comarca}</span>
|
||||||
|
<div class="hc-track"><div class="hc-fill" style="width:${hc.errorRate}%;"></div></div>
|
||||||
|
<span class="hc-pct">${hc.errorRate}% error</span>
|
||||||
|
</div>`).join('');
|
||||||
|
hardSection = `
|
||||||
|
<div class="hard-comarques-section">
|
||||||
|
<div class="q-label" style="margin-bottom:12px;">LES TEVES COMARQUES MÉS DIFÍCILS</div>
|
||||||
|
${hardRows}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 `<div class="gs-row">
|
||||||
|
<span class="gs-name">${label}</span>
|
||||||
|
<div class="gs-track"><div class="gs-fill" style="width:${g.avgPct}%;background:${color};"></div></div>
|
||||||
|
<span class="gs-pct" style="color:${color};">${g.avgPct}%</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
groupSection = `
|
||||||
|
<div class="group-stats-section">
|
||||||
|
<div class="q-label" style="margin-bottom:12px;">RENDIMENT PER GRUP</div>
|
||||||
|
${gsRows}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('stats-content').innerHTML = `
|
document.getElementById('stats-content').innerHTML = `
|
||||||
<div class="stats-hero">
|
<div class="stats-hero">
|
||||||
<span class="stats-avatar">${data.player.avatar}</span>
|
<span class="stats-avatar">${data.player.avatar}</span>
|
||||||
<div class="stats-name">${data.player.name}</div>
|
<div class="stats-name">${data.player.name}</div>
|
||||||
<div class="stats-stars">⭐ ${data.totalStars} estreles acumulades</div>
|
<div class="stats-stars">⭐ ${data.totalStars} estreles acumulades</div>
|
||||||
|
${streakBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
@@ -1204,11 +1336,21 @@ async function loadStats() {
|
|||||||
<div class="stat-val">${data.totalStars}</div>
|
<div class="stat-val">${data.totalStars}</div>
|
||||||
<div class="stat-lbl">Estreles totals</div>
|
<div class="stat-lbl">Estreles totals</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-val">${streak > 0 ? streak + '🔥' : '0'}</div>
|
||||||
|
<div class="stat-lbl">Dies de ratxa</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-val" style="font-size:1.5rem;">⏱️ ${formatTime(totalS)}</div>
|
||||||
|
<div class="stat-lbl">Temps total</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-bars">
|
<div class="level-bars">
|
||||||
<div class="q-label" style="margin-bottom:14px;">MILLOR RESULTAT PER NIVELL</div>
|
<div class="q-label" style="margin-bottom:14px;">MILLOR RESULTAT PER NIVELL</div>
|
||||||
${barRows}
|
${barRows}
|
||||||
</div>
|
</div>
|
||||||
|
${hardSection}
|
||||||
|
${groupSection}
|
||||||
${data.recent?.length ? `
|
${data.recent?.length ? `
|
||||||
<div class="recent-list">
|
<div class="recent-list">
|
||||||
<div class="recent-title">ÚLTIMES PARTIDES</div>
|
<div class="recent-title">ÚLTIMES PARTIDES</div>
|
||||||
@@ -1416,6 +1558,8 @@ function goHome(){ showScreen('screen-home'); }
|
|||||||
|
|
||||||
function startLevel(n){
|
function startLevel(n){
|
||||||
currentLevel=n; currentQ=0; score=0;
|
currentLevel=n; currentQ=0; score=0;
|
||||||
|
sessionAnswers = []; // reset per-question tracking for the new session
|
||||||
|
questionStartTime = null;
|
||||||
COMARQUES = getActiveComarques(); // refresh from current filter
|
COMARQUES = getActiveComarques(); // refresh from current filter
|
||||||
// For map levels (5 & 6), exclude comarques without SVG path (noMap:true)
|
// For map levels (5 & 6), exclude comarques without SVG path (noMap:true)
|
||||||
const mapComarques = COMARQUES.filter(c => !c.noMap && COMARCA_PATHS[c.name]);
|
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='';
|
const grid=document.getElementById('l2-opts');grid.innerHTML='';
|
||||||
opts.forEach(opt=>{
|
opts.forEach(opt=>{
|
||||||
const btn=document.createElement('button');btn.className='opt-btn';btn.textContent=opt.capital;
|
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);
|
document.querySelectorAll('.opt-btn').forEach(b=>b.disabled=true);
|
||||||
btn.classList.add(ok?'correct':'wrong');
|
btn.classList.add(ok?'correct':'wrong');
|
||||||
if(!ok)document.querySelectorAll('.opt-btn').forEach(b=>{if(b.textContent===right)b.classList.add('correct');});
|
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);
|
setTimeout(()=>{currentQ++;renderL2();},1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1522,13 +1672,18 @@ function renderL4(){
|
|||||||
document.getElementById('l4-input').style.borderColor='#ddd';
|
document.getElementById('l4-input').style.borderColor='#ddd';
|
||||||
document.getElementById('l4-fb').textContent='';
|
document.getElementById('l4-fb').textContent='';
|
||||||
document.getElementById('l4-input').focus();
|
document.getElementById('l4-input').focus();
|
||||||
|
// Start timing for this question
|
||||||
|
questionStartTime = Date.now();
|
||||||
}
|
}
|
||||||
function checkL4(){
|
function checkL4(){
|
||||||
const q=questions[currentQ],val=document.getElementById('l4-input').value;
|
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 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');
|
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);}
|
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);}
|
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);}
|
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}`);
|
if(svg) svg.setAttribute('viewBox',`0 0 ${MAP_W} ${MAP_H}`);
|
||||||
}
|
}
|
||||||
function nextMapQ(){
|
function nextMapQ(){
|
||||||
if(currentQ>=questions.length){showResult(5);return;}
|
if(currentQ>=questions.length){showResult(currentLevel===6?6:5);return;}
|
||||||
mapAnswered=false;mapTarget=questions[currentQ];
|
mapAnswered=false;mapTarget=questions[currentQ];
|
||||||
document.getElementById('map-prog').style.width=(currentQ/questions.length*100)+'%';
|
document.getElementById('map-prog').style.width=(currentQ/questions.length*100)+'%';
|
||||||
document.getElementById('map-ctr').textContent=`Pregunta ${currentQ+1} de ${questions.length}`;
|
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;}
|
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;}
|
else{document.getElementById('map-qlabel').textContent='QUINA COMARCA TÉ LA CAPITAL...';document.getElementById('map-qtext').textContent='📍 '+mapTarget.capital;}
|
||||||
document.getElementById('map-qhint').textContent='';resetMapPaths();
|
document.getElementById('map-qhint').textContent='';resetMapPaths();
|
||||||
|
// Start timing for this question
|
||||||
|
questionStartTime = Date.now();
|
||||||
}
|
}
|
||||||
function handleMapClick(path){
|
function handleMapClick(path){
|
||||||
if(mapAnswered)return;
|
if(mapAnswered)return;
|
||||||
const clicked=path.getAttribute('data-name');const ok=clicked===mapTarget.name;mapAnswered=true;
|
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){
|
if(ok){
|
||||||
path.style.fill='#4DBD6E';path.style.filter='drop-shadow(0 0 12px #4DBD6E)';
|
path.style.fill='#4DBD6E';path.style.filter='drop-shadow(0 0 12px #4DBD6E)';
|
||||||
score++;showFeedback(true);document.getElementById('map-qhint').textContent='✅ Capital: '+mapTarget.capital;
|
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
|
// Hide the Next button until an answer is chosen
|
||||||
document.getElementById('mill-next').style.display = 'none';
|
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. */
|
/** Handle option click. Disables all options, colours correct/wrong, shows Next. */
|
||||||
function millAnswer(btn) {
|
function millAnswer(btn) {
|
||||||
const chosen = btn.dataset.opt;
|
const chosen = btn.dataset.opt;
|
||||||
const correct = btn.dataset.correct;
|
const correct = btn.dataset.correct;
|
||||||
|
const response_ms = questionStartTime ? Date.now() - questionStartTime : 0;
|
||||||
|
|
||||||
// Disable all option buttons
|
// Disable all option buttons
|
||||||
document.querySelectorAll('.mill-opt').forEach(b => {
|
document.querySelectorAll('.mill-opt').forEach(b => {
|
||||||
@@ -1808,7 +1971,11 @@ function millAnswer(btn) {
|
|||||||
b.onclick = null;
|
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');
|
btn.classList.add('correct');
|
||||||
millScore++;
|
millScore++;
|
||||||
score++; // global score used by showResult()
|
score++; // global score used by showResult()
|
||||||
@@ -1985,6 +2152,8 @@ function _buildPuzzleTray() {
|
|||||||
el.addEventListener('pointerdown', _puzzleDragStart, {passive:false});
|
el.addEventListener('pointerdown', _puzzleDragStart, {passive:false});
|
||||||
tray.appendChild(el);
|
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. */
|
/** 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;
|
hit = Math.hypot(svgX - info.cx, svgY - info.cy) < 65;
|
||||||
}
|
}
|
||||||
if (hit) {
|
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);
|
_puzzlePlace(name);
|
||||||
trayEl.remove();
|
trayEl.remove();
|
||||||
} else {
|
} else {
|
||||||
// Deixada incorrecta — torna a la safata
|
// Deixada incorrecta — torna a la safata
|
||||||
puzzleWrong++;
|
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.opacity = '1';
|
||||||
trayEl.style.pointerEvents = '';
|
trayEl.style.pointerEvents = '';
|
||||||
trayEl.style.animation = 'wShake .4s ease';
|
trayEl.style.animation = 'wShake .4s ease';
|
||||||
|
|||||||
181
server.js
181
server.js
@@ -12,7 +12,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Run schema migrations on startup.
|
* 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() {
|
async function initSchema() {
|
||||||
await pool.query(`
|
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_player ON sessions(player_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_level ON sessions(player_id, level);
|
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');
|
console.log('✅ Schema ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,22 +64,62 @@ app.use(express.json());
|
|||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build per-level best-score stats from a flat sessions array.
|
* Compute consecutive-day streak up to today (UTC).
|
||||||
* @param {Array} sessions
|
* A streak starts if the most recent session was today or yesterday.
|
||||||
* @returns {Object} keyed by level 1–5
|
* @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) {
|
function buildLevelStats(sessions) {
|
||||||
const stats = {};
|
const stats = {};
|
||||||
for (let lvl = 1; lvl <= 5; lvl++) {
|
for (let lvl = 1; lvl <= 8; lvl++) {
|
||||||
const ls = sessions.filter(s => s.level === 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;
|
if (!ls.length) continue;
|
||||||
const best = ls.reduce((b, s) => (s.score / s.total > b.score / b.total ? s : b));
|
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] = {
|
stats[lvl] = {
|
||||||
played: ls.length,
|
played: ls.length,
|
||||||
bestScore: best.score,
|
bestScore: best.score,
|
||||||
bestTotal: best.total,
|
bestTotal: best.total,
|
||||||
bestPct: Math.round(best.score / best.total * 100),
|
bestPct: Math.round(best.score / best.total * 100),
|
||||||
|
avgPct,
|
||||||
|
evolution,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return stats;
|
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) => {
|
app.get('/api/players/:id/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const playerRes = await pool.query(
|
const playerRes = await pool.query(
|
||||||
@@ -127,6 +186,7 @@ app.get('/api/players/:id/stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const player = playerRes.rows[0];
|
const player = playerRes.rows[0];
|
||||||
|
|
||||||
|
// All sessions for this player (include created_at for streak + evolution)
|
||||||
const sessionRes = await pool.query(
|
const sessionRes = await pool.query(
|
||||||
`SELECT level, score, total, stars, duration_seconds, created_at
|
`SELECT level, score, total, stars, duration_seconds, created_at
|
||||||
FROM sessions WHERE player_id = $1 ORDER BY created_at DESC`,
|
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;
|
const sessions = sessionRes.rows;
|
||||||
|
|
||||||
|
// Aggregate scalars
|
||||||
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
|
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
|
||||||
const totalSessions = sessions.length;
|
const totalSessions = sessions.length;
|
||||||
const levelStats = buildLevelStats(sessions);
|
const totalSeconds = sessions.reduce((s, r) => s + (r.duration_seconds || 0), 0);
|
||||||
const recent = sessions.slice(0, 10).map(s => ({
|
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,
|
level: s.level, score: s.score, total: s.total,
|
||||||
stars: s.stars, date: s.created_at,
|
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) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
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) => {
|
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)) {
|
if ([player_id, level, score, total, stars].some(v => v == null)) {
|
||||||
return res.status(400).json({ error: 'Falten camps obligatoris' });
|
return res.status(400).json({ error: 'Falten camps obligatoris' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playerRes = await pool.query(
|
const playerRes = await pool.query(
|
||||||
'SELECT id FROM players WHERE id = $1', [player_id]
|
'SELECT id FROM players WHERE id = $1', [player_id]
|
||||||
@@ -161,12 +286,36 @@ app.post('/api/sessions', async (req, res) => {
|
|||||||
if (!playerRes.rows.length) {
|
if (!playerRes.rows.length) {
|
||||||
return res.status(404).json({ error: 'Jugador no trobat' });
|
return res.status(404).json({ error: 'Jugador no trobat' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert session
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO sessions (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) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
[player_id, level, score, total, stars, duration_seconds]
|
[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) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user