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

200 lines
13 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 metrics = [...root.querySelectorAll('[data-metric]')].map(button => ({name:button.dataset.metric, unit:button.querySelector('span').textContent}));
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'],
['during last 12 weeks', 'Rolling period ending today'],
['during last 6 months', 'Calendar months'],
['during last 30 days', 'Rolling days'],
['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_]+/)) {
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 (/^(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*summarize\s/.test(line) || /^\s*sort by\s+\w*$/.test(line) || /^\s*(where|and)\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`}));
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)) {
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'}));
} 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)) {
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 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'}]:[];
from=cursor;
} else if (/^\s*(where|and)\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'}));
} 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;
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});
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();
})();