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 @@