22 KiB
22 KiB
Changelog
[Unreleased] — 2026-04-01
Security fixes (second-pass audit)
- Sport value not validated before YAML write (
edit/server.py) —sportfield now validated againstSPORTSallowlist before being written to the sidecar - No image content-type validation (
edit/server.py) — arbitrary file uploads rejected; onlyimage/*content types accepted - XSS via unescaped filename in
innerHTML(edit/server.py) —escapeHtml()applied to filenames inrenderImageListbefore interpolation - No upload size limit (
edit/server.py) — 50 MB limit enforced before writing to disk; returns HTTP 413 on oversize uploads - Exception message leaks internal paths (
edit/server.py) — 422 response now returnstype(exc).__name__only, notstr(exc)which could expose filesystem paths
Bug fixes — data (second-pass audit)
- Disambiguated ID not written into JSON body (
writer.py) — collision suffix was added to the filename butdetail["id"]still held the original ID; fixed to updatedetail["id"]after disambiguation write_activityreturn value ignored (cli.py) — caller was using the pre-collision ID to build the index summary; now captures the canonical return value fromwrite_activity- TOCTOU race in collision guard (
writer.py,cli.py) — concurrent workers could both see no existing file and overwrite each other; workers now write to unique.pending.jsonfiles and the main process arbitrates by quality score viafinalize_pending() athlete.yamlmerge has no field allowlist (render/merge.py) —merge_all()now applies only_ATHLETE_EDITABLEkeys (max_hr,ftp_w,hr_zones,power_zones,seasons,gear) from the sidecar- Timezone offsets without colon (
parsers/tcx.py) — regex updated to[+-]\d{2}:?\d{2}so+0200is handled alongside+02:00 - Power data in GPX extensions not parsed (
parsers/gpx.py) — extension tagspwr,power, andwattsnow parsed; MMP no longer alwaysNonefor GPX files with power meters speedsarray misaligned withcoordinates(simplify.py) — speeds array now uses the same lat/lon null filter as coordinates
Bug fixes — frontend (second-pass audit)
- Hardcoded nav links ignore
BASE_URL(Base.astro) —/,/stats/,/athlete/nav hrefs now usebaseUrlfromimport.meta.env.BASE_URL - Undeclared
errorvariable inuploadImages(EditDrawer.svelte) — catch block now usessaveStatus/saveOk(existing error state) instead of undeclarederror ResizeObserverstale closure in MmpChart (MmpChart.svelte) — reactive variables keep the closure current so resize re-renders with correct data after range selection changesresetTrimguard always true (ActivityCharts.svelte) — trackslastResetTabto force trim reset on every tab switch regardless of whether min/max happen to match- No
onDestroycleanup in ActivityCharts (ActivityCharts.svelte) — chart SVG now removed on component unmount to prevent memory leaks - Invalid URL
tabparameter shows blank content (AthleteView.svelte) —tabquery param validated againstTABSarray; invalid values fall back to'power'
Schema (second-pass audit)
activity_summarymissingcustomproperty (bas-v1.schema.json) —merge.pyalways addscustomto summaries but it wasn't declared; added to schemaskiingmissing from SCHEMA.md sport enum — added alongside sub-sport enum updates- Summary fields table incomplete (
SCHEMA.md) —sub_sport,mmp,best_efforts,best_climb_m,preview_coordsall added to the summary fields table
Tests (second-pass audit)
test_id_utc_conversion(test_writer.py) — verifies non-UTC timestamps are converted to UTC in generated IDstest_build_summary_required_fields(test_writer.py) — verifies all schema-required fields present inbuild_summaryoutput- Skiing and swimming sport variants (
test_sport.py) —test_skiing_variantsandtest_swimming_variantsadded - Non-canonical IDs in test fixtures (
test_merge.py) — fixture IDs updated to canonical2024-01-01T080000Z-morning-rideformat
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