Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package

This commit is contained in:
Keysat
2026-05-31 21:04:48 -05:00
commit 0265699504
67 changed files with 4578 additions and 0 deletions
+172
View File
@@ -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;
}