From ec6175b143fe68c35a01f37e592ed043014470e5 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 30 Mar 2026 09:05:18 +0200 Subject: [PATCH] athlete page first draft --- CLAUDE.md | 133 ++++++++++++++++- bincio/extract/cli.py | 18 ++- bincio/extract/metrics.py | 55 +++++++ bincio/extract/writer.py | 52 +++++++ site/src/components/AthleteView.svelte | 111 ++++++++++++++ site/src/components/MmpChart.svelte | 199 +++++++++++++++++++++++++ site/src/lib/types.ts | 21 +++ site/src/pages/athlete/index.astro | 8 + 8 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 site/src/components/AthleteView.svelte create mode 100644 site/src/components/MmpChart.svelte create mode 100644 site/src/pages/athlete/index.astro diff --git a/CLAUDE.md b/CLAUDE.md index dadec22..96df3dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -361,6 +361,134 @@ are served at `data/activities/images/{id}/{filename}` by the Astro dev server. - **`highlight`**: sorts to top of feed; visual badge TBD - **Edit UI**: drawer in Astro site, `bincio edit` is a pure write API (no HTML serving) +## Athlete page — design plan + +### Goal + +A `/athlete` page (and `/athlete/edit` drawer) giving the user: +1. **Performance analytics** — power curve (MMP), best efforts, optionally fitness/freshness +2. **Profile editing** — zones, gear (bikes/shoes), personal data — no YAML editing required + +### Mean Maximal Power (MMP) curve + +For every duration D, the MMP is the highest average power sustained over any contiguous +D-second window across all activities. Plotted on a log-scale x-axis. + +**Key features:** +- **Time range filter**: all-time, last 30/90/365 days, or user-defined seasons +- **Season overlay**: multiple seasons plotted on the same chart for comparison + (e.g. "2023 vs 2024 vs 2025" — this is the primary use case) +- **Durations**: a fixed log-scale set, e.g.: + `1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600` seconds +- **Null handling**: if an activity is shorter than duration D, it contributes nothing + to that point. No interpolation. The curve simply ends where data runs out. +- **Modelled curve overlay** (future): 2-parameter Critical Power model fitted to + the data; shows predicted W for any duration, even beyond recorded efforts. + +**Where to compute:** + +At **extract time**, each activity gets an `mmp` array: +```json +"mmp": [[1, 850], [5, 720], [30, 580], [300, 340], [3600, 210]] +``` +Each pair is `[duration_s, avg_watts]`. Only activities with power data get this field. + +The site then takes the **element-wise max** across all activities (filtered by date range). +This keeps the site fully static — no server needed to render the curve. + +Computing MMP per activity is O(n × D) where n = timeseries length, D = number of +duration points (~15). At 1 Hz, a 2-hour ride is 7200 points × 15 durations = trivial. +Use a sliding window approach: for each duration d, maintain a running sum and advance +the window one sample at a time. + +**Season definition** (user-configurable): +```yaml +athlete: + seasons: + - name: "2025" + start: "2025-01-01" + end: "2025-12-31" + - name: "2024" + start: "2024-01-01" + end: "2024-12-31" +``` +If no seasons defined, the UI offers fixed presets (last 30d / 90d / 365d / all-time). + +### Athlete profile editing — reusing edit infrastructure + +Same pattern as activity editing: + +``` +bincio edit --data-dir ~/bincio_data # same server, new endpoints +``` + +New API endpoints: +- `GET /api/athlete` — current athlete config (zones, gear, display name) +- `POST /api/athlete` — write `edits/athlete.yaml`, trigger `merge_all()` + +`edits/athlete.yaml` format: +```yaml +display_name: "Davide" +handle: "brutsalvadi" +max_hr: 190 +ftp_w: 210 +hr_zones: + - [0, 104] + - [104, 142] + - [142, 165] + - [165, 176] + - [176, 999] +power_zones: + - [0, 115] + # ... +gear: + bikes: + - name: "Trek Domane" + type: cycling + notes: "Road endurance" + shoes: + - name: "Asics GT-2000" + type: running +seasons: + - name: "2025" + start: "2025-01-01" + end: "2025-12-31" +``` + +The server reads `extract_config.yaml` as base defaults, applies `edits/athlete.yaml` +overrides on top, and writes back to `edits/athlete.yaml` on POST. The `extract_config.yaml` +is never written by the server — it stays as the authoritative static config. + +`merge_all()` also writes athlete data into `_merged/athlete.json` which the site reads. + +### AthleteDrawer.svelte (profile editing) + +Reuses the same drawer pattern as `EditDrawer.svelte`: +- Number inputs for `max_hr`, `ftp_w` +- Zone editor: table of rows `[lo, hi]` with + / − buttons; auto-fills `lo` from previous `hi` +- Gear list: add/remove bikes and shoes; name + type + notes fields +- Season list: add/remove date ranges with names + +### Site page: `/athlete` + +Two tabs or sections: +1. **Performance** — MMP curve chart (Observable Plot, log x-axis), date range selector +2. **Profile** — display of current zones, gear list; Edit button opens AthleteDrawer + +The MMP chart uses `index.json`'s `activities` array (already loaded by the feed) — filter +to power-having activities, pull their `mmp` arrays, take element-wise max per season. + +### Implementation order + +1. Add `mmp` computation to `metrics.py` and writer +2. Add `mmp` field to BAS schema and `types.ts` +3. Add `/api/athlete` GET+POST to the edit server +4. `merge_all()` writes `_merged/athlete.json` +5. Astro page `site/src/pages/athlete/index.astro` +6. `MmpChart.svelte` — Observable Plot line, log-scale x, multi-season overlay +7. `AthleteDrawer.svelte` — zones + gear editing form +8. Season config in `extract_config.yaml` / `edits/athlete.yaml` + ## Known issues / next steps - `bincio render` Python CLI is a stub — site is built via `npm run build` directly @@ -377,7 +505,10 @@ are served at `data/activities/images/{id}/{filename}` by the Astro dev server. - [ ] `bincio render` Python CLI wraps `astro build` properly - [ ] Friends/federation pages in site -- [ ] Personal records page +- [ ] Athlete page: MMP power curve with season overlay +- [ ] Athlete page: profile editor (zones, gear, seasons) via AthleteDrawer +- [ ] MMP computation at extract time → `mmp` field in BAS JSON +- [ ] Personal records page (best efforts: 5km, 10km, etc.) - [ ] Activity search / full-text filter in feed - [ ] Map thumbnail in activity cards (SVG path from GeoJSON) - [ ] GitHub Actions template for auto-publish diff --git a/bincio/extract/cli.py b/bincio/extract/cli.py index 77384d2..60655d9 100644 --- a/bincio/extract/cli.py +++ b/bincio/extract/cli.py @@ -94,6 +94,7 @@ def _process_file(path: Path) -> dict: "started_at": activity.started_at.isoformat(), "distance_m": metrics.distance_m, "source": summary.get("source"), + "mmp": metrics.mmp, } @@ -210,12 +211,25 @@ def extract( )) summaries.append(result["summary"]) - from bincio.extract.writer import write_index + from bincio.extract.writer import write_athlete_json, write_index existing = _load_existing_summaries(cfg.output_dir) merged = {s["id"]: s for s in existing} for s in summaries: merged[s["id"]] = s - write_index(list(merged.values()), cfg.output_dir, owner) + all_summaries = list(merged.values()) + write_index(all_summaries, cfg.output_dir, owner) + + athlete_config: dict = {} + if cfg.athlete: + ath = cfg.athlete + athlete_config = {k: v for k, v in { + "max_hr": ath.max_hr, + "ftp_w": ath.ftp_w, + "hr_zones": ath.hr_zones, + "power_zones": ath.power_zones, + }.items() if v is not None} + write_athlete_json(all_summaries, cfg.output_dir, athlete_config) + dedup.save() console.print( diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 68cfe2f..22f44f6 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -6,10 +6,14 @@ Uses inline haversine rather than geopy.geodesic to keep the hot path fast. import math from dataclasses import dataclass +from datetime import datetime from typing import Optional 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] + # Speed below which we consider the athlete stopped (km/h) _STOPPED_THRESHOLD_KMH = 1.0 _EARTH_R = 6_371_000.0 # metres @@ -42,6 +46,7 @@ class ComputedMetrics: bbox: Optional[tuple[float, float, float, float]] # min_lon, min_lat, max_lon, max_lat 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 def compute(activity: ParsedActivity) -> ComputedMetrics: @@ -58,6 +63,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: max_pow = _max_nonnull([p.power_w for p in pts]) bbox = _bbox(pts) start_ll, end_ll = _endpoints(pts) + mmp = compute_mmp(pts, activity.started_at) return ComputedMetrics( distance_m=distance_m, @@ -75,9 +81,57 @@ def compute(activity: ParsedActivity) -> ComputedMetrics: bbox=bbox, start_latlng=start_ll, end_latlng=end_ll, + mmp=mmp, ) +# ── mean maximal power ──────────────────────────────────────────────────────── + +def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[list[int]]]: + """Compute Mean Maximal Power curve at the standard MMP_DURATIONS_S. + + Builds a 1 Hz power series (same downsampling as timeseries.py), then uses + a O(n) sliding-window sum for each duration. Returns a list of + [duration_s, avg_watts] pairs (integers), or None when the activity has no + power data. Only durations shorter than the total activity are included. + """ + # 1 Hz downsample: at most one sample per second, skip sub-second duplicates. + # Seconds without a recorded sample are omitted (not zero-filled) so that + # paused-recording gaps don't silently lower power averages. + power_1hz: list[int] = [] + 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 + if p.power_w is not None: + power_1hz.append(p.power_w) + + if len(power_1hz) < 2: + return None + + n = len(power_1hz) + results: list[list[int]] = [] + + for d in MMP_DURATIONS_S: + if d > n: + break # activity shorter than this duration — stop (durations are sorted) + + # Sliding window of exactly d samples = d seconds at 1 Hz. + window_sum = sum(power_1hz[:d]) + best = window_sum + + for i in range(1, n - d + 1): + window_sum += power_1hz[i + d - 1] - power_1hz[i - 1] + if window_sum > best: + best = window_sum + + results.append([d, round(best / d)]) + + return results if results 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. @@ -209,4 +263,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, ) diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index 8295ceb..9ca7bbc 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -68,6 +68,7 @@ def write_activity( "bbox": list(metrics.bbox) if metrics.bbox else None, "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, "laps": [_serialise_lap(lap) for lap in activity.laps], "timeseries": build_timeseries(activity.points, activity.started_at, privacy), "source": source, @@ -115,6 +116,7 @@ def build_summary( "max_hr_bpm": metrics.max_hr_bpm, "avg_cadence_rpm": metrics.avg_cadence_rpm, "avg_power_w": metrics.avg_power_w, + "mmp": metrics.mmp, "source": _infer_source(activity), "privacy": privacy, "detail_url": f"activities/{activity_id}.json", @@ -124,6 +126,56 @@ 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. + """ + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + + def _cutoff_iso(days: int) -> str: + from datetime import timedelta + return (now - timedelta(days=days)).isoformat() + + cutoff_365 = _cutoff_iso(365) + cutoff_90 = _cutoff_iso(90) + + 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: + if d not in best or w > best[d]: + 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] + + 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, + }, + **athlete_config, + } + (output_dir / "athlete.json").write_text( + json.dumps(athlete, indent=2, ensure_ascii=False) + ) + + def write_index(summaries: list[dict], output_dir: Path, owner: dict) -> None: """Write index.json (sorted newest first).""" sorted_summaries = sorted( diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte new file mode 100644 index 0000000..846f40c --- /dev/null +++ b/site/src/components/AthleteView.svelte @@ -0,0 +1,111 @@ + + +{#if loading} +

Loading…

+{:else if error} +

{error}

+{:else if athlete} + + +
+

Power Curve

+ {#if athlete.power_curve.all_time} +
+ +
+ {:else} +

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

+ {/if} +
+ + +
+

Profile

+
+ + +
+

Key numbers

+ {#if athlete.max_hr} +
+ Max HR + {athlete.max_hr} bpm +
+ {/if} + {#if athlete.ftp_w} +
+ FTP + {athlete.ftp_w} W +
+ {/if} + {#if !athlete.max_hr && !athlete.ftp_w} +

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

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

HR Zones

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

Power Zones

+ {#each athlete.power_zones as zone, i} +
+ Z{i + 1} + {fmtZone(athlete.power_zones, i)} +
+ {/each} +
+ {/if} + +
+
+ +{/if} diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte new file mode 100644 index 0000000..d2025f6 --- /dev/null +++ b/site/src/components/MmpChart.svelte @@ -0,0 +1,199 @@ + + + +
+ {#each allRangeKeys as key, i} + {@const active = selectedRanges.has(key)} + {@const color = curveColor(key, i)} + + {/each} +
+ + +
+ +{#if !plotData.length} +

No power data for the selected range.

+{/if} diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 31cf332..b39c932 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -4,6 +4,25 @@ export type Sport = "cycling" | "running" | "hiking" | "walking" | "swimming" | export type SubSport = "road" | "mountain" | "gravel" | "indoor" | "trail" | "track" | "nordic" | null; export type Privacy = "public" | "blur_start" | "no_gps" | "private"; +/** [duration_s, avg_watts] pairs, sorted by duration ascending. */ +export type MmpCurve = [number, number][]; + +export interface AthletePowerCurve { + all_time: MmpCurve | null; + last_365d: MmpCurve | null; + last_90d: MmpCurve | null; +} + +export interface AthleteJson { + bas_version: string; + generated_at: string; + power_curve: AthletePowerCurve; + max_hr?: number; + ftp_w?: number; + hr_zones?: [number, number][]; + power_zones?: [number, number][]; +} + export interface ActivitySummary { id: string; title: string; @@ -20,6 +39,7 @@ export interface ActivitySummary { max_hr_bpm: number | null; avg_cadence_rpm: number | null; avg_power_w: number | null; + mmp: MmpCurve | null; source: string | null; privacy: Privacy; detail_url: string | null; @@ -66,6 +86,7 @@ export interface ActivityDetail extends ActivitySummary { end_latlng: [number, number] | null; laps: Lap[]; timeseries: Timeseries; + mmp: MmpCurve | null; strava_id: string | null; duplicate_of: string | null; custom: Record; diff --git a/site/src/pages/athlete/index.astro b/site/src/pages/athlete/index.astro new file mode 100644 index 0000000..256ccbc --- /dev/null +++ b/site/src/pages/athlete/index.astro @@ -0,0 +1,8 @@ +--- +import Base from '../../layouts/Base.astro'; +import AthleteView from '../../components/AthleteView.svelte'; +--- + +

Athlete

+ +