Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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));
|
||||
|
||||
// ---- 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 = `
|
||||
<rect x="14" y="6" width="18" height="${tubeH}" rx="9" fill="#f0f0f0" stroke="#ddd"/>
|
||||
<rect x="14" y="${6 + (tubeH - 4 - fillH)}" width="18" height="${fillH + 4}" rx="9" fill="${RED}"/>
|
||||
<circle cx="23" cy="188" r="14" fill="${RED}"/>
|
||||
<circle cx="23" cy="188" r="7" fill="#fff" opacity=".35"/>`;
|
||||
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 += `<rect class="heat-cell" x="${x}" y="${y}" width="${cell}" height="${cell}" rx="3" fill="${shade(counts.get(iso) || 0)}"><title>${iso}: ${counts.get(iso) || 0}</title></rect>`;
|
||||
if (r === 0 && d.getMonth() !== lastMonth) {
|
||||
parts += `<text x="${x}" y="8" font-size="9" fill="#9aa3b2">${MON[d.getMonth()]}</text>`;
|
||||
lastMonth = d.getMonth();
|
||||
}
|
||||
}
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
}
|
||||
svg.innerHTML = parts;
|
||||
return svg;
|
||||
}
|
||||
Reference in New Issue
Block a user