Make stats DSL primary with highlighted editor, completion and live diagnostics

This commit is contained in:
Peter Stockings
2026-10-02 16:15:38 +10:00
parent f951e888bd
commit 987f470ea5
14 changed files with 11534 additions and 105 deletions
+30 -10
View File
@@ -49,17 +49,37 @@ migrations. SQLite and deployed PostgreSQL migrations have been verified; see
### Exploring running stats
Open **Stats** for the guided question builder: select up to two metrics, filter
by distance and run-average heart rate, group by week or month, and compare an
equally long preceding period. Periods use your account timezone and end today
unless you choose an end date. The explorer covers whole runs and uses persisted
summaries without loading recording samples. Metric definitions and the number
of runs with usable data appear alongside each result.
Open **Stats** for the query editor with syntax highlighting, contextual
autocomplete, and live validation from the execution parser. Use **Ctrl+Space**
for suggestions and **Ctrl/Cmd+Enter** to run. **Format** normalizes a valid query;
invalid drafts retain the displayed results. Guided controls and the language
reference remain available below the editor. CodeMirror 5.65.20 and its completion
addon are vendored locally under the MIT license; the editor needs no CDN access.
**Show query** exposes the same question as an editable DSL. Applying it updates
the guided controls. Select a chart point or use the evidence period picker to
inspect contributing runs. Save named views in this browser, or bookmark the
results URL; saved views follow relative dates as new runs are imported.
Queries support up to six metrics, numeric comparisons and inclusive ranges,
case-insensitive title search, `has`/`missing` sensor filters, daily/weekly/monthly
grouping, and evidence sorting. Periods use your account timezone: rolling days,
weeks, calendar months, or inclusive explicit dates. Comparisons cover an equally
long preceding period. Pace filters accept values such as `5:30 /km`; duration
accepts `h`, `min`, or `s`, and distance accepts `km` or `m`.
```text
runs
during last 6 months
where distance between 8 and 20 km
and pace <= 5:30 /km
and has cadence
summarize pace, heart_rate, cadence
by month
compare previous period
sort by pace asc
```
The explorer covers whole runs and uses persisted summaries without loading
recording samples. Metric definitions and usable-run coverage appear with each
result. Select a chart point or the evidence period picker to inspect contributing
runs. Save named views in this browser, or bookmark the results URL; relative
queries follow the calendar as new runs are imported.
### Bringing in more activities
+18 -1
View File
@@ -29,7 +29,7 @@ from .extensions import db, limiter
from .history import activity_page, latest_mapped_run, overview, records_for
from .models import Activity, Friendship, GarminConnection, User
from .services import activity_data, import_cache, import_upload, route_data
from .stats import DEFAULT, METRICS, explore, parse_query, query_text
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
@@ -242,6 +242,23 @@ def stats_runs():
)
@web.post("/stats/validate")
@login_required
@limiter.limit("120 per minute")
def stats_validate():
payload = request.get_json(silent=True)
if not isinstance(payload, dict) or not isinstance(payload.get("query"), str):
return jsonify(valid=False, diagnostics=[{"line": 1, "message": "Supply query text."}]), 400
try:
query = parse_query({"query": payload["query"]})
period_bounds(query, current_user.timezone)
return jsonify(valid=True, canonical=query_text(query), diagnostics=[])
except (ValueError, OverflowError) as exc:
return jsonify(
valid=False, diagnostics=[{"line": getattr(exc, "line", 1), "message": str(exc)}]
)
@web.route("/friends", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])
+199
View File
@@ -0,0 +1,199 @@
(() => {
'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();
})();
+3
View File
@@ -1,4 +1,7 @@
.stats-nav{padding:9px 13px;border-radius:5px;color:var(--muted);font-size:13px}.stats-nav.active,.stats-nav:hover{background:#292f32;color:var(--ink)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.question-panel{padding:26px;background:linear-gradient(120deg,#232a2d,var(--white));border-top:2px solid var(--green)}.studio-row{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.studio-row .eyebrow{margin:0}.studio-sentence{font-size:23px;line-height:2.4;letter-spacing:-.4px;margin:16px 0 22px}.studio-sentence select,.studio-sentence input{background:#343532;color:#ffc1a5;border:1px solid #605043;border-radius:7px;padding:8px 12px;font-size:17px;max-width:100%}.studio-sentence input{width:80px}.studio-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;border-top:1px solid var(--line);padding-top:20px}.studio-filters label{font-size:12px;color:var(--muted)}.studio-filters input,.studio-filters select{display:block;width:100%;margin-top:7px;padding:10px 12px;background:var(--paper);color:var(--ink);border:1px solid #485154;border-radius:7px;color-scheme:dark}.studio-filters label span{font-size:11px}.studio-bottom{margin-top:24px}.studio-presets{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:18px 0 28px}.studio-presets:empty{display:none}.studio-presets a{font-size:12px;border:1px solid #485154;padding:7px 12px;border-radius:20px;background:var(--white)}.studio-presets a:hover{border-color:var(--green)}.studio-presets button{background:none;color:var(--muted);padding:4px}.studio-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:20px 0}.studio-metrics .stat-value{font-size:36px}.studio-metrics .stat-card p{font-size:12px;color:var(--muted)}.studio-metrics details{font-size:12px;margin-top:18px;color:var(--muted)}.studio-metrics summary{cursor:pointer;color:var(--ink)}.studio-metrics details p{margin-top:8px}.result-heading p{font-size:12px;color:var(--muted);margin-top:8px}.studio-charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;padding:14px 26px 20px}.studio-charts:has(.studio-chart:only-child){grid-template-columns:1fr}.studio-chart{min-width:0}.studio-chart h3{font-size:14px}.studio-chart h3 small{color:var(--muted);font-weight:400}.studio-chart>div{height:260px;margin-top:16px}.studio-chart-help{font-size:12px;color:var(--muted);padding:0 26px 24px}.studio-evidence{margin-top:24px}.studio-pagination{display:flex;align-items:center;justify-content:flex-end;gap:12px;padding:18px 24px;flex-wrap:wrap;font-size:12px;color:var(--muted)}.studio-pagination span{margin-right:auto}.studio-empty{padding:30px;margin-top:20px}.studio-empty p{color:var(--muted);margin-top:10px}#query-editor{margin-top:20px}#query-editor label{font-size:12px;color:var(--muted)}#query-input{display:block;resize:vertical;width:100%;padding:16px;border:1px solid #485154;border-radius:8px;margin:8px 0;background:var(--paper);color:var(--ink);font:14px/1.8 Consolas,monospace}.studio-error{margin-top:16px;color:#ffb5a1}#studio-status{margin-top:16px}.studio-chart canvas{cursor:pointer}
.studio-period{font-size:12px;color:var(--muted)}.studio-period select{padding:8px;border:1px solid #485154;border-radius:6px;background:var(--paper);color:var(--ink);margin-left:8px}
.query-caption{display:block;color:var(--muted);font-size:12px;margin-top:5px}.query-actions{display:flex;gap:8px;flex-wrap:wrap}.query-actions .button{padding:8px 12px}.query-actions kbd{margin-left:5px;color:var(--muted);font-size:11px;font-weight:400}#query-editor{margin-top:20px}.query-state{margin-top:12px;font-size:12px;color:var(--muted)}.query-valid{color:#9cd5ac}.query-invalid{color:#ffb5a1}.query-shortcut{font-size:11px}.query-help{margin-top:22px;padding-top:16px;border-top:1px solid var(--line)}.query-help summary{cursor:pointer;color:var(--muted);font-size:13px}.query-help[open] summary{color:var(--ink);margin-bottom:16px}.query-reference{display:grid;grid-template-columns:1fr 1fr;gap:28px}.query-reference h3{font-size:14px;margin-bottom:12px}.query-reference pre{font:13px/1.8 Consolas,monospace;color:#d8dcdf;white-space:pre-wrap}.metric-reference{display:grid;grid-template-columns:1fr 1fr;gap:8px}.metric-insert{background:#232b2e;border:1px solid #394448;border-radius:6px;text-align:left;padding:8px 10px;display:flex;justify-content:space-between;gap:8px;color:#b8dbe4;min-width:0;font-size:12px}.metric-insert code{overflow-wrap:anywhere}.metric-insert span{color:var(--muted);font-size:11px}.metric-insert:hover{border-color:var(--green)}.query-help .field-help{margin:12px 0}.query-diagnostic{display:block;width:100%;text-align:left;background:#3a2724;color:#ffc1ad;padding:10px 12px;border-radius:6px;margin-top:10px;font-size:13px}.query-error-dot{color:#ff9c86;font-size:11px}.query-error-text{text-decoration:underline wavy #ff9c86;text-underline-offset:5px}#run-query:disabled{opacity:.5;cursor:not-allowed}
#stats-studio .CodeMirror{height:auto;min-height:235px;border:1px solid #465054;border-radius:9px;background:#111719;color:#e0e5e6;font:15px/1.9 Consolas,"Cascadia Code",monospace;overflow:hidden}#stats-studio .CodeMirror-scroll{max-height:420px;min-height:235px}#stats-studio .CodeMirror-lines{padding:16px 0}#stats-studio .CodeMirror-gutters{background:#151c1f;border-right:1px solid #2c363b}#stats-studio .CodeMirror-linenumber{color:#76888e;padding:0 8px 0 6px}#stats-studio .query-errors{width:16px}#stats-studio .CodeMirror-cursor{border-left-color:#f5f2ed}#stats-studio .CodeMirror-selected{background:#36484e}#stats-studio .cm-keyword{color:#ffa777}#stats-studio .cm-variable-2{color:#a9d8ea}#stats-studio .cm-number{color:#d7c9f4}#stats-studio .cm-atom{color:#b9d895}#stats-studio .cm-string{color:#bbdca7}#stats-studio .cm-comment{color:#8b999e;font-style:italic}#stats-studio .cm-operator{color:#efce98}.CodeMirror-hints{background:#212b30;color:#e2e8e8;border:1px solid #4e6168;border-radius:8px;padding:5px;max-width:min(410px,calc(100vw - 28px));max-height:260px;box-shadow:0 12px 32px #0006;z-index:1200}.CodeMirror-hint{color:#e2e8e8;padding:8px 10px;display:flex;flex-direction:column;gap:2px;white-space:normal;font:13px/1.5 Consolas,monospace;border-radius:5px}.CodeMirror-hint small{color:#a6bac2;font:11px/1.5 "Segoe UI",sans-serif}.CodeMirror-hint-active{background:#3c4f54;color:#fff}.CodeMirror-hint-active small{color:#d0dfe4}
@media(max-width:680px){.query-reference{grid-template-columns:1fr}.query-actions kbd{display:none}.query-shortcut{display:none}#stats-studio .CodeMirror{font-size:14px}.metric-reference{grid-template-columns:1fr 1fr}.query-actions .button{font-size:11px}}
@media(max-width:900px){.studio-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.studio-charts{grid-template-columns:1fr}}@media(max-width:680px){.question-panel{padding:18px}.studio-sentence{font-size:18px}.studio-sentence select{font-size:16px;padding:7px}.studio-filters{grid-template-columns:1fr 1fr;gap:12px}.studio-metrics{grid-template-columns:1fr}.stats-nav{padding:8px;font-size:12px}.app-header{gap:6px}.studio-charts{padding:16px}.studio-chart-help{padding:0 16px 18px}.studio-evidence .section-head{flex-wrap:wrap}.studio-row .field-help{max-width:100%}}
+1 -24
View File
@@ -3,32 +3,9 @@
const root = document.getElementById('stats-studio');
if (!root) return;
const form = document.getElementById('explorer-form');
const editor = document.getElementById('query-editor');
const input = document.getElementById('query-input');
const toggle = document.getElementById('query-toggle');
const status = document.getElementById('studio-status');
const value = name => form.elements.namedItem(name).value;
function serialize() {
const lines = ['runs', ` during last ${value('weeks')} weeks`];
if (value('end')) lines.push(` ending ${value('end')}`);
lines.push(` where distance >= ${value('minimum') || 0} km`);
[['maximum', 'distance <=', 'km'], ['hr_min', 'heart_rate >=', 'bpm'], ['hr_max', 'heart_rate <=', 'bpm']].forEach(([key, clause, unit]) => {
if (value(key)) lines.push(` and ${clause} ${value(key)} ${unit}`);
});
lines.push(` summarize ${value('metric')}${value('secondary') === 'none' ? '' : ', ' + value('secondary')}`, ` by ${value('group')}`, ` compare ${value('compare') === 'yes' ? 'previous period' : 'none'}`);
return lines.join('\n');
}
form.addEventListener('change', event => { if (event.target !== input) input.value = serialize(); });
toggle.addEventListener('click', () => {
if (!editor.hidden) {
// Apply edited DSL on the server before returning to synchronized guided controls.
if (input.value.trim() !== serialize().trim()) { input.name = 'query'; form.submit(); return; }
}
editor.hidden = !editor.hidden;
toggle.setAttribute('aria-expanded', String(!editor.hidden));
toggle.textContent = editor.hidden ? '〈/〉 Show query' : '〈/〉 Hide query';
});
form.addEventListener('submit', () => { if (!editor.hidden) input.name = 'query'; });
const storageKey = `stride.stats.views.${root.dataset.owner}`;
const views = document.getElementById('saved-views');
let saved = [];
@@ -48,7 +25,7 @@
document.getElementById('save-view').onclick = () => {
const name = window.prompt('Name this view', `${value('metric').replaceAll('_', ' ')} · last ${value('weeks')} weeks`);
if (!name || !name.trim()) return;
const query = editor.hidden ? serialize() : input.value;
const query = window.strideQueryEditor ? window.strideQueryEditor.getValue() : input.value;
saved = [{name: name.trim().slice(0, 80), query}, ...saved.filter(v => v.query !== query)].slice(0, 12);
persist(); renderSaved();
};
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+344
View File
@@ -0,0 +1,344 @@
/* BASICS */
.CodeMirror {
/* Set height, width, borders, and global font properties here */
font-family: monospace;
height: 300px;
color: black;
direction: ltr;
}
/* PADDING */
.CodeMirror-lines {
padding: 4px 0; /* Vertical padding around content */
}
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
padding: 0 4px; /* Horizontal padding of content */
}
.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: #f7f7f7;
white-space: nowrap;
}
.CodeMirror-linenumbers {}
.CodeMirror-linenumber {
padding: 0 3px 0 5px;
min-width: 20px;
text-align: right;
color: #999;
white-space: nowrap;
}
.CodeMirror-guttermarker { color: black; }
.CodeMirror-guttermarker-subtle { color: #999; }
/* CURSOR */
.CodeMirror-cursor {
border-left: 1px solid black;
border-right: none;
width: 0;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
border: 0 !important;
background: #7e7;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-fat-cursor .CodeMirror-line::selection,
.cm-fat-cursor .CodeMirror-line > span::selection,
.cm-fat-cursor .CodeMirror-line > span > span::selection { background: transparent; }
.cm-fat-cursor .CodeMirror-line::-moz-selection,
.cm-fat-cursor .CodeMirror-line > span::-moz-selection,
.cm-fat-cursor .CodeMirror-line > span > span::-moz-selection { background: transparent; }
.cm-fat-cursor { caret-color: transparent; }
@-moz-keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
@-webkit-keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
@keyframes blink {
0% {}
50% { background-color: transparent; }
100% {}
}
/* Can style cursor different in overwrite (non-insert) mode */
.CodeMirror-overwrite .CodeMirror-cursor {}
.cm-tab { display: inline-block; text-decoration: inherit; }
.CodeMirror-rulers {
position: absolute;
left: 0; right: 0; top: -50px; bottom: 0;
overflow: hidden;
}
.CodeMirror-ruler {
border-left: 1px solid #ccc;
top: 0; bottom: 0;
position: absolute;
}
/* DEFAULT THEME */
.cm-s-default .cm-header {color: blue;}
.cm-s-default .cm-quote {color: #090;}
.cm-negative {color: #d44;}
.cm-positive {color: #292;}
.cm-header, .cm-strong {font-weight: bold;}
.cm-em {font-style: italic;}
.cm-link {text-decoration: underline;}
.cm-strikethrough {text-decoration: line-through;}
.cm-s-default .cm-keyword {color: #708;}
.cm-s-default .cm-atom {color: #219;}
.cm-s-default .cm-number {color: #164;}
.cm-s-default .cm-def {color: #00f;}
.cm-s-default .cm-variable,
.cm-s-default .cm-punctuation,
.cm-s-default .cm-property,
.cm-s-default .cm-operator {}
.cm-s-default .cm-variable-2 {color: #05a;}
.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;}
.cm-s-default .cm-comment {color: #a50;}
.cm-s-default .cm-string {color: #a11;}
.cm-s-default .cm-string-2 {color: #f50;}
.cm-s-default .cm-meta {color: #555;}
.cm-s-default .cm-qualifier {color: #555;}
.cm-s-default .cm-builtin {color: #30a;}
.cm-s-default .cm-bracket {color: #997;}
.cm-s-default .cm-tag {color: #170;}
.cm-s-default .cm-attribute {color: #00c;}
.cm-s-default .cm-hr {color: #999;}
.cm-s-default .cm-link {color: #00c;}
.cm-s-default .cm-error {color: #f00;}
.cm-invalidchar {color: #f00;}
.CodeMirror-composing { border-bottom: 2px solid; }
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;}
div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;}
.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); }
.CodeMirror-activeline-background {background: #e8f2ff;}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror {
position: relative;
overflow: hidden;
background: white;
}
.CodeMirror-scroll {
overflow: scroll !important; /* Things will break if this is overridden */
/* 50px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -50px; margin-right: -50px;
padding-bottom: 50px;
height: 100%;
outline: none; /* Prevent dragging from highlighting the element */
position: relative;
z-index: 0;
}
.CodeMirror-sizer {
position: relative;
border-right: 50px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
outline: none;
}
.CodeMirror-vscrollbar {
right: 0; top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0; left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-scrollbar-filler {
right: 0; bottom: 0;
}
.CodeMirror-gutter-filler {
left: 0; bottom: 0;
}
.CodeMirror-gutters {
position: absolute; left: 0; top: 0;
min-height: 100%;
z-index: 3;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
display: inline-block;
vertical-align: top;
margin-bottom: -50px;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0; bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-gutter-wrapper ::selection { background-color: transparent }
.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent }
.CodeMirror-lines {
cursor: text;
min-height: 1px; /* prevents collapsing before first draw */
}
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
}
.CodeMirror-wrap pre.CodeMirror-line,
.CodeMirror-wrap pre.CodeMirror-line-like {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.CodeMirror-linebackground {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
padding: 0.1px; /* Force widget margins to stay inside of the container */
}
.CodeMirror-widget {}
.CodeMirror-rtl pre { direction: rtl; }
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-cursor {
position: absolute;
pointer-events: none;
}
.CodeMirror-measure pre { position: static; }
div.CodeMirror-cursors {
visibility: hidden;
position: relative;
z-index: 3;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected { background: #d9d9d9; }
.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; }
.CodeMirror-crosshair { cursor: crosshair; }
.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; }
.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; }
.cm-searching {
background-color: #ffa;
background-color: rgba(255, 255, 0, .4);
}
/* Used to force a border model for a node */
.cm-force-border { padding-right: .1px; }
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack:after { content: ''; }
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext { background: none; }
+9884
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
.CodeMirror-hints {
position: absolute;
z-index: 10;
overflow: hidden;
list-style: none;
margin: 0;
padding: 2px;
-webkit-box-shadow: 2px 3px 5px rgba(0,0,0,.2);
-moz-box-shadow: 2px 3px 5px rgba(0,0,0,.2);
box-shadow: 2px 3px 5px rgba(0,0,0,.2);
border-radius: 3px;
border: 1px solid silver;
background: white;
font-size: 90%;
font-family: monospace;
max-height: 20em;
overflow-y: auto;
box-sizing: border-box;
}
.CodeMirror-hint {
margin: 0;
padding: 0 4px;
border-radius: 2px;
white-space: pre;
color: black;
cursor: pointer;
}
li.CodeMirror-hint-active {
background: #08f;
color: white;
}
+523
View File
@@ -0,0 +1,523 @@
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: https://codemirror.net/5/LICENSE
// declare global: DOMRect
(function(mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"));
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror"], mod);
else // Plain browser env
mod(CodeMirror);
})(function(CodeMirror) {
"use strict";
var HINT_ELEMENT_CLASS = "CodeMirror-hint";
var ACTIVE_HINT_ELEMENT_CLASS = "CodeMirror-hint-active";
// This is the old interface, kept around for now to stay
// backwards-compatible.
CodeMirror.showHint = function(cm, getHints, options) {
if (!getHints) return cm.showHint(options);
if (options && options.async) getHints.async = true;
var newOpts = {hint: getHints};
if (options) for (var prop in options) newOpts[prop] = options[prop];
return cm.showHint(newOpts);
};
CodeMirror.defineExtension("showHint", function(options) {
options = parseOptions(this, this.getCursor("start"), options);
var selections = this.listSelections()
if (selections.length > 1) return;
// By default, don't allow completion when something is selected.
// A hint function can have a `supportsSelection` property to
// indicate that it can handle selections.
if (this.somethingSelected()) {
if (!options.hint.supportsSelection) return;
// Don't try with cross-line selections
for (var i = 0; i < selections.length; i++)
if (selections[i].head.line != selections[i].anchor.line) return;
}
if (this.state.completionActive) this.state.completionActive.close();
var completion = this.state.completionActive = new Completion(this, options);
if (!completion.options.hint) return;
CodeMirror.signal(this, "startCompletion", this);
completion.update(true);
});
CodeMirror.defineExtension("closeHint", function() {
if (this.state.completionActive) this.state.completionActive.close()
})
function Completion(cm, options) {
this.cm = cm;
this.options = options;
this.widget = null;
this.debounce = 0;
this.tick = 0;
this.startPos = this.cm.getCursor("start");
this.startLen = this.cm.getLine(this.startPos.line).length - this.cm.getSelection().length;
if (this.options.updateOnCursorActivity) {
var self = this;
cm.on("cursorActivity", this.activityFunc = function() { self.cursorActivity(); });
}
}
var requestAnimationFrame = window.requestAnimationFrame || function(fn) {
return setTimeout(fn, 1000/60);
};
var cancelAnimationFrame = window.cancelAnimationFrame || clearTimeout;
Completion.prototype = {
close: function() {
if (!this.active()) return;
this.cm.state.completionActive = null;
this.tick = null;
if (this.options.updateOnCursorActivity) {
this.cm.off("cursorActivity", this.activityFunc);
}
if (this.widget && this.data) CodeMirror.signal(this.data, "close");
if (this.widget) this.widget.close();
CodeMirror.signal(this.cm, "endCompletion", this.cm);
},
active: function() {
return this.cm.state.completionActive == this;
},
pick: function(data, i) {
var completion = data.list[i], self = this;
this.cm.operation(function() {
if (completion.hint)
completion.hint(self.cm, data, completion);
else
self.cm.replaceRange(getText(completion), completion.from || data.from,
completion.to || data.to, "complete");
CodeMirror.signal(data, "pick", completion);
self.cm.scrollIntoView();
});
if (this.options.closeOnPick) {
this.close();
}
},
cursorActivity: function() {
if (this.debounce) {
cancelAnimationFrame(this.debounce);
this.debounce = 0;
}
var identStart = this.startPos;
if(this.data) {
identStart = this.data.from;
}
var pos = this.cm.getCursor(), line = this.cm.getLine(pos.line);
if (pos.line != this.startPos.line || line.length - pos.ch != this.startLen - this.startPos.ch ||
pos.ch < identStart.ch || this.cm.somethingSelected() ||
(!pos.ch || this.options.closeCharacters.test(line.charAt(pos.ch - 1)))) {
this.close();
} else {
var self = this;
this.debounce = requestAnimationFrame(function() {self.update();});
if (this.widget) this.widget.disable();
}
},
update: function(first) {
if (this.tick == null) return
var self = this, myTick = ++this.tick
fetchHints(this.options.hint, this.cm, this.options, function(data) {
if (self.tick == myTick) self.finishUpdate(data, first)
})
},
finishUpdate: function(data, first) {
if (this.data) CodeMirror.signal(this.data, "update");
var picked = (this.widget && this.widget.picked) || (first && this.options.completeSingle);
if (this.widget) this.widget.close();
this.data = data;
if (data && data.list.length) {
if (picked && data.list.length == 1) {
this.pick(data, 0);
} else {
this.widget = new Widget(this, data);
CodeMirror.signal(data, "shown");
}
}
}
};
function parseOptions(cm, pos, options) {
var editor = cm.options.hintOptions;
var out = {};
for (var prop in defaultOptions) out[prop] = defaultOptions[prop];
if (editor) for (var prop in editor)
if (editor[prop] !== undefined) out[prop] = editor[prop];
if (options) for (var prop in options)
if (options[prop] !== undefined) out[prop] = options[prop];
if (out.hint.resolve) out.hint = out.hint.resolve(cm, pos)
return out;
}
function getText(completion) {
if (typeof completion == "string") return completion;
else return completion.text;
}
function buildKeyMap(completion, handle) {
var baseMap = {
Up: function() {handle.moveFocus(-1);},
Down: function() {handle.moveFocus(1);},
PageUp: function() {handle.moveFocus(-handle.menuSize() + 1, true);},
PageDown: function() {handle.moveFocus(handle.menuSize() - 1, true);},
Home: function() {handle.setFocus(0);},
End: function() {handle.setFocus(handle.length - 1);},
Enter: handle.pick,
Tab: handle.pick,
Esc: handle.close
};
var mac = /Mac/.test(navigator.platform);
if (mac) {
baseMap["Ctrl-P"] = function() {handle.moveFocus(-1);};
baseMap["Ctrl-N"] = function() {handle.moveFocus(1);};
}
var custom = completion.options.customKeys;
var ourMap = custom ? {} : baseMap;
function addBinding(key, val) {
var bound;
if (typeof val != "string")
bound = function(cm) { return val(cm, handle); };
// This mechanism is deprecated
else if (baseMap.hasOwnProperty(val))
bound = baseMap[val];
else
bound = val;
ourMap[key] = bound;
}
if (custom)
for (var key in custom) if (custom.hasOwnProperty(key))
addBinding(key, custom[key]);
var extra = completion.options.extraKeys;
if (extra)
for (var key in extra) if (extra.hasOwnProperty(key))
addBinding(key, extra[key]);
return ourMap;
}
function getHintElement(hintsElement, el) {
while (el && el != hintsElement) {
if (el.nodeName.toUpperCase() === "LI" && el.parentNode == hintsElement) return el;
el = el.parentNode;
}
}
function Widget(completion, data) {
this.id = "cm-complete-" + Math.floor(Math.random(1e6))
this.completion = completion;
this.data = data;
this.picked = false;
var widget = this, cm = completion.cm;
var ownerDocument = cm.getInputField().ownerDocument;
var parentWindow = ownerDocument.defaultView || ownerDocument.parentWindow;
var hints = this.hints = ownerDocument.createElement("ul");
hints.setAttribute("role", "listbox")
hints.setAttribute("aria-expanded", "true")
hints.id = this.id
var theme = completion.cm.options.theme;
hints.className = "CodeMirror-hints " + theme;
this.selectedHint = data.selectedHint || 0;
var completions = data.list;
for (var i = 0; i < completions.length; ++i) {
var elt = hints.appendChild(ownerDocument.createElement("li")), cur = completions[i];
var className = HINT_ELEMENT_CLASS + (i != this.selectedHint ? "" : " " + ACTIVE_HINT_ELEMENT_CLASS);
if (cur.className != null) className = cur.className + " " + className;
elt.className = className;
if (i == this.selectedHint) elt.setAttribute("aria-selected", "true")
elt.id = this.id + "-" + i
elt.setAttribute("role", "option")
if (cur.render) cur.render(elt, data, cur);
else elt.appendChild(ownerDocument.createTextNode(cur.displayText || getText(cur)));
elt.hintId = i;
}
var container = completion.options.container || ownerDocument.body;
var pos = cm.cursorCoords(completion.options.alignWithWord ? data.from : null);
var left = pos.left, top = pos.bottom, below = true;
var offsetLeft = 0, offsetTop = 0;
if (container !== ownerDocument.body) {
// We offset the cursor position because left and top are relative to the offsetParent's top left corner.
var isContainerPositioned = ['absolute', 'relative', 'fixed'].indexOf(parentWindow.getComputedStyle(container).position) !== -1;
var offsetParent = isContainerPositioned ? container : container.offsetParent;
var offsetParentPosition = offsetParent.getBoundingClientRect();
var bodyPosition = ownerDocument.body.getBoundingClientRect();
offsetLeft = (offsetParentPosition.left - bodyPosition.left - offsetParent.scrollLeft);
offsetTop = (offsetParentPosition.top - bodyPosition.top - offsetParent.scrollTop);
}
hints.style.left = (left - offsetLeft) + "px";
hints.style.top = (top - offsetTop) + "px";
// If we're at the edge of the screen, then we want the menu to appear on the left of the cursor.
var winW = parentWindow.innerWidth || Math.max(ownerDocument.body.offsetWidth, ownerDocument.documentElement.offsetWidth);
var winH = parentWindow.innerHeight || Math.max(ownerDocument.body.offsetHeight, ownerDocument.documentElement.offsetHeight);
container.appendChild(hints);
cm.getInputField().setAttribute("aria-autocomplete", "list")
cm.getInputField().setAttribute("aria-owns", this.id)
cm.getInputField().setAttribute("aria-activedescendant", this.id + "-" + this.selectedHint)
var box = completion.options.moveOnOverlap ? hints.getBoundingClientRect() : new DOMRect();
var scrolls = completion.options.paddingForScrollbar ? hints.scrollHeight > hints.clientHeight + 1 : false;
// Compute in the timeout to avoid reflow on init
var startScroll;
setTimeout(function() { startScroll = cm.getScrollInfo(); });
var overlapY = box.bottom - winH;
if (overlapY > 0) { // Does not fit below
var height = box.bottom - box.top, spaceAbove = box.top - (pos.bottom - pos.top) - 2
if (winH - box.top < spaceAbove) { // More room at the top
if (height > spaceAbove) hints.style.height = (height = spaceAbove) + "px";
hints.style.top = ((top = pos.top - height) - offsetTop) + "px";
below = false;
} else {
hints.style.height = (winH - box.top - 2) + "px";
}
}
var overlapX = box.right - winW;
if (scrolls) overlapX += cm.display.nativeBarWidth;
if (overlapX > 0) {
if (box.right - box.left > winW) {
hints.style.width = (winW - 5) + "px";
overlapX -= (box.right - box.left) - winW;
}
hints.style.left = (left = Math.max(pos.left - overlapX - offsetLeft, 0)) + "px";
}
if (scrolls) for (var node = hints.firstChild; node; node = node.nextSibling)
node.style.paddingRight = cm.display.nativeBarWidth + "px"
cm.addKeyMap(this.keyMap = buildKeyMap(completion, {
moveFocus: function(n, avoidWrap) { widget.changeActive(widget.selectedHint + n, avoidWrap); },
setFocus: function(n) { widget.changeActive(n); },
menuSize: function() { return widget.screenAmount(); },
length: completions.length,
close: function() { completion.close(); },
pick: function() { widget.pick(); },
data: data
}));
if (completion.options.closeOnUnfocus) {
var closingOnBlur;
cm.on("blur", this.onBlur = function() { closingOnBlur = setTimeout(function() { completion.close(); }, 100); });
cm.on("focus", this.onFocus = function() { clearTimeout(closingOnBlur); });
}
cm.on("scroll", this.onScroll = function() {
var curScroll = cm.getScrollInfo(), editor = cm.getWrapperElement().getBoundingClientRect();
if (!startScroll) startScroll = cm.getScrollInfo();
var newTop = top + startScroll.top - curScroll.top;
var point = newTop - (parentWindow.pageYOffset || (ownerDocument.documentElement || ownerDocument.body).scrollTop);
if (!below) point += hints.offsetHeight;
if (point <= editor.top || point >= editor.bottom) return completion.close();
hints.style.top = newTop + "px";
hints.style.left = (left + startScroll.left - curScroll.left) + "px";
});
CodeMirror.on(hints, "dblclick", function(e) {
var t = getHintElement(hints, e.target || e.srcElement);
if (t && t.hintId != null) {widget.changeActive(t.hintId); widget.pick();}
});
CodeMirror.on(hints, "click", function(e) {
var t = getHintElement(hints, e.target || e.srcElement);
if (t && t.hintId != null) {
widget.changeActive(t.hintId);
if (completion.options.completeOnSingleClick) widget.pick();
}
});
CodeMirror.on(hints, "mousedown", function() {
setTimeout(function(){cm.focus();}, 20);
});
// The first hint doesn't need to be scrolled to on init
var selectedHintRange = this.getSelectedHintRange();
if (selectedHintRange.from !== 0 || selectedHintRange.to !== 0) {
this.scrollToActive();
}
CodeMirror.signal(data, "select", completions[this.selectedHint], hints.childNodes[this.selectedHint]);
return true;
}
Widget.prototype = {
close: function() {
if (this.completion.widget != this) return;
this.completion.widget = null;
if (this.hints.parentNode) this.hints.parentNode.removeChild(this.hints);
this.completion.cm.removeKeyMap(this.keyMap);
var input = this.completion.cm.getInputField()
input.removeAttribute("aria-activedescendant")
input.removeAttribute("aria-owns")
var cm = this.completion.cm;
if (this.completion.options.closeOnUnfocus) {
cm.off("blur", this.onBlur);
cm.off("focus", this.onFocus);
}
cm.off("scroll", this.onScroll);
},
disable: function() {
this.completion.cm.removeKeyMap(this.keyMap);
var widget = this;
this.keyMap = {Enter: function() { widget.picked = true; }};
this.completion.cm.addKeyMap(this.keyMap);
},
pick: function() {
this.completion.pick(this.data, this.selectedHint);
},
changeActive: function(i, avoidWrap) {
if (i >= this.data.list.length)
i = avoidWrap ? this.data.list.length - 1 : 0;
else if (i < 0)
i = avoidWrap ? 0 : this.data.list.length - 1;
if (this.selectedHint == i) return;
var node = this.hints.childNodes[this.selectedHint];
if (node) {
node.className = node.className.replace(" " + ACTIVE_HINT_ELEMENT_CLASS, "");
node.removeAttribute("aria-selected")
}
node = this.hints.childNodes[this.selectedHint = i];
node.className += " " + ACTIVE_HINT_ELEMENT_CLASS;
node.setAttribute("aria-selected", "true")
this.completion.cm.getInputField().setAttribute("aria-activedescendant", node.id)
this.scrollToActive()
CodeMirror.signal(this.data, "select", this.data.list[this.selectedHint], node);
},
scrollToActive: function() {
var selectedHintRange = this.getSelectedHintRange();
var node1 = this.hints.childNodes[selectedHintRange.from];
var node2 = this.hints.childNodes[selectedHintRange.to];
var firstNode = this.hints.firstChild;
if (node1.offsetTop < this.hints.scrollTop)
this.hints.scrollTop = node1.offsetTop - firstNode.offsetTop;
else if (node2.offsetTop + node2.offsetHeight > this.hints.scrollTop + this.hints.clientHeight)
this.hints.scrollTop = node2.offsetTop + node2.offsetHeight - this.hints.clientHeight + firstNode.offsetTop;
},
screenAmount: function() {
return Math.floor(this.hints.clientHeight / this.hints.firstChild.offsetHeight) || 1;
},
getSelectedHintRange: function() {
var margin = this.completion.options.scrollMargin || 0;
return {
from: Math.max(0, this.selectedHint - margin),
to: Math.min(this.data.list.length - 1, this.selectedHint + margin),
};
}
};
function applicableHelpers(cm, helpers) {
if (!cm.somethingSelected()) return helpers
var result = []
for (var i = 0; i < helpers.length; i++)
if (helpers[i].supportsSelection) result.push(helpers[i])
return result
}
function fetchHints(hint, cm, options, callback) {
if (hint.async) {
hint(cm, callback, options)
} else {
var result = hint(cm, options)
if (result && result.then) result.then(callback)
else callback(result)
}
}
function resolveAutoHints(cm, pos) {
var helpers = cm.getHelpers(pos, "hint"), words
if (helpers.length) {
var resolved = function(cm, callback, options) {
var app = applicableHelpers(cm, helpers);
function run(i) {
if (i == app.length) return callback(null)
fetchHints(app[i], cm, options, function(result) {
if (result && result.list.length > 0) callback(result)
else run(i + 1)
})
}
run(0)
}
resolved.async = true
resolved.supportsSelection = true
return resolved
} else if (words = cm.getHelper(cm.getCursor(), "hintWords")) {
return function(cm) { return CodeMirror.hint.fromList(cm, {words: words}) }
} else if (CodeMirror.hint.anyword) {
return function(cm, options) { return CodeMirror.hint.anyword(cm, options) }
} else {
return function() {}
}
}
CodeMirror.registerHelper("hint", "auto", {
resolve: resolveAutoHints
});
CodeMirror.registerHelper("hint", "fromList", function(cm, options) {
var cur = cm.getCursor(), token = cm.getTokenAt(cur)
var term, from = CodeMirror.Pos(cur.line, token.start), to = cur
if (token.start < cur.ch && /\w/.test(token.string.charAt(cur.ch - token.start - 1))) {
term = token.string.substr(0, cur.ch - token.start)
} else {
term = ""
from = cur
}
var found = [];
for (var i = 0; i < options.words.length; i++) {
var word = options.words[i];
if (word.slice(0, term.length) == term)
found.push(word);
}
if (found.length) return {list: found, from: from, to: to};
});
CodeMirror.commands.autocomplete = CodeMirror.showHint;
var defaultOptions = {
hint: CodeMirror.hint.auto,
completeSingle: true,
alignWithWord: true,
closeCharacters: /[\s()\[\]{};:>,]/,
closeOnPick: true,
closeOnUnfocus: true,
updateOnCursorActivity: true,
completeOnSingleClick: true,
container: null,
customKeys: null,
extraKeys: null,
paddingForScrollbar: true,
moveOnOverlap: true,
};
CodeMirror.defineOption("hintOptions", null);
});
+145 -59
View File
@@ -1,13 +1,16 @@
"""Validated run explorer queries; never load recording samples."""
import calendar
import json
import math
import re
import operator
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
from .analytics import pace_text
from .extensions import db
from .models import Activity
from .stats_dsl import UNITS, QueryError, parse_dsl
METRICS = {
"pace": ("Pace", "/km", "Total timer time divided by total distance."),
@@ -39,58 +42,37 @@ DEFAULT = {
"hr_max": None,
"compare": True,
"end": None,
"start": None,
"days": None,
"months": None,
"extra_metrics": [],
"filters": [],
"sort": ["date", "desc"],
}
def parse_query(args):
q = dict(DEFAULT)
if "query" in args:
text = args["query"]
if len(text) > 2000:
raise ValueError("Keep the query under 2,000 characters.")
patterns = [
(r"runs", None),
(r"during last (\d+) weeks", "weeks"),
(r"ending (\d{4}-\d{2}-\d{2})", "end"),
(r"where distance >= ([0-9.eE+-]+) km", "minimum"),
(r"and distance <= ([0-9.eE+-]+) km", "maximum"),
(r"and heart_rate >= ([0-9.eE+-]+) bpm", "hr_min"),
(r"and heart_rate <= ([0-9.eE+-]+) bpm", "hr_max"),
(r"summarize (\w+)(?:, (\w+))?", "metrics"),
(r"by (week|month)", "group"),
(r"compare (previous period|none)", "compare"),
]
seen = set()
for line in text.strip().splitlines():
line = line.strip()
if not line:
continue
for pattern, key in patterns:
match = re.fullmatch(pattern, line)
if match:
if key in seen:
raise ValueError(f"Repeated query clause: {line}")
seen.add(key)
if key == "metrics":
q["metric"], q["secondary"] = match.group(1), match.group(2) or "none"
elif key == "compare":
q[key] = match.group(1) == "previous period"
elif key:
q[key] = match.group(1)
break
else:
raise ValueError(f"Unrecognised query clause: {line}")
if None not in seen or "metrics" not in seen:
raise ValueError("Include 'runs' and 'summarize pace, heart_rate' (or other metrics).")
else:
for key in q:
if key in args:
q[key] = args[key] or None
q["compare"] = args.get("compare", "yes") == "yes"
return parse_dsl(args["query"], METRICS, DEFAULT)
for key in (
"metric",
"secondary",
"weeks",
"group",
"minimum",
"maximum",
"hr_min",
"hr_max",
"end",
):
if key in args:
q[key] = args[key] or None
q["compare"] = args.get("compare", "yes") == "yes"
if q["metric"] not in METRICS or q["secondary"] not in {*METRICS, "none"}:
raise ValueError("Choose a supported metric.")
if q["group"] not in {"week", "month"}:
raise ValueError("Group by week or month.")
if q["group"] not in {"day", "week", "month"}:
raise ValueError("Group by day, week or month.")
try:
q["weeks"] = int(q["weeks"])
if not 1 <= q["weeks"] <= 104:
@@ -111,19 +93,48 @@ def parse_query(args):
return q
def number_text(value):
return str(int(value)) if value == int(value) else repr(value)
def query_text(q):
lines = ["runs", f" during last {q['weeks']} weeks"]
if q["end"]:
if q["start"]:
period = f"during {q['start']} to {q['end']}"
elif q["months"]:
period = f"during last {q['months']} months"
elif q["days"]:
period = f"during last {q['days']} days"
else:
period = f"during last {q['weeks']} weeks"
lines = ["runs", " " + period]
if q["end"] and not q["start"]:
lines.append(f" ending {q['end']}")
lines.append(f" where distance >= {q['minimum']:g} km")
lines.append(f" where distance >= {number_text(q['minimum'])} km")
for key, clause, unit in (
("maximum", "distance <=", "km"),
("hr_min", "heart_rate >=", "bpm"),
("hr_max", "heart_rate <=", "bpm"),
):
if q[key] is not None:
lines.append(f" and {clause} {q[key]:g} {unit}")
metrics = q["metric"] + (f", {q['secondary']}" if q["secondary"] != "none" else "")
lines.append(f" and {clause} {number_text(q[key])} {unit}")
for condition in q["filters"]:
field, op = condition["field"], condition["op"]
if op in {"has", "missing"}:
lines.append(f" and {op} {field}")
elif op == "contains":
lines.append(
f" and title contains {json.dumps(condition['value'], ensure_ascii=False)}"
)
else:
unit = next(iter(UNITS[field]))
value = condition["value"] / UNITS[field][unit]
formatted = number_text(value)
if field == "pace" and value > 0 and value.is_integer():
formatted = pace_text(value)
elif field == "duration" and value * 60 == round(value * 60) and value < 1:
formatted, unit = str(round(value * 60)), "min"
lines.append(f" and {field} {op} {formatted} {unit}")
metrics = ", ".join(query_metrics(q))
lines.extend(
[
f" summarize {metrics}",
@@ -131,9 +142,53 @@ def query_text(q):
" compare " + ("previous period" if q["compare"] else "none"),
]
)
if q["sort"] != ["date", "desc"]:
lines.append(f" sort by {q['sort'][0]} {q['sort'][1]}")
return "\n".join(lines)
def query_metrics(q):
return list(
dict.fromkeys(
[
q["metric"],
*([q["secondary"]] if q["secondary"] != "none" else []),
*q["extra_metrics"],
]
)
)
def matches(row, conditions):
operations = {
">=": operator.ge,
"<=": operator.le,
">": operator.gt,
"<": operator.lt,
"=": operator.eq,
"!=": operator.ne,
}
for condition in conditions:
field, op = condition["field"], condition["op"]
if op == "contains":
if condition["value"].casefold() not in row.title.casefold():
return False
continue
value = metric_value(row, field)
valid = (
isinstance(value, (float, int)) and not isinstance(value, bool) and math.isfinite(value)
)
if op == "has":
if not valid:
return False
elif op == "missing":
if valid:
return False
elif not valid or not operations[op](value, condition["value"]):
return False
return True
def metric_value(row, key):
if key == "pace":
return row.duration_s * 1000 / row.distance_m if row.distance_m > 0 else None
@@ -185,12 +240,28 @@ def format_value(value, key):
)
def explore(owner_id, timezone, q, page=1, selected_bucket=None):
def period_bounds(q, timezone):
zone = ZoneInfo(timezone)
end_day = q["end"] or datetime.now(UTC).astimezone(zone).date()
end = end_day + timedelta(days=1)
start = end - timedelta(weeks=q["weeks"])
previous = start - timedelta(weeks=q["weeks"])
if q["start"]:
start = q["start"]
elif q["months"]:
month_index = end.year * 12 + end.month - 1 - q["months"]
year, month = divmod(month_index, 12)
start = date(year, month + 1, min(end.day, calendar.monthrange(year, month + 1)[1]))
else:
start = end - timedelta(days=q["days"]) if q["days"] else end - timedelta(weeks=q["weeks"])
previous = start - (end - start)
if start.year < 4 or previous.year < 1:
raise QueryError("Choose dates after year 0003.")
return start, end, previous
def explore(owner_id, timezone, q, page=1, selected_bucket=None):
zone = ZoneInfo(timezone)
start, end, previous = period_bounds(q, timezone)
end_day = end - timedelta(days=1)
boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC)
filters = [
Activity.owner_id == owner_id,
@@ -199,13 +270,15 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None):
Activity.started_at < boundary(end),
Activity.distance_m >= q["minimum"] * 1000,
]
for key, column, multiplier, operator in (
for key, column, multiplier, comparison in (
("maximum", Activity.distance_m, 1000, "le"),
("hr_min", Activity.avg_hr, 1, "ge"),
("hr_max", Activity.avg_hr, 1, "le"),
):
if q[key] is not None:
filters.append(column <= q[key] * multiplier if operator == "le" else column >= q[key])
filters.append(
column <= q[key] * multiplier if comparison == "le" else column >= q[key]
)
rows = db.session.execute(
db.select(
Activity.id,
@@ -225,11 +298,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None):
.astimezone(zone)
.date()
)
rows = [r for r in rows if matches(r, q["filters"])]
current = [r for r in rows if localday(r) >= start]
prior = [r for r in rows if localday(r) < start]
keys = list(
dict.fromkeys([q["metric"], *([q["secondary"]] if q["secondary"] != "none" else [])])
)
keys = query_metrics(q)
cards = []
for key in keys:
value, coverage = aggregate(current, key)
@@ -249,7 +321,11 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None):
}
)
bucket = lambda day: (
day.replace(day=1) if q["group"] == "month" else day - timedelta(days=day.weekday())
day
if q["group"] == "day"
else day.replace(day=1)
if q["group"] == "month"
else day - timedelta(days=day.weekday())
)
dates = sorted({bucket(start + timedelta(days=n)) for n in range((end - start).days)})
series = []
@@ -277,6 +353,16 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None):
if selected_bucket not in dates:
raise ValueError("Choose a period from the current chart.")
evidence = [r for r in current if bucket(localday(r)) == selected_bucket]
sort_key, direction = q["sort"]
if sort_key != "date":
available = [r for r in evidence if metric_value(r, sort_key) is not None]
unavailable = [r for r in evidence if metric_value(r, sort_key) is None]
evidence = (
sorted(available, key=lambda r: metric_value(r, sort_key), reverse=direction == "desc")
+ unavailable
)
elif direction == "asc":
evidence = list(reversed(evidence))
pages = max(1, math.ceil(len(evidence) / 25))
if not 1 <= page <= pages:
raise ValueError("That results page does not exist.")
+188
View File
@@ -0,0 +1,188 @@
"""Small, explicit running query language with source-located diagnostics."""
import difflib
import json
import math
import re
from datetime import date
NUMBER = r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
UNITS = {
"distance": {"km": 1, "m": 0.001},
"duration": {"h": 1, "min": 1 / 60, "s": 1 / 3600},
"pace": {"/km": 1},
"heart_rate": {"bpm": 1},
"cadence": {"spm": 1},
"power": {"W": 1},
"elevation": {"m": 1},
"ground_contact": {"ms": 1},
"stride_length": {"m": 1},
}
class QueryError(ValueError):
def __init__(self, message, line=1):
super().__init__(message)
self.line = line
def parse_dsl(text, metrics, defaults):
if not isinstance(text, str) or len(text) > 4000:
raise QueryError("Keep the query under 4,000 characters.")
q = dict(defaults)
q["filters"] = []
q["extra_metrics"] = []
seen = set()
locations = {}
def claim(key, line):
if key in seen:
raise QueryError(f"Repeated {key} clause. Keep one per query.", line)
seen.add(key)
locations[key] = line
def metric(key, line):
if key not in metrics:
suggestion = difflib.get_close_matches(key, metrics, n=1)
hint = (
f" Did you mean '{suggestion[0]}'?"
if suggestion
else " Open the metric reference for supported names."
)
raise QueryError(f"Unknown metric '{key}'.{hint}", line)
def amount(key, raw, unit, line):
if unit not in UNITS[key]:
raise QueryError(f"Use {' or '.join(UNITS[key])} for {key}.", line)
if key == "pace" and ":" in raw:
minutes, seconds = raw.split(":")
if not minutes.isdecimal() or not seconds.isdecimal() or int(seconds) >= 60:
raise QueryError("Write pace like 5:30 /km (seconds from 00 to 59).", line)
value = int(minutes) * 60 + int(seconds)
else:
try:
value = float(raw)
except ValueError:
raise QueryError("Use a numeric value with its unit.", line) from None
if not math.isfinite(value) or not 0 <= value <= 1000000:
raise QueryError("Filter values must be finite and between 0 and 1,000,000.", line)
return value * UNITS[key][unit]
def condition(key, op, value, line):
legacy = {
("distance", ">="): "minimum",
("distance", "<="): "maximum",
("heart_rate", ">="): "hr_min",
("heart_rate", "<="): "hr_max",
}.get((key, op))
if legacy:
if value > 1000:
raise QueryError("Distance and heart rate bounds must be at most 1,000.", line)
old = q[legacy]
q[legacy] = value if old is None else max(old, value) if op == ">=" else min(old, value)
locations[legacy] = line
else:
q["filters"].append({"field": key, "op": op, "value": value})
for line_number, source in enumerate(text.splitlines(), 1):
line = source.strip()
if not line or line.startswith("#"):
continue
if line == "runs":
claim("runs", line_number)
elif match := re.fullmatch(r"during last (\d+) (days?|weeks?|months?)", line):
claim("during", line_number)
count, unit = int(match[1]), match[2].rstrip("s")
limit = {"day": 732, "week": 104, "month": 24}[unit]
if not 1 <= count <= limit:
raise QueryError(f"Use 1–{limit} {unit}s.", line_number)
if unit == "week":
q["weeks"] = count
else:
q["days" if unit == "day" else "months"] = count
elif match := re.fullmatch(r"during (\d{4}-\d{2}-\d{2}) to (\d{4}-\d{2}-\d{2})", line):
claim("during", line_number)
try:
q["start"], q["end"] = date.fromisoformat(match[1]), date.fromisoformat(match[2])
if not 0 <= (q["end"] - q["start"]).days < 732:
raise ValueError
except ValueError:
raise QueryError(
"Use valid dates in order, covering at most 732 days.", line_number
) from None
elif match := re.fullmatch(r"ending (\d{4}-\d{2}-\d{2})", line):
claim("ending", line_number)
if q["start"]:
raise QueryError(
"An explicit date range already supplies its end date.", line_number
)
try:
q["end"] = date.fromisoformat(match[1])
except ValueError:
raise QueryError("Use a valid end date, YYYY-MM-DD.", line_number) from None
elif line.startswith("summarize "):
claim("summarize", line_number)
keys = [key.strip() for key in line[10:].split(",")]
if not 1 <= len(keys) <= 6 or len(set(keys)) != len(keys):
raise QueryError("Choose 1–6 different metrics, separated by commas.", line_number)
for key in keys:
metric(key, line_number)
q["metric"] = keys[0]
q["secondary"] = keys[1] if len(keys) > 1 else "none"
q["extra_metrics"] = keys[2:]
elif match := re.fullmatch(r"by (day|week|month)", line):
claim("by", line_number)
q["group"] = match[1]
elif match := re.fullmatch(r"compare (previous period|none)", line):
claim("compare", line_number)
q["compare"] = match[1] == "previous period"
elif match := re.fullmatch(r"sort by (\w+) (asc|desc)", line):
claim("sort", line_number)
if match[1] != "date":
metric(match[1], line_number)
q["sort"] = [match[1], match[2]]
elif match := re.fullmatch(r'(?:where|and) title contains (".*")', line):
try:
title = json.loads(match[1])
if not isinstance(title, str) or not 1 <= len(title) <= 160:
raise ValueError
except ValueError:
raise QueryError(
'Use a title fragment in double quotes, e.g. "River".', line_number
) from None
q["filters"].append({"field": "title", "op": "contains", "value": title})
elif match := re.fullmatch(r"(?:where|and) (has|missing) (\w+)", line):
metric(match[2], line_number)
q["filters"].append({"field": match[2], "op": match[1]})
elif match := re.fullmatch(r"(?:where|and) (\w+) between (\S+) and (\S+) (\S+)", line):
key, low, high, unit = match.groups()
metric(key, line_number)
lower, upper = amount(key, low, unit, line_number), amount(key, high, unit, line_number)
if lower > upper:
raise QueryError("The lower bound must not exceed the upper bound.", line_number)
condition(key, ">=", lower, line_number)
condition(key, "<=", upper, line_number)
elif match := re.fullmatch(r"(?:where|and) (\w+) (>=|<=|>|<|=|!=) (\S+) (\S+)", line):
key, op, raw, unit = match.groups()
metric(key, line_number)
value = amount(key, raw, unit, line_number)
condition(key, op, value, line_number)
else:
raise QueryError(
"Unrecognised clause. Use autocomplete (Ctrl+Space) or a starting query.",
line_number,
)
if len(q["filters"]) > 20:
raise QueryError("Use at most 20 filter conditions.", line_number)
if not {"runs", "summarize"} <= seen:
raise QueryError("Include 'runs' and a 'summarize' clause.")
if "ending" in seen and q["start"]:
raise QueryError(
"Use either an explicit date range or an ending clause.", locations["during"]
)
for low, high in (("minimum", "maximum"), ("hr_min", "hr_max")):
if q[low] is not None and q[high] is not None and q[low] > q[high]:
raise QueryError(
"A filter's minimum cannot exceed its maximum.", locations.get(high, 1)
)
return q
+19 -10
View File
@@ -1,14 +1,25 @@
{% extends 'base.html' %}
{% block title %}Stats studio · Stride{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='stats.css') }}">{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='vendor/codemirror.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='vendor/show-hint.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='stats.css') }}">{% endblock %}
{% block content %}
<div id="stats-studio" data-owner="{{ current_user.id }}" data-query="{{ query_text }}">
<div class="page-heading"><div><span class="eyebrow">YOUR RUNNING / STATS STUDIO</span><h1>Ask more of your runs.</h1><p>Build a question. Find the pattern. Explore the runs behind it.</p></div><button type="button" class="button secondary" id="save-view">☆ Save view</button></div>
<div id="stats-studio" data-owner="{{ current_user.id }}" data-query="{{ query_text }}" data-csrf="{{ csrf_token() }}" data-validate-url="{{ url_for('web.stats_validate') }}">
<div class="page-heading"><div><span class="eyebrow">YOUR RUNNING / STATS STUDIO</span><h1>Your runs. Your questions.</h1><p>Write a query, explore the pattern, and inspect the runs behind it.</p></div><button type="button" class="button secondary" id="save-view">☆ Save view</button></div>
<div id="saved-views" class="studio-presets" aria-label="Saved views"></div>
<form method="get" id="explorer-form" class="panel question-panel">
<div class="studio-row"><span class="eyebrow">YOUR QUESTION</span><button type="button" class="text-button" id="query-toggle" aria-expanded="{{ 'true' if error else 'false' }}" aria-controls="query-editor">〈/〉 {{ 'Hide' if error else 'Show' }} query</button></div>
<form method="get" id="explorer-form" class="panel question-panel" novalidate>
<div class="studio-row"><div><span class="eyebrow">RUNNING QUERY</span><span class="query-caption">A small language for a deeper look.</span></div><div class="query-actions"><button type="button" class="button secondary" id="format-query">Format</button><button type="button" class="button secondary" id="complete-query">Suggest <kbd>Ctrl Space</kbd></button></div></div>
<div id="query-editor"><label class="sr-only" for="query-input">Running stats query</label><textarea id="query-input" name="query" spellcheck="false" maxlength="4000" rows="9">{{ submitted_query if submitted_query else query_text }}</textarea></div>
<div class="studio-row query-state"><span id="query-validation" role="status">Checking query...</span><span class="query-shortcut"><kbd>Ctrl / Cmd Enter</kbd> to run</span></div>
<div id="query-diagnostics" aria-live="polite">{% if error %}<p class="studio-error" role="alert">{{ error }}</p>{% endif %}</div>
<div class="studio-row studio-bottom"><span class="field-help">Whole-run metrics · {{ current_user.timezone }}<br>Lower pace values mean faster running.</span><button class="button primary" id="run-query" type="submit">Run query {{ icon('arrow') }}</button></div>
<details class="query-help"><summary>Language reference &amp; examples</summary><div class="query-reference"><div><h3>Filter and compare</h3><pre>during last 6 months
where distance between 8 and 20 km
and pace &lt;= 5:30 /km
and has cadence
and title contains "River"
sort by pace asc</pre><p class="field-help">Periods: days, weeks, calendar months, or YYYY-MM-DD to YYYY-MM-DD. Group by day, week or month. Comparisons: previous period or none. Operators: &gt;, &gt;=, &lt;, &lt;=, =, !=. Comments start with # on their own line.</p></div><div><h3>Metrics · select up to six</h3><div class="metric-reference">{% for key, info in metrics.items() %}<button type="button" class="metric-insert" data-metric="{{ key }}"><code>{{ key }}</code><span>{{ info[1] }}</span></button>{% endfor %}</div><p class="field-help">duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.</p></div></div></details>
<details id="guided-controls" class="query-help"><summary>Guided controls</summary><p class="field-help">Adjust the basic clauses without removing additional metrics and filters.</p>
<div class="studio-sentence">Show <label class="sr-only" for="metric">Primary metric</label><select id="metric" name="metric">{% for key, info in metrics.items() %}<option value="{{ key }}" {{ 'selected' if query.metric == key }}>{{ info[0]|lower }}</option>{% endfor %}</select> and <label class="sr-only" for="secondary">Second metric</label><select id="secondary" name="secondary"><option value="none" {{ 'selected' if query.secondary == 'none' }}>no second metric</option>{% for key, info in metrics.items() %}<option value="{{ key }}" {{ 'selected' if query.secondary == key }}>{{ info[0]|lower }}</option>{% endfor %}</select><br>
from runs during the last <label class="sr-only" for="weeks">Number of weeks</label><input id="weeks" name="weeks" type="number" min="1" max="104" value="{{ query.weeks }}" required> weeks, grouped by <label class="sr-only" for="group">Grouping</label><select id="group" name="group"><option value="week" {{ 'selected' if query.group == 'week' }}>week</option><option value="month" {{ 'selected' if query.group == 'month' }}>month</option></select>.</div>
from runs during the last <label class="sr-only" for="weeks">Number of weeks</label><input id="weeks" name="weeks" type="number" min="1" max="104" value="{{ query.weeks }}" required> weeks, grouped by <label class="sr-only" for="group">Grouping</label><select id="group" name="group"><option value="day" {{ 'selected' if query.group == 'day' }}>day</option><option value="week" {{ 'selected' if query.group == 'week' }}>week</option><option value="month" {{ 'selected' if query.group == 'month' }}>month</option></select>.</div>
<div class="studio-filters">
<label>Distance at least · km<input name="minimum" type="number" min="0" max="1000" step="any" value="{{ query.minimum }}"></label>
<label>Distance at most · km<input name="maximum" type="number" min="0" max="1000" step="any" value="{{ query.maximum if query.maximum is not none else '' }}" placeholder="Any"></label>
@@ -17,9 +28,7 @@ from runs during the last <label class="sr-only" for="weeks">Number of weeks</la
<label>Ending on<input name="end" type="date" value="{{ query.end or '' }}" aria-describedby="end-help"><span id="end-help">Blank = today</span></label>
<label>Compare with<select name="compare"><option value="yes" {{ 'selected' if query.compare }}>Previous period</option><option value="no" {{ 'selected' if not query.compare }}>No comparison</option></select></label>
</div>
<div id="query-editor" {% if not error %}hidden{% endif %}><label for="query-input">Edit query</label><textarea id="query-input" spellcheck="false" rows="8">{{ submitted_query if error and submitted_query else query_text }}</textarea><p class="field-help">Supported clauses: runs, during last N weeks, ending YYYY-MM-DD, distance and heart_rate filters, summarize, by week/month, compare previous period/none. Filters use km and bpm.</p></div>
{% if error %}<p class="studio-error" role="alert">{{ error }}</p>{% endif %}
<div class="studio-row studio-bottom"><span class="field-help">Whole runs · Recorded timer time · {{ current_user.timezone }}</span><button class="button primary" type="submit">Explore {{ icon('arrow') }}</button></div>
</details>
</form>
<div class="studio-presets" aria-label="Starting questions"><span class="subtle-label">START WITH</span><a href="{{ url_for('web.stats',metric='distance',secondary='duration') }}">Weekly volume</a><a href="{{ url_for('web.stats',metric='pace',secondary='heart_rate',minimum=10) }}">Long-run progression</a><a href="{{ url_for('web.stats',metric='pace',secondary='heart_rate',hr_min=140,hr_max=150) }}">Pace at similar heart rate</a><a href="{{ url_for('web.stats',metric='cadence',secondary='ground_contact') }}">Running form</a></div>
{% if results %}
@@ -34,4 +43,4 @@ from runs during the last <label class="sr-only" for="weeks">Number of weeks</la
<div id="studio-status" role="status" class="field-help"></div>
</div>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='stats.js') }}"></script>{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/codemirror.js') }}"></script><script src="{{ url_for('static',filename='vendor/show-hint.js') }}"></script><script src="{{ url_for('static',filename='stats-editor.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='stats.js') }}"></script>{% endblock %}
+122 -1
View File
@@ -6,7 +6,8 @@ from test_app import add_history, login
from app.extensions import db
from app.models import Activity
from app.stats import explore, parse_query, query_text
from app.stats import explore, parse_query, period_bounds, query_text
from app.stats_dsl import QueryError
pytest_plugins = ("test_app",)
@@ -164,3 +165,123 @@ def test_evidence_pagination(app):
assert len(second.json["rows"]) == 6
assert not {r["id"] for r in response.json["rows"]} & {r["id"] for r in second.json["rows"]}
assert client.get("/stats/runs", query_string={**args, "page": 3}).status_code == 400
def test_expanded_dsl_roundtrip_units_comments_and_dates():
source = """# My long runs
runs
during 2026-07-01 to 2026-09-30
where distance between 8000 and 20000 m
and pace <= 5:30 /km
and duration >= 45 min
and has cadence
and missing power
and title contains "River"
summarize pace, heart_rate, cadence, duration
by day
sort by pace asc
compare none"""
query = parse_query({"query": source})
assert query["extra_metrics"] == ["cadence", "duration"]
assert query["group"] == "day"
assert query["minimum"] == 8
assert query["maximum"] == 20
assert query["filters"][0]["value"] == 330
assert query["filters"][1]["value"] == 0.75
assert parse_query({"query": query_text(query)}) == query
months = parse_query({"query": "runs\nduring last 1 month\nending 2026-03-30\nsummarize pace"})
assert period_bounds(months, "Australia/Sydney")[0].isoformat() == "2026-02-28"
days = parse_query({"query": "runs\nduring last 30 days\nsummarize distance"})
assert parse_query({"query": query_text(days)}) == days
@pytest.mark.parametrize(
("clause", "message"),
[
("and pace <= 5:99 /km", "seconds"),
("and pace <= 5:30 bpm", "Use /km"),
("and heart_rate between 150 and 140 bpm", "lower bound"),
("and has heart_rae", "heart_rate"),
("during last 25 months", "1–24"),
("summarize pace, pace", "different metrics"),
("sort by owner_id asc", "Unknown metric"),
],
)
def test_source_located_diagnostics(clause, message):
source = "runs\n" + clause
if not clause.startswith("summarize"):
source += "\nsummarize pace"
with pytest.raises(QueryError, match=message) as error:
parse_query({"query": source})
assert error.value.line == 2
def test_live_validation_and_extended_execution(app):
client = app.test_client()
assert client.post("/stats/validate", json={"query": "runs"}).status_code == 302
login(client)
assert client.post("/stats/validate", json={"query": "runs"}).json["valid"] is False
bad = client.post(
"/stats/validate", json={"query": "runs\nand pace <= 5:99 /km\nsummarize pace"}
)
assert bad.json["diagnostics"][0]["line"] == 2
source = """runs
during 2026-09-01 to 2026-10-02
where distance between 4 and 6 km
and pace >= 5:30 /km
and duration >= 25 min
and title contains "QUIET"
and missing cadence
summarize pace, heart_rate, distance, duration
by day
compare none
sort by distance desc"""
validation = client.post("/stats/validate", json={"query": source})
assert validation.json["valid"] is True
response = client.get("/stats", query_string={"query": source})
assert response.status_code == 200
assert b"A quiet morning run" in response.data
with app.app_context():
result = explore(1, "Australia/Sydney", parse_query({"query": source}))
assert result["count"] == 1
assert len(result["cards"]) == 4
assert client.post("/stats/validate", json={"query": 123}).status_code == 400
assert client.post("/stats/validate", json={"query": "a" * 4001}).json["valid"] is False
def test_validation_requires_csrf_and_does_not_read_activities(app):
client = app.test_client()
login(client)
with app.app_context():
statements = []
def record(_conn, _cursor, statement, _parameters, _context, _many):
statements.append(statement)
event.listen(db.engine, "before_cursor_execute", record)
engine = db.engine
try:
response = client.post("/stats/validate", json={"query": "runs\nsummarize pace"})
assert response.json["valid"] is True
assert not any("FROM activity" in statement for statement in statements)
finally:
event.remove(engine, "before_cursor_execute", record)
app.config["WTF_CSRF_ENABLED"] = True
assert client.post("/stats/validate", json={"query": "runs\nsummarize pace"}).status_code == 400
def test_fractional_units_roundtrip_and_strict_sorting(app):
query = parse_query(
{"query": "runs\nwhere duration >= 1 min\nand distance > 0.123456789 km\nsummarize pace"}
)
assert parse_query({"query": query_text(query)}) == query
with app.app_context():
add_history(3)
db.session.commit()
query = parse_query(
{"query": "runs\nduring 2026-09-01 to 2026-10-02\nsummarize pace\nsort by pace asc"}
)
rows = explore(1, "Australia/Sydney", query)["rows"]
assert rows[-1]["id"] == 1
query["filters"] = [{"field": "pace", "op": "<", "value": 300}]
assert explore(1, "Australia/Sydney", query)["count"] == 0