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=>{
|
||||
|
||||
Reference in New Issue
Block a user