Make stats DSL primary with highlighted editor, completion and live diagnostics
This commit is contained in:
+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.")
|
||||
|
||||
Reference in New Issue
Block a user