Add guided running stats explorer with validated queries and comparisons

This commit is contained in:
Peter Stockings
2026-10-02 15:48:28 +10:00
parent 794e74166a
commit f951e888bd
8 changed files with 694 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.stats-nav{padding:9px 13px;border-radius:5px;color:var(--muted);font-size:13px}.stats-nav.active,.stats-nav:hover{background:#292f32;color:var(--ink)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.question-panel{padding:26px;background:linear-gradient(120deg,#232a2d,var(--white));border-top:2px solid var(--green)}.studio-row{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.studio-row .eyebrow{margin:0}.studio-sentence{font-size:23px;line-height:2.4;letter-spacing:-.4px;margin:16px 0 22px}.studio-sentence select,.studio-sentence input{background:#343532;color:#ffc1a5;border:1px solid #605043;border-radius:7px;padding:8px 12px;font-size:17px;max-width:100%}.studio-sentence input{width:80px}.studio-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;border-top:1px solid var(--line);padding-top:20px}.studio-filters label{font-size:12px;color:var(--muted)}.studio-filters input,.studio-filters select{display:block;width:100%;margin-top:7px;padding:10px 12px;background:var(--paper);color:var(--ink);border:1px solid #485154;border-radius:7px;color-scheme:dark}.studio-filters label span{font-size:11px}.studio-bottom{margin-top:24px}.studio-presets{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:18px 0 28px}.studio-presets:empty{display:none}.studio-presets a{font-size:12px;border:1px solid #485154;padding:7px 12px;border-radius:20px;background:var(--white)}.studio-presets a:hover{border-color:var(--green)}.studio-presets button{background:none;color:var(--muted);padding:4px}.studio-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:20px 0}.studio-metrics .stat-value{font-size:36px}.studio-metrics .stat-card p{font-size:12px;color:var(--muted)}.studio-metrics details{font-size:12px;margin-top:18px;color:var(--muted)}.studio-metrics summary{cursor:pointer;color:var(--ink)}.studio-metrics details p{margin-top:8px}.result-heading p{font-size:12px;color:var(--muted);margin-top:8px}.studio-charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;padding:14px 26px 20px}.studio-charts:has(.studio-chart:only-child){grid-template-columns:1fr}.studio-chart{min-width:0}.studio-chart h3{font-size:14px}.studio-chart h3 small{color:var(--muted);font-weight:400}.studio-chart>div{height:260px;margin-top:16px}.studio-chart-help{font-size:12px;color:var(--muted);padding:0 26px 24px}.studio-evidence{margin-top:24px}.studio-pagination{display:flex;align-items:center;justify-content:flex-end;gap:12px;padding:18px 24px;flex-wrap:wrap;font-size:12px;color:var(--muted)}.studio-pagination span{margin-right:auto}.studio-empty{padding:30px;margin-top:20px}.studio-empty p{color:var(--muted);margin-top:10px}#query-editor{margin-top:20px}#query-editor label{font-size:12px;color:var(--muted)}#query-input{display:block;resize:vertical;width:100%;padding:16px;border:1px solid #485154;border-radius:8px;margin:8px 0;background:var(--paper);color:var(--ink);font:14px/1.8 Consolas,monospace}.studio-error{margin-top:16px;color:#ffb5a1}#studio-status{margin-top:16px}.studio-chart canvas{cursor:pointer}
.studio-period{font-size:12px;color:var(--muted)}.studio-period select{padding:8px;border:1px solid #485154;border-radius:6px;background:var(--paper);color:var(--ink);margin-left:8px}
@media(max-width:900px){.studio-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.studio-charts{grid-template-columns:1fr}}@media(max-width:680px){.question-panel{padding:18px}.studio-sentence{font-size:18px}.studio-sentence select{font-size:16px;padding:7px}.studio-filters{grid-template-columns:1fr 1fr;gap:12px}.studio-metrics{grid-template-columns:1fr}.stats-nav{padding:8px;font-size:12px}.app-header{gap:6px}.studio-charts{padding:16px}.studio-chart-help{padding:0 16px 18px}.studio-evidence .section-head{flex-wrap:wrap}.studio-row .field-help{max-width:100%}}
+117
View File
@@ -0,0 +1,117 @@
(() => {
'use strict';
const root = document.getElementById('stats-studio');
if (!root) return;
const form = document.getElementById('explorer-form');
const editor = document.getElementById('query-editor');
const input = document.getElementById('query-input');
const toggle = document.getElementById('query-toggle');
const status = document.getElementById('studio-status');
const value = name => form.elements.namedItem(name).value;
function serialize() {
const lines = ['runs', ` during last ${value('weeks')} weeks`];
if (value('end')) lines.push(` ending ${value('end')}`);
lines.push(` where distance >= ${value('minimum') || 0} km`);
[['maximum', 'distance <=', 'km'], ['hr_min', 'heart_rate >=', 'bpm'], ['hr_max', 'heart_rate <=', 'bpm']].forEach(([key, clause, unit]) => {
if (value(key)) lines.push(` and ${clause} ${value(key)} ${unit}`);
});
lines.push(` summarize ${value('metric')}${value('secondary') === 'none' ? '' : ', ' + value('secondary')}`, ` by ${value('group')}`, ` compare ${value('compare') === 'yes' ? 'previous period' : 'none'}`);
return lines.join('\n');
}
form.addEventListener('change', event => { if (event.target !== input) input.value = serialize(); });
toggle.addEventListener('click', () => {
if (!editor.hidden) {
// Apply edited DSL on the server before returning to synchronized guided controls.
if (input.value.trim() !== serialize().trim()) { input.name = 'query'; form.submit(); return; }
}
editor.hidden = !editor.hidden;
toggle.setAttribute('aria-expanded', String(!editor.hidden));
toggle.textContent = editor.hidden ? '〈/〉 Show query' : '〈/〉 Hide query';
});
form.addEventListener('submit', () => { if (!editor.hidden) input.name = 'query'; });
const storageKey = `stride.stats.views.${root.dataset.owner}`;
const views = document.getElementById('saved-views');
let saved = [];
try { const parsed = JSON.parse(localStorage.getItem(storageKey) || '[]'); if (Array.isArray(parsed)) saved = parsed.filter(x => typeof x.name === 'string' && typeof x.query === 'string').slice(0, 12); } catch (_) { /* Storage may be unavailable. */ }
function renderSaved() {
views.replaceChildren();
saved.forEach((view, index) => {
const wrapper = document.createElement('span');
const link = document.createElement('a'); link.textContent = view.name;
link.href = '/stats?' + new URLSearchParams({query: view.query});
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.setAttribute('aria-label', `Remove ${view.name}`);
remove.onclick = () => { saved.splice(index, 1); persist(); renderSaved(); };
wrapper.append(link, remove); views.append(wrapper);
});
}
function persist() { try { localStorage.setItem(storageKey, JSON.stringify(saved)); status.textContent = 'Saved views are stored in this browser.'; } catch (_) { status.textContent = 'Browser storage is unavailable; bookmark this results page to save the question.'; } }
document.getElementById('save-view').onclick = () => {
const name = window.prompt('Name this view', `${value('metric').replaceAll('_', ' ')} · last ${value('weeks')} weeks`);
if (!name || !name.trim()) return;
const query = editor.hidden ? serialize() : input.value;
saved = [{name: name.trim().slice(0, 80), query}, ...saved.filter(v => v.query !== query)].slice(0, 12);
persist(); renderSaved();
};
renderSaved();
const data = document.getElementById('stats-data');
if (!data || typeof Chart === 'undefined') return;
const chartData = JSON.parse(data.textContent);
const pace = n => { const s = Math.round(n); return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`; };
const clear = document.getElementById('clear-selection');
const tbody = document.getElementById('evidence-body');
const original = [...tbody.children].map(row => row.cloneNode(true));
const pagination = root.querySelector('.studio-pagination');
const period = document.getElementById('evidence-period');
const originalPagination = [...pagination.children].map(node => node.cloneNode(true));
let pending = null;
clear.onclick = () => { pending?.abort(); period.value = ''; tbody.replaceChildren(...original.map(row => row.cloneNode(true))); pagination.replaceChildren(...originalPagination.map(node => node.cloneNode(true))); clear.hidden = true; document.getElementById('evidence-heading').textContent = 'Contributing runs'; status.textContent = ''; };
period.onchange = () => { if (period.value) loadBucket(period.value); else clear.click(); };
async function loadBucket(bucket, page = 1) {
pending?.abort();
const controller = new AbortController(); pending = controller;
const url = new URL('/stats/runs', location.origin);
url.search = new URLSearchParams({query:root.dataset.query,bucket,page});
status.textContent = 'Loading contributing runs…';
try {
const response = await fetch(url, {signal:controller.signal});
if (!response.ok) throw new Error();
const result = await response.json();
tbody.replaceChildren();
result.rows.forEach(row => {
const tr = document.createElement('tr'); const td = document.createElement('td');
const a = document.createElement('a'); a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`;
td.append(a); tr.append(td);
[`${row.distance} km`, ...row.values].forEach(text => { const cell = document.createElement('td'); cell.textContent = text; tr.append(cell); });
tbody.append(tr);
});
document.getElementById('evidence-heading').textContent = `${bucket} · ${result.count} runs`;
period.value = bucket;
pagination.replaceChildren();
const label = document.createElement('span'); label.textContent = `Page ${result.page} of ${result.pages} · selected period`; pagination.append(label);
[[result.page > 1, 'Previous', result.page - 1], [result.page < result.pages, 'Next', result.page + 1]].forEach(([show, name, target]) => {
if (!show) return; const button = document.createElement('button'); button.type = 'button'; button.className = 'button secondary'; button.textContent = name; button.onclick = () => loadBucket(bucket, target); pagination.append(button);
});
clear.hidden = false; status.textContent = 'Selected period loaded.';
} catch (error) { if (error.name !== 'AbortError') status.textContent = 'Could not load this period. Try again.'; }
}
chartData.series.forEach((series, index) => {
const canvas = document.getElementById(`stats-chart-${index}`);
if (!canvas) return;
new Chart(canvas, {
type: ['distance', 'duration', 'elevation'].includes(series.key) ? 'bar' : 'line',
data: {labels: chartData.labels, datasets: [
{label:'Current', data:series.values, borderColor:'#f48459', backgroundColor:'#f4845940', borderWidth:2, pointRadius:3, tension:.2, spanGaps:false},
...(series.previous.length ? [{label:'Previous (aligned)', data:series.previous, borderColor:'#9ba4a5', backgroundColor:'#9ba4a526', borderDash:[5,5], borderWidth:2, pointRadius:2, spanGaps:false}] : [])
]},
options: {responsive:true, maintainAspectRatio:false, animation:false,
plugins:{legend:{labels:{color:'#bcc4c5'}}, tooltip:{callbacks:{label:c => `${c.dataset.label}: ${c.parsed.y == null ? '—' : series.key === 'pace' ? pace(c.parsed.y) : c.parsed.y.toFixed(1)} ${series.unit}`}}},
scales:{x:{ticks:{color:'#9ba4a5',maxTicksLimit:5},grid:{display:false}},y:{title:{display:true,text:series.unit,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>series.key==='pace'?pace(n):new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(n)},grid:{color:'#303638'}}},
onClick:(_, elements) => {
const point = elements.find(e => e.datasetIndex === 0); if (!point) return;
const bucket = chartData.labels[point.index];
loadBucket(bucket);
}
}
});
});
})();