athlete page first draft

This commit is contained in:
Davide Scaini
2026-03-30 09:05:18 +02:00
parent 2a1493a3e5
commit ec6175b143
8 changed files with 594 additions and 3 deletions
+132 -1
View File
@@ -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
View File
@@ -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(
+55
View File
@@ -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,
) )
+52
View File
@@ -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(
+111
View File
@@ -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}
+199
View File
@@ -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}
+21
View File
@@ -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>;
+8
View File
@@ -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>