diff --git a/CLAUDE.md b/CLAUDE.md index 9ab1861..683fec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,16 +24,15 @@ Anyone can publish their data as BAS JSON and others can include it. - **TCX files** from Garmin use both `http://` and `https://` namespace URIs — parser handles both -## User's data +## Your data -- Source: `~/src/cycling_data_davide/` +- Source: `~/your-activity-data/` - `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants) - - `Karoo_2026/` — recent Karoo device FIT files - - `Karoo/` — older Karoo FIT files + - Any subdirectories with FIT files from Garmin/Karoo devices - `activities.csv` — Strava metadata (names, descriptions, gear) - Extracted output: `~/bincio_data/` (or `/tmp/bincio_test/` for testing) -- ~3,200 input files → ~2,082 unique activities after dedup -- Date range: 2014–2026 + +Configure input paths in `extract_config.yaml`. ## Project structure @@ -51,32 +50,29 @@ bincio/ Python package writer.py BAS JSON + GeoJSON writer config.py extract_config.yaml loader cli.py `bincio extract` CLI - import_/ - strava.py Strava API importer (OAuth2, streams → BAS JSON) - cli.py `bincio import strava` CLI render/ cli.py `bincio render` CLI (symlinks data, runs astro build/dev) + merge.py sidecar edit overlay (produces _merged/) edit/ - server.py FastAPI write API (activity edits, image upload, file upload) cli.py `bincio edit` CLI + server.py FastAPI write API for the edit drawer schema/ bas-v1.schema.json JSON Schema for BAS SCHEMA.md Human-readable BAS spec site/ Astro project src/ - layouts/Base.astro Nav (upload button + theme toggle), theme CSS vars + layouts/Base.astro pages/ index.astro Activity feed (loads index.json client-side) activity/[id].astro Single activity (SSG, loads detail JSON client-side) stats/index.astro Heatmap + year totals - athlete/index.astro MMP curve + athlete profile (planned) components/ ActivityFeed.svelte Card grid, sport filter, pagination - ActivityDetail.svelte Map + stats + charts wrapper + ActivityDetail.svelte Map + stats + charts + photo gallery ActivityMap.svelte MapLibre GL (gradient track, linked hover dot) - ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence/power tabs) - StatsView.svelte Yearly heatmap + click-to-pin tooltip - EditDrawer.svelte Slide-in activity editor + ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs) + StatsView.svelte Yearly heatmap + totals + EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set) lib/ types.ts BAS TypeScript types format.ts formatDistance, formatDuration, sportIcon, etc. @@ -85,26 +81,19 @@ site/ Astro project ## How to run ```bash -# Fast dev loop (50-file sample → /tmp/bincio_dev/, no real data touched) -uv run bincio extract --dev 50 -uv run bincio import strava --dev 50 # 50 most recent Strava activities -uv run bincio render --serve --data-dir /tmp/bincio_dev - -# Full extract from local files +# Extract cd ~/src/bincio_activity -uv run bincio extract # uses extract_config.yaml +uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test -# Import from Strava (credentials in extract_config.yaml under import.strava) -uv sync --extra strava -uv run bincio import strava # first run opens browser for OAuth -uv run bincio import strava # subsequent runs are incremental +# Site dev server +cd site +ln -sf /tmp/bincio_test/_merged public/data # point at merged output +cp .env.example .env && $EDITOR .env # set BINCIO_DATA_DIR +npm run dev -# Site dev server (render handles symlink + merge automatically) -uv run bincio render --serve - -# Edit server (enables drawer + file upload in the site) -uv run bincio edit --data-dir ~/bincio_data # port 4041 -# Set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env +# Edit server (optional — enables edit drawer in the site) +uv run bincio edit --data-dir /tmp/bincio_test +# set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env # Tests uv run pytest @@ -164,551 +153,71 @@ vite: { }, ``` -## StatsView heatmap — colour intensity scaling - -Two approaches have been tried. The **active one is percentile-based** (preferred for now). - -### Option A — Linear / max-relative (simpler, currently inactive) - -```ts -$: maxDailyKm = Math.max(...[...byDate.values()].map(v => v / 1000), 1); -// inside cellColors loop: -const km = total / 1000; -const intensity = Math.min(0.12 + (km / maxDailyKm) * 0.88, 1.0); -``` - -- Busiest day = full brightness; all others scale linearly against it. -- Intuitive: you can visually read "this day was ~50% of my biggest day". -- Downside: one outlier (e.g. a 250 km day) compresses everything else into - near-darkness. Cross-sport comparison is unfair (10 km run vs 10 km cycling - look very different even when filtered to a single sport). -- Legend shows actual max km: `More (X km max)`. - -### Option B — Percentile rank (active) - -```ts -$: sortedDaily = [...byDate.values()].sort((a, b) => a - b); - -function pctRank(value: number, sorted: number[]): number { - if (!sorted.length) return 0; - let lo = 0, hi = sorted.length; - while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] <= value) lo = mid + 1; else hi = mid; } - return lo / sorted.length; -} - -// inside cellColors loop: -const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88; -``` - -- Each day is ranked against all other active days; the laziest active day = - intensity 0.12, the busiest = 1.0. The colour scale spreads evenly regardless - of km gaps. -- GitHub-contribution-graph style: easy to see "busy vs quiet" relative to - your own habits. -- Downside: absolute effort is not visible. A 5 km walk and a 200 km ride can - look the same if they're both 95th-percentile days for their respective sports. -- Legend says `More (percentile · max X km)` to hint at both dimensions. - -### Shared infrastructure - -- Blended colours: in "All" sport view, each cell's RGB is a weighted average - of sport colours by distance that day. -- `applyIntensity(hex, t)`: lerps from zinc-800 (#27272a = 39,39,42) to the - target colour, so dim cells fade into the background rather than going black. -- `$: cellColors = Map` — precomputed reactively so Svelte - 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. +Users edit activities via **sidecar markdown files** in the data dir. No database, no server — consistent with the project's static-files-only philosophy. ### File naming ``` ~/bincio_data/ - 2024-05-15T10:30:00Z_cycling.json ← immutable extract output (never touched) - 2024-05-15T10:30:00Z_cycling.md ← user edits (sidecar) + activities/{id}.json ← immutable extract output + edits/{id}.md ← user edits (sidecar) + edits/images/{id}/ ← uploaded photos + _merged/ ← render-time merge output (gitignored-style) ``` -Same stem as the JSON, `.md` extension. `bincio extract` never writes `.md` files, -so re-running extract is always safe and will never clobber user edits. - ### Sidecar format -YAML frontmatter + optional Markdown body: - ```markdown --- title: "Epic climb up Monte Grappa" -sport: cycling # override detected sport -hide_stats: [cadence] # suppress specific stat panels in detail view -highlight: true # pin/feature in feed (shown first, maybe badged) -private: false # exclude from public feed -gear: "Trek Domane" # freeform gear note +sport: cycling +hide_stats: [cadence] +highlight: true +private: false +gear: "Trek Domane" --- -Rode with Marco and Giulia. Legs felt great after the rest week... +Rode with friends. Legs felt great after the rest week... ``` -- All frontmatter keys are optional; omit means "keep extracted value" -- The Markdown body becomes the activity's `description`, rendered as HTML in the detail page -- `hide_stats` takes stat panel names: `elevation`, `speed`, `heart_rate`, `cadence`, `power` - -### Where overrides are applied: the render stage - -The **render stage** (`bincio render`) is the right place — not extract, not the browser. - -- Extract → clean BAS JSON (immutable) -- Render → merges sidecars → Astro build consumes enriched data - -A `bincio.render.merge` module walks the data dir, finds `*.md` sidecars, -and produces either enriched JSON files or a separate `overrides/index.json` -that Astro reads at build time. The site never needs to fetch a `.md` file -at runtime — all merging is build-time, keeping the static-first guarantee. - -### Federation angle - -Sidecars work for *remote* activities too: if you include someone else's BAS feed, -you can write local `.md` sidecars for their activity IDs. Your render stage applies -your overrides on top of their data. This is a natural extension of the local case. - ### Editing UX: drawer in Astro + `bincio edit` write API -The edit UI is a **slide-in drawer** (`EditDrawer.svelte`) in the Astro site. -The drawer fetches from and POSTs to the `bincio edit` FastAPI server (write API only — -the server no longer serves its own HTML UI). +- `bincio edit --data-dir ~/bincio_data` starts a FastAPI server on port 4041 +- Set `PUBLIC_EDIT_URL=http://localhost:4041` in `site/.env` to enable the edit button +- Clicking Edit on any activity detail page opens a slide-in drawer +- Saving writes the sidecar and triggers `merge_all()` automatically +- `bincio render` always runs `merge_all()` before build/serve and symlinks `public/data` → `_merged/` -**How it works:** +### `PUBLIC_EDIT_URL` as feature flag -``` -bincio render --serve # Astro dev server, port 4321 -bincio edit --data-dir ~/… # write API only, port 4041 -``` - -- Edit button appears on the activity detail page **only when `PUBLIC_EDIT_URL` is set** in `site/.env` -- Clicking Edit opens the drawer in the same page — no navigation, no copy-pasting IDs -- Drawer fetches `GET /api/activity/{id}` to pre-fill, `POST /api/activity/{id}` to save -- After save: server runs `merge_all()` automatically → Astro serves updated data immediately on refresh -- Closing the drawer applies `title` + `description` changes optimistically to the local page state - (no full reload required to see the text change) - -**`PUBLIC_EDIT_URL` as feature flag:** -- **Unset** → no Edit button, no drawer. Works as a normal static site. Safe for public hosting. -- **Set** (e.g. `http://localhost:4041`) → editing enabled. Lives in `site/.env` (gitignored). - Each deployment opts in explicitly. - -**Edit server API (`bincio edit --data-dir `):** -- `GET /api/activity/{id}` — current values (sidecar overrides layered on BAS JSON) -- `POST /api/activity/{id}` — write sidecar `.md`, trigger `merge_all()` -- `POST /api/activity/{id}/images` — multipart upload → `edits/images/{id}/{filename}` -- `DELETE /api/activity/{id}/images/{filename}` — remove uploaded image - -**Edit drawer features:** -- Title, sport dropdown, gear -- Markdown textarea for description (images inserted as `![name](filename)` references) -- Image drag-and-drop zone with chip list + delete -- Hide stat panels (elevation, speed, heart_rate, cadence, power) — toggle buttons -- Highlight flag (★ — sorts to top of feed, visual badge) -- Private flag (⊘ — suppressed from index at render time) - -### Image storage and serving - -``` -~/bincio_data/ - edits/ - 2024-05-15T10:30:00Z_cycling.md - images/ - 2024-05-15T10:30:00Z_cycling/ - col-summit.jpg - group-photo.jpg -``` - -Images are referenced in the markdown body with relative paths: `![Summit](col-summit.jpg)`. -`merge_all()` symlinks `edits/images/{id}/` → `_merged/activities/images/{id}/` so images -are served at `data/activities/images/{id}/{filename}` by the Astro dev server. -`ActivityDetail.svelte` rewrites relative image paths to this URL when rendering markdown. - -**Note:** browsers cannot display `.HEIC` files. Convert to JPEG/PNG first: -`sips -s format jpeg photo.HEIC --out photo.jpg` (macOS). - -### Decided - -- **Sidecar location**: `edits/` subdirectory — cleaner, easier to backup/sync independently -- **Merge output**: `data/_merged/` — extracted data stays pristine; `public/data` → `_merged/` -- **`private: true`**: suppressed from `index.json` at render time (not client-side hide) -- **`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` - -## Data ingestion - -How activity data gets into BincioActivity. Three orthogonal vectors. - -### Vector 1 — Web file upload ✓ - -Drag a FIT/GPX/TCX onto the site while the edit server is running — activity -appears immediately. - -**Backend** (`bincio/edit/server.py`): -``` -POST /api/upload multipart FIT/GPX/TCX (also .gz variants) -→ stages file to data_dir/_uploads/ -→ parse_file() → compute() → write_activity() → build_summary() -→ 409 if activity already exists (same timestamp = same ID) -→ updates index.json + merge_all() -→ returns { ok: true, id: "2024-05-15T103000Z" } -→ cleans up staged file in finally block -``` - -**Frontend** (`site/src/layouts/Base.astro`): -- `↑` button in nav right cluster, only rendered when `PUBLIC_EDIT_URL` is set -- Modal with drag-and-drop zone + click-to-browse -- Auto-redirects to `/activity/{id}/` on success -- Escape / backdrop click closes modal - -### Vector 2 — `bincio import strava` ✓ - -**`bincio/import_/strava.py`** + **`bincio/import_/cli.py`** - -Install: `uv sync --extra strava` - -```bash -# First run (full sync — opens browser): -bincio import strava --client-id 12345 --client-secret abc --output ~/bincio_data - -# Subsequent runs (incremental — picks up from last sync automatically): -bincio import strava --client-id 12345 --client-secret abc - -# Explicit date range: -bincio import strava --client-id 12345 --client-secret abc --since 2025-01-01 - -# Force re-auth (rotate credentials or re-authorize): -bincio import strava --client-id 12345 --client-secret abc --reauth - -# Credentials via env vars (good for scripts): -export STRAVA_CLIENT_ID=12345 -export STRAVA_CLIENT_SECRET=abc -bincio import strava --output ~/bincio_data -``` - -**Getting Strava API credentials (~2 minutes, no approval needed):** -1. Go to `strava.com/settings/api` -2. Create an application — name and website can be anything; set - **Authorization Callback Domain** to `localhost` -3. Paste Client ID and Client Secret into `extract_config.yaml`: - ```yaml - import: - strava: - client_id: 12345 - client_secret: your_secret_here - ``` - `extract_config.yaml` is gitignored — safe to store credentials there. - -Strava's "developer" label is misleading: formal review is only required for -commercial apps that authenticate *other users*. For a personal self-hosted tool -you authenticate your own account — no review, no fees. -Rate limits: **100 req / 15 min, 1000 / day** (generous for personal use). - -**How the importer works:** - -*OAuth dance (first run):* -- Starts a one-shot local HTTP server on port 8976 -- Opens `strava.com/oauth/authorize?scope=activity:read_all` in the browser -- Receives the authorization code at `/callback` -- Exchanges code for access + refresh tokens -- Saves to `~/.config/bincio/strava.json` (keyed by client_id) -- Subsequent runs load saved tokens and refresh silently when expired (6h TTL) - -*Sync loop:* -- Reads `data_dir/_strava_sync.json` for set of already-imported Strava IDs - and timestamp of last sync -- Uses Strava `after=` parameter for server-side filtering (efficient — - no need to scan all pages on incremental runs; 1-hour overlap to catch late saves) -- Per activity: `GET /activities/{id}/streams` → `_strava_to_parsed()` → - `compute()` → `_patch_from_summary()` → `write_activity()` → `build_summary()` -- Writes updated `index.json` + `_strava_sync.json` -- Calls `merge_all()` if `edits/` directory exists - -*Conversion details:* -- `sport_type` (or `type`) → `normalise_sport()` — same mapping as FIT/GPX -- Streams: `time` (s since start) + `latlng` + `altitude` + `heartrate` + - `cadence` + `watts` + `velocity_smooth` (m/s → km/h) → `DataPoint` list -- `source_hash`: `sha256("strava:{id}")` — stable, not file-content-based -- `_patch_from_summary()`: fills `None` metric fields (distance, duration, - elevation, HR, power) from the Strava activity summary for activities with - missing/sparse sensors or manual entries -- Rate limit: warns at 85% of 15-min window; auto-retries with 60s sleep on 429 - -### Vector 3 — Platform watch mode (planned) - -```bash -bincio extract --watch ~/Dropbox/Garmin/Activities --output ~/bincio_data -``` - -Directory watcher (`watchfiles` / `inotify`) for ongoing FIT sync from Karoo, -Garmin, Wahoo. New file → auto-extract → merge. Not yet implemented. - -### Vector 4 — Federation (planned) - -```yaml -# extract_config.yaml -sources: - - url: https://alice.example.com/data/index.json - handle: alice -``` - -`bincio render` fetches remote BAS index files at build time. No API keys. -Local `.md` sidecars can annotate remote activities. - ---- +- **Unset** → no Edit button, normal static site +- **Set** → edit drawer enabled; lives in `site/.env` (gitignored) ## Known issues / next steps -- `bincio render` Python CLI is a stub — site is built via `npm run build` directly -- Activity IDs in existing test data still use `+0000` format (pre-fix); re-run extract to get `Z` format +- `bincio render` Python CLI is functional but `--watch` mode not yet implemented +- Activity IDs in older test data may use `+0000` format (pre-fix); re-run extract to get `Z` format - Some activities appear with both untitled and titled IDs (near-dedup timing race) -- Stats page heatmap month labels are embedded in the week-column flex grid (fixed March 2026); `getWeeks` uses `localISO()` not `toISOString()` to avoid UTC/local date mismatch - Federation (remote data sources) not yet implemented in site - Friends pages (`/friends/{handle}/`) not yet implemented -- `bincio render` should automate: symlink data → `astro build` -- The `site/.env` file is gitignored — document the setup for new users -- Add `--workers` benchmark: on 8 cores, ~7 min for 3,200 activities first run - -## URL state — design pattern - -All pages with filter or tab state persist it in the URL query string so the browser -back button restores the exact view. The pattern used in every component: - -```ts -let mounted = false; - -$: if (mounted) { - const params = new URLSearchParams(window.location.search); - if (value === DEFAULT) params.delete('key'); else params.set('key', value); - const qs = params.toString(); - history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); -} - -onMount(() => { - value = new URLSearchParams(window.location.search).get('key') ?? DEFAULT; - mounted = true; -}); -``` - -Key decisions: -- `replaceState` not `pushState` — clicking filters doesn't create history entries; back always goes to the previous *page*, not previous filter state -- `mounted` flag — prevents the reactive block from firing before `onMount` reads the URL (which would overwrite the URL with the default before reading it) -- Default values omitted — `?sport=all` and `?tab=power` are never written; clean URLs - -Current URL params: -| Page | Param | Values | -|------|-------|--------| -| `/` (feed) | `sport` | `cycling`, `running`, etc. | -| `/stats/` | `sport` | same | -| `/athlete/` | `tab` | `records`, `profile` (power is default) | -| `/athlete/?tab=records` | `sport` | `cycling`, `swimming`, etc. (running is default) | - -If you add a new filter to any page, follow this pattern. Never use `pushState` for filter changes. +- The `site/.env` file is gitignored — copy from `site/.env.example` ## What "good" looks like (not yet done) - [ ] `bincio render` Python CLI wraps `astro build` properly - [ ] Friends/federation pages in site -- [ ] 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.) +- [ ] Personal records page - [ ] Activity search / full-text filter in feed -- [ ] Map thumbnail in activity cards (SVG path from GeoJSON) - [ ] GitHub Actions template for auto-publish -- [x] **Ingestion: web file upload** — `POST /api/upload` in edit server, drag-and-drop in nav -- [x] **Ingestion: `bincio import strava`** — OAuth2 + streams API, idempotent incremental sync -- [ ] **Ingestion: `bincio extract --watch`** — directory watcher for ongoing FIT sync -- [ ] **Ingestion: `bincio import garmin`** — garminconnect library or FIT folder sync -- [ ] **Ingestion: federation** — `sources:` in config, remote BAS index pull at render time - [ ] Karoo/Garmin Connect importers beyond Strava - [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort - [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge) -- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site (no separate HTML from server) -- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled +- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site +- [x] `PUBLIC_EDIT_URL` feature flag - [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) +- [x] Photo gallery with lightbox on activity detail page - [ ] `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/extract_config.example.yaml b/extract_config.example.yaml index b29f260..a02d6f2 100644 --- a/extract_config.example.yaml +++ b/extract_config.example.yaml @@ -4,10 +4,9 @@ owner: input: dirs: - - ~/Activities # add as many dirs as needed; scanned recursively - # Optional — only needed for Strava bulk exports. - # Provides activity titles, descriptions, and authoritative sport type. - # If you only have device files (Garmin, Wahoo, Karoo, etc.) you can delete this line. + - ~/Activities/gpx + - ~/Activities/fit + # Strava bulk export metadata — provides names, descriptions, gear # metadata_csv: ~/strava_export/activities.csv output: @@ -30,31 +29,3 @@ classifier: enabled: false # ML activity type classifier (requires scikit-learn extra) incremental: true # skip files whose hash hasn't changed since last run - -# ── Platform importers ───────────────────────────────────────────────────────── -# Credentials for `bincio import strava`. -# Get them from strava.com/settings/api (2 minutes, no approval needed). -# Authorization Callback Domain must be set to: localhost -# import: -# strava: -# client_id: 12345 -# client_secret: your_client_secret_here - -# ── Athlete zones ─────────────────────────────────────────────────────────────── -# 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 deleted file mode 100644 index e2c65f5..0000000 --- a/extract_config.yaml +++ /dev/null @@ -1,55 +0,0 @@ -owner: - handle: brutsalvadi - display_name: Bru - -input: - dirs: - - ~/src/cycling_data_davide/activities - - ~/src/cycling_data_davide/Karoo_2026 - - ~/src/cycling_data_davide/Karoo - # Strava bulk export metadata — provides names, descriptions, gear - metadata_csv: ~/src/cycling_data_davide/activities.csv - -output: - dir: ~/src/bincio_data - -default_privacy: public - -sensors: - heart_rate: true - cadence: true - temperature: true - power: true - -track: - simplify: rdp - rdp_epsilon: 0.0001 # ~11m at equator - timeseries_hz: 1 # 1 sample/second max - -classifier: - enabled: false # ML activity type classifier (requires scikit-learn extra) - -incremental: true # skip files whose hash hasn't changed since last run - -import: - strava: - client_id: 218194 # paste your Client ID from strava.com/settings/api - client_secret: e787e62357c80c754e4f6129bfdfb849c4c03490 # paste your Client Secret - -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/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/backfill.py b/scripts/backfill.py deleted file mode 100644 index cdb833b..0000000 --- a/scripts/backfill.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Backfill MMP and best-effort records into existing BAS activity JSONs. - -Reads 1Hz timeseries (power_w, speed_kmh, elevation_m) from already-extracted -detail JSONs — no need to re-parse source FIT/GPX/TCX files. - -Run once after upgrading to the MMP + records extract pipeline, or whenever -the computation logic changes and you want to refresh all activities. - -Usage: - uv run python scripts/backfill.py [--data-dir ~/src/bincio_data] -""" - -import json -import sys -from pathlib import Path - -import click -from rich.console import Console -from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn - -console = Console() - -# ── MMP ─────────────────────────────────────────────────────────────────────── - -MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600] - - -def _compute_mmp(power_w: list) -> list[list[int]] | None: - samples = [w for w in power_w if w is not None] - if len(samples) < 2: - return None - n = len(samples) - results = [] - for d in MMP_DURATIONS_S: - if d > n: - break - window_sum = sum(samples[:d]) - best = window_sum - for i in range(1, n - d + 1): - window_sum += samples[i + d - 1] - samples[i - 1] - if window_sum > best: - best = window_sum - results.append([d, round(best / d)]) - return results if results else None - - -# ── Best efforts ────────────────────────────────────────────────────────────── - -BEST_EFFORT_DISTANCES: dict[str, list[float]] = { - "running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195], - "cycling": [5.0, 10.0, 20.0, 50.0, 100.0], - "swimming": [0.1, 0.2, 0.5, 1.0, 2.0], -} - - -def _fastest_time(speed_kmh: list, target_km: float) -> int | None: - left = 0 - window_dist = 0.0 - best_s = None - for right, spd in enumerate(speed_kmh): - window_dist += (spd or 0.0) / 3600.0 - while window_dist >= target_km and left <= right: - window_s = right - left + 1 - if best_s is None or window_s < best_s: - best_s = window_s - window_dist -= (speed_kmh[left] or 0.0) / 3600.0 - left += 1 - return best_s - - -def _compute_best_efforts(speed_kmh: list, sport: str) -> list[list[float]] | None: - targets = BEST_EFFORT_DISTANCES.get(sport, []) - if not targets or not speed_kmh: - return None - results = [] - for d_km in targets: - t_s = _fastest_time(speed_kmh, d_km) - if t_s is not None: - results.append([d_km, t_s]) - return results if results else None - - -def _compute_best_climb(elevation_m: list) -> float | None: - valid = [e for e in elevation_m if e is not None] - if len(valid) < 2: - return None - max_gain = current = 0.0 - for a, b in zip(valid, valid[1:]): - current = max(0.0, current + (b - a)) - if current > max_gain: - max_gain = current - return round(max_gain, 1) if max_gain > 0 else None - - -# ── Main ────────────────────────────────────────────────────────────────────── - -@click.command() -@click.option("--data-dir", default="~/src/bincio_data", show_default=True) -@click.option("--dry-run", is_flag=True) -@click.option("--force", is_flag=True, help="Recompute even if fields already present.") -def main(data_dir: str, dry_run: bool, force: bool) -> None: - """Backfill mmp, best_efforts, and best_climb_m into existing activity JSONs.""" - data = Path(data_dir).expanduser() - acts_dir = data / "activities" - - if not acts_dir.exists(): - console.print(f"[red]Activities dir not found: {acts_dir}[/red]") - sys.exit(1) - - jsons = sorted(acts_dir.glob("*.json")) - console.print(f"Found [bold]{len(jsons)}[/bold] activity JSONs in {acts_dir}") - - updated = skipped = 0 - - with Progress( - TextColumn("[progress.description]{task.description}"), - BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), - console=console, - ) as progress: - task = progress.add_task("Backfilling…", total=len(jsons)) - - for path in jsons: - progress.advance(task) - try: - detail = json.loads(path.read_text()) - except Exception: - skipped += 1 - continue - - already_done = ( - detail.get("mmp") is not None - and detail.get("best_efforts") is not None - or detail.get("best_efforts") == [] # explicitly empty = computed, no results - ) - if already_done and not force: - skipped += 1 - continue - - sport = detail.get("sport", "other") - ts = detail.get("timeseries") or {} - power_w = ts.get("power_w") or [] - speed_kmh = ts.get("speed_kmh") or [] - ele_m = ts.get("elevation_m") or [] - - changed = False - - if detail.get("mmp") is None or force: - mmp = _compute_mmp(power_w) - if mmp is not None: - detail["mmp"] = mmp - changed = True - - if detail.get("best_efforts") is None or force: - be = _compute_best_efforts(speed_kmh, sport) - detail["best_efforts"] = be # store None or list (None = sport has no targets) - changed = True - - if (detail.get("best_climb_m") is None or force) and sport == "cycling": - bc = _compute_best_climb(ele_m) - if bc is not None: - detail["best_climb_m"] = bc - changed = True - - if changed: - if not dry_run: - path.write_text(json.dumps(detail, indent=2, ensure_ascii=False)) - updated += 1 - - console.print( - f"\n[green]Done.[/green] " - f"Updated [bold]{updated}[/bold], skipped [bold]{skipped}[/bold]." - ) - if dry_run: - console.print("[yellow]Dry run — nothing written.[/yellow]") - return - - # Patch index.json summaries - console.print("Patching index.json summaries…") - index_path = data / "index.json" - index = json.loads(index_path.read_text()) - - lookup: dict[str, dict] = {} - for path in acts_dir.glob("*.json"): - try: - d = json.loads(path.read_text()) - lookup[d["id"]] = { - "mmp": d.get("mmp"), - "best_efforts": d.get("best_efforts"), - "best_climb_m": d.get("best_climb_m"), - } - except Exception: - pass - - patched = 0 - for s in index.get("activities", []): - row = lookup.get(s["id"]) - if not row: - continue - if row.get("mmp") and not s.get("mmp"): - s["mmp"] = row["mmp"]; patched += 1 - if row.get("best_efforts") is not None and s.get("best_efforts") is None: - s["best_efforts"] = row["best_efforts"]; patched += 1 - if row.get("best_climb_m") and not s.get("best_climb_m"): - s["best_climb_m"] = row["best_climb_m"]; patched += 1 - - index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False)) - console.print(f" {patched} fields patched in index.json.") - - # Rebuild athlete.json - console.print("Rebuilding athlete.json…") - from bincio.extract.writer import write_athlete_json - owner = index.get("owner", {}) - athlete_cfg = {k: v for k, v in (owner.get("athlete") or {}).items() if v is not None} - write_athlete_json(index.get("activities", []), data, athlete_cfg) - console.print(" athlete.json written.") - - # Re-merge - console.print("Running merge_all…") - from bincio.render.merge import merge_all - n = merge_all(data) - console.print(f" merge_all done ({n} sidecars).") - - -if __name__ == "__main__": - main() diff --git a/scripts/backfill_mmp.py b/scripts/backfill_mmp.py deleted file mode 100644 index 6a35687..0000000 --- a/scripts/backfill_mmp.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Backfill MMP curve into existing BAS activity JSONs and index.json. - -Reads power_w from the already-extracted 1Hz timeseries — no need to re-parse -source FIT files. Run once after upgrading to the MMP-enabled extract pipeline. - -Usage: - uv run python scripts/backfill_mmp.py [--data-dir ~/src/bincio_data] -""" - -import json -import sys -from pathlib import Path - -import click -from rich.console import Console -from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn - -console = Console() - -MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600] - - -def compute_mmp_from_timeseries(power_w: list) -> list[list[int]] | None: - """Compute MMP from an already-extracted 1Hz power array (nulls dropped).""" - samples = [w for w in power_w if w is not None] - if len(samples) < 2: - return None - - n = len(samples) - results = [] - for d in MMP_DURATIONS_S: - if d > n: - break - window_sum = sum(samples[:d]) - best = window_sum - for i in range(1, n - d + 1): - window_sum += samples[i + d - 1] - samples[i - 1] - if window_sum > best: - best = window_sum - results.append([d, round(best / d)]) - - return results if results else None - - -@click.command() -@click.option("--data-dir", default="~/src/bincio_data", show_default=True, - help="Path to the BAS data directory.") -@click.option("--dry-run", is_flag=True, help="Compute but don't write anything.") -def main(data_dir: str, dry_run: bool) -> None: - """Backfill mmp field into existing activity JSONs and index.json.""" - data = Path(data_dir).expanduser() - acts_dir = data / "activities" - - if not acts_dir.exists(): - console.print(f"[red]Activities dir not found: {acts_dir}[/red]") - sys.exit(1) - - jsons = sorted(acts_dir.glob("*.json")) - console.print(f"Found [bold]{len(jsons)}[/bold] activity JSONs in {acts_dir}") - - updated = skipped = no_power = 0 - - with Progress( - TextColumn("[progress.description]{task.description}"), - BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), - console=console, - ) as progress: - task = progress.add_task("Backfilling MMP…", total=len(jsons)) - - for path in jsons: - progress.advance(task) - try: - detail = json.loads(path.read_text()) - except Exception: - skipped += 1 - continue - - # Skip if already has mmp - if detail.get("mmp") is not None: - skipped += 1 - continue - - ts = detail.get("timeseries") or {} - power_w = ts.get("power_w") or [] - mmp = compute_mmp_from_timeseries(power_w) - - if not mmp: - no_power += 1 - continue - - detail["mmp"] = mmp - if not dry_run: - path.write_text(json.dumps(detail, indent=2, ensure_ascii=False)) - updated += 1 - - console.print( - f"\n[green]Done.[/green] " - f"Updated [bold]{updated}[/bold], " - f"already had mmp [bold]{skipped}[/bold], " - f"no power data [bold]{no_power}[/bold]." - ) - - if dry_run: - console.print("[yellow]Dry run — nothing written.[/yellow]") - return - - # Rebuild index.json summaries with mmp - console.print("Updating index.json summaries…") - index_path = data / "index.json" - if index_path.exists(): - index = json.loads(index_path.read_text()) - # Build a lookup from the now-updated detail JSONs - mmp_by_id: dict[str, list] = {} - for path in acts_dir.glob("*.json"): - try: - d = json.loads(path.read_text()) - if d.get("mmp"): - mmp_by_id[d["id"]] = d["mmp"] - except Exception: - pass - - for s in index.get("activities", []): - if s["id"] in mmp_by_id and not s.get("mmp"): - s["mmp"] = mmp_by_id[s["id"]] - - index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False)) - console.print(f" Patched {len(mmp_by_id)} summaries in index.json.") - - # Rebuild athlete.json - console.print("Rebuilding athlete.json…") - from bincio.extract.writer import write_athlete_json - index = json.loads(index_path.read_text()) - owner = index.get("owner", {}) - athlete_cfg = {k: v for k, v in (owner.get("athlete") or {}).items() if v is not None} - write_athlete_json(index.get("activities", []), data, athlete_cfg) - console.print(" athlete.json written.") - - # Re-run merge_all so _merged picks up the changes - console.print("Running merge_all…") - from bincio.render.merge import merge_all - n = merge_all(data) - console.print(f" merge_all done ({n} sidecars).") - - -if __name__ == "__main__": - main() diff --git a/site/.env b/site/.env deleted file mode 100644 index bb084dc..0000000 --- a/site/.env +++ /dev/null @@ -1,2 +0,0 @@ -BINCIO_DATA_DIR=/tmp/bincio_test -PUBLIC_EDIT_URL=http://localhost:4041