import { api } from '/js/api.js'; import { h, parseISO, isoOf, todayISO } from '/js/app.js'; let radarChart = null; let lineChart = null; const RED = '#EF0107'; export async function renderStats(view, { catById }) { const stats = await api.stats(); if (radarChart) { radarChart.destroy(); radarChart = null; } if (lineChart) { lineChart.destroy(); lineChart = null; } view.innerHTML = ''; // ---- top numbers ---- view.append(h('div', { class: 'stat-grid' }, stat(stats.totalSessions, 'Sessions'), stat(stats.totalDays, 'Training days'), stat(`${stats.current}πŸ”₯`, 'Day streak'), stat(stats.longest, 'Best streak'))); // ---- main goal thermometer ---- const main = stats.goals.find((g) => g.is_main); if (main) view.append(thermometer(main)); // ---- personal records ---- const records = (stats.records || []).length ? stats.records : null; if (records) { const card = h('div', { class: 'card' }, h('h2', {}, 'πŸ† Personal records')); for (const r of records) { card.append(h('div', { class: 'record-row' }, h('span', { class: 'emoji' }, r.emoji), h('div', { style: 'flex:1' }, h('div', {}, `${r.category} β€” ${r.name}`)), h('span', { class: 'record-val' }, r.record != null ? `${r.record}${r.unit ? ' ' + r.unit : ''}` : 'β€”'))); } view.append(card); } // ---- heatmap ---- view.append(h('div', { class: 'card' }, h('h2', {}, 'πŸ”₯ Training calendar'), h('div', { class: 'heatmap' }, heatmapSvg(stats.heatmap)), h('p', { class: 'muted small' }, 'Each square is a day. Brighter red = more training!'))); // ---- radar ---- const radarData = stats.radar.filter((r) => true); if (radarData.length >= 3) { const canvas = h('canvas', { height: 280 }); view.append(h('div', { class: 'card' }, h('h2', {}, 'πŸ•ΈοΈ Training spread'), canvas)); radarChart = new Chart(canvas, { type: 'radar', data: { labels: radarData.map((r) => r.name), datasets: [{ label: 'Sessions', data: radarData.map((r) => r.sessions), backgroundColor: 'rgba(239,1,7,.18)', borderColor: RED, borderWidth: 2, pointBackgroundColor: RED, }], }, options: { plugins: { legend: { display: false } }, scales: { r: { beginAtZero: true, ticks: { precision: 0 }, pointLabels: { font: { size: 11 } } } }, }, }); } // ---- line chart with metric picker ---- const metrics = []; for (const c of stats.radar.map((r) => catById(r.category_id)).filter(Boolean)) { for (const m of c.metrics || []) { if (stats.series[m.id]?.length) metrics.push({ ...m, catName: c.name, emoji: c.emoji }); } } if (metrics.length) { const sel = h('select', {}, metrics.map((m) => h('option', { value: m.id }, `${m.emoji} ${m.catName} β€” ${m.name}`))); const canvas = h('canvas', { height: 260 }); const draw = () => { const m = metrics.find((x) => x.id === Number(sel.value)); const pts = stats.series[m.id] || []; if (lineChart) lineChart.destroy(); lineChart = new Chart(canvas, { type: 'line', data: { labels: pts.map((p) => p.day.slice(5)), datasets: [{ label: `${m.name}${m.unit ? ' (' + m.unit + ')' : ''}`, data: pts.map((p) => p.value), borderColor: RED, backgroundColor: 'rgba(239,1,7,.12)', fill: true, tension: .3, pointRadius: 4, pointBackgroundColor: RED, }], }, options: { plugins: { legend: { display: true } }, scales: { y: { beginAtZero: true } } }, }); }; sel.addEventListener('change', draw); view.append(h('div', { class: 'card' }, h('h2', {}, 'πŸ“ˆ Improvement over time'), h('div', { class: 'field' }, sel), canvas)); draw(); } // ---- all goals ---- if (stats.goals.length) { const card = h('div', { class: 'card' }, h('h2', {}, 'πŸ† Goal progress')); for (const g of stats.goals) { const done = g.pct >= 100; const catName = g.category_id ? (catById(g.category_id)?.name || '') : 'Overall'; card.append(h('div', { class: 'goal' }, h('div', { class: 'top' }, h('span', { class: 'label' }, (g.is_main ? '⭐ ' : '') + (g.label || catName)), h('span', { class: 'muted small' }, `${Math.round(g.current)} / ${g.target} (${g.pct}%)`)), h('div', { class: 'bar' + (done ? ' done' : '') }, h('span', { style: `width:${g.pct}%` })))); } view.append(card); } } const stat = (num, lbl) => h('div', { class: 'stat' }, h('div', { class: 'num' }, String(num)), h('div', { class: 'lbl' }, lbl)); function thermometer(g) { const pct = g.pct; const tubeH = 180, fillH = Math.round((tubeH - 8) * pct / 100); const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('viewBox', '0 0 46 200'); svg.setAttribute('width', '46'); svg.setAttribute('height', '200'); svg.innerHTML = ` `; return h('div', { class: 'card' }, h('h2', {}, '✈️ ' + (g.label || 'Main goal')), h('div', { class: 'thermo-wrap' }, svg, h('div', { class: 'reward' }, h('div', { class: 'pct' }, `${pct}%`), h('div', {}, `${Math.round(g.current)} of ${g.target}`), g.reward && h('p', { class: 'muted', style: 'margin-top:8px' }, '🎁 ' + g.reward)))); } function heatmapSvg(rows) { const counts = new Map(rows.map((r) => [r.day, r.count])); const max = Math.max(1, ...rows.map((r) => r.count)); const weeks = 27, cell = 14, gap = 3; const end = parseISO(todayISO()); const start = new Date(end); start.setDate(start.getDate() - weeks * 7); // back up to Sunday start.setDate(start.getDate() - start.getDay()); const shade = (n) => { if (!n) return '#ebedf3'; const t = n / max; // 0..1 const light = 88 - Math.round(t * 50); // 88% -> 38% return `hsl(2 90% ${light}%)`; }; const svgNS = 'http://www.w3.org/2000/svg'; const w = weeks * (cell + gap) + 24, hgt = 7 * (cell + gap) + 16; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`); svg.setAttribute('width', w); svg.setAttribute('height', hgt); let parts = ''; const MON = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; const d = new Date(start); let lastMonth = -1; for (let col = 0; col <= weeks; col++) { for (let r = 0; r < 7; r++) { const iso = isoOf(d); if (d <= end) { const x = col * (cell + gap); const y = r * (cell + gap) + 12; parts += `${iso}: ${counts.get(iso) || 0}`; if (r === 0 && d.getMonth() !== lastMonth) { parts += `${MON[d.getMonth()]}`; lastMonth = d.getMonth(); } } d.setDate(d.getDate() + 1); } } svg.innerHTML = parts; return svg; }