Extend run stats DSL with statistical and temporal analysis
This commit is contained in:
+48
-17
@@ -7,7 +7,10 @@
|
||||
const validation = document.getElementById('query-validation');
|
||||
const diagnostics = document.getElementById('query-diagnostics');
|
||||
const run = document.getElementById('run-query');
|
||||
const metrics = [...root.querySelectorAll('[data-metric]')].map(button => ({name:button.dataset.metric, unit:button.querySelector('span').textContent}));
|
||||
const language = JSON.parse(document.getElementById('query-language').textContent);
|
||||
const metrics = Object.entries(language.fields).map(([name, field]) => ({name, ...field}));
|
||||
let derivedMetadata = {};
|
||||
const availableMetrics = () => [...metrics, ...getValue().split('\n').map(line => line.match(/^\s*let ([a-z][a-z0-9_]*) = /)?.[1]).filter(name=>name && !names.has(name)).map(name => ({name, unit:derivedMetadata[name]?.unit || 'calculated', units:derivedMetadata[name] ? [derivedMetadata[name].unit] : []}))];
|
||||
const names = new Set(metrics.map(metric => metric.name));
|
||||
let editor = null;
|
||||
let timer = null;
|
||||
@@ -24,6 +27,22 @@
|
||||
['during last 12 weeks', 'Rolling period ending today'],
|
||||
['during last 6 months', 'Calendar months'],
|
||||
['during last 30 days', 'Rolling days'],
|
||||
['during all time', 'Your complete recorded running history'],
|
||||
['let efficiency = speed / heart_rate', 'Reusable per-run arithmetic; missing inputs stay missing'],
|
||||
['where gap_since_previous_run >= 7 days', 'Elapsed time since the previous recorded run'],
|
||||
['or distance >= 20 km', 'Alternative filter; AND binds more strongly than OR'],
|
||||
['summarize count(), median(pace), p90(heart_rate)', 'Counts, percentiles and statistical summaries'],
|
||||
['having count() >= 3 runs', 'Keep groups with at least three runs'],
|
||||
['rank by sum(distance) desc', 'Rank groups by a selected summary expression'],
|
||||
['take 10', 'Keep the first ten sorted runs before grouping'],
|
||||
['limit 10', 'Keep ten groups; most recent for time groups'],
|
||||
['trend rolling 4 buckets', 'Recalculate summaries over four consecutive buckets'],
|
||||
['trend cumulative', 'Recalculate using all earlier displayed buckets'],
|
||||
['trend change', 'Difference from the preceding bucket'],
|
||||
['trend percent_change', 'Percentage change from the preceding bucket'],
|
||||
['plot speed vs heart_rate', 'Run-level scatter plot and Pearson correlation'],
|
||||
['chart bar', 'Choose auto, line, bar or none'],
|
||||
['by distance bins 5 km', 'Group runs into half-open distance ranges'],
|
||||
['during 2026-07-01 to 2026-09-30', 'Inclusive local-date range'],
|
||||
['ending 2026-10-02', 'Optional end date for a rolling period'],
|
||||
['where distance between 8 and 20 km', 'Inclusive distance range'],
|
||||
@@ -46,12 +65,13 @@
|
||||
if (stream.match(/#.*/)) return 'comment';
|
||||
if (stream.match(/"(?:[^"\\]|\\.)*"/)) return 'string';
|
||||
if (stream.match(/\d{4}-\d{2}-\d{2}\b|\d+:\d{2}\b|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/)) return 'number';
|
||||
if (stream.match(/>=|<=|!=|>|<|=/)) return 'operator';
|
||||
if (stream.match(/>=|<=|!=|>|<|=|[+*\/-]/)) return 'operator';
|
||||
if (stream.match(/\/km\b/)) return 'atom';
|
||||
if (stream.match(/[a-zA-Z_]+/)) {
|
||||
if (stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/)) {
|
||||
const word = stream.current();
|
||||
if (names.has(word)) return 'variable-2';
|
||||
if (/^(runs|during|last|ending|where|and|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to)$/.test(word)) return 'keyword';
|
||||
if (names.has(word) || availableMetrics().some(metric=>metric.name===word)) return 'variable-2';
|
||||
if (language.functions.includes(word)) return 'builtin';
|
||||
if (/^(runs|during|last|ending|where|and|or|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to|let|having|rank|take|limit|trend|rolling|cumulative|change|percent_change|plot|vs|chart|bins|all|time)$/.test(word)) return 'keyword';
|
||||
if (/^(day|days|week|weeks|month|months|km|m|bpm|spm|W|ms|h|min|s|date)$/.test(word)) return 'atom';
|
||||
return null;
|
||||
}
|
||||
@@ -64,29 +84,40 @@
|
||||
const before = line.slice(0,line.length-token.length);
|
||||
let list = [];
|
||||
let from = {line:cursor.line,ch:cursor.ch-token.length};
|
||||
if (/^\s*summarize\s/.test(line) || /^\s*sort by\s+\w*$/.test(line) || /^\s*(where|and)\s+(?:(has|missing)\s+)?\w*$/.test(line)) {
|
||||
if (/^\s*having (?:\w+|\w+\(\w*\))\s+$/.test(line)) {
|
||||
from=cursor;list=['>= ','<= ','> ','< ','= ','!= '].map(text=>({text,detail:'Filter grouped summary'}));
|
||||
} else if (/^\s*rank by (?:\w+|\w+\(\w*\))\s+\w*$/.test(line)) {
|
||||
list=['asc','desc'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Group ranking order'}));
|
||||
} else if (/^\s*chart\s+\w*$/.test(line)) {
|
||||
list=['auto','line','bar','none'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Chart presentation'}));
|
||||
} else if (/^\s*trend\s+[\w ]*$/.test(line)) {
|
||||
from={line:cursor.line,ch:line.indexOf('trend')+6};const prefix=line.slice(from.ch);
|
||||
list=['cumulative','rolling 4 buckets','change','percent_change'].filter(text=>text.startsWith(prefix)).map(text=>({text,detail:'Transform displayed time buckets'}));
|
||||
} else if (/^\s*plot\s+\w+\s+$/.test(line)) {
|
||||
from=cursor;list=[{text:'vs ',detail:'Second scatter axis'}];
|
||||
} else if (/^\s*(summarize|having|rank by|let \w+ =)\s/.test(line) || /^\s*plot\s+(?:\w+ vs )?\w*$/.test(line) || /^\s*sort by\s+\w*$/.test(line) || /^\s*(where|and|or)\s+(?:(has|missing)\s+)?\w*$/.test(line)) {
|
||||
const used=/^\s*summarize\s/.test(line)?new Set(before.replace(/^\s*summarize\s+/,'').split(',').map(name=>name.trim())):new Set();
|
||||
list = metrics.filter(metric=>metric.name.startsWith(token)&&!used.has(metric.name)).map(metric=>({text:metric.name, detail:`${metric.unit} · recorded run metric`}));
|
||||
list = availableMetrics().filter(metric=>metric.name.startsWith(token)&&!used.has(metric.name)).map(metric=>({text:metric.name + (/\(\w*$/.test(line) && cm.getLine(cursor.line)[cursor.ch] !== ')' ? ')' : ''), detail:`${metric.unit} · run metric`}));
|
||||
if (/^\s*(summarize|having|rank by)\s/.test(line) && !/\(\w*$/.test(line)) language.functions.filter(name=>name.startsWith(token)).forEach(name=>list.push({text:name==='count'?'count()':name+'(',detail:'Statistical function · available values only'}));
|
||||
if (/sort by/.test(line) && 'date'.startsWith(token)) list.push({text:'date',detail:'Local run date'});
|
||||
if (/^\s*(where|and)\s+\w*$/.test(line)) ['has','missing','title'].forEach(word=>{if(word.startsWith(token))list.push({text:word,detail:'Filter recorded data'});});
|
||||
} else if (/^\s*(where|and)\s+\w+\s*$/.test(line)) {
|
||||
if (/^\s*(where|and|or)\s+\w*$/.test(line)) ['has','missing','title'].forEach(word=>{if(word.startsWith(token))list.push({text:word,detail:'Filter recorded data'});});
|
||||
} else if (/^\s*(where|and|or)\s+\w+\s*$/.test(line)) {
|
||||
from = cursor;
|
||||
list = ['>= ', '<= ', '> ', '< ', '= ', '!= ', 'between '].map(text=>({text,detail:'Comparison operator'}));
|
||||
} else if (/^\s*by\s/.test(line)) {
|
||||
list = ['day','week','month'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Group results'}));
|
||||
list = [...language.groups, ...metrics.map(metric=>`${metric.name} bins 5 ${metric.units[0]}`)].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Group results'}));
|
||||
} else if (/^\s*sort by\s+\w+\s+/.test(line)) {
|
||||
list = ['asc','desc'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Evidence order'}));
|
||||
} else if (/^\s*(where|and)\s+\w+\s+(?:>=|<=|>|<|=|!=|between)\s*$/.test(line)) {
|
||||
} else if (/^\s*(where|and|or)\s+\w+\s+(?:>=|<=|>|<|=|!=|between)\s*$/.test(line)) {
|
||||
const field=line.trim().split(/\s+/)[1];
|
||||
const values={distance:'8 km',pace:'5:30 /km',duration:'45 min',heart_rate:'145 bpm',cadence:'175 spm',power:'250 W',elevation:'100 m',ground_contact:'250 ms',stride_length:'1 m'};
|
||||
const values={distance:'8 km',pace:'5:30 /km',duration:'45 min',heart_rate:'145 bpm',cadence:'175 spm',power:'250 W',elevation:'100 m',ground_contact:'250 ms',stride_length:'1 m',gap_since_previous_run:'7 days',speed:'12 km/h',rolling_7d_distance:'30 km',run_day_streak:'3 days'};
|
||||
const between=/between\s*$/.test(line);
|
||||
const ranges={distance:'8 and 20 km',pace:'5:00 and 6:00 /km',duration:'30 and 60 min',heart_rate:'140 and 150 bpm',cadence:'170 and 185 spm',power:'200 and 300 W',elevation:'50 and 150 m',ground_contact:'230 and 270 ms',stride_length:'0.8 and 1.2 m'};
|
||||
list=values[field]?[{text:between?ranges[field]:values[field],detail:'Example value · adjust to your question'}]:[];
|
||||
const metric=availableMetrics().find(metric=>metric.name===field); const example=values[field] || (metric?.units.length ? `5 ${metric.units[0]}` : null); list=example?[{text:between?(ranges[field] || `5 and 10 ${metric.units[0]}`):example,detail:'Example value · adjust to your question'}]:[];
|
||||
from=cursor;
|
||||
} else if (/^\s*(where|and)\s+\w+\s+(?:>=|<=|>|<|=|!=|between)\s/.test(line) && /\d\s+[^ ]*$/.test(line)) {
|
||||
} else if (/^\s*(where|and|or)\s+\w+\s+(?:>=|<=|>|<|=|!=|between)\s/.test(line) && /\d\s+[^ ]*$/.test(line)) {
|
||||
const field = line.trim().split(/\s+/)[1];
|
||||
const units = {duration:['h','min','s'],distance:['km','m'],pace:['/km']};
|
||||
list = (units[field] || [metrics.find(metric=>metric.name===field)?.unit]).filter(Boolean).filter(text=>text.startsWith(token)).map(text=>({text,detail:'Unit'}));
|
||||
list = (availableMetrics().find(metric=>metric.name===field)?.units || []).filter(text=>text.startsWith(token)).map(text=>({text,detail:'Unit'}));
|
||||
} else {
|
||||
const prefix=line.trimStart();
|
||||
from = {line:cursor.line,ch:0};
|
||||
@@ -135,7 +166,7 @@
|
||||
const result=await response.json();
|
||||
if(number!==requestNumber||text!==getValue())return null;
|
||||
clearErrors();
|
||||
validated={text,...result};run.disabled=!result.valid;
|
||||
validated={text,...result};run.disabled=!result.valid; if(result.valid) derivedMetadata=Object.fromEntries((result.derived||[]).map(spec=>[spec.name,spec]));
|
||||
validation.className=result.valid?'query-valid':'query-invalid';
|
||||
validation.textContent=result.valid?(result.canonical.trim()===root.dataset.query.trim()?'Valid query':'Valid query · changes not run'):'Fix the highlighted clause';
|
||||
(result.diagnostics||[]).forEach(diagnostic=>{
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
#stats-studio .CodeMirror{height:auto;min-height:235px;border:1px solid #465054;border-radius:9px;background:#111719;color:#e0e5e6;font:15px/1.9 Consolas,"Cascadia Code",monospace;overflow:hidden}#stats-studio .CodeMirror-scroll{max-height:420px;min-height:235px}#stats-studio .CodeMirror-lines{padding:16px 0}#stats-studio .CodeMirror-gutters{background:#151c1f;border-right:1px solid #2c363b}#stats-studio .CodeMirror-linenumber{color:#76888e;padding:0 8px 0 6px}#stats-studio .query-errors{width:16px}#stats-studio .CodeMirror-cursor{border-left-color:#f5f2ed}#stats-studio .CodeMirror-selected{background:#36484e}#stats-studio .cm-keyword{color:#ffa777}#stats-studio .cm-variable-2{color:#a9d8ea}#stats-studio .cm-number{color:#d7c9f4}#stats-studio .cm-atom{color:#b9d895}#stats-studio .cm-string{color:#bbdca7}#stats-studio .cm-comment{color:#8b999e;font-style:italic}#stats-studio .cm-operator{color:#efce98}.CodeMirror-hints{background:#212b30;color:#e2e8e8;border:1px solid #4e6168;border-radius:8px;padding:5px;max-width:min(410px,calc(100vw - 28px));max-height:260px;box-shadow:0 12px 32px #0006;z-index:1200}.CodeMirror-hint{color:#e2e8e8;padding:8px 10px;display:flex;flex-direction:column;gap:2px;white-space:normal;font:13px/1.5 Consolas,monospace;border-radius:5px}.CodeMirror-hint small{color:#a6bac2;font:11px/1.5 "Segoe UI",sans-serif}.CodeMirror-hint-active{background:#3c4f54;color:#fff}.CodeMirror-hint-active small{color:#d0dfe4}
|
||||
@media(max-width:680px){.query-reference{grid-template-columns:1fr}.query-actions kbd{display:none}.query-shortcut{display:none}#stats-studio .CodeMirror{font-size:14px}.metric-reference{grid-template-columns:1fr 1fr}.query-actions .button{font-size:11px}}
|
||||
@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%}}
|
||||
|
||||
.studio-groups,.studio-scatter{margin-top:24px}.studio-scatter .studio-chart{padding:0 26px 24px}.studio-groups .section-head,.studio-scatter .section-head{flex-wrap:wrap;gap:12px}.group-drill{background:none;color:var(--ink);padding:0;text-align:left;border-bottom:1px dotted var(--green);font:inherit;cursor:pointer}.group-drill:hover{color:var(--green)}.previous-run{display:block;color:var(--muted);margin-top:8px;font-size:11px}.previous-run a{font-size:11px}#stats-studio .cm-builtin{color:#e9bfec}.studio-groups .activity-table-wrap{max-height:480px;overflow:auto}.studio-groups th{position:sticky;top:0;background:var(--white);z-index:1}
|
||||
|
||||
.scatter-wrap{height:320px;margin:0 26px 20px;min-width:0}@media(max-width:680px){.scatter-wrap{height:280px;margin:0 16px 18px}}
|
||||
|
||||
+19
-6
@@ -43,11 +43,11 @@
|
||||
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) {
|
||||
async function loadBucket(bucket, page = 1, scope = 'trend') {
|
||||
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});
|
||||
url.search = new URLSearchParams({query:root.dataset.query,bucket,page,scope});
|
||||
status.textContent = 'Loading contributing runs…';
|
||||
try {
|
||||
const response = await fetch(url, {signal:controller.signal});
|
||||
@@ -58,6 +58,12 @@
|
||||
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);
|
||||
if (root.dataset.showPrevious === 'true') {
|
||||
const detail=document.createElement('small');detail.className='previous-run';
|
||||
detail.append('Previous: ');
|
||||
if(row.previous_run){const previous=document.createElement('a');previous.href=`/activities/${row.previous_run.id}`;previous.textContent=`${row.previous_run.date} · ${row.previous_run.title}`;detail.append(previous);}else detail.append('No earlier recorded run');
|
||||
td.append(detail);
|
||||
}
|
||||
[`${row.distance} km`, ...row.values].forEach(text => { const cell = document.createElement('td'); cell.textContent = text; tr.append(cell); });
|
||||
tbody.append(tr);
|
||||
});
|
||||
@@ -66,23 +72,24 @@
|
||||
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);
|
||||
if (!show) return; const button = document.createElement('button'); button.type = 'button'; button.className = 'button secondary'; button.textContent = name; button.onclick = () => loadBucket(bucket, target, scope); 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.'; }
|
||||
}
|
||||
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: ['distance', 'duration', 'elevation'].includes(series.key) ? 'bar' : 'line',
|
||||
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.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'}}},
|
||||
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];
|
||||
@@ -91,4 +98,10 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
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}`);}}}});
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user