Add saved analysis workspace and live dashboard statistics

This commit is contained in:
Peter Stockings
2026-10-03 12:27:20 +10:00
parent 5ede1a01d2
commit e6e2f22479
31 changed files with 1986 additions and 131 deletions
+113 -13
View File
@@ -71,7 +71,7 @@ def formatted(number, key, q):
return "—"
spec = q["aggregates"].get(key)
raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else key
if raw == "pace":
if raw in {"pace", "gap"}:
return format_value(number, "pace")
if key == "count" or (spec and spec["function"] == "count"):
return f"{number:,.0f}"
@@ -130,9 +130,12 @@ def ordered(rows, ordering):
def prepare(raw, zone, q, catalog):
rows, windows, totals = [], {7: deque(), 28: deque()}, {7: 0.0, 28: 0.0}
previous, last_day, streak = None, None, 0
trees = [
(spec["name"], expression_tree(spec["expression"], {**catalog})[0]) for spec in q["derived"]
]
trees = {
spec["name"]: expression_tree(spec["expression"], catalog)[0]
for spec in q["derived"]
if "window" not in spec
}
custom_windows = {spec["name"]: deque() for spec in q["derived"] if "window" in spec}
for record in raw:
row = SimpleNamespace(**record._mapping)
row.instant = (
@@ -150,6 +153,7 @@ def prepare(raw, zone, q, catalog):
if row.distance_m > 0
else None,
max_heart_rate=(row.analysis_summary or {}).get("max_hr")
or (row.analysis_summary or {}).get("summary", {}).get("max_hr")
or (row.metrics or {}).get("max_heart_rate"),
calories=(row.metrics or {}).get("total_calories"),
training_effect=(row.metrics or {}).get("total_training_effect"),
@@ -189,8 +193,17 @@ def prepare(raw, zone, q, catalog):
streak = streak + 1 if last_day and (row.day - last_day).days == 1 else 1
last_day = row.day
row.values["run_day_streak"] = streak
for name, tree in trees:
row.values[name] = evaluate(tree, row.values)
for spec in q["derived"]:
name = spec["name"]
if "window" not in spec:
row.values[name] = evaluate(trees[name], row.values)
continue
window = custom_windows[name]
config = spec["window"]
while window and (row.instant - window[0][0]).total_seconds() >= config["days"] * 86400:
window.popleft()
window.append((row.instant, row.values[config["field"]]))
row.values[name] = statistic([item[1] for item in window], config["function"])
rows.append(row)
previous = row
return rows
@@ -199,9 +212,19 @@ def prepare(raw, zone, q, catalog):
def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False):
zone, catalog = ZoneInfo(timezone), catalog_for(q)
start, end, prior_start = period_bounds(q, timezone)
prior_end = start
if q["compare"] and q["comparison"] == "year":
import calendar
def previous_year(day):
return day.replace(
year=day.year - 1, day=min(day.day, calendar.monthrange(day.year - 1, day.month)[1])
)
prior_start, prior_end = previous_year(start), previous_year(end)
boundary = lambda day: datetime.combine(day, datetime.min.time(), zone).astimezone(UTC)
# Temporal columns use every earlier run, independently of query filters.
raw = db.session.execute(
statement = (
db.select(
Activity.id,
Activity.title,
@@ -219,8 +242,20 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
Activity.started_at < boundary(end),
)
.order_by(Activity.started_at, Activity.id)
).all()
)
if q["source"] != "runs" and not q["all_time"]:
lower = min(
start,
prior_start if q["compare"] else start,
start - timedelta(days=q["baseline"] or 0),
)
statement = statement.where(Activity.started_at >= boundary(lower))
raw = db.session.execute(statement).all()
history = prepare(raw, zone, q, catalog)
if q["source"] != "runs":
from .stats_sources import expand
history = expand(history, q, catalog)
if q["all_time"] and history:
start = history[0].day
prior_start = start
@@ -242,7 +277,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
[
row
for row in history
if q["compare"] and prior_start <= row.day < start and included(row)
if q["compare"] and prior_start <= row.day < prior_end and included(row)
],
q["sort"],
)
@@ -288,7 +323,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return f"{token:02d}:00"
if group == "run":
row = next(row for row in current if row.id == token)
return row.local.strftime("%d %b %Y %H:%M") + f" · #{row.id}"
return row.local.strftime("%d %b %Y %H:%M") + (
f" · {row.title}" if q["source"] != "runs" else f" · #{row.id}"
)
if group == "bin":
unit = catalog[width["field"]][1]
return f"{token * width['width']:g}–{(token + 1) * width['width']:g} {unit}"
@@ -372,11 +409,16 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
kept_prior = {row.id for token in tokens for row in prior_groups[token]}
prior = [row for row in prior if row.id in kept_prior]
descriptors = {key: describe(key, catalog, q["aggregates"]) for key in keys}
for key, title in q["labels"].items():
if key in descriptors:
descriptors[key] = (title, *descriptors[key][1:])
cards = []
for key in keys:
number, coverage = summarize(current, key, q)
before, _ = summarize(prior, key, q)
title, unit, definition = descriptors[key]
if unit == "runs":
unit = q["source"]
cards.append(
{
"key": key,
@@ -438,6 +480,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return results
title, unit, _ = descriptors[key]
if unit == "runs":
unit = q["source"]
pace_axis = unit == "/km"
if q["trend"] == "percent_change":
unit, pace_axis = "%", False
@@ -459,7 +503,57 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"type": q["chart"],
"group": group,
"trend": q["trend"],
"display": q["display"],
"reference": q["reference"],
"dates": [token.isoformat() for token in tokens] if group == "day" else [],
"source": q["source"],
}
if q["cohorts"]:
cohorts = [{"label": cohort["name"], "values": []} for cohort in q["cohorts"]]
unassigned = {"label": "Other / missing", "values": []}
for token in tokens:
partitions = [[] for _ in range(len(cohorts) + 1)]
for row in current_groups[token]:
selected = next(
(
i
for i, cohort in enumerate(q["cohorts"])
if matches(row, cohort["conditions"])
),
len(cohorts),
)
partitions[selected].append(row)
for partition, cohort in zip(partitions, [*cohorts, unassigned], strict=True):
cohort["values"].append(measure(partition, keys[0]))
chart["cohorts"] = [*cohorts, unassigned]
from .stats_insights import enrich
insights = enrich(
chart, q, keys, tokens, current_groups, history, included, start, measure, catalog
)
chart["drill_labels"] = []
for key, series in zip(keys, chart["series"], strict=True):
histogram = series.get("histogram")
if not histogram or not histogram["counts"]:
continue
field = q["aggregates"].get(key, {}).get("field") or (
"count" if key in q["aggregates"] else key
)
edges = histogram["edges"]
bins = [[] for _ in histogram["counts"]]
for row in current:
number = value(row, field)
if valid(number):
index = min(len(bins) - 1, int((number - edges[0]) / histogram["width"]))
bins[index].append(row)
histogram["buckets"] = []
for index in range(len(histogram["counts"])):
name = f"Distribution · {key} · {index + 1}: {edges[index]:g}–{edges[index + 1]:g} {catalog[field][1]}"
rows = bins[index]
evidence_groups[name] = rows
raw_evidence_groups[name] = rows
histogram["buckets"].append(name)
chart["drill_labels"].append(name)
correlation = None
if q["plot"]:
x, y = q["plot"]
@@ -467,8 +561,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
{
"x": value(row, x),
"y": value(row, y),
"id": row.id,
"id": getattr(row, "activity_id", row.id),
"label": row.title + " · " + row.local.strftime("%d %b %Y"),
"color": value(row, q["color"]) if q["color"] else None,
"size": value(row, q["size"]) if q["size"] else None,
}
for row in rows
if valid(value(row, x)) and valid(value(row, y))
@@ -486,6 +582,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"current": plotted,
"previous": points(prior) if q["compare"] else [],
"correlation": correlation,
"color": catalog[q["color"]] if q["color"] else None,
"size": catalog[q["size"]] if q["size"] else None,
}
evidence = current
if selected_bucket is not None:
@@ -509,7 +607,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
for key in keys
]
return {
"id": row.id,
"id": getattr(row, "activity_id", row.id),
"title": row.title,
"date": row.local.strftime("%d %b %Y %H:%M"),
"distance": f"{row.distance_m / 1000:.2f}",
@@ -526,7 +624,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return {
"cards": cards,
"source": q["source"],
"chart": chart,
"insights": insights,
"groups": groups,
"evidence_columns": [
{"key": field, "label": catalog[field][0], "unit": catalog[field][1]}
@@ -546,7 +646,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"start": start,
"end": end - timedelta(days=1),
"previous_start": prior_start,
"previous_end": start - timedelta(days=1),
"previous_end": prior_end - timedelta(days=1),
"page": page,
"pages": pages,
"evidence_count": len(evidence),