Add saved analysis workspace and live dashboard statistics
This commit is contained in:
+113
-13
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user