From 4537273de94632cef60b4ec09832bc2a6a669d43 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 29 Mar 2026 22:06:22 +0200 Subject: [PATCH] get default hr and power zones from config file --- CHEATSHEET.md | 22 +++++++++ CLAUDE.md | 49 +++++++++++++++++- README.md | 23 +++++++++ bincio/extract/cli.py | 10 ++++ bincio/extract/config.py | 18 +++++++ extract_config.example.yaml | 18 +++++++ extract_config.yaml | 18 +++++++ site/src/components/ActivityCharts.svelte | 60 ++++++++++++++++++++--- site/src/components/ActivityDetail.svelte | 5 +- site/src/lib/types.ts | 9 +++- site/src/pages/activity/[id].astro | 8 +-- 11 files changed, 224 insertions(+), 16 deletions(-) diff --git a/CHEATSHEET.md b/CHEATSHEET.md index 3b8c7d3..36b8518 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -129,8 +129,30 @@ incremental: true # false = re-process everything track: rdp_epsilon: 0.0001 # GPS simplification — larger = fewer points timeseries_hz: 1 # samples/sec in stored JSON (1 = 1 Hz) + +athlete: + max_hr: 182 # used for context; zones below are authoritative + ftp_w: 280 # functional threshold power in watts + hr_zones: # 5-zone Coggan, explicit bpm boundaries [[lo, hi], ...] + - [0, 115] # Z1 recovery + - [115, 137] # Z2 endurance + - [137, 155] # Z3 tempo + - [155, 169] # Z4 threshold + - [169, 999] # Z5 VO2max + power_zones: # 7-zone Coggan, explicit watt boundaries + - [0, 168] # Z1 active recovery (< 55% FTP) + - [168, 224] # Z2 endurance (55–75%) + - [224, 266] # Z3 tempo (75–90%) + - [266, 308] # Z4 threshold (90–105%) + - [308, 364] # Z5 VO2max (105–120%) + - [364, 420] # Z6 anaerobic (120–150%) + - [420, 9999] # Z7 neuromuscular (> 150%) ``` +Zones are written into `index.json` under `owner.athlete` at extract time and +displayed as overlays on HR and Power histograms in the activity detail page. +After changing zones, re-run `uv run bincio extract` to update `index.json`. + --- ## Privacy diff --git a/CLAUDE.md b/CLAUDE.md index 60e2517..dadec22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ site/ Astro project ActivityFeed.svelte Card grid, sport filter, pagination ActivityDetail.svelte Map + stats + charts wrapper ActivityMap.svelte MapLibre GL (gradient track, linked hover dot) - ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs) + ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence/power tabs) StatsView.svelte Yearly heatmap + totals lib/ types.ts BAS TypeScript types @@ -199,6 +199,46 @@ const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88; detects the dependency change when the sport filter or scale method changes (plain function calls with static args don't trigger Svelte re-renders). +## ActivityCharts — controls and athlete zones + +`ActivityCharts.svelte` renders Observable Plot charts for the activity detail page. + +### Chart controls + +- **Metric tabs**: Elevation · Speed · Heart Rate · Cadence · Power +- **Chart type toggle** (right-aligned): `↗ Line` | `▭ Hist` +- **X-axis toggle** (line mode only, shown when speed data present): `Time` | `Dist` + - Distance is integrated from `speed_kmh` at 1 Hz — no extra data needed. +- **Histogram controls** (visible only in histogram mode): + - **Dual range slider** — trims the x domain; two overlapping `` with CSS track highlight. + - **Bins slider** — exact bin count using explicit evenly-spaced thresholds (not d3's "nice" count, which ignores narrow ranges). + +### Athlete zones + +Zones are configured in `extract_config.yaml` under `athlete:` and written into +`index.json` at extract time (`owner.athlete`). The Astro activity page reads them +from the index and passes them down: `[id].astro` → `ActivityDetail` → `ActivityCharts`. + +When viewing HR or Power in histogram mode, zone boundaries are drawn as dashed +vertical rules with Z1–Z5/Z7 labels at the top of the chart. +Labels and rules are clipped to the current trim range automatically. + +Zone color palettes: +- HR (5 zones): `#60a5fa #4ade80 #facc15 #fb923c #f87171` +- Power (7 zones): `#60a5fa #34d399 #facc15 #fb923c #f87171 #c084fc #f43f5e` + +### Zone calculation reference (Coggan) + +| Zone | HR (% max HR) | Power (% FTP) | +|------|--------------|---------------| +| Z1 | < 55% | < 55% | +| Z2 | 55–75% | 55–75% | +| Z3 | 75–87% | 75–90% | +| Z4 | 87–93% | 90–105% | +| Z5 | > 93% | 105–120% | +| Z6 | — | 120–150% | +| Z7 | — | > 150% | + ## Activity sidecar edits — design spec Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir. @@ -348,6 +388,13 @@ are served at `data/activities/images/{id}/{filename}` by the Astro dev server. - [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled - [x] Markdown rendering in activity description with image path rewriting - [x] `hide_stats` support in activity detail stats panel +- [x] ActivityCharts power tab (elevation/speed/HR/cadence/power) +- [x] Chart type toggle: line ↔ histogram +- [x] X-axis toggle: time ↔ distance (integrated from speed) +- [x] Histogram dual range slider + bins slider (exact thresholds) +- [x] Athlete zones in `extract_config.yaml` → `index.json` → chart overlays +- [x] StatsView heatmap click-to-pin tooltip (Esc / click-outside to dismiss) - [ ] `bincio render --watch` incremental rebuild on sidecar/data changes - [ ] Highlight badge in activity feed cards - [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI) +- [ ] HR / power zone defaults from `max_hr` / `ftp_w` when explicit zones not set diff --git a/README.md b/README.md index f46a800..f2547c6 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,31 @@ track: timeseries_hz: 1 # data samples per second stored in JSON incremental: true # skip files whose hash hasn't changed + +# Optional: athlete profile for zone overlays on HR/power charts +athlete: + max_hr: 182 + ftp_w: 280 + hr_zones: # [[lo, hi], ...] in bpm — 5-zone Coggan + - [0, 115] + - [115, 137] + - [137, 155] + - [155, 169] + - [169, 999] + power_zones: # [[lo, hi], ...] in watts — 7-zone Coggan + - [0, 168] + - [168, 224] + - [224, 266] + - [266, 308] + - [308, 364] + - [364, 420] + - [420, 9999] ``` +Zones are written into `index.json` at extract time and displayed as overlays on +HR and Power histograms in the activity detail page. After changing zones, re-run +`uv run bincio extract` to regenerate `index.json`. + ### Privacy levels | Level | GPS track | Stats | Appears in index | diff --git a/bincio/extract/cli.py b/bincio/extract/cli.py index e789ade..77384d2 100644 --- a/bincio/extract/cli.py +++ b/bincio/extract/cli.py @@ -149,6 +149,16 @@ def extract( console.print(f"Using [bold]{n_workers}[/bold] worker processes.") owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name} + if cfg.athlete: + ath = cfg.athlete + owner["athlete"] = { + 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 + } summaries: list[dict] = [] errors: list[tuple[str, str]] = [] skipped = 0 diff --git a/bincio/extract/config.py b/bincio/extract/config.py index 1aa51d3..8e4a461 100644 --- a/bincio/extract/config.py +++ b/bincio/extract/config.py @@ -27,6 +27,14 @@ class ClassifierConfig: enabled: bool = False # off by default; opt-in +@dataclass +class AthleteConfig: + max_hr: int | None = None + ftp_w: int | None = None + hr_zones: list[list[int]] | None = None # [[lo, hi], ...] + power_zones: list[list[int]] | None = None + + @dataclass class ExtractConfig: input_dirs: list[Path] @@ -39,6 +47,7 @@ class ExtractConfig: incremental: bool = True owner_handle: str = "me" owner_display_name: str = "Me" + athlete: AthleteConfig | None = None def load_config(path: Path) -> ExtractConfig: @@ -70,6 +79,14 @@ def load_config(path: Path) -> ExtractConfig: cls_raw = raw.get("classifier", {}) classifier = ClassifierConfig(enabled=cls_raw.get("enabled", False)) + ath_raw = raw.get("athlete", {}) + athlete = AthleteConfig( + max_hr=ath_raw.get("max_hr"), + ftp_w=ath_raw.get("ftp_w"), + hr_zones=ath_raw.get("hr_zones"), + power_zones=ath_raw.get("power_zones"), + ) if ath_raw else None + return ExtractConfig( input_dirs=dirs, output_dir=out, @@ -81,6 +98,7 @@ def load_config(path: Path) -> ExtractConfig: incremental=raw.get("incremental", True), owner_handle=owner.get("handle", "me"), owner_display_name=owner.get("display_name", "Me"), + athlete=athlete, ) diff --git a/extract_config.example.yaml b/extract_config.example.yaml index 9173454..3f82c00 100644 --- a/extract_config.example.yaml +++ b/extract_config.example.yaml @@ -30,3 +30,21 @@ classifier: enabled: false # ML activity type classifier (requires scikit-learn extra) incremental: true # skip files whose hash hasn't changed since last run + +# athlete: +# max_hr: 182 # used to derive default HR zone display +# ftp_w: 280 # functional threshold power in watts +# hr_zones: # explicit bpm boundaries [[lo, hi], ...] +# - [0, 115] # Z1 recovery +# - [115, 137] # Z2 endurance +# - [137, 155] # Z3 tempo +# - [155, 169] # Z4 threshold +# - [169, 999] # Z5 VO2max +# power_zones: # explicit watt boundaries +# - [0, 168] # Z1 active recovery (< 55% FTP) +# - [168, 224] # Z2 endurance (55–78%) +# - [224, 266] # Z3 tempo (78–95%) +# - [266, 308] # Z4 threshold (95–109%) +# - [308, 364] # Z5 VO2max (109–130%) +# - [364, 420] # Z6 anaerobic (130–150%) +# - [420, 9999] # Z7 neuromuscular (> 150%) diff --git a/extract_config.yaml b/extract_config.yaml index 9623156..cac7401 100644 --- a/extract_config.yaml +++ b/extract_config.yaml @@ -30,3 +30,21 @@ classifier: enabled: false # ML activity type classifier (requires scikit-learn extra) incremental: true # skip files whose hash hasn't changed since last run + +athlete: + max_hr: 190 + ftp_w: 210 + hr_zones: # 5-zone Coggan, % of max HR 190 bpm + - [0, 104] # Z1 recovery < 55% + - [104, 142] # Z2 endurance 55–75% + - [142, 165] # Z3 tempo 75–87% + - [165, 176] # Z4 threshold 87–93% + - [176, 999] # Z5 VO2max > 93% + power_zones: # 7-zone Coggan, % of FTP 210 W + - [0, 115] # Z1 active recovery < 55% + - [115, 157] # Z2 endurance 55–75% + - [157, 189] # Z3 tempo 75–90% + - [189, 220] # Z4 threshold 90–105% + - [220, 252] # Z5 VO2max 105–120% + - [252, 315] # Z6 anaerobic 120–150% + - [315, 9999] # Z7 neuromuscular > 150% diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index a550018..6e0e62d 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -1,11 +1,15 @@ diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 79787b6..b7e454c 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -1,7 +1,7 @@