athlete page first draft
This commit is contained in:
@@ -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
|
- **`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)
|
- **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
|
## Known issues / next steps
|
||||||
|
|
||||||
- `bincio render` Python CLI is a stub — site is built via `npm run build` directly
|
- `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
|
- [ ] `bincio render` Python CLI wraps `astro build` properly
|
||||||
- [ ] Friends/federation pages in site
|
- [ ] 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
|
- [ ] Activity search / full-text filter in feed
|
||||||
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
|
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
|
||||||
- [ ] GitHub Actions template for auto-publish
|
- [ ] GitHub Actions template for auto-publish
|
||||||
|
|||||||
+16
-2
@@ -94,6 +94,7 @@ def _process_file(path: Path) -> dict:
|
|||||||
"started_at": activity.started_at.isoformat(),
|
"started_at": activity.started_at.isoformat(),
|
||||||
"distance_m": metrics.distance_m,
|
"distance_m": metrics.distance_m,
|
||||||
"source": summary.get("source"),
|
"source": summary.get("source"),
|
||||||
|
"mmp": metrics.mmp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -210,12 +211,25 @@ def extract(
|
|||||||
))
|
))
|
||||||
summaries.append(result["summary"])
|
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)
|
existing = _load_existing_summaries(cfg.output_dir)
|
||||||
merged = {s["id"]: s for s in existing}
|
merged = {s["id"]: s for s in existing}
|
||||||
for s in summaries:
|
for s in summaries:
|
||||||
merged[s["id"]] = s
|
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()
|
dedup.save()
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ Uses inline haversine rather than geopy.geodesic to keep the hot path fast.
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from bincio.extract.models import DataPoint, ParsedActivity
|
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)
|
# Speed below which we consider the athlete stopped (km/h)
|
||||||
_STOPPED_THRESHOLD_KMH = 1.0
|
_STOPPED_THRESHOLD_KMH = 1.0
|
||||||
_EARTH_R = 6_371_000.0 # metres
|
_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
|
bbox: Optional[tuple[float, float, float, float]] # min_lon, min_lat, max_lon, max_lat
|
||||||
start_latlng: Optional[tuple[float, float]]
|
start_latlng: Optional[tuple[float, float]]
|
||||||
end_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:
|
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])
|
max_pow = _max_nonnull([p.power_w for p in pts])
|
||||||
bbox = _bbox(pts)
|
bbox = _bbox(pts)
|
||||||
start_ll, end_ll = _endpoints(pts)
|
start_ll, end_ll = _endpoints(pts)
|
||||||
|
mmp = compute_mmp(pts, activity.started_at)
|
||||||
|
|
||||||
return ComputedMetrics(
|
return ComputedMetrics(
|
||||||
distance_m=distance_m,
|
distance_m=distance_m,
|
||||||
@@ -75,9 +81,57 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
|||||||
bbox=bbox,
|
bbox=bbox,
|
||||||
start_latlng=start_ll,
|
start_latlng=start_ll,
|
||||||
end_latlng=end_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 ──────────────────────────────────────────────────────
|
# ── single-pass GPS stats ──────────────────────────────────────────────────────
|
||||||
# distance, moving time, avg speed, and max speed are all derived from the same
|
# 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.
|
# 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_hr_bpm=None, max_hr_bpm=None,
|
||||||
avg_cadence_rpm=None, avg_power_w=None, max_power_w=None,
|
avg_cadence_rpm=None, avg_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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ def write_activity(
|
|||||||
"bbox": list(metrics.bbox) if metrics.bbox else None,
|
"bbox": list(metrics.bbox) if metrics.bbox else None,
|
||||||
"start_latlng": list(metrics.start_latlng) if metrics.start_latlng 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,
|
"end_latlng": list(metrics.end_latlng) if metrics.end_latlng else None,
|
||||||
|
"mmp": metrics.mmp,
|
||||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||||
"timeseries": build_timeseries(activity.points, activity.started_at, privacy),
|
"timeseries": build_timeseries(activity.points, activity.started_at, privacy),
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -115,6 +116,7 @@ def build_summary(
|
|||||||
"max_hr_bpm": metrics.max_hr_bpm,
|
"max_hr_bpm": metrics.max_hr_bpm,
|
||||||
"avg_cadence_rpm": metrics.avg_cadence_rpm,
|
"avg_cadence_rpm": metrics.avg_cadence_rpm,
|
||||||
"avg_power_w": metrics.avg_power_w,
|
"avg_power_w": metrics.avg_power_w,
|
||||||
|
"mmp": metrics.mmp,
|
||||||
"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",
|
||||||
@@ -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:
|
def write_index(summaries: list[dict], output_dir: Path, owner: dict) -> None:
|
||||||
"""Write index.json (sorted newest first)."""
|
"""Write index.json (sorted newest first)."""
|
||||||
sorted_summaries = sorted(
|
sorted_summaries = sorted(
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
||||||
|
import MmpChart from './MmpChart.svelte';
|
||||||
|
|
||||||
|
let athlete: AthleteJson | null = null;
|
||||||
|
let activities: ActivitySummary[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [athleteRes, indexRes] = await Promise.all([
|
||||||
|
fetch(`${import.meta.env.BASE_URL}data/athlete.json`),
|
||||||
|
fetch(`${import.meta.env.BASE_URL}data/index.json`),
|
||||||
|
]);
|
||||||
|
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 {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtZone(zones: [number, number][], i: number): string {
|
||||||
|
const [lo, hi] = zones[i];
|
||||||
|
return hi >= 9000 ? `${lo}+ W` : `${lo}–${hi} W`;
|
||||||
|
}
|
||||||
|
function fmtHrZone(zones: [number, number][], i: number): string {
|
||||||
|
const [lo, hi] = zones[i];
|
||||||
|
return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-zinc-400 text-sm">Loading…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-400 text-sm">{error}</p>
|
||||||
|
{:else if athlete}
|
||||||
|
|
||||||
|
<!-- Power curve section -->
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Power Curve</h2>
|
||||||
|
{#if athlete.power_curve.all_time}
|
||||||
|
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
|
||||||
|
<MmpChart {athlete} {activities} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data and re-run <code class="text-zinc-300">bincio extract</code>.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Profile section -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Profile</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<!-- Key numbers -->
|
||||||
|
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-3">
|
||||||
|
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">Key numbers</h3>
|
||||||
|
{#if athlete.max_hr}
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-zinc-400">Max HR</span>
|
||||||
|
<span class="text-white font-medium">{athlete.max_hr} bpm</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if athlete.ftp_w}
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-zinc-400">FTP</span>
|
||||||
|
<span class="text-white font-medium">{athlete.ftp_w} W</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !athlete.max_hr && !athlete.ftp_w}
|
||||||
|
<p class="text-zinc-500 text-sm">Set <code>athlete.max_hr</code> and <code>athlete.ftp_w</code> in your config.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HR zones -->
|
||||||
|
{#if athlete.hr_zones}
|
||||||
|
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">HR Zones</h3>
|
||||||
|
{#each athlete.hr_zones as zone, i}
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<span class="text-zinc-400">Z{i + 1}</span>
|
||||||
|
<span class="text-white">{fmtHrZone(athlete.hr_zones, i)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Power zones -->
|
||||||
|
{#if athlete.power_zones}
|
||||||
|
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-2">
|
||||||
|
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">Power Zones</h3>
|
||||||
|
{#each athlete.power_zones as zone, i}
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<span class="text-zinc-400">Z{i + 1}</span>
|
||||||
|
<span class="text-white">{fmtZone(athlete.power_zones, i)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<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[] = [];
|
||||||
|
|
||||||
|
// ── Range selection ────────────────────────────────────────────────────────
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Colours for overlaid curves (cycling through a palette)
|
||||||
|
const PALETTE = [
|
||||||
|
'#60a5fa', // blue-400
|
||||||
|
'#f97316', // orange-500
|
||||||
|
'#34d399', // emerald-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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MMP curve computation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mergeMmps(mmps: MmpCurve[]): MmpCurve {
|
||||||
|
const best = new Map<number, number>();
|
||||||
|
for (const curve of mmps) {
|
||||||
|
for (const [d, w] of curve) {
|
||||||
|
const prev = best.get(d);
|
||||||
|
if (prev === undefined || w > prev) best.set(d, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...best.entries()].sort((a, b) => a[0] - b[0]) as MmpCurve;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mmpsForRange(key: RangeKey): MmpCurve | null {
|
||||||
|
// Built-in preset ranges come from athlete.json (pre-computed at extract time)
|
||||||
|
if (key in PRESET_LABELS) {
|
||||||
|
return (athlete.power_curve as any)[key] ?? null;
|
||||||
|
}
|
||||||
|
// User-defined seasons: compute on-the-fly from per-activity mmp in index.json
|
||||||
|
const season = seasons.find(s => s.name === key);
|
||||||
|
if (!season) return null;
|
||||||
|
const curves = activities
|
||||||
|
.filter(a => a.mmp && a.started_at >= season.start && a.started_at <= season.end + 'T23:59:59')
|
||||||
|
.map(a => a.mmp!);
|
||||||
|
return curves.length ? mergeMmps(curves) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart rendering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 = mmpsForRange(key);
|
||||||
|
if (!curve) return [];
|
||||||
|
return curve.map(([d, w]) => ({ d, w, label: key }));
|
||||||
|
});
|
||||||
|
|
||||||
|
$: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
|
||||||
|
|
||||||
|
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: 52,
|
||||||
|
marginBottom: 40,
|
||||||
|
style: { background: 'transparent', color: '#e4e4e7' },
|
||||||
|
x: {
|
||||||
|
type: 'log',
|
||||||
|
label: 'Duration',
|
||||||
|
tickFormat: (d: number) => formatDuration(d),
|
||||||
|
grid: true,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
label: 'Avg power (W)',
|
||||||
|
grid: true,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
domain: selectedKeys,
|
||||||
|
range: selectedKeys.map((k, i) => curveColor(k, i)),
|
||||||
|
legend: selectedKeys.length > 1,
|
||||||
|
},
|
||||||
|
marks: [
|
||||||
|
Plot.line(data, {
|
||||||
|
x: 'd',
|
||||||
|
y: 'w',
|
||||||
|
stroke: 'label',
|
||||||
|
strokeWidth: 2,
|
||||||
|
curve: 'monotone-x',
|
||||||
|
}),
|
||||||
|
Plot.dot(data, {
|
||||||
|
x: 'd',
|
||||||
|
y: 'w',
|
||||||
|
fill: 'label',
|
||||||
|
r: 3,
|
||||||
|
tip: true,
|
||||||
|
title: (d: any) => `${labelFn(d.label)}\n${formatDuration(d.d)}: ${d.w} W`,
|
||||||
|
}),
|
||||||
|
...(athlete.ftp_w ? [
|
||||||
|
Plot.ruleY([athlete.ftp_w], {
|
||||||
|
stroke: '#71717a',
|
||||||
|
strokeDasharray: '4 3',
|
||||||
|
}),
|
||||||
|
Plot.text([{ x: 3600, y: athlete.ftp_w }], {
|
||||||
|
x: 'x', y: 'y',
|
||||||
|
text: () => `FTP ${athlete.ftp_w} W`,
|
||||||
|
fill: '#71717a',
|
||||||
|
fontSize: 11,
|
||||||
|
dy: -6,
|
||||||
|
textAnchor: 'end',
|
||||||
|
}),
|
||||||
|
] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
chartEl.appendChild(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: renderChart(plotData, colorMap);
|
||||||
|
|
||||||
|
// Re-render on resize
|
||||||
|
onMount(() => {
|
||||||
|
const ro = new ResizeObserver(() => renderChart(plotData, colorMap));
|
||||||
|
ro.observe(chartEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Toggle helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleRange(key: RangeKey) {
|
||||||
|
const next = new Set(selectedRanges);
|
||||||
|
if (next.has(key)) {
|
||||||
|
if (next.size > 1) next.delete(key); // always keep at least one
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
selectedRanges = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRangeKeys = [
|
||||||
|
...Object.keys(PRESET_LABELS),
|
||||||
|
...seasons.map(s => s.name),
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Range selector pills -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
||||||
|
|
||||||
|
{#if !plotData.length}
|
||||||
|
<p class="text-zinc-500 text-sm mt-4">No power data for the selected range.</p>
|
||||||
|
{/if}
|
||||||
@@ -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 SubSport = "road" | "mountain" | "gravel" | "indoor" | "trail" | "track" | "nordic" | null;
|
||||||
export type Privacy = "public" | "blur_start" | "no_gps" | "private";
|
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 {
|
export interface ActivitySummary {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,6 +39,7 @@ export interface ActivitySummary {
|
|||||||
max_hr_bpm: number | null;
|
max_hr_bpm: number | null;
|
||||||
avg_cadence_rpm: number | null;
|
avg_cadence_rpm: number | null;
|
||||||
avg_power_w: number | null;
|
avg_power_w: number | null;
|
||||||
|
mmp: MmpCurve | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
privacy: Privacy;
|
privacy: Privacy;
|
||||||
detail_url: string | null;
|
detail_url: string | null;
|
||||||
@@ -66,6 +86,7 @@ export interface ActivityDetail extends ActivitySummary {
|
|||||||
end_latlng: [number, number] | null;
|
end_latlng: [number, number] | null;
|
||||||
laps: Lap[];
|
laps: Lap[];
|
||||||
timeseries: Timeseries;
|
timeseries: Timeseries;
|
||||||
|
mmp: MmpCurve | null;
|
||||||
strava_id: string | null;
|
strava_id: string | null;
|
||||||
duplicate_of: string | null;
|
duplicate_of: string | null;
|
||||||
custom: Record<string, unknown>;
|
custom: Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import AthleteView from '../../components/AthleteView.svelte';
|
||||||
|
---
|
||||||
|
<Base title="Athlete — BincioActivity">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
|
||||||
|
<AthleteView client:load />
|
||||||
|
</Base>
|
||||||
Reference in New Issue
Block a user