Add saved analysis workspace and live dashboard statistics
This commit is contained in:
+11
-57
@@ -2,48 +2,22 @@
|
||||
'use strict';
|
||||
const root = document.getElementById('stats-studio');
|
||||
if (!root) return;
|
||||
const form = document.getElementById('explorer-form');
|
||||
const input = document.getElementById('query-input');
|
||||
const status = document.getElementById('studio-status');
|
||||
const value = name => form.elements.namedItem(name).value;
|
||||
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 = window.strideQueryEditor ? window.strideQueryEditor.getValue() : 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');
|
||||
let selectionScope = 'trend';
|
||||
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(); };
|
||||
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 ${root.dataset.source}`; status.textContent = ''; };
|
||||
period.onchange = () => { if (period.value) loadBucket(period.value,1,selectionScope); else clear.click(); };
|
||||
async function loadBucket(bucket, page = 1, scope = 'trend') {
|
||||
selectionScope=scope;
|
||||
pending?.abort();
|
||||
const controller = new AbortController(); pending = controller;
|
||||
const url = new URL('/stats/runs', location.origin);
|
||||
@@ -67,7 +41,7 @@
|
||||
[`${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`;
|
||||
document.getElementById('evidence-heading').textContent = `${bucket} · ${result.count} ${root.dataset.source}`;
|
||||
period.value = bucket;
|
||||
pagination.replaceChildren();
|
||||
const label = document.createElement('span'); label.textContent = `Page ${result.page} of ${result.pages} · selected period`; pagination.append(label);
|
||||
@@ -78,30 +52,10 @@
|
||||
} catch (error) { if (error.name !== 'AbortError') status.textContent = 'Could not load this period. Try again.'; }
|
||||
}
|
||||
root.querySelectorAll('[data-bucket]').forEach(button=>button.onclick=()=>loadBucket(button.dataset.bucket,1,'group'));
|
||||
chartData.series.forEach((series, index) => {
|
||||
const canvas = document.getElementById(`stats-chart-${index}`);
|
||||
if (!canvas) return;
|
||||
new Chart(canvas, {
|
||||
type: chartData.type === 'auto' ? (['bin','weekday','hour','run','all'].includes(chartData.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line') : chartData.type,
|
||||
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.pace_axis ? pace(c.parsed.y) : c.parsed.y.toFixed(2)} ${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.pace_axis?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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const scatter=chartData.scatter;
|
||||
const scatterCanvas=document.getElementById('stats-scatter');
|
||||
if(scatter&&scatterCanvas){
|
||||
const numeric=(n,info)=>info[1]==='/km'?pace(n):new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(n);
|
||||
new Chart(scatterCanvas,{type:'scatter',data:{datasets:[{label:'Current',data:scatter.current,backgroundColor:'#f48459',pointRadius:4},...(scatter.previous.length?[{label:'Previous',data:scatter.previous,backgroundColor:'#9ba4a5',pointRadius:3}]:[])]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#bcc4c5'}},tooltip:{callbacks:{label:c=>`${c.raw.label}: ${numeric(c.raw.x,scatter.x)} ${scatter.x[1]} / ${numeric(c.raw.y,scatter.y)} ${scatter.y[1]}`}}},scales:{x:{title:{display:true,text:`${scatter.x[0]} (${scatter.x[1]})`,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>numeric(n,scatter.x)},grid:{color:'#303638'}},y:{title:{display:true,text:`${scatter.y[0]} (${scatter.y[1]})`,color:'#9ba4a5'},ticks:{color:'#9ba4a5',callback:n=>numeric(n,scatter.y)},grid:{color:'#303638'}}},onClick:(_,elements,chart)=>{const point=elements[0];if(point){const row=chart.data.datasets[point.datasetIndex].data[point.index];location.assign(`/activities/${row.id}`);}}}});
|
||||
}
|
||||
const plots=document.getElementById('analysis-plots');
|
||||
if(plots)StrideCharts.render(plots,chartData,bucket=>loadBucket(bucket));
|
||||
const insightPanel=document.getElementById('analysis-insights');
|
||||
if(insightPanel)StrideCharts.insights(insightPanel,JSON.parse(document.getElementById('insights-data').textContent),bucket=>loadBucket(bucket,1,'group'));
|
||||
const initialBucket=new URL(location.href).searchParams.get('bucket');
|
||||
if(initialBucket)loadBucket(initialBucket,1,new URL(location.href).searchParams.get('scope')||'trend');
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user