Make stats DSL primary with highlighted editor, completion and live diagnostics
This commit is contained in:
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+344
@@ -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; }
|
||||
Vendored
+9884
File diff suppressed because it is too large
Load Diff
Vendored
+37
@@ -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;
|
||||
}
|
||||
Vendored
+523
@@ -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
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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 & 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 <= 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: >, >=, <, <=, =, !=. 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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user