From a6a81f9421df82dcea0907be8ead7087804318df Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 30 Mar 2026 10:53:51 +0200 Subject: [PATCH] personal records tab into athlete page --- bincio/extract/metrics.py | 109 +++++++++++- bincio/extract/writer.py | 94 +++++++++-- scripts/backfill.py | 225 +++++++++++++++++++++++++ site/src/components/AthleteView.svelte | 68 +++++--- site/src/components/RecordsView.svelte | 215 +++++++++++++++++++++++ site/src/lib/types.ts | 24 +++ 6 files changed, 692 insertions(+), 43 deletions(-) create mode 100644 scripts/backfill.py create mode 100644 site/src/components/RecordsView.svelte diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 22f44f6..378ebec 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -14,6 +14,17 @@ from bincio.extract.models import DataPoint, ParsedActivity # 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] +# Standard best-effort distances (km) per sport. +BEST_EFFORT_DISTANCES: dict[str, list[float]] = { + "running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195], + "cycling": [5.0, 10.0, 20.0, 50.0, 100.0], + "swimming": [0.1, 0.2, 0.5, 1.0, 2.0], + "hiking": [], # no sliding-window records; aggregate from summaries only + "walking": [], + "skiing": [], + "other": [], +} + # Speed below which we consider the athlete stopped (km/h) _STOPPED_THRESHOLD_KMH = 1.0 _EARTH_R = 6_371_000.0 # metres @@ -47,6 +58,9 @@ class ComputedMetrics: start_latlng: Optional[tuple[float, float]] end_latlng: Optional[tuple[float, float]] mmp: Optional[list[list[int]]] # [[duration_s, avg_watts], ...] — None if no power data + # [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets + best_efforts: Optional[list[list[float]]] + best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only) def compute(activity: ParsedActivity) -> ComputedMetrics: @@ -64,6 +78,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: bbox = _bbox(pts) start_ll, end_ll = _endpoints(pts) mmp = compute_mmp(pts, activity.started_at) + best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport) return ComputedMetrics( distance_m=distance_m, @@ -82,6 +97,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: start_latlng=start_ll, end_latlng=end_ll, mmp=mmp, + best_efforts=best_efforts, + best_climb_m=best_climb_m, ) @@ -132,6 +149,96 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis return results if results else None +# ── best efforts & best climb ───────────────────────────────────────────────── + +def compute_best_efforts( + pts: list[DataPoint], + started_at: datetime, + sport: str, +) -> tuple[Optional[list[list[float]]], Optional[float]]: + """Return (best_efforts, best_climb_m) for this activity. + + best_efforts: [[distance_km, time_s], ...] — one entry per target distance + where the activity was long enough to contain that effort. + best_climb_m: maximum net elevation gain over any contiguous window (cycling). + + Both use the same 1 Hz downsampled series as the timeseries writer. + """ + targets = BEST_EFFORT_DISTANCES.get(sport, []) + + # Build 1 Hz speed (km/h) and elevation (m) arrays — same downsampling as timeseries.py + speed_1hz: list[float] = [] + ele_1hz: list[Optional[float]] = [] + last_t = -1 + for p in pts: + t = int((p.timestamp - started_at).total_seconds()) + if t < 0 or t == last_t: + continue + last_t = t + speed_1hz.append(p.speed_kmh if p.speed_kmh is not None else 0.0) + ele_1hz.append(p.elevation_m) + + best_efforts: Optional[list[list[float]]] = None + if targets and speed_1hz: + results = [] + for d_km in targets: + t_s = _fastest_time_for_distance(speed_1hz, d_km) + if t_s is not None: + results.append([d_km, t_s]) + best_efforts = results if results else None + + best_climb_m: Optional[float] = None + if sport == "cycling": + best_climb_m = _best_climb(ele_1hz) + + return best_efforts, best_climb_m + + +def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Optional[int]: + """Minimum number of seconds to cover target_km using a two-pointer sliding window. + + Each sample contributes speed_kmh / 3600 km (one second at that speed). + Nulls/zeros extend the window without adding distance — naturally deprioritised. + """ + n = len(speed_1hz) + left = 0 + window_dist = 0.0 + best_s: Optional[int] = None + + for right in range(n): + window_dist += speed_1hz[right] / 3600.0 + + # Shrink from the left while we still cover the target + while window_dist >= target_km and left <= right: + window_s = right - left + 1 + if best_s is None or window_s < best_s: + best_s = window_s + window_dist -= speed_1hz[left] / 3600.0 + left += 1 + + return best_s + + +def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]: + """Maximum net elevation gain over any contiguous window (Kadane's on deltas). + + Ignores samples where elevation is None. Returns None if fewer than two + valid elevation samples exist. + """ + valid = [e for e in ele_1hz if e is not None] + if len(valid) < 2: + return None + + max_gain = 0.0 + current = 0.0 + for a, b in zip(valid, valid[1:]): + current = max(0.0, current + (b - a)) + if current > max_gain: + max_gain = current + + return round(max_gain, 1) if max_gain > 0 else None + + # ── single-pass GPS stats ────────────────────────────────────────────────────── # distance, moving time, avg speed, and max speed are all derived from the same # per-segment loop, so we compute them in one pass instead of four. @@ -263,5 +370,5 @@ def _empty() -> ComputedMetrics: avg_hr_bpm=None, max_hr_bpm=None, avg_cadence_rpm=None, avg_power_w=None, max_power_w=None, bbox=None, start_latlng=None, end_latlng=None, - mmp=None, + mmp=None, best_efforts=None, best_climb_m=None, ) diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index 9ca7bbc..f4d11fb 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -69,6 +69,8 @@ def write_activity( "start_latlng": list(metrics.start_latlng) if metrics.start_latlng else None, "end_latlng": list(metrics.end_latlng) if metrics.end_latlng else None, "mmp": metrics.mmp, + "best_efforts": metrics.best_efforts, + "best_climb_m": metrics.best_climb_m, "laps": [_serialise_lap(lap) for lap in activity.laps], "timeseries": build_timeseries(activity.points, activity.started_at, privacy), "source": source, @@ -117,6 +119,8 @@ def build_summary( "avg_cadence_rpm": metrics.avg_cadence_rpm, "avg_power_w": metrics.avg_power_w, "mmp": metrics.mmp, + "best_efforts": metrics.best_efforts, + "best_climb_m": metrics.best_climb_m, "source": _infer_source(activity), "privacy": privacy, "detail_url": f"activities/{activity_id}.json", @@ -127,16 +131,7 @@ def build_summary( def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config: dict) -> None: - """Aggregate per-activity MMP curves into athlete.json. - - Computes element-wise max MMP for: - - all_time - - last_365d - - last_90d - - The site reads this single file for the athlete/power-curve page. - Per-activity mmp is already in each summary for client-side season filtering. - """ + """Aggregate per-activity MMP curves and personal records into athlete.json.""" from datetime import datetime, timezone now = datetime.now(timezone.utc) @@ -148,8 +143,9 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config: cutoff_365 = _cutoff_iso(365) cutoff_90 = _cutoff_iso(90) + # ── MMP aggregation ─────────────────────────────────────────────────────── + def _merge_mmps(activity_mmps: list[list[list[int]]]) -> list[list[int]]: - """Element-wise max across a list of mmp arrays.""" best: dict[int, int] = {} for mmp in activity_mmps: for d, w in mmp: @@ -157,18 +153,82 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config: best[d] = w return [[d, w] for d, w in sorted(best.items())] - all_mmps = [s["mmp"] for s in summaries if s.get("mmp")] - mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_365] - mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_90] + all_mmps = [s["mmp"] for s in summaries if s.get("mmp")] + mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_365] + mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_90] + + # ── Personal records aggregation ────────────────────────────────────────── + # records[sport][distance_km] = {time_s, activity_id, started_at, title} + # best_climb[activity_id] = {climb_m, started_at, title} + + SPORTS = ["running", "cycling", "swimming", "hiking", "walking", "skiing", "other"] + records: dict[str, dict[float, dict]] = {s: {} for s in SPORTS} + best_climb: list[dict] = [] # top 10 best climbs for cycling + + for s in summaries: + sport = s.get("sport", "other") + act_id = s.get("id", "") + started = s.get("started_at", "") + title = s.get("title", "") + + # Distance-based best efforts + for d_km, t_s in (s.get("best_efforts") or []): + bucket = records.get(sport, {}) + existing = bucket.get(d_km) + if existing is None or t_s < existing["time_s"]: + bucket[d_km] = { + "time_s": t_s, + "activity_id": act_id, + "started_at": started, + "title": title, + } + records[sport] = bucket + + # Best climb (cycling only) — collect all, trim to top 10 after loop + climb = s.get("best_climb_m") + if climb and sport == "cycling": + best_climb.append({ + "climb_m": climb, + "activity_id": act_id, + "started_at": started, + "title": title, + }) + + # Hiking / walking: track longest distance and most elevation from summaries + if sport in ("hiking", "walking"): + dist = s.get("distance_m") or 0 + elev = s.get("elevation_gain_m") or 0 + for metric, key, val in [("longest_m", "distance_m", dist), + ("most_elevation_m", "elevation_gain_m", elev)]: + bucket = records[sport] + existing = bucket.get(metric) + if val and (existing is None or val > existing.get("value", 0)): + bucket[metric] = { + "value": val, + "activity_id": act_id, + "started_at": started, + "title": title, + } + records[sport] = bucket + + # Serialise records: convert float keys to strings for JSON + def _serialise_sport_records(bucket: dict) -> dict: + return {str(k): v for k, v in bucket.items()} athlete = { "bas_version": "1.0", "generated_at": now.isoformat(), "power_curve": { - "all_time": _merge_mmps(all_mmps) if all_mmps else None, - "last_365d": _merge_mmps(mmps_365) if mmps_365 else None, - "last_90d": _merge_mmps(mmps_90) if mmps_90 else None, + "all_time": _merge_mmps(all_mmps) if all_mmps else None, + "last_365d": _merge_mmps(mmps_365) if mmps_365 else None, + "last_90d": _merge_mmps(mmps_90) if mmps_90 else None, }, + "records": { + sport: _serialise_sport_records(records[sport]) + for sport in SPORTS + if records[sport] + }, + "best_climbs": sorted(best_climb, key=lambda x: x["climb_m"], reverse=True)[:10], **athlete_config, } (output_dir / "athlete.json").write_text( diff --git a/scripts/backfill.py b/scripts/backfill.py new file mode 100644 index 0000000..cdb833b --- /dev/null +++ b/scripts/backfill.py @@ -0,0 +1,225 @@ +"""Backfill MMP and best-effort records into existing BAS activity JSONs. + +Reads 1Hz timeseries (power_w, speed_kmh, elevation_m) from already-extracted +detail JSONs — no need to re-parse source FIT/GPX/TCX files. + +Run once after upgrading to the MMP + records extract pipeline, or whenever +the computation logic changes and you want to refresh all activities. + +Usage: + uv run python scripts/backfill.py [--data-dir ~/src/bincio_data] +""" + +import json +import sys +from pathlib import Path + +import click +from rich.console import Console +from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn + +console = Console() + +# ── MMP ─────────────────────────────────────────────────────────────────────── + +MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600] + + +def _compute_mmp(power_w: list) -> list[list[int]] | None: + samples = [w for w in power_w if w is not None] + if len(samples) < 2: + return None + n = len(samples) + results = [] + for d in MMP_DURATIONS_S: + if d > n: + break + window_sum = sum(samples[:d]) + best = window_sum + for i in range(1, n - d + 1): + window_sum += samples[i + d - 1] - samples[i - 1] + if window_sum > best: + best = window_sum + results.append([d, round(best / d)]) + return results if results else None + + +# ── Best efforts ────────────────────────────────────────────────────────────── + +BEST_EFFORT_DISTANCES: dict[str, list[float]] = { + "running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195], + "cycling": [5.0, 10.0, 20.0, 50.0, 100.0], + "swimming": [0.1, 0.2, 0.5, 1.0, 2.0], +} + + +def _fastest_time(speed_kmh: list, target_km: float) -> int | None: + left = 0 + window_dist = 0.0 + best_s = None + for right, spd in enumerate(speed_kmh): + window_dist += (spd or 0.0) / 3600.0 + while window_dist >= target_km and left <= right: + window_s = right - left + 1 + if best_s is None or window_s < best_s: + best_s = window_s + window_dist -= (speed_kmh[left] or 0.0) / 3600.0 + left += 1 + return best_s + + +def _compute_best_efforts(speed_kmh: list, sport: str) -> list[list[float]] | None: + targets = BEST_EFFORT_DISTANCES.get(sport, []) + if not targets or not speed_kmh: + return None + results = [] + for d_km in targets: + t_s = _fastest_time(speed_kmh, d_km) + if t_s is not None: + results.append([d_km, t_s]) + return results if results else None + + +def _compute_best_climb(elevation_m: list) -> float | None: + valid = [e for e in elevation_m if e is not None] + if len(valid) < 2: + return None + max_gain = current = 0.0 + for a, b in zip(valid, valid[1:]): + current = max(0.0, current + (b - a)) + if current > max_gain: + max_gain = current + return round(max_gain, 1) if max_gain > 0 else None + + +# ── Main ────────────────────────────────────────────────────────────────────── + +@click.command() +@click.option("--data-dir", default="~/src/bincio_data", show_default=True) +@click.option("--dry-run", is_flag=True) +@click.option("--force", is_flag=True, help="Recompute even if fields already present.") +def main(data_dir: str, dry_run: bool, force: bool) -> None: + """Backfill mmp, best_efforts, and best_climb_m into existing activity JSONs.""" + data = Path(data_dir).expanduser() + acts_dir = data / "activities" + + if not acts_dir.exists(): + console.print(f"[red]Activities dir not found: {acts_dir}[/red]") + sys.exit(1) + + jsons = sorted(acts_dir.glob("*.json")) + console.print(f"Found [bold]{len(jsons)}[/bold] activity JSONs in {acts_dir}") + + updated = skipped = 0 + + with Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Backfilling…", total=len(jsons)) + + for path in jsons: + progress.advance(task) + try: + detail = json.loads(path.read_text()) + except Exception: + skipped += 1 + continue + + already_done = ( + detail.get("mmp") is not None + and detail.get("best_efforts") is not None + or detail.get("best_efforts") == [] # explicitly empty = computed, no results + ) + if already_done and not force: + skipped += 1 + continue + + sport = detail.get("sport", "other") + ts = detail.get("timeseries") or {} + power_w = ts.get("power_w") or [] + speed_kmh = ts.get("speed_kmh") or [] + ele_m = ts.get("elevation_m") or [] + + changed = False + + if detail.get("mmp") is None or force: + mmp = _compute_mmp(power_w) + if mmp is not None: + detail["mmp"] = mmp + changed = True + + if detail.get("best_efforts") is None or force: + be = _compute_best_efforts(speed_kmh, sport) + detail["best_efforts"] = be # store None or list (None = sport has no targets) + changed = True + + if (detail.get("best_climb_m") is None or force) and sport == "cycling": + bc = _compute_best_climb(ele_m) + if bc is not None: + detail["best_climb_m"] = bc + changed = True + + if changed: + if not dry_run: + path.write_text(json.dumps(detail, indent=2, ensure_ascii=False)) + updated += 1 + + console.print( + f"\n[green]Done.[/green] " + f"Updated [bold]{updated}[/bold], skipped [bold]{skipped}[/bold]." + ) + if dry_run: + console.print("[yellow]Dry run — nothing written.[/yellow]") + return + + # Patch index.json summaries + console.print("Patching index.json summaries…") + index_path = data / "index.json" + index = json.loads(index_path.read_text()) + + lookup: dict[str, dict] = {} + for path in acts_dir.glob("*.json"): + try: + d = json.loads(path.read_text()) + lookup[d["id"]] = { + "mmp": d.get("mmp"), + "best_efforts": d.get("best_efforts"), + "best_climb_m": d.get("best_climb_m"), + } + except Exception: + pass + + patched = 0 + for s in index.get("activities", []): + row = lookup.get(s["id"]) + if not row: + continue + if row.get("mmp") and not s.get("mmp"): + s["mmp"] = row["mmp"]; patched += 1 + if row.get("best_efforts") is not None and s.get("best_efforts") is None: + s["best_efforts"] = row["best_efforts"]; patched += 1 + if row.get("best_climb_m") and not s.get("best_climb_m"): + s["best_climb_m"] = row["best_climb_m"]; patched += 1 + + index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False)) + console.print(f" {patched} fields patched in index.json.") + + # Rebuild athlete.json + console.print("Rebuilding athlete.json…") + from bincio.extract.writer import write_athlete_json + owner = index.get("owner", {}) + athlete_cfg = {k: v for k, v in (owner.get("athlete") or {}).items() if v is not None} + write_athlete_json(index.get("activities", []), data, athlete_cfg) + console.print(" athlete.json written.") + + # Re-merge + console.print("Running merge_all…") + from bincio.render.merge import merge_all + n = merge_all(data) + console.print(f" merge_all done ({n} sidecars).") + + +if __name__ == "__main__": + main() diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 7bbaa3f..c20e642 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types'; import MmpChart from './MmpChart.svelte'; + import RecordsView from './RecordsView.svelte'; import AthleteDrawer from './AthleteDrawer.svelte'; let athlete: AthleteJson | null = null; @@ -10,6 +11,9 @@ let error: string | null = null; let drawerOpen = false; + type Tab = 'power' | 'records' | 'profile'; + let activeTab: Tab = 'power'; + const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; onMount(async () => { @@ -21,9 +25,7 @@ if (!athleteRes.ok) throw new Error('athlete.json not found — run bincio extract first'); athlete = await athleteRes.json(); const index: BASIndex = await indexRes.json(); - // Only activities with power data contribute to the curve activities = index.activities.filter(a => a.mmp && a.privacy !== 'private'); - } catch (e: any) { error = e.message; } finally { @@ -32,7 +34,6 @@ }); async function onSaved() { - // Reload athlete.json after edits are saved const res = await fetch(`${import.meta.env.BASE_URL}data/athlete.json?t=${Date.now()}`); if (res.ok) athlete = await res.json(); drawerOpen = false; @@ -46,6 +47,12 @@ const [lo, hi] = zones[i]; return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`; } + + const TABS: { key: Tab; label: string }[] = [ + { key: 'power', label: 'Power Curve' }, + { key: 'records', label: 'Records' }, + { key: 'profile', label: 'Profile' }, + ]; {#if loading} @@ -54,19 +61,31 @@

{error}

{:else if athlete} - - {#if editUrl} -
+ +
+ + {#if editUrl} -
- {/if} + {/if} +
- -
-

Power Curve

+ + {#if activeTab === 'power'} {#if athlete.power_curve.all_time}
@@ -74,14 +93,15 @@ {:else}

No power data found. Make sure your activities include power meter data and re-run bincio extract.

{/if} -
- -
-

Profile

+ + {:else if activeTab === 'records'} + + + + {:else if activeTab === 'profile'}
-

Key numbers

{#if athlete.max_hr} @@ -97,38 +117,36 @@
{/if} {#if !athlete.max_hr && !athlete.ftp_w} -

Set athlete.max_hr and athlete.ftp_w in your config.

+

Set athlete.max_hr and athlete.ftp_w in your config, or use Edit profile.

{/if}
- {#if athlete.hr_zones}

HR Zones

- {#each athlete.hr_zones as zone, i} + {#each athlete.hr_zones as _zone, i}
Z{i + 1} - {fmtHrZone(athlete.hr_zones, i)} + {fmtHrZone(athlete.hr_zones!, i)}
{/each}
{/if} - {#if athlete.power_zones}

Power Zones

- {#each athlete.power_zones as zone, i} + {#each athlete.power_zones as _zone, i}
Z{i + 1} - {fmtZone(athlete.power_zones, i)} + {fmtZone(athlete.power_zones!, i)}
{/each}
{/if} -
+ {/if} {/if} diff --git a/site/src/components/RecordsView.svelte b/site/src/components/RecordsView.svelte new file mode 100644 index 0000000..7f2cf91 --- /dev/null +++ b/site/src/components/RecordsView.svelte @@ -0,0 +1,215 @@ + + + +
+ {#each TABS as tab} + {@const active = activeTab === tab.key} + {@const has = hasRecords(tab.key)} + + {/each} +
+ + +{#if activeTab === 'running' || activeTab === 'cycling' || activeTab === 'swimming'} + {@const rows = distanceRecords(activeTab)} + {#if rows.length} +
+ + + + + + + + + + + + {#each rows as { distKm, rec }, i} + + + + + + + + {/each} + +
DistanceTime + {activeTab === 'running' ? 'Pace' : 'Speed'} + DateActivity
{distLabel(distKm)}{fmtTime(rec.time_s)} + {activeTab === 'running' + ? fmtPace(distKm, rec.time_s) + : fmtSpeed(distKm, rec.time_s)} + {formatDate(rec.started_at)} + {rec.title} +
+
+ + + {#if activeTab === 'cycling' && (athlete as any).best_climbs?.length} + {@const climbs = (athlete as any).best_climbs} +
+

⛰️ Best climb in one go

+
+ + + + + + + + + + + {#each climbs as bc, i} + + + + + + + {/each} + +
#ElevationDateActivity
{i + 1}{Math.round(bc.climb_m)} m{formatDate(bc.started_at)} + {bc.title} +
+
+
+ {/if} + + {:else} +

No {activeTab} records yet. Records are computed from activities with GPS speed data.

+ {/if} + + +{:else} + {@const longest = valueRecord(activeTab, 'longest_m')} + {@const mostElev = valueRecord(activeTab, 'most_elevation_m')} + + {#if longest || mostElev} +
+ {#if longest} +
+

Longest {activeTab}

+

{(longest.value / 1000).toFixed(1)} km

+ + {longest.title} · {formatDate(longest.started_at)} + +
+ {/if} + {#if mostElev} +
+

Most elevation

+

{Math.round(mostElev.value)} m

+ + {mostElev.title} · {formatDate(mostElev.started_at)} + +
+ {/if} +
+ {:else} +

No {activeTab} records yet.

+ {/if} +{/if} diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index b39c932..03ce82a 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -13,14 +13,38 @@ export interface AthletePowerCurve { last_90d: MmpCurve | null; } +export interface EffortRecord { + time_s: number; + activity_id: string; + started_at: string; + title: string; +} + +export interface ValueRecord { + value: number; + activity_id: string; + started_at: string; + title: string; +} + +export interface BestClimb { + climb_m: number; + activity_id: string; + started_at: string; + title: string; +} + export interface AthleteJson { bas_version: string; generated_at: string; power_curve: AthletePowerCurve; + records?: Record>; + best_climbs?: BestClimb[]; max_hr?: number; ftp_w?: number; hr_zones?: [number, number][]; power_zones?: [number, number][]; + seasons?: { name: string; start: string; end: string }[]; } export interface ActivitySummary {