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