(() => { '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(); })();