Files
Stride/app/static/stats.js
T

118 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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);
}
}
});
});
})();