Files
Stride/app/static/stats-editor.js
T

252 lines
18 KiB
JavaScript

(() => {
'use strict';
const root = document.getElementById('stats-studio');
if (!root) return;
const form = document.getElementById('explorer-form');
const input = document.getElementById('query-input');
const validation = document.getElementById('query-validation');
const diagnostics = document.getElementById('query-diagnostics');
const run = document.getElementById('run-query');
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;
let controller = null;
let marks = [];
let validated = null;
let requestNumber = 0;
const getValue = () => editor ? editor.getValue() : input.value;
const setValue = text => { if (editor) editor.setValue(text); else { input.value=text; changed(); } };
window.strideQueryEditor = {getValue, setValue};
const templates = [
['runs', 'Your recorded running activities'],
['laps', 'Recorded lap inputs from compact analysis'],
['segments', 'Five-minute moving segments from full-resolution recordings'],
['intervals', 'Continuous 30-second moving windows, with elapsed offset and terrain'],
['efforts', 'Recorded best-effort windows at 1, 2, 5 and 10 km'],
['days', 'Daily workload and rest days, using local calendar windows'],
['health', 'Private daily recovery observations from Garmin'],
['reduce by run min 3 min', 'Match recording windows first; one observation per qualifying session'],
['long run distance >= 10 km', 'Choose the qualifying long-run threshold for daily continuity'],
['route similar to 1', 'Approximate full-course match to one of your own recorded routes'],
['where elapsed >= 10 min', 'Exclude the first ten elapsed recording minutes'],
['and grade between -2 and 2 %', 'Require approximately flat smoothed terrain'],
['cohort "Easy" where heart_rate <= 145 bpm', 'First-match partitions for a stacked additive summary'],
['compare previous year', 'Same local-date interval in the preceding year'],
['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, area, histogram, box, heatmap or none'],
['display overlay', 'Plot multiple series together with axes by unit'],
['baseline previous 90 days', 'Earlier matched-run baseline before the query starts'],
['flag above baseline 1.5 sd min 3 runs sustained 2 buckets', 'Persistent descriptive deviation with minimum coverage'],
['color by elevation_per_km', 'Scatter color encodes this numeric metric'],
['size by distance', 'Scatter size encodes this numeric metric'],
['reference heart_rate = 150 bpm', 'Draw a reference line for a selected metric'],
['let hr_14d = rolling_mean(heart_rate, 14 days)', 'Custom rolling mean, sum or count before filtering'],
['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'],
['where pace <= 5:30 /km', 'Faster than or equal to this pace'],
['and heart_rate between 140 and 150 bpm', 'Run-average heart rate'],
['and duration >= 45 min', 'Recorded timer duration'],
['and has cadence', 'Require recorded cadence'],
['and missing power', 'Find runs without power data'],
['and title contains "River"', 'Case-insensitive title search'],
['summarize pace, heart_rate, cadence', 'Up to six metrics'],
['by day', 'Daily buckets'], ['by week', 'Weeks start on Monday'], ['by month', 'Calendar months'],
['compare previous period', 'Equal-duration preceding period'], ['compare none', 'Current period only'],
['sort by pace asc', 'Fastest runs first'], ['sort by distance desc', 'Longest runs first'],
['sort by date desc', 'Newest runs first']
];
if (typeof CodeMirror !== 'undefined') {
CodeMirror.defineMode('stride-query', () => ({token(stream) {
if (stream.eatSpace()) return null;
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(/\/km\b/)) return 'atom';
if (stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/)) {
const word = stream.current();
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|baseline|flag|sd|min|sustained|buckets|above|below|outside|display|overlay|separate|color|size|reference|as)$/.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;
}
stream.next(); return null;
}}));
function hints(cm) {
const cursor = cm.getCursor();
const line = cm.getLine(cursor.line).slice(0,cursor.ch);
const token = line.match(/[\w/]*$/)[0];
const before = line.slice(0,line.length-token.length);
let list = [];
let from = {line:cursor.line,ch:cursor.ch-token.length};
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','area','histogram','box','heatmap','stacked','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 = 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|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 = [...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|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',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'};
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|or)\s+\w+\s+(?:>=|<=|>|<|=|!=|between)\s/.test(line) && /\d\s+[^ ]*$/.test(line)) {
const field = line.trim().split(/\s+/)[1];
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};
list = templates.filter(([text])=>text.startsWith(prefix)).map(([text,detail])=>({text:(text==='runs'?'':' ')+text,detail}));
}
return {list:list.map(item=>({...item,render(element){const label=document.createElement('span');label.textContent=item.text.trim();const detail=document.createElement('small');detail.textContent=item.detail;element.append(label,detail);}})),from,to:cursor};
}
const suggest = cm => cm.showHint({hint:hints,completeSingle:false});
editor = CodeMirror.fromTextArea(input, {
mode:'stride-query',lineNumbers:true,lineWrapping:true,indentUnit:2,tabSize:2,
gutters:['CodeMirror-linenumbers','query-errors'],autofocus:false,
extraKeys:{'Ctrl-Space':suggest,'Cmd-Space':suggest,'Ctrl-Enter':()=>form.requestSubmit(),'Cmd-Enter':()=>form.requestSubmit(),
Tab:cm=>{if(cm.somethingSelected())cm.indentSelection('add');else cm.replaceSelection(' ','end');},
'Shift-Tab':cm=>cm.indentSelection('subtract'),Esc:()=>run.focus()}
});
editor.getInputField().setAttribute('aria-label','Running stats query');
editor.getInputField().setAttribute('aria-describedby','query-validation query-diagnostics');
editor.on('change',changed);
editor.on('inputRead',(cm,change)=>{if(change.origin==='+input' && change.text.join('').match(/[\w, <>/=]$/))suggest(cm);});
document.getElementById('complete-query').onclick=()=>{editor.focus();suggest(editor);};
} else {
input.addEventListener('input',changed);
document.getElementById('complete-query').disabled=true;
input.addEventListener('keydown',event=>{if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();form.requestSubmit();}});
}
function clearErrors() {
marks.forEach(mark=>mark.clear()); marks=[];
if(editor)editor.clearGutter('query-errors');
diagnostics.replaceChildren();
}
function changed() {
clearTimeout(timer); controller?.abort(); requestNumber++;
validated=null; run.disabled=false; clearErrors();
validation.className='';validation.textContent='Checking query…';
timer=setTimeout(()=>validate(),300);
}
async function validate() {
clearTimeout(timer); controller?.abort();
controller=new AbortController();
const number=++requestNumber;
const text=getValue();
try {
const response=await fetch(root.dataset.validateUrl,{method:'POST',headers:{'Content-Type':'application/json','X-CSRFToken':root.dataset.csrf},body:JSON.stringify({query:text}),signal:controller.signal});
if(!response.ok)throw new Error();
const result=await response.json();
if(number!==requestNumber||text!==getValue())return null;
clearErrors();
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=>{
const line=Math.max(0,Math.min((editor?editor.lineCount():text.split('\n').length)-1,diagnostic.line-1));
const button=document.createElement('button');button.type='button';button.className='query-diagnostic';button.textContent=`Line ${line+1}: ${diagnostic.message}`;
button.onclick=()=>{if(editor){editor.focus();editor.setCursor({line,ch:0});}else input.focus();};diagnostics.append(button);
if(editor){const marker=document.createElement('span');marker.className='query-error-dot';marker.textContent='●';marker.setAttribute('aria-label',diagnostic.message);editor.setGutterMarker(line,'query-errors',marker);marks.push(editor.markText({line,ch:0},{line,ch:editor.getLine(line).length},{className:'query-error-text'}));}
});
return validated;
} catch(error) {
if(error.name==='AbortError')return null;
if(number!==requestNumber)return null;
validated=null;run.disabled=false;validation.className='query-invalid';validation.textContent='Live validation unavailable · Run query will validate on the server';
return {valid:true,text};
}
}
form.addEventListener('submit',async event=>{
event.preventDefault();
const text=getValue();
const result=validated?.text===text?validated:await validate();
if(!result?.valid||getValue()!==text)return;
if(editor)editor.save();else input.value=text;
// Submit only the authoritative DSL. Helper controls never override it.
const url=new URL(form.action||location.pathname,location.origin);
url.search=new URLSearchParams({query:text});
const analysis=new URL(location.href).searchParams.get('analysis');if(analysis)url.searchParams.set('analysis',analysis);
location.assign(url);
});
document.getElementById('format-query').onclick=async()=>{
const result=validated?.text===getValue()?validated:await validate();
if(!result?.valid||!result.canonical)return;
const comments=getValue().split('\n').filter(line=>line.trim().startsWith('#'));
setValue([...comments,result.canonical].join('\n'));if(editor)editor.focus();
};
root.querySelectorAll('[data-metric]').forEach(button=>button.onclick=()=>{
if(editor){editor.replaceSelection(button.dataset.metric);editor.focus();}
else{input.setRangeText(button.dataset.metric,input.selectionStart,input.selectionEnd,'end');changed();input.focus();}
});
document.getElementById('guided-controls').addEventListener('change',event=>{
const field=event.target.name;let lines=getValue().split('\n');
const replace=(pattern,text)=>{const index=lines.findIndex(line=>pattern.test(line.trim()));if(index<0){if(text)lines.push(' '+text);}else if(text)lines[index]=' '+text;else lines.splice(index,1);};
const fieldValue=name=>form.elements.namedItem(name).value;
if(field==='metric'||field==='secondary'){
const existing=lines.find(line=>/^\s*summarize /.test(line))?.trim().slice(10).split(',').map(key=>key.trim())||[];
const selected=[fieldValue('metric'),...(fieldValue('secondary')==='none'?[]:[fieldValue('secondary')]),...existing.slice(2)];
replace(/^summarize /,'summarize '+[...new Set(selected)].join(', '));
}else if(field==='weeks')replace(/^during /,`during last ${fieldValue('weeks')} weeks`);
else if(field==='group')replace(/^by /,`by ${fieldValue('group')}`);
else if(field==='compare')replace(/^compare /,`compare ${fieldValue('compare')==='yes'?'previous period':'none'}`);
else if(field==='end'){
if(lines.some(line=>/^\s*during \d{4}/.test(line))){validation.textContent='Edit the end of the explicit date range in the query.';return;}
replace(/^ending /,fieldValue('end')?`ending ${fieldValue('end')}`:'');
}else{
const filters={minimum:['distance','>=','km'],maximum:['distance','<=','km'],hr_min:['heart_rate','>=','bpm'],hr_max:['heart_rate','<=','bpm']};
if(!filters[field])return;
const [metric,op,unit]=filters[field];
replace(new RegExp(`^(?:where|and) ${metric} ${op} `),fieldValue(field)?`and ${metric} ${op} ${fieldValue(field)} ${unit}`:'');
}
setValue(lines.join('\n'));
});
validate();
})();