18 KiB
18 KiB
Changelog
[Unreleased] — 2026-03-31
Security fixes
- Path traversal prevention (
edit/server.py) — all routes now validateactivity_idagainst[a-zA-Z0-9\-]+regex via_check_id(); invalid IDs return 400 - Path traversal in
delete_image—filenameparameter now stripped to basename viaPath(filename).namebefore use in filesystem paths - Path traversal in
upload_activity— uploadedfile.filenamestripped to basename viaPath(file.filename).name - XSS in activity description (
ActivityDetail.svelte) —marked()output now wrapped inDOMPurify.sanitize()before{@html}rendering - CORS restricted (
edit/server.py) —allow_origins=["*"]replaced withallow_origin_regexmatchinglocalhostorigins only - YAML injection in
hide_stats— values filtered against aSTAT_PANELSallowlist before writing to YAML frontmatter - Regex injection in
deleteImage(EditDrawer.svelte) — filename special characters escaped beforeRegExpconstruction
Bug fixes — data
- MMP sliding window on non-contiguous data (
metrics.py) — power series now built as a dense 1 Hz array with gaps zero-filled (standard GoldenCheetah/WKO approach); recording pauses no longer inflate MMP values - Best-effort times on non-contiguous data (
metrics.py) — speed series uses same zero-fill; pauses count as 0 km/h so windows cannot span them silently - Activity ID collision (
writer.py) — when two activities share the same start-time + title, the second is disambiguated with a 6-character source hash suffix; re-extracting the same file is idempotent - Misaligned lat/lon arrays (
ActivityMap.svelte) — lat and lon were filtered for nulls independently; now filtered as pairs so indices always stay aligned - Falsy
0.0speed check (metrics.py:89-90,parsers/fit.py:89) —if avg_speed_kmh/if speed_rawreplaced withis not None; 0.0 is no longer silently dropped - TCX timestamps with numeric timezone offsets (
parsers/tcx.py) —+02:00-style offsets now parsed correctly and converted to UTC; previously crashed withValueError
Bug fixes — frontend
- Backdrop dismiss fires
savedevent (EditDrawer.svelte) — backdrop click and ×-button now dispatchcloseinstead ofsaved, preventing unsaved data from overwriting the displayed title/description - No error handling in
uploadImages(EditDrawer.svelte) — wrapped upload loop in try/catch/finally so a network error clears theuploadingspinner and surfaces an error message instead of locking the UI - Stats page pagination (
StatsView.svelte) — heatmap now shows 4 years per page with ← Newer / Older → controls;?page=persisted in URL
Bug fixes — data (continued)
_best_climbjoins non-contiguous elevation segments (metrics.py) —Noneelevation samples now reset the Kadane's window instead of being skipped and joined; GPS blackout segments can no longer inflate climb valuessave_athletemutatedathlete.jsonin-place (edit/server.py,render/merge.py) — server now only writesedits/athlete.yaml;merge_all()applies the sidecar overlay when producing_merged/athlete.json, preserving extract immutabilitypreview_coordsoff-by-one (simplify.py) — subsampler was appending the final GPS point on top ofmax_points, returningmax_points + 1; now samplesmax_points - 1slots then appends last point- Non-monotonic timeseries (
timeseries.py) — dedup guard changed fromt == last_ttot <= last_t; backwards timestamps from corrupt files are now dropped instead of creating out-of-ordertarrays _patch_duplicate_ofsilently swallows exceptions (cli.py) — changed bareexcept: passto log a warning so failures surface during extract
Bug fixes — frontend (continued)
- Hardcoded
/activity/URLs ignoreBASE_URL(RecordsView.svelte,Base.astro) —baseprop now threaded from Astro page →AthleteView→RecordsView; upload redirect usesimport.meta.env.BASE_URLviadefine:vars - No error handling on stats page fetch (
StatsView.svelte) —index.jsonfetch wrapped in try/catch; error message displayed in place of heatmap instead of silent failure - Map doesn't resize on container change (
ActivityMap.svelte) —ResizeObserveradded to callmap.resize()when the map container is resized formatDurationfractional seconds (format.ts) — input floored withMath.floorbefore arithmetic;1500.7 sno longer displays as25m 00.7s- Empty YAML config crashes (
render/cli.py,edit/cli.py) —yaml.safe_load()result guarded withor {}; empty config file no longer throwsAttributeErroron.get()
Schema
- Writer output now matches schema (
bas-v1.schema.json) —mmp,best_efforts,best_climb_m,preview_coords, andcustomare all declared in the schema; previouslyadditionalProperties: falsecaused validation failures skiingadded to sport enum — was produced by the extractor but missing from the schema definition- Sub-sport enum extended —
nordic,alpine,open_water,pooladded to schema - Activity ID format corrected in SCHEMA.md — examples updated from
+0200offset toZUTC suffix (matching actual code behaviour since v0.1.0)
Tests
- Exact ID assertions (
test_writer.py) —test_id_with_titleandtest_id_without_titlenow assert the full ID string (2024-06-01T073012Z-morning-ride) instead of substrings normalise_sub_sporttest coverage (test_sport.py) — 3 new tests: Strava CamelCase conversion, ski variants, and unknown/None →None- Invalid sport in test_merge (
test_merge.py) —sport: "gravel"replaced with valid"running"
Navigation
- URL state persistence — filter and tab state is now stored in the URL query string so the browser back button always restores the exact view you left
- Activity feed (
/):?sport=cycling— sport filter survives back navigation - Stats page (
/stats/):?sport=cycling— same - Athlete page (
/athlete/):?tab=records— active tab survives back navigation - Records tab (
/athlete/?tab=records):?sport=cycling— sport filter within records also persisted; full URL example:/athlete/?tab=records&sport=cycling - All use
history.replaceState(notpushState) so clicking filters does not pollute the history stack — back always goes to the previous page, not the previous filter state - Default values are omitted from the URL for cleanliness (
sport=alland the default tab are never written)
- Activity feed (
Sport classification
- Sub-sport detection —
normalise_sub_sport()insport.pyinfers sub_sport from raw sport type strings- CamelCase Strava types handled correctly (
MountainBikeRide→cycling / mountain,GravelRide→cycling / gravel,AlpineSki→skiing / alpine,NordicSki→skiing / nordic, etc.) - All parsers (Strava importer, GPX, TCX) now populate
sub_sport; FIT parser was already correct - Sub-sport shown as a secondary pill on activity detail page: 🚴 Cycling + MTB
- CamelCase Strava types handled correctly (
Developer experience
--dev Nflag onbincio extract— samples N files evenly across the full file list (date + format diversity) and writes to/tmp/bincio_dev/;incrementalis disabled automatically--dev Nflag onbincio import strava— imports only the N most recent activities to/tmp/bincio_dev/- Dev loop:
bincio extract --dev 50 && bincio import strava --dev 50 && bincio render --serve --data-dir /tmp/bincio_dev
Data ingestion
-
bincio import strava— OAuth2 Strava importer (bincio/import_/strava.py+bincio/import_/cli.py)- One-shot local OAuth2 callback server (port 8976); opens browser, receives code, exchanges for tokens
- Tokens saved to
~/.config/bincio/strava.json; auto-refreshed on expiry (6h TTL) - Fetches paginated activity list with
after=timestamp for efficient incremental runs - Per activity:
GET /activities/{id}/streams→_strava_to_parsed()→compute()→write_activity() _patch_from_summary(): fillsNonemetrics from Strava summary when sensors are missing (manual entries, indoor rides)- Sync state persisted in
data_dir/_strava_sync.json(imported IDs + last sync timestamp) - Rate limit tracking via
X-RateLimit-Usage; warns at 85% of 15-min window; auto-retries on 429 - Credentials read from (in order): CLI flags → env vars →
extract_config.yamlunderimport.strava - Install:
uv sync --extra strava
-
Web file upload —
POST /api/uploadinbincio/edit/server.py- Accepts FIT/GPX/TCX (
.gzvariants too); 409 if activity already exists - Runs full extract pipeline inline:
parse_file()→compute()→write_activity()→merge_all() - Staged to
data_dir/_uploads/during processing; cleaned up infinally ↑button in site nav, gated behindPUBLIC_EDIT_URL; drag-and-drop modal; auto-redirects on success
- Accepts FIT/GPX/TCX (
-
extract_config.yamlis now gitignored — safe to store credentials underimport.stravaStravaConfigdataclass added tobincio/extract/config.py; parsed fromimport.strava:blockextract_config.example.yamlis the tracked template
-
Theme-aware heatmap (
StatsView.svelte) —applyIntensity()now lerps from the correct background colour in both dark (zinc-800#27272a) and light (zinc-200#e4e4e7) modes;emptyColorandbaseRgbreactive todata-themeviaMutationObserver
Athlete page
/athletepage — three-tab layout: Power Curve · Records · Profile- Mean Maximal Power (MMP) curve — computed at extract time for each activity with power data
- Sliding-window O(n) algorithm over 1 Hz power timeseries; 15 standard durations (1 s → 1 h)
- Multi-curve overlay with range selector: All time / Last 365 d / Last 90 d / user-defined seasons
- Log-scale x-axis via Observable Plot; FTP reference line; per-point tooltips
- Seasons configurable in
extract_config.yamlunderathlete.seasons
- Personal records (Records tab) — sport-specific best efforts computed via sliding window
- Running: 400 m, 1 km, 1 mile, 5 km, 10 km, half marathon, marathon
- Cycling: 5 km, 10 km, 20 km, 50 km, 100 km
- Swimming: 100 m, 200 m, 500 m, 1 km, 2 km
- Table shows time, pace (running) or speed (cycling/swimming), date, activity link
- Hiking / Walking: longest distance and most elevation gain
- Best climbs — top 10 biggest single climbs (Kadane's algorithm on 1 Hz elevation deltas); ranked table with elevation, date, activity link
- Profile tab — max HR, FTP, HR zones, power zones
bincio editathlete API (GET /api/athlete,POST /api/athlete) — reads/writesedits/athlete.yamlAthleteDrawer.svelte— slide-in profile editor (gated behindPUBLIC_EDIT_URL)- Max HR and FTP number inputs
- HR and power zone tables: changing a zone's upper bound auto-cascades to the next zone's lower bound
- Season list: name + date range, add/remove rows
athlete.json— written at extract time; contains pre-aggregated MMP curves and records; symlinked into_merged/bymerge_all()
Extraction pipeline
- MMP computation —
compute_mmp()added tometrics.py; stored in both detail JSON and index summary (enables client-side season filtering without extra fetches) - Best-effort computation —
compute_best_efforts()two-pointer sliding window on 1 Hz speed;_best_climb()Kadane's on elevation deltas write_athlete_json()— aggregates MMP and records from all summaries intoathlete.json
Scripts
scripts/backfill.py— backfillsmmp,best_efforts, andbest_climb_minto existing activity JSONs from already-extracted 1 Hz timeseries; no FIT re-parsing needed (~20 s for 2500 activities)
[0.1.0] — 2026-03-29
Extraction pipeline
- Parallel extraction — activities now processed with
ProcessPoolExecutor; large shared state (Strava lookup, known hashes) sent once per worker viainitializer=rather than once per task - TCX parser fixes — handles both
http://andhttps://Garmin namespace URIs - Sport classification overhaul
- FIT parser now reads sport from the
sessionframe as fallback when no separatesportframe is present (fixes Karoo and Strava-generated FIT files) - Strava CSV
Activity Typeused as authoritative override when present - Expanded sport mapping: e-bike variants (
ebikeride,e_bike_ride),ride,run, date-prefix stripping, and more - Skiing added as first-class sport:
cycling|running|hiking|walking|swimming|skiing|other - Nordic sub-sport: FIT sub_sport values
cross_country_skiing,nordic_skiing,skate_skiing,backcountry_skiing→"nordic"
- FIT parser now reads sport from the
- Distance calculation fix — when a FIT device records
distance = 0.0(notnull), the extractor now falls back to haversine-computed GPS distance instead of using the zero value directly; fixes skiing activities that had valid tracks and speeds but showed 0 km metadata_csvis fully optional — omitting it from config works cleanly; only needed for Strava bulk exports
Site — maps & charts
- MapLibre GL map fully working on the activity detail page
- Static import +
optimizeDeps.include(notexclude) fixes silent tile worker failure build.target: 'es2022'required for MapLibre's ES2022 class field syntax- MapLibre v5 requires explicit
center/zoomin Map constructor andsetLngLat()beforeaddTo()
- Static import +
- Observable Plot charts (elevation, speed, HR, cadence) working
- Switched from dynamic
await import()to static import — fixes unreliable Svelte reactivity - Curve name is
"monotone-x"not"monotoneX"
- Switched from dynamic
- Power chart added as fifth tab alongside elevation/speed/HR/cadence
- HR and power zone histograms — configurable zone boundaries via
athlete.hr_zones/athlete.power_zonesinextract_config.yaml; histogram x-axis capped at actual data max so sentinel values (999,9999) don't stretch the axis - Adjustable trim range on histograms
Site — activity feed
- SVG track thumbnails on feed cards — drawn from
preview_coords(no extra fetch) - Sport filter bar — pill buttons for All / Cycling / Running / Hiking / Walking / Swimming / Skiing / Other
Site — stats page
- Sport filter bar — same pill UI as the feed; all stats and heatmap reflect the selected sport
- Heatmap colour improvements
- Blended colours in "All" mode: each cell's RGB is a weighted average of sport colours by distance
- Percentile-based intensity scaling (active): each day ranked against all active days, spreading colour evenly regardless of km outliers; configurable back to linear/max-relative (documented in CLAUDE.md)
applyIntensity()lerps from zinc-800 background to full sport colour — dim cells fade into the background rather than going black$: cellColorsprecomputed as a reactiveMap<string, string>— fixes Svelte not re-rendering cells when filter changes
- Month label fix — labels embedded in the week-column flex grid (no more absolute-positioning bugs);
getWeeks()uses local date formatting (localISO()) instead oftoISOString()to avoid UTC/local mismatch that produced a spurious "Dec" label at column 0 - Cell tooltips — hovering a cell shows a floating card with date, and for each activity: name, sport, distance, duration; each activity is a clickable link to its detail page; 120 ms grace period when moving from cell to tooltip
Site — activity editing (bincio edit)
bincio editwrite API — FastAPI server (--data-dir, default port 4041)GET /api/activity/{id}— current values with sidecar overrides appliedPOST /api/activity/{id}— writes sidecar.md, triggersmerge_all()POST /api/activity/{id}/images— multipart image uploadDELETE /api/activity/{id}/images/{filename}
- Activity sidecar system (
bincio/render/merge.py)- Sidecars live in
edits/alongside extracted data (never co-mingled with immutable BAS JSON) - Fields:
title,sport,description,hide_stats,highlight,private,gear merge_all()produces_merged/output;public/data→_merged/at runtime
- Sidecars live in
EditDrawer.svelte— slide-in drawer in the Astro site (no separate HTML from the server)- Opens in-page via Edit button; only rendered when
PUBLIC_EDIT_URLenv var is set - Title, sport dropdown, gear, markdown description textarea
- Image drag-and-drop with chip list + delete
- Hide-stats toggle buttons (elevation, speed, heart_rate, cadence, power)
- Highlight and private flags
- Optimistic local update on save — title and description update immediately without reload
- Opens in-page via Edit button; only rendered when
- Photo gallery + lightbox on activity detail page — keyboard navigation (←/→/Esc), filename + counter overlay
- Markdown descriptions rendered with
marked; local relative images suppressed from inline rendering (shown in gallery instead)
Documentation
- README rewritten — philosophy statement front and centre, clear two-stage architecture diagram, quick start
- CHEATSHEET.md added — daily workflow, all CLI commands, config reference, privacy table, patching snippets, diagnostic scripts, key files table
- CLAUDE.md updated — MapLibre GL v5 gotchas, Observable Plot curve names, heatmap colour scaling approaches (linear vs percentile), sidecar/edit architecture decisions
extract_config.example.yamlcleaned up — personal paths removed,metadata_csvcommented out with explanation
Infrastructure
publish.sh— builds and pushes static site to GitHub Pages via orphan branch