# BincioActivity — Context for Claude ## What this project is BincioActivity is a federated, open-source, self-hosted activity stats platform (think personal Strava). Two-stage pipeline: 1. **`bincio extract`** (Python): GPX/FIT/TCX → BAS JSON data store 2. **`bincio render`** (Astro/Node): BAS data store → static website The BAS (BincioActivity Schema) JSON files are the federation protocol. Anyone can publish their data as BAS JSON and others can include it. ## Key design decisions - **No database, no server** — everything is static files - **Python with uv** for the extract stage - **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site - **Haversine** (not geopy) for distance calculations (10x faster) - **Worker initializer pattern** for ProcessPoolExecutor — large shared data (strava_lookup dict, known_hashes frozenset) is sent once per worker via `initializer=`, not once per task - **BAS activity IDs** always use UTC with Z suffix for URL safety - **TCX files** from Garmin use both `http://` and `https://` namespace URIs — parser handles both ## User's data - Source: `~/src/cycling_data_davide/` - `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants) - `Karoo_2026/` — recent Karoo device FIT files - `Karoo/` — older Karoo FIT files - `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 ## Project structure ``` bincio/ Python package extract/ models.py DataPoint, ParsedActivity, LapData parsers/ GPX, FIT, TCX parsers + factory sport.py sport name normalisation metrics.py haversine-based stats computation (single pass) timeseries.py downsample to 1Hz, build BAS timeseries object simplify.py RDP track simplification → GeoJSON dedup.py exact (hash) + near-duplicate detection strava_csv.py Strava activities.csv importer writer.py BAS JSON + GeoJSON writer config.py extract_config.yaml loader cli.py `bincio extract` CLI render/ cli.py `bincio render` CLI (symlinks data, runs astro build/dev) schema/ bas-v1.schema.json JSON Schema for BAS SCHEMA.md Human-readable BAS spec site/ Astro project src/ 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 components/ 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) StatsView.svelte Yearly heatmap + totals lib/ types.ts BAS TypeScript types format.ts formatDistance, formatDuration, sportIcon, etc. ``` ## How to run ```bash # Extract cd ~/src/bincio_activity uv run bincio extract --input ~/src/cycling_data_davide/activities --output /tmp/bincio_test # Site dev server cd site ln -sf /tmp/bincio_test public/data # symlink data BINCIO_DATA_DIR=/tmp/bincio_test npm run dev # Tests uv run pytest ``` ## MapLibre GL + Vite/Astro — known gotchas Learnt the hard way during debugging (March 2026): - **`maplibregl.workerUrl = ...` is the v3 API and silently no-ops in v4+.** The v5 API is `maplibregl.setWorkerUrl(url)`, but you don't need it at all in a normal Vite environment — MapLibre handles the blob worker automatically. - **`optimizeDeps: { exclude: ['maplibre-gl'] }` breaks tile loading.** It prevents Vite from converting MapLibre's UMD bundle to ESM. The UMD bundle uses AMD `define()` internally; served raw, the tile worker blob fails silently → black map, no tiles. The correct setting is `include: ['maplibre-gl']`. - **`build.target: 'es2022'` (and `optimizeDeps.esbuildOptions.target`) is required.** MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it, helpers like `__publicField` aren't available inside the serialised worker blob scope → tile loading fails. This is a known upstream issue (maplibre-gl-js #6680). - **Use static imports, not dynamic `await import('maplibre-gl')`, when possible.** With `client:only="svelte"` in Astro, SSR never runs for the component so there is no `window is not defined` risk. Static import lets Vite pre-bundle correctly. - **Use `client:only="svelte"` (not `client:load`) for the activity detail page.** `client:load` does SSR + hydration; complex interactive components with MapLibre can hit hydration mismatch issues. `client:only` mounts fresh on the client only. - **MapLibre v5 requires explicit `center` and `zoom` in the Map constructor.** v4 silently defaulted to `center: [0,0], zoom: 0`. v5 leaves internal projection state undefined → `Cannot read properties of undefined (reading 'lng')` crashes on any operation that touches coordinates (markers, resize, render). Always pass `center` and `zoom` even if you plan to `fitBounds` later. - **MapLibre v5 requires `setLngLat()` on markers before `.addTo(map)`.** v4 tolerated markers without coordinates. v5 calls `Marker._update()` inside `addTo()`, which needs valid lngLat → same `'lng'` crash. Set a dummy `[0, 0]` if the real position arrives later (e.g. hover markers). ## Observable Plot — known gotchas - **Curve names are hyphenated, not camelCase.** Use `"monotone-x"`, not `"monotoneX"`. Plot uses its own curve name registry (not raw d3 identifiers). Wrong names throw `unknown curve` at runtime. The working `astro.config.mjs` Vite section: ```js vite: { optimizeDeps: { include: ['maplibre-gl'], esbuildOptions: { target: 'es2022' }, }, build: { target: 'es2022' }, }, ``` ## Activity sidecar edits — design spec Users edit activities via **sidecar markdown files** that live alongside BAS JSON 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) ``` 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 --- Rode with Marco and Giulia. 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). **How it works:** ``` 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