nicholasg1997
feat: enhance coaching UI with demo group selection and avatar integration
d143012
"""Synthetic Strava-style activities for demos and local testing.
Each demo profile is designed to trip a *different* signal in the analytics
engine, so the demo dropdown doubles as a tour of what the engine detects:
excelling -> increasing_consistency + improving pace, safe build
stagnating -> flat mileage & pace -> pace_trend "plateauing"
returning_after_break -> ~2-week gap at the end -> "returning_after_break"
Dates are relative to "now" and built fresh on every call (via the builder
functions below), so the runs always land in the right rolling-week buckets no
matter how long the app has been running.
"""
from datetime import datetime, timedelta
def _iso(days_ago: float, hour: int = 8) -> str:
d = (datetime.now() - timedelta(days=days_ago)).replace(
hour=hour, minute=0, second=0, microsecond=0
)
return d.isoformat()
def _excelling() -> list[dict]:
"""Consistent runner on a healthy upward trend over ~8 weeks.
Mileage grows ~8% week-over-week (under the 10% spike threshold), pace
steadily improves, and run count ticks up this week (3 -> 4).
"""
return [
# This week (0–6d): 4 runs, ~19.5 km, ~4:55/km
{"type": "Run", "distance": 5000, "moving_time": 1475, "start_date": _iso(1)},
{"type": "Run", "distance": 5500, "moving_time": 1620, "start_date": _iso(3)},
{"type": "Run", "distance": 4000, "moving_time": 1180, "start_date": _iso(5)},
{"type": "Run", "distance": 5000, "moving_time": 1475, "start_date": _iso(6)},
# Last week (7–13d): 3 runs, ~18 km, ~5:05/km
{"type": "Run", "distance": 6000, "moving_time": 1830, "start_date": _iso(8)},
{"type": "Run", "distance": 6000, "moving_time": 1830, "start_date": _iso(10)},
{"type": "Run", "distance": 6000, "moving_time": 1830, "start_date": _iso(12)},
# 2 weeks ago (14–20d): 3 runs, ~16.5 km, ~5:15/km
{"type": "Run", "distance": 5500, "moving_time": 1733, "start_date": _iso(15)},
{"type": "Run", "distance": 5500, "moving_time": 1733, "start_date": _iso(17)},
{"type": "Run", "distance": 5500, "moving_time": 1733, "start_date": _iso(19)},
# 3 weeks ago (21–27d): 3 runs, ~15 km, ~5:25/km
{"type": "Run", "distance": 5000, "moving_time": 1625, "start_date": _iso(22)},
{"type": "Run", "distance": 5000, "moving_time": 1625, "start_date": _iso(24)},
{"type": "Run", "distance": 5000, "moving_time": 1625, "start_date": _iso(26)},
# 4 weeks ago (28–34d): 2 runs, ~13 km, ~5:35/km
{"type": "Run", "distance": 6500, "moving_time": 2178, "start_date": _iso(29)},
{"type": "Run", "distance": 6500, "moving_time": 2178, "start_date": _iso(32)},
# 5 weeks ago (35–41d): 2 runs, ~12 km, ~5:40/km
{"type": "Run", "distance": 6000, "moving_time": 2040, "start_date": _iso(36)},
{"type": "Run", "distance": 6000, "moving_time": 2040, "start_date": _iso(39)},
# 6 weeks ago (42–48d): 2 runs, ~10 km, ~5:45/km
{"type": "Run", "distance": 5000, "moving_time": 1725, "start_date": _iso(43)},
{"type": "Run", "distance": 5000, "moving_time": 1725, "start_date": _iso(46)},
# 7 weeks ago (49–55d): 1 run, ~8 km, ~5:50/km
{"type": "Run", "distance": 8000, "moving_time": 2800, "start_date": _iso(51)},
# --- junk that must be filtered out ---
{"type": "Run", "distance": 40, "moving_time": 9, "start_date": _iso(2)},
{"type": "Ride", "distance": 24000, "moving_time": 3600, "start_date": _iso(4)},
]
def _stagnating() -> list[dict]:
"""Reliable but stuck: same mileage and pace every week for a month.
Trends: mileage change ~0%, pace flat -> "plateauing", no growth signals.
The story the coach should tell is 'add stimulus to break the plateau'.
"""
runs = []
# 8 weeks, 3 runs each, every run 5 km at exactly 5:20/km.
for week in range(8):
base = week * 7
for offset in (1, 3, 5):
runs.append(
{"type": "Run", "distance": 5000, "moving_time": 1600,
"start_date": _iso(base + offset)}
)
# a little junk for realism
runs.append({"type": "Ride", "distance": 18000, "moving_time": 3000, "start_date": _iso(4)})
return runs
def _returning_after_break() -> list[dict]:
"""Trained well, then took ~2 weeks off — last run was 16 days ago.
days_since_last_run > 10 -> "returning_after_break". This and last week are
empty, so the chart shows the drop-off and the coach should ease them back in.
"""
return [
# This week & last week (0–13d): EMPTY — the break.
# 2 weeks ago (14–20d): last runs before the break
{"type": "Run", "distance": 5000, "moving_time": 1600, "start_date": _iso(16)},
{"type": "Run", "distance": 6000, "moving_time": 1980, "start_date": _iso(18)},
{"type": "Run", "distance": 5000, "moving_time": 1650, "start_date": _iso(20)},
# 3 weeks ago (21–27d): a solid block
{"type": "Run", "distance": 6000, "moving_time": 2040, "start_date": _iso(23)},
{"type": "Run", "distance": 7000, "moving_time": 2380, "start_date": _iso(25)},
{"type": "Run", "distance": 5000, "moving_time": 1700, "start_date": _iso(27)},
# 4 weeks ago (28–34d)
{"type": "Run", "distance": 6000, "moving_time": 2010, "start_date": _iso(29)},
{"type": "Run", "distance": 6000, "moving_time": 2010, "start_date": _iso(31)},
{"type": "Run", "distance": 7000, "moving_time": 2345, "start_date": _iso(33)},
# 5 weeks ago (35–41d)
{"type": "Run", "distance": 5000, "moving_time": 1700, "start_date": _iso(36)},
{"type": "Run", "distance": 6000, "moving_time": 2040, "start_date": _iso(38)},
# 6 weeks ago (42–48d)
{"type": "Run", "distance": 6000, "moving_time": 2010, "start_date": _iso(43)},
{"type": "Run", "distance": 6000, "moving_time": 2010, "start_date": _iso(45)},
{"type": "Run", "distance": 5000, "moving_time": 1700, "start_date": _iso(47)},
# 7 weeks ago (49–55d)
{"type": "Run", "distance": 5000, "moving_time": 1725, "start_date": _iso(51)},
{"type": "Run", "distance": 6000, "moving_time": 2070, "start_date": _iso(54)},
# --- junk that must be filtered out ---
{"type": "Run", "distance": 25, "moving_time": 6, "start_date": _iso(17)},
]
_DEMOS = {
"excelling": _excelling,
"stagnating": _stagnating,
"returning_after_break": _returning_after_break,
}
# Keep the dropdown choices and the dispatch in sync from one source of truth.
DEMO_GROUPS = tuple(_DEMOS)
def demo_activities(demo: str = "excelling") -> list[dict]:
"""Return a fresh synthetic activity list for the chosen demo profile."""
builder = _DEMOS.get(demo, _excelling)
return builder()