16 KiB
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:
bincio extract(Python): GPX/FIT/TCX → BAS JSON data storebincio 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
- Unified data layout — single-user and multi-user share the same structure: activities always live in
{data-root}/{handle}/. The only difference is the presence ofinstance.db(auth). No mode switching, no migration. - No database, no server — everything is static files; multi-user VPS mode adds SQLite auth only
- 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://andhttps://namespace URIs — parser handles both - Shard manifest for multi-user — no activity data duplication; root
index.jsonlists user shard URLs; browser resolves all shards concurrently; same mechanism handles yearly pagination and remote federation - Iterative RDP implemented inline in
simplify.py— nordpPyPI package (not available as a pure-Python wheel for Pyodide)
Your data
- Source:
~/your-activity-data/activities/— Strava export (GPX, FIT, TCX, all with .gz variants)- 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)
Configure input paths in extract_config.yaml.
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 (iterative, no rdp dep)
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)
merge.py sidecar edit overlay (produces _merged/)
edit/
cli.py `bincio edit` CLI (single-user local only)
server.py FastAPI write API for the edit drawer
serve/
cli.py `bincio serve` CLI (multi-user VPS)
server.py FastAPI: auth, user mgmt, write API (auth-gated)
db.py SQLite data layer (users, sessions, invites)
init_cmd.py `bincio init` CLI: bootstrap instance.db + admin user
schema/
bas-v1.schema.json JSON Schema for BAS
SCHEMA.md Human-readable BAS spec
site/ Astro project
src/
layouts/Base.astro Reads instancePrivate from index.json; injects auth wall
pages/
index.astro Activity feed (loads index.json client-side)
activity/[id].astro Single activity (SSG, loads detail JSON client-side)
activity/local/ IDB-only activities (converted locally via Pyodide)
stats/index.astro Heatmap + year totals
u/[handle].astro Per-user profile pages (multi-user)
login/index.astro Login form (public page)
register/index.astro Registration with invite code (public page)
invites/index.astro Invite management
convert/index.astro Local file conversion via Pyodide (browser-only)
components/
ActivityFeed.svelte Card grid, sport filter, pagination
ActivityDetail.svelte Map + stats + charts + photo gallery
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
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)
LocalActivityDetail.svelte Detail view for IDB-only (locally converted) activities
lib/
types.ts BAS TypeScript types
format.ts formatDistance, formatDuration, sportIcon, etc.
localstore.ts IndexedDB store for locally converted activities
dataloader.ts Fetches index.json, resolves shards recursively
How to run
# Single-user (no login)
cd ~/src/bincio_activity
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/{handle}/
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321/u/{handle}/
# Multi-user (with login)
uv run bincio init --data-dir /tmp/bincio_test --handle dave
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/dave/
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321 (login required)
# bincio dev does everything: merges sidecars, writes manifest,
# symlinks public/data, starts bincio serve (if instance.db exists),
# starts astro dev. Ctrl+C stops all.
# 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 ismaplibregl.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 AMDdefine()internally; served raw, the tile worker blob fails silently → black map, no tiles. The correct setting isinclude: ['maplibre-gl']. -
build.target: 'es2022'(andoptimizeDeps.esbuildOptions.target) is required. MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it, helpers like__publicFieldaren'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. Withclient:only="svelte"in Astro, SSR never runs for the component so there is nowindow is not definedrisk. Static import lets Vite pre-bundle correctly. -
Use
client:only="svelte"(notclient:load) for the activity detail page.client:loaddoes SSR + hydration; complex interactive components with MapLibre can hit hydration mismatch issues.client:onlymounts fresh on the client only. -
MapLibre v5 requires explicit
centerandzoomin the Map constructor. v4 silently defaulted tocenter: [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 passcenterandzoomeven if you plan tofitBoundslater. -
MapLibre v5 requires
setLngLat()on markers before.addTo(map). v4 tolerated markers without coordinates. v5 callsMarker._update()insideaddTo(), 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 throwunknown curveat runtime.
The working astro.config.mjs Vite section:
vite: {
optimizeDeps: {
include: ['maplibre-gl'],
esbuildOptions: { target: 'es2022' },
},
build: { target: 'es2022' },
},
Activity sidecar edits — design spec
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/
activities/{id}.json ← immutable extract output
edits/{id}.md ← user edits (sidecar)
edits/images/{id}/ ← uploaded photos
_merged/ ← render-time merge output (gitignored-style)
Sidecar format
---
title: "Epic climb up Monte Grappa"
sport: cycling
hide_stats: [cadence]
highlight: true
private: false
gear: "Trek Domane"
---
Rode with friends. Legs felt great after the rest week...
The sidecar private: true flag maps to privacy: "unlisted" in the merged JSON.
unlisted means: not shown in the public feed, but the activity detail, GPS track,
and timeseries are all accessible by direct URL (security by obscurity, same model
as the detail JSON itself). Use no_gps if the GPS track must not be published.
Editing UX: drawer in Astro + bincio edit write API
bincio edit --data-dir ~/bincio_datastarts a FastAPI server on port 4041- Set
PUBLIC_EDIT_URL=http://localhost:4041insite/.envto 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 renderalways runsmerge_all()before build/serve and symlinkspublic/data→_merged/
PUBLIC_EDIT_URL as feature flag
- Unset → no Edit button, normal static site
- Set → edit drawer enabled; lives in
site/.env(gitignored)
Multi-user VPS architecture
bincio serve is a FastAPI app that owns auth and write ops. nginx proxies /api/* to it; static files are served by nginx directly. The Vite dev server replicates this proxy for local testing.
Key facts:
- Session cookie:
bincio_session, httpOnly, SameSite=Lax, 30-day max-age - Rate limiting: 10 login attempts / 15 min / IP (in-memory, resets on restart)
- Invite limits: admins unlimited, regular users 3 each (
_MAX_USER_INVITESindb.py) - Instance privacy:
instance.private=truein rootindex.json→Base.astroinjects afetch('/api/me')auth wall;/login/and/register/havepublic={true}to skip it - Incremental rebuild:
POST /api/activity/{id}triggersbincio render --handle {user}as a fire-and-forget subprocess (only if--site-dirwas passed tobincio serve)
Password reset (no email — out-of-band code)
There is no email infrastructure. Password resets work via admin-generated one-time codes:
- Admin opens
/admin/→ clicks "Reset pwd" next to the user → a code appears inline (monospace, click to copy). Valid for 24 hours, tied to that handle. - Admin sends the code out-of-band (Signal, Telegram, etc.).
- User goes to
/reset-password/, enters handle + code + new password → done.
API:
POST /api/admin/users/{handle}/reset-password-code(admin) →{code, expires_in_hours: 24}POST /api/auth/reset-password(public) → body{handle, code, password}
DB: reset_codes table (code, handle, created_by, created_at, expires_at, used_at).
Generating a new code invalidates any prior unused code for the same handle.
Used codes are kept for audit. change_password() in db.py updates the bcrypt hash.
- Write API in
bincio servedelegates tobincio.edit.server._apply_sidecar_edit; the Strava sync delegates tobincio.edit.server.strava_syncwith a temporary data_dir swap
Instance settings (stored in instance.db settings table)
| Key | Default | Set by | Description |
|---|---|---|---|
max_users |
— (unlimited) | bincio init --max-users N or bincio serve --max-users N |
Cap on registered users; 0 or absent = unlimited |
store_originals |
true |
bincio init (first run only) |
Whether uploaded source files and raw Strava API data are kept in {user_dir}/originals/ |
get_setting / set_setting in db.py are the read/write accessors. Any new instance-wide flag should use this table rather than a new column.
Original file storage
When a user uploads a FIT/GPX/TCX file the server may keep the source in {user_dir}/originals/{filename} rather than always deleting it after extraction. The per-upload store_original form field controls the behaviour for a single upload (sent by the UI checkbox). The instance-level store_originals setting provides the default that pre-populates the checkbox (read from GET /api/me → store_originals_default).
For Strava sync, store_originals=true causes POST /api/strava/sync to save {"meta":…,"streams":…} JSON per activity to {user_dir}/originals/strava/{activity_id}.json.
Feedback storage
User feedback submitted via /feedback/ is stored as flat files under the instance data root (NOT inside a user's own data dir):
{data_root}/
_feedback/
{handle}.json ← append-only list of submissions for that user
{handle}/
{timestamp}_{token}_{filename} ← attached images
Each entry in {handle}.json:
{ "id": "1712345678_ab12cd34", "handle": "brut", "submitted_at": "...", "text": "...", "images": ["..."] }
To read feedback on the VPS:
cat /var/bincio/data/_feedback/brut.json | python3 -m json.tool
ls /var/bincio/data/_feedback/brut/ # attached images
There is no admin UI for feedback — it is intentionally read via SSH/shell only.
About pages
Static public pages at /about/ (EN), /about/it/ (IT), /about/es/ (ES), /about/ca/ (CA). All use public={true} to bypass the auth wall. Each page:
- Shows a Ko-fi donation button at the top.
- Fetches
GET /api/statson load and renders a community/invitation tree (member count, each user's display name, membership duration, and who invited them). Hidden silently in single-user mode. - Contains project description, data storage explanation, early-software caveat, and liability disclaimer.
Known issues / next steps
bincio render --watchmode not yet implemented as a standalone command, butbincio devnow watches the data directory viawatchfiles(bundled with uvicorn) and re-runsmerge_allautomatically when sidecars or activity files change- Activity IDs in older test data may use
+0000format (pre-fix); re-run extract to getZformat - Some activities appear with both untitled and titled IDs (near-dedup timing race)
- Remote federation (remote shard URLs in root manifest) is parsed but not yet displayed with attribution in the UI
- The
site/.envfile is gitignored — copy fromsite/.env.example
What "good" looks like (not yet done)
- Friends/federation pages in site (remote shard attribution)
- Personal records page
- Activity search / full-text filter in feed
- GitHub Actions template for auto-publish
- Karoo/Garmin Connect importers beyond Strava
bincio render --watchincremental rebuild on sidecar/data changes- Highlight badge in activity feed cards
- Per-instance user limit (
max_userssetting, enforced at registration) - Original file storage option (per-upload checkbox +
store_originalsinstance setting) - About page — multilingual (EN/IT/ES/CA), Ko-fi button, community invitation tree
GET /api/stats— public endpoint with member count and invitation treebincio.render.merge— sidecar parser,_merged/output, private filter, highlight sortbincio editFastAPI write API (GET/POST activity, image upload/delete, triggers merge)EditDrawer.svelte— slide-in edit UI in the Astro sitePUBLIC_EDIT_URLfeature flag- Markdown rendering in activity description with image path rewriting
- Photo gallery with lightbox on activity detail page
bincio serve— multi-user VPS server (auth, invites, write API)bincio init— instance bootstrap (SQLite, admin user, root manifest)- Login, register, invites pages
- Per-user profile pages (
/u/{handle}/) - Instance privacy (auth wall, private-by-default)
- Shard-based combined feed (no duplication, concurrent resolution)
- Local file conversion via Pyodide (
/convert/page, IDB storage)