VAM: drop duration curve, show avg climbing VAM in Nerd Corner
Remove the per-duration VAM curve everywhere (metrics, summaries, detail JSON, athlete.json, VamChart.svelte, AthleteView VAM tab). Keep only climbing_vam_mh per activity. Add it to activity summaries so NerdCorner can plot average climbing VAM per week/month year-over-year alongside distance/elevation/time. Add --backfill-vam-summary flag to copy the field from existing detail JSONs into index.json without re-extracting.
This commit is contained in:
+18
-59
@@ -14,10 +14,7 @@ from bincio.extract.models import DataPoint, ParsedActivity
|
|||||||
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
|
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
|
||||||
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
|
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
|
||||||
|
|
||||||
# VAM curve durations — start at 60 s (shorter windows are too noisy for elevation data).
|
|
||||||
VAM_DURATIONS_S = [60, 120, 180, 300, 600, 1200, 1800, 3600]
|
|
||||||
_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"})
|
_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"})
|
||||||
_MIN_CLIMB_GAIN_M = 10.0 # minimum net gain in a window for VAM to be meaningful
|
|
||||||
|
|
||||||
# Standard best-effort distances (km) per sport.
|
# Standard best-effort distances (km) per sport.
|
||||||
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
|
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
|
||||||
@@ -67,8 +64,7 @@ class ComputedMetrics:
|
|||||||
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
|
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
|
||||||
best_efforts: Optional[list[list[float]]]
|
best_efforts: Optional[list[list[float]]]
|
||||||
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
||||||
climbing_vam_mh: Optional[int] # VAM on ascending segments only (m/h)
|
climbing_vam_mh: Optional[int] # average VAM on ascending segments only (m/h)
|
||||||
vam_curve: Optional[list[list[int]]] # [[duration_s, vam_mh], ...]
|
|
||||||
|
|
||||||
|
|
||||||
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||||
@@ -88,7 +84,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
|||||||
start_ll, end_ll = _endpoints(pts)
|
start_ll, end_ll = _endpoints(pts)
|
||||||
mmp = compute_mmp(pts, activity.started_at)
|
mmp = compute_mmp(pts, activity.started_at)
|
||||||
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
||||||
climbing_vam_mh, vam_curve = compute_vam(pts, activity.started_at, activity.sport)
|
climbing_vam_mh = compute_vam(pts, activity.started_at, activity.sport)
|
||||||
|
|
||||||
return ComputedMetrics(
|
return ComputedMetrics(
|
||||||
distance_m=distance_m,
|
distance_m=distance_m,
|
||||||
@@ -111,7 +107,6 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
|||||||
best_efforts=best_efforts,
|
best_efforts=best_efforts,
|
||||||
best_climb_m=best_climb_m,
|
best_climb_m=best_climb_m,
|
||||||
climbing_vam_mh=climbing_vam_mh,
|
climbing_vam_mh=climbing_vam_mh,
|
||||||
vam_curve=vam_curve,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,33 +183,18 @@ def _rolling_mean_ele(data: list[float], win: int) -> list[float]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _vam_from_ele_1hz(ele_1hz: list[float]) -> tuple[Optional[int], Optional[list[list[int]]]]:
|
def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[int]:
|
||||||
"""Core VAM computation from a dense 1 Hz elevation array."""
|
"""Climbing VAM from a dense 1 Hz elevation array.
|
||||||
|
|
||||||
|
Accumulates gain and time only on ascending seconds, identified by a 30 s
|
||||||
|
forward-lookahead on the smoothed elevation signal.
|
||||||
|
Returns climbing_vam_mh (m/h), or None when there is too little climbing data.
|
||||||
|
"""
|
||||||
n = len(ele_1hz)
|
n = len(ele_1hz)
|
||||||
if n < 60:
|
if n < 60:
|
||||||
return None, None
|
return None
|
||||||
ele_smooth = _rolling_mean_ele(ele_1hz, 30)
|
ele_smooth = _rolling_mean_ele(ele_1hz, 30)
|
||||||
|
|
||||||
# VAM curve: best VAM per standard duration, windows with net gain ≥ threshold only
|
|
||||||
vam_results: list[list[int]] = []
|
|
||||||
for d in VAM_DURATIONS_S:
|
|
||||||
if d >= n:
|
|
||||||
break
|
|
||||||
best_vam: Optional[float] = None
|
|
||||||
for i in range(n - d):
|
|
||||||
net_gain = ele_smooth[i + d] - ele_smooth[i]
|
|
||||||
if net_gain < _MIN_CLIMB_GAIN_M:
|
|
||||||
continue
|
|
||||||
vam = net_gain * 3600.0 / d
|
|
||||||
if best_vam is None or vam > best_vam:
|
|
||||||
best_vam = vam
|
|
||||||
if best_vam is not None:
|
|
||||||
vam_results.append([d, round(best_vam)])
|
|
||||||
vam_curve: Optional[list[list[int]]] = vam_results if vam_results else None
|
|
||||||
|
|
||||||
# Climbing VAM: accumulate gain and time only on ascending seconds.
|
|
||||||
# A second is climbing if the 30 s forward elevation gain exceeds 2 m
|
|
||||||
# (roughly 1 % gradient at 7 km/h).
|
|
||||||
climbing_gain = 0.0
|
climbing_gain = 0.0
|
||||||
climbing_time = 0
|
climbing_time = 0
|
||||||
for i in range(n - 1):
|
for i in range(n - 1):
|
||||||
@@ -225,11 +205,9 @@ def _vam_from_ele_1hz(ele_1hz: list[float]) -> tuple[Optional[int], Optional[lis
|
|||||||
climbing_gain += inst
|
climbing_gain += inst
|
||||||
climbing_time += 1
|
climbing_time += 1
|
||||||
|
|
||||||
climbing_vam_mh: Optional[int] = None
|
|
||||||
if climbing_time >= 60 and climbing_gain >= 5.0:
|
if climbing_time >= 60 and climbing_gain >= 5.0:
|
||||||
climbing_vam_mh = round(climbing_gain * 3600.0 / climbing_time)
|
return round(climbing_gain * 3600.0 / climbing_time)
|
||||||
|
return None
|
||||||
return climbing_vam_mh, vam_curve
|
|
||||||
|
|
||||||
|
|
||||||
def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
|
def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
|
||||||
@@ -255,18 +233,14 @@ def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
|
|||||||
return [e if e is not None else first_valid for e in ele_raw]
|
return [e if e is not None else first_valid for e in ele_raw]
|
||||||
|
|
||||||
|
|
||||||
def compute_vam(
|
def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[int]:
|
||||||
pts: list[DataPoint],
|
"""Compute average climbing VAM (m/h) from DataPoints.
|
||||||
started_at: datetime,
|
|
||||||
sport: str,
|
|
||||||
) -> tuple[Optional[int], Optional[list[list[int]]]]:
|
|
||||||
"""Compute climbing VAM and VAM duration curve from DataPoints.
|
|
||||||
|
|
||||||
Returns (climbing_vam_mh, vam_curve).
|
|
||||||
Only computed for cycling, running, hiking, walking.
|
Only computed for cycling, running, hiking, walking.
|
||||||
|
Returns None when the activity has insufficient climbing data.
|
||||||
"""
|
"""
|
||||||
if sport not in _VAM_SPORTS:
|
if sport not in _VAM_SPORTS:
|
||||||
return None, None
|
return None
|
||||||
sparse: dict[int, Optional[float]] = {}
|
sparse: dict[int, Optional[float]] = {}
|
||||||
last_t = -1
|
last_t = -1
|
||||||
for p in pts:
|
for p in pts:
|
||||||
@@ -277,22 +251,7 @@ def compute_vam(
|
|||||||
last_t = t
|
last_t = t
|
||||||
ele_1hz = _build_ele_1hz(sparse)
|
ele_1hz = _build_ele_1hz(sparse)
|
||||||
if ele_1hz is None:
|
if ele_1hz is None:
|
||||||
return None, None
|
return None
|
||||||
return _vam_from_ele_1hz(ele_1hz)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_vam_from_timeseries(ts: dict, sport: str) -> tuple[Optional[int], Optional[list[list[int]]]]:
|
|
||||||
"""Compute VAM from a stored timeseries dict (used for backfill without re-parsing files)."""
|
|
||||||
if sport not in _VAM_SPORTS:
|
|
||||||
return None, None
|
|
||||||
t_vals = ts.get("t") or []
|
|
||||||
ele_vals = ts.get("elevation_m") or []
|
|
||||||
if not t_vals or not ele_vals:
|
|
||||||
return None, None
|
|
||||||
sparse: dict[int, Optional[float]] = {int(t): e for t, e in zip(t_vals, ele_vals)}
|
|
||||||
ele_1hz = _build_ele_1hz(sparse)
|
|
||||||
if ele_1hz is None:
|
|
||||||
return None, None
|
|
||||||
return _vam_from_ele_1hz(ele_1hz)
|
return _vam_from_ele_1hz(ele_1hz)
|
||||||
|
|
||||||
|
|
||||||
@@ -659,5 +618,5 @@ def _empty() -> ComputedMetrics:
|
|||||||
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
||||||
bbox=None, start_latlng=None, end_latlng=None,
|
bbox=None, start_latlng=None, end_latlng=None,
|
||||||
mmp=None, best_efforts=None, best_climb_m=None,
|
mmp=None, best_efforts=None, best_climb_m=None,
|
||||||
climbing_vam_mh=None, vam_curve=None,
|
climbing_vam_mh=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ def write_activity(
|
|||||||
"best_efforts": metrics.best_efforts,
|
"best_efforts": metrics.best_efforts,
|
||||||
"best_climb_m": metrics.best_climb_m,
|
"best_climb_m": metrics.best_climb_m,
|
||||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||||
"vam_curve": metrics.vam_curve,
|
|
||||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||||
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -259,7 +258,7 @@ def build_summary(
|
|||||||
"mmp": metrics.mmp,
|
"mmp": metrics.mmp,
|
||||||
"best_efforts": metrics.best_efforts,
|
"best_efforts": metrics.best_efforts,
|
||||||
"best_climb_m": metrics.best_climb_m,
|
"best_climb_m": metrics.best_climb_m,
|
||||||
"vam_curve": metrics.vam_curve,
|
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||||
"source": _infer_source(activity),
|
"source": _infer_source(activity),
|
||||||
"privacy": privacy,
|
"privacy": privacy,
|
||||||
"detail_url": f"activities/{activity_id}.json",
|
"detail_url": f"activities/{activity_id}.json",
|
||||||
@@ -303,25 +302,6 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
|||||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
|
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
|
||||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
|
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
|
||||||
|
|
||||||
# ── VAM curve aggregation ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _merge_vam_curves(vam_lists: list[list[list[int]]]) -> list[list[int]]:
|
|
||||||
best: dict[int, int] = {}
|
|
||||||
for vc in vam_lists:
|
|
||||||
for d, v in vc:
|
|
||||||
if d not in best or v > best[d]:
|
|
||||||
best[d] = v
|
|
||||||
return [[d, v] for d, v in sorted(best.items())]
|
|
||||||
|
|
||||||
_VAM_SPORTS = {"cycling", "running", "hiking", "walking"}
|
|
||||||
|
|
||||||
def _has_vam(s: dict) -> bool:
|
|
||||||
return bool(s.get("vam_curve")) and s.get("sport") in _VAM_SPORTS and _is_outdoor(s)
|
|
||||||
|
|
||||||
all_vams = [s["vam_curve"] for s in summaries if _has_vam(s)]
|
|
||||||
vams_365 = [s["vam_curve"] for s in summaries if _has_vam(s) and s["started_at"] >= cutoff_365]
|
|
||||||
vams_90 = [s["vam_curve"] for s in summaries if _has_vam(s) and s["started_at"] >= cutoff_90]
|
|
||||||
|
|
||||||
# ── Personal records aggregation ──────────────────────────────────────────
|
# ── Personal records aggregation ──────────────────────────────────────────
|
||||||
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
|
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
|
||||||
# best_climb[activity_id] = {climb_m, started_at, title}
|
# best_climb[activity_id] = {climb_m, started_at, title}
|
||||||
@@ -390,11 +370,6 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
|||||||
"last_365d": _merge_mmps(mmps_365) if mmps_365 else None,
|
"last_365d": _merge_mmps(mmps_365) if mmps_365 else None,
|
||||||
"last_90d": _merge_mmps(mmps_90) if mmps_90 else None,
|
"last_90d": _merge_mmps(mmps_90) if mmps_90 else None,
|
||||||
},
|
},
|
||||||
"vam_curve": {
|
|
||||||
"all_time": _merge_vam_curves(all_vams) if all_vams else None,
|
|
||||||
"last_365d": _merge_vam_curves(vams_365) if vams_365 else None,
|
|
||||||
"last_90d": _merge_vam_curves(vams_90) if vams_90 else None,
|
|
||||||
},
|
|
||||||
"records": {
|
"records": {
|
||||||
sport: _serialise_sport_records(records[sport])
|
sport: _serialise_sport_records(records[sport])
|
||||||
for sport in SPORTS
|
for sport in SPORTS
|
||||||
|
|||||||
+22
-33
@@ -116,7 +116,7 @@ def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None:
|
|||||||
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
|
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
|
||||||
|
|
||||||
targets = [data / handle] if handle else _user_dirs(data)
|
targets = [data / handle] if handle else _user_dirs(data)
|
||||||
_COMPUTED = {"bas_version", "generated_at", "power_curve", "vam_curve", "records", "best_climbs"}
|
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
|
||||||
for user_dir in targets:
|
for user_dir in targets:
|
||||||
index_path = user_dir / "index.json"
|
index_path = user_dir / "index.json"
|
||||||
if not index_path.exists():
|
if not index_path.exists():
|
||||||
@@ -377,10 +377,12 @@ def _link_data(site: Path, data: Path) -> None:
|
|||||||
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
|
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
|
||||||
"""Recompute climbing_vam_mh and vam_curve for all activities from stored timeseries."""
|
"""Copy climbing_vam_mh from detail JSONs into index.json summaries.
|
||||||
|
|
||||||
|
Needed once after the vam_curve→climbing_vam_mh-in-summary migration.
|
||||||
|
"""
|
||||||
import json
|
import json
|
||||||
from bincio.extract.metrics import compute_vam_from_timeseries
|
|
||||||
|
|
||||||
targets = [data / handle] if handle else _user_dirs(data)
|
targets = [data / handle] if handle else _user_dirs(data)
|
||||||
for user_dir in targets:
|
for user_dir in targets:
|
||||||
@@ -394,30 +396,17 @@ def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for act_path in acts_dir.glob("*.json"):
|
for s in index_data.get("activities", []):
|
||||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
if "climbing_vam_mh" in s:
|
||||||
continue
|
continue # already backfilled
|
||||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
act_path = acts_dir / f"{s['id']}.json"
|
||||||
if not ts_path.exists():
|
if not act_path.exists():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||||
sport = detail.get("sport", "other")
|
vam = detail.get("climbing_vam_mh")
|
||||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
if vam is not None:
|
||||||
new_vam, new_curve = compute_vam_from_timeseries(ts, sport)
|
s["climbing_vam_mh"] = vam
|
||||||
if (new_vam == detail.get("climbing_vam_mh")
|
|
||||||
and new_curve == detail.get("vam_curve")):
|
|
||||||
continue
|
|
||||||
detail["climbing_vam_mh"] = new_vam
|
|
||||||
detail["vam_curve"] = new_curve
|
|
||||||
act_path.write_text(
|
|
||||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
||||||
)
|
|
||||||
act_id = act_path.stem
|
|
||||||
for s in index_data.get("activities", []):
|
|
||||||
if s.get("id") == act_id:
|
|
||||||
s["vam_curve"] = new_curve
|
|
||||||
break
|
|
||||||
updated += 1
|
updated += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -426,7 +415,7 @@ def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
|||||||
index_path.write_text(
|
index_path.write_text(
|
||||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
)
|
)
|
||||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} activity(ies) updated")
|
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated")
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@@ -452,9 +441,9 @@ def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
|||||||
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
|
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
|
||||||
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
|
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
|
||||||
"(run once after upgrading the dropout-skip fix).")
|
"(run once after upgrading the dropout-skip fix).")
|
||||||
@click.option("--recompute-vam", "recompute_vam", is_flag=True,
|
@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True,
|
||||||
help="Recompute climbing_vam_mh and vam_curve for all activities from stored "
|
help="Copy climbing_vam_mh from detail JSONs into index.json summaries "
|
||||||
"timeseries (run once after adding VAM support).")
|
"(run once after the VAM curve → summary migration).")
|
||||||
def render(
|
def render(
|
||||||
config_path: Optional[str],
|
config_path: Optional[str],
|
||||||
data_dir: Optional[str],
|
data_dir: Optional[str],
|
||||||
@@ -466,7 +455,7 @@ def render(
|
|||||||
no_build: bool,
|
no_build: bool,
|
||||||
recompute_climbs: bool,
|
recompute_climbs: bool,
|
||||||
recompute_elevation: bool,
|
recompute_elevation: bool,
|
||||||
recompute_vam: bool,
|
backfill_vam_summary: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
||||||
|
|
||||||
@@ -484,9 +473,9 @@ def render(
|
|||||||
console.print("Recomputing elevation gain/loss from timeseries…")
|
console.print("Recomputing elevation gain/loss from timeseries…")
|
||||||
_recompute_elevation(data, handle=handle)
|
_recompute_elevation(data, handle=handle)
|
||||||
|
|
||||||
if recompute_vam:
|
if backfill_vam_summary:
|
||||||
console.print("Recomputing VAM from timeseries…")
|
console.print("Backfilling climbing_vam_mh into summaries…")
|
||||||
_recompute_vam(data, handle=handle)
|
_backfill_vam_summary(data, handle=handle)
|
||||||
|
|
||||||
_merge_edits(data, handle=handle)
|
_merge_edits(data, handle=handle)
|
||||||
_rebuild_athlete_json(data, handle=handle)
|
_rebuild_athlete_json(data, handle=handle)
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ FEED_PAGE_SIZE = 50
|
|||||||
# Extra fields stripped from the combined feed — preview_coords is the biggest
|
# Extra fields stripped from the combined feed — preview_coords is the biggest
|
||||||
# contributor (~24% of shard size) but the feed cards need it for thumbnails,
|
# contributor (~24% of shard size) but the feed cards need it for thumbnails,
|
||||||
# so we keep it. mmp is never displayed in feed cards.
|
# so we keep it. mmp is never displayed in feed cards.
|
||||||
_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp", "vam_curve"}
|
_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
|
||||||
|
|
||||||
|
|
||||||
def write_combined_feed(data_dir: Path) -> int:
|
def write_combined_feed(data_dir: Path) -> int:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
||||||
import MmpChart from './MmpChart.svelte';
|
import MmpChart from './MmpChart.svelte';
|
||||||
import VamChart from './VamChart.svelte';
|
|
||||||
import RecordsView from './RecordsView.svelte';
|
import RecordsView from './RecordsView.svelte';
|
||||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
import Explore from './Explore.svelte';
|
import Explore from './Explore.svelte';
|
||||||
@@ -20,13 +19,12 @@
|
|||||||
|
|
||||||
let athlete: AthleteJson | null = null;
|
let athlete: AthleteJson | null = null;
|
||||||
let activities: ActivitySummary[] = [];
|
let activities: ActivitySummary[] = [];
|
||||||
let vamActivities: ActivitySummary[] = [];
|
|
||||||
let allActivities: ActivitySummary[] = [];
|
let allActivities: ActivitySummary[] = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let drawerOpen = false;
|
let drawerOpen = false;
|
||||||
|
|
||||||
type Tab = 'power' | 'vam' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
||||||
let activeTab: Tab = 'power';
|
let activeTab: Tab = 'power';
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
let isOwner = false;
|
let isOwner = false;
|
||||||
@@ -96,7 +94,7 @@
|
|||||||
isOwner = (e as CustomEvent<string>).detail === handle;
|
isOwner = (e as CustomEvent<string>).detail === handle;
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
const TABS: Tab[] = ['power', 'vam', 'records', 'segments', 'profile', 'explore', 'nerd'];
|
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd'];
|
||||||
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
||||||
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||||||
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
||||||
@@ -133,7 +131,6 @@
|
|||||||
athlete = resolvedAthlete;
|
athlete = resolvedAthlete;
|
||||||
allActivities = index.activities.filter(a => !isUnlisted(a.privacy));
|
allActivities = index.activities.filter(a => !isUnlisted(a.privacy));
|
||||||
activities = allActivities.filter(a => a.mmp);
|
activities = allActivities.filter(a => a.mmp);
|
||||||
vamActivities = allActivities.filter(a => a.vam_curve);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -159,19 +156,15 @@
|
|||||||
return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`;
|
return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean; requiresVam?: boolean }[] = [
|
const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean }[] = [
|
||||||
{ key: 'power', label: 'Power Curve' },
|
{ key: 'power', label: 'Power Curve' },
|
||||||
{ key: 'vam', label: 'VAM Curve', requiresVam: true },
|
|
||||||
{ key: 'records', label: 'Records' },
|
{ key: 'records', label: 'Records' },
|
||||||
{ key: 'segments', label: 'Segments' },
|
{ key: 'segments', label: 'Segments' },
|
||||||
{ key: 'profile', label: 'Profile' },
|
{ key: 'profile', label: 'Profile' },
|
||||||
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
||||||
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
||||||
];
|
];
|
||||||
$: TABS = ALL_TABS.filter(t =>
|
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
||||||
(!t.ownerOnly || isOwner) &&
|
|
||||||
(!t.requiresVam || athlete?.vam_curve?.all_time != null)
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -223,16 +216,6 @@
|
|||||||
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p>
|
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- VAM Curve tab -->
|
|
||||||
{:else if activeTab === 'vam'}
|
|
||||||
{#if athlete.vam_curve?.all_time}
|
|
||||||
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
|
|
||||||
<VamChart {athlete} activities={vamActivities} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-zinc-500 text-sm">No climbing data found.</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Records tab -->
|
<!-- Records tab -->
|
||||||
{:else if activeTab === 'records'}
|
{:else if activeTab === 'records'}
|
||||||
<RecordsView {athlete} {base} />
|
<RecordsView {athlete} {base} />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
export let activities: ActivitySummary[] = [];
|
export let activities: ActivitySummary[] = [];
|
||||||
|
|
||||||
type Metric = 'distance' | 'elevation' | 'time';
|
type Metric = 'distance' | 'elevation' | 'time' | 'vam';
|
||||||
type Granularity = 'week' | 'month';
|
type Granularity = 'week' | 'month';
|
||||||
|
|
||||||
let metric: Metric = 'distance';
|
let metric: Metric = 'distance';
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
distance: 'Distance (km)',
|
distance: 'Distance (km)',
|
||||||
elevation: 'Elevation gain (m)',
|
elevation: 'Elevation gain (m)',
|
||||||
time: 'Moving time (h)',
|
time: 'Moving time (h)',
|
||||||
|
vam: 'Avg climbing VAM (m/h)',
|
||||||
};
|
};
|
||||||
const METRIC_FMT: Record<Metric, (v: number) => string> = {
|
const METRIC_FMT: Record<Metric, (v: number) => string> = {
|
||||||
distance: v => `${Math.round(v)} km`,
|
distance: v => `${Math.round(v)} km`,
|
||||||
elevation: v => `${Math.round(v)} m`,
|
elevation: v => `${Math.round(v)} m`,
|
||||||
time: v => `${v.toFixed(1)} h`,
|
time: v => `${v.toFixed(1)} h`,
|
||||||
|
vam: v => `${Math.round(v)} m/h`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cool→warm ramp for past years; current year is always blue-400
|
// Cool→warm ramp for past years; current year is always blue-400
|
||||||
@@ -57,18 +59,34 @@
|
|||||||
function buildData(acts: ActivitySummary[], m: Metric, g: Granularity) {
|
function buildData(acts: ActivitySummary[], m: Metric, g: Granularity) {
|
||||||
const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1;
|
const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1;
|
||||||
const byYear = new Map<number, Map<number, number>>();
|
const byYear = new Map<number, Map<number, number>>();
|
||||||
|
const byYearCnt = new Map<number, Map<number, number>>(); // for VAM averaging
|
||||||
|
|
||||||
for (const act of acts) {
|
for (const act of acts) {
|
||||||
if (!act.started_at) continue;
|
if (!act.started_at) continue;
|
||||||
|
if (m === 'vam' && act.climbing_vam_mh == null) continue;
|
||||||
const d = new Date(act.started_at);
|
const d = new Date(act.started_at);
|
||||||
const yr = d.getFullYear();
|
const yr = d.getFullYear();
|
||||||
const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1;
|
const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1;
|
||||||
const val = m === 'distance' ? (act.distance_m ?? 0) / 1000
|
const val = m === 'distance' ? (act.distance_m ?? 0) / 1000
|
||||||
: m === 'elevation' ? (act.elevation_gain_m ?? 0)
|
: m === 'elevation' ? (act.elevation_gain_m ?? 0)
|
||||||
|
: m === 'vam' ? (act.climbing_vam_mh ?? 0)
|
||||||
: (act.moving_time_s ?? 0) / 3600;
|
: (act.moving_time_s ?? 0) / 3600;
|
||||||
if (!byYear.has(yr)) byYear.set(yr, new Map());
|
if (!byYear.has(yr)) byYear.set(yr, new Map());
|
||||||
|
if (!byYearCnt.has(yr)) byYearCnt.set(yr, new Map());
|
||||||
const ym = byYear.get(yr)!;
|
const ym = byYear.get(yr)!;
|
||||||
|
const ymc = byYearCnt.get(yr)!;
|
||||||
ym.set(per, (ym.get(per) ?? 0) + val);
|
ym.set(per, (ym.get(per) ?? 0) + val);
|
||||||
|
ymc.set(per, (ymc.get(per) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAM: convert sums to averages
|
||||||
|
if (m === 'vam') {
|
||||||
|
for (const [yr, ym] of byYear) {
|
||||||
|
const ymc = byYearCnt.get(yr)!;
|
||||||
|
for (const [per, sum] of ym) {
|
||||||
|
ym.set(per, sum / (ymc.get(per) ?? 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const years = [...byYear.keys()].sort();
|
const years = [...byYear.keys()].sort();
|
||||||
@@ -241,6 +259,7 @@
|
|||||||
<button class="pill" class:active={metric === 'distance'} on:click={() => metric = 'distance'}>Distance</button>
|
<button class="pill" class:active={metric === 'distance'} on:click={() => metric = 'distance'}>Distance</button>
|
||||||
<button class="pill" class:active={metric === 'elevation'} on:click={() => metric = 'elevation'}>Elevation</button>
|
<button class="pill" class:active={metric === 'elevation'} on:click={() => metric = 'elevation'}>Elevation</button>
|
||||||
<button class="pill" class:active={metric === 'time'} on:click={() => metric = 'time'}>Time</button>
|
<button class="pill" class:active={metric === 'time'} on:click={() => metric = 'time'}>Time</button>
|
||||||
|
<button class="pill" class:active={metric === 'vam'} on:click={() => metric = 'vam'}>Climbing VAM</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pill-group">
|
<div class="pill-group">
|
||||||
<button class="pill" class:active={granularity === 'week'} on:click={() => granularity = 'week'}>Weekly</button>
|
<button class="pill" class:active={granularity === 'week'} on:click={() => granularity = 'week'}>Weekly</button>
|
||||||
@@ -250,8 +269,10 @@
|
|||||||
|
|
||||||
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
||||||
|
|
||||||
<p class="section-label">Cumulative</p>
|
{#if metric !== 'vam'}
|
||||||
<div bind:this={chartCumEl} class="w-full min-h-[320px]"></div>
|
<p class="section-label">Cumulative</p>
|
||||||
|
<div bind:this={chartCumEl} class="w-full min-h-[320px]"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !rows.length}
|
{#if !rows.length}
|
||||||
<p class="text-zinc-500 text-sm mt-4">No activity data to display.</p>
|
<p class="text-zinc-500 text-sm mt-4">No activity data to display.</p>
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import * as Plot from '@observablehq/plot';
|
|
||||||
import type { AthleteJson, MmpCurve, ActivitySummary } from '../lib/types';
|
|
||||||
|
|
||||||
export let athlete: AthleteJson;
|
|
||||||
export let activities: ActivitySummary[] = [];
|
|
||||||
|
|
||||||
type RangeKey = 'all_time' | 'last_365d' | 'last_90d' | string;
|
|
||||||
|
|
||||||
interface Season { name: string; start: string; end: string }
|
|
||||||
const seasons: Season[] = (athlete as any).seasons ?? [];
|
|
||||||
|
|
||||||
let selectedRanges: Set<RangeKey> = new Set(['all_time']);
|
|
||||||
|
|
||||||
const PRESET_LABELS: Record<string, string> = {
|
|
||||||
all_time: 'All time',
|
|
||||||
last_365d: 'Last 365 d',
|
|
||||||
last_90d: 'Last 90 d',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PALETTE = [
|
|
||||||
'#34d399', // emerald-400
|
|
||||||
'#f97316', // orange-500
|
|
||||||
'#60a5fa', // blue-400
|
|
||||||
'#a78bfa', // violet-400
|
|
||||||
'#f43f5e', // rose-500
|
|
||||||
'#facc15', // yellow-400
|
|
||||||
'#22d3ee', // cyan-400
|
|
||||||
];
|
|
||||||
|
|
||||||
function curveColor(key: RangeKey, index: number): string {
|
|
||||||
return PALETTE[index % PALETTE.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeVams(curves: MmpCurve[]): MmpCurve {
|
|
||||||
const best = new Map<number, number>();
|
|
||||||
for (const curve of curves) {
|
|
||||||
for (const [d, v] of curve) {
|
|
||||||
const prev = best.get(d);
|
|
||||||
if (prev === undefined || v > prev) best.set(d, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...best.entries()].sort((a, b) => a[0] - b[0]) as MmpCurve;
|
|
||||||
}
|
|
||||||
|
|
||||||
function vamForRange(key: RangeKey): MmpCurve | null {
|
|
||||||
if (key in PRESET_LABELS) {
|
|
||||||
return athlete.vam_curve?.[key as keyof typeof athlete.vam_curve] ?? null;
|
|
||||||
}
|
|
||||||
const season = seasons.find(s => s.name === key);
|
|
||||||
if (!season) return null;
|
|
||||||
const curves = activities
|
|
||||||
.filter(a => a.vam_curve && a.started_at >= season.start && a.started_at <= season.end + 'T23:59:59')
|
|
||||||
.map(a => a.vam_curve!);
|
|
||||||
return curves.length ? mergeVams(curves) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let chartEl: HTMLElement;
|
|
||||||
|
|
||||||
function formatDuration(s: number): string {
|
|
||||||
if (s < 60) return `${s}s`;
|
|
||||||
if (s < 3600) return `${Math.round(s / 60)}min`;
|
|
||||||
return `${s / 3600}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: selectedKeys = [...selectedRanges];
|
|
||||||
|
|
||||||
$: plotData = selectedKeys.flatMap((key, i) => {
|
|
||||||
const curve = vamForRange(key);
|
|
||||||
if (!curve) return [];
|
|
||||||
return curve.map(([d, v]) => ({ d, v, label: key }));
|
|
||||||
});
|
|
||||||
|
|
||||||
$: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
|
|
||||||
|
|
||||||
function getAxisColor() {
|
|
||||||
return document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChart(data: typeof plotData, cmap: typeof colorMap) {
|
|
||||||
if (!chartEl) return;
|
|
||||||
chartEl.innerHTML = '';
|
|
||||||
if (!data.length) return;
|
|
||||||
|
|
||||||
const labelFn = (key: string) => PRESET_LABELS[key] ?? key;
|
|
||||||
|
|
||||||
const chart = Plot.plot({
|
|
||||||
width: chartEl.clientWidth || 700,
|
|
||||||
height: 320,
|
|
||||||
marginLeft: 60,
|
|
||||||
marginBottom: 40,
|
|
||||||
style: { background: 'transparent', color: getAxisColor() },
|
|
||||||
x: {
|
|
||||||
type: 'log',
|
|
||||||
label: 'Duration',
|
|
||||||
tickFormat: (d: number) => formatDuration(d),
|
|
||||||
grid: true,
|
|
||||||
domain: [data[0]?.d ?? 60, Math.max(3600, ...data.map(d => d.d))],
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
label: 'VAM (m/h)',
|
|
||||||
grid: true,
|
|
||||||
zero: true,
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
domain: selectedKeys,
|
|
||||||
range: selectedKeys.map((k, i) => curveColor(k, i)),
|
|
||||||
legend: selectedKeys.length > 1,
|
|
||||||
},
|
|
||||||
marks: [
|
|
||||||
Plot.line(data, {
|
|
||||||
x: 'd',
|
|
||||||
y: 'v',
|
|
||||||
stroke: 'label',
|
|
||||||
strokeWidth: 2,
|
|
||||||
curve: 'monotone-x',
|
|
||||||
}),
|
|
||||||
Plot.dot(data, {
|
|
||||||
x: 'd',
|
|
||||||
y: 'v',
|
|
||||||
fill: 'label',
|
|
||||||
r: 3,
|
|
||||||
tip: true,
|
|
||||||
title: (d: any) => `${labelFn(d.label)}\n${formatDuration(d.d)}: ${d.v.toLocaleString()} m/h`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
chartEl.appendChild(chart);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: renderChart(plotData, colorMap);
|
|
||||||
|
|
||||||
let currentPlotData = plotData;
|
|
||||||
let currentColorMap = colorMap;
|
|
||||||
$: currentPlotData = plotData;
|
|
||||||
$: currentColorMap = colorMap;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap));
|
|
||||||
ro.observe(chartEl);
|
|
||||||
const mo = new MutationObserver(() => renderChart(currentPlotData, currentColorMap));
|
|
||||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
||||||
return () => { ro.disconnect(); mo.disconnect(); };
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleRange(key: RangeKey) {
|
|
||||||
const next = new Set(selectedRanges);
|
|
||||||
if (next.has(key)) {
|
|
||||||
if (next.size > 1) next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
selectedRanges = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allRangeKeys = [
|
|
||||||
...Object.keys(PRESET_LABELS),
|
|
||||||
...seasons.map(s => s.name),
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(.plot-tip text) { fill: #18181b !important; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
{#each allRangeKeys as key, i}
|
|
||||||
{@const active = selectedRanges.has(key)}
|
|
||||||
{@const color = curveColor(key, i)}
|
|
||||||
<button
|
|
||||||
on:click={() => toggleRange(key)}
|
|
||||||
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
|
|
||||||
style={active
|
|
||||||
? `background:${color}22; border-color:${color}; color:${color}`
|
|
||||||
: 'background:transparent; border-color:#3f3f46; color:#71717a'}
|
|
||||||
>
|
|
||||||
{PRESET_LABELS[key] ?? key}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
|
||||||
|
|
||||||
{#if !plotData.length}
|
|
||||||
<p class="text-zinc-500 text-sm mt-4">No VAM data for the selected range.</p>
|
|
||||||
{/if}
|
|
||||||
@@ -36,17 +36,10 @@ export interface BestClimb {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AthleteVamCurve {
|
|
||||||
all_time: MmpCurve | null;
|
|
||||||
last_365d: MmpCurve | null;
|
|
||||||
last_90d: MmpCurve | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AthleteJson {
|
export interface AthleteJson {
|
||||||
bas_version: string;
|
bas_version: string;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
power_curve: AthletePowerCurve;
|
power_curve: AthletePowerCurve;
|
||||||
vam_curve?: AthleteVamCurve | null;
|
|
||||||
records?: Record<string, Record<string, EffortRecord | ValueRecord>>;
|
records?: Record<string, Record<string, EffortRecord | ValueRecord>>;
|
||||||
best_climbs?: BestClimb[];
|
best_climbs?: BestClimb[];
|
||||||
max_hr?: number;
|
max_hr?: number;
|
||||||
@@ -73,7 +66,7 @@ export interface ActivitySummary {
|
|||||||
avg_cadence_rpm: number | null;
|
avg_cadence_rpm: number | null;
|
||||||
avg_power_w: number | null;
|
avg_power_w: number | null;
|
||||||
mmp: MmpCurve | null;
|
mmp: MmpCurve | null;
|
||||||
vam_curve?: MmpCurve | null;
|
climbing_vam_mh?: number | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
privacy: Privacy;
|
privacy: Privacy;
|
||||||
detail_url: string | null;
|
detail_url: string | null;
|
||||||
@@ -130,7 +123,6 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
|
|||||||
/** URL to fetch the timeseries — present for server-extracted activities. */
|
/** URL to fetch the timeseries — present for server-extracted activities. */
|
||||||
timeseries_url?: string | null;
|
timeseries_url?: string | null;
|
||||||
mmp: MmpCurve | null;
|
mmp: MmpCurve | null;
|
||||||
climbing_vam_mh?: number | null;
|
|
||||||
strava_id: string | null;
|
strava_id: string | null;
|
||||||
duplicate_of: string | null;
|
duplicate_of: string | null;
|
||||||
source_file?: string | null;
|
source_file?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user