15 KiB
BincioActivity — Architecture
Overview
BincioActivity is a two-stage pipeline that turns raw activity files (GPX, FIT, TCX) into a self-hosted static website. There is no database and no application server — everything is files.
Raw files ──► BAS data store ──► Static site
extract render
(Python) (Astro/Node)
Stage 1 — Extract
bincio extract reads your activity files and writes a BAS (BincioActivity Schema) data store: a directory of JSON files.
flowchart LR
subgraph Input
A[GPX files]
B[FIT files]
C[TCX files]
D[activities.csv\nStrava metadata]
end
subgraph Extract ["bincio extract (Python)"]
E[Parse]
F[Compute metrics]
G[Deduplicate]
H[Write BAS JSON]
end
subgraph Output ["BAS data store ~/bincio_data/"]
I[activities/\n*.json *.geojson]
J[index.json\nall summaries]
K[athlete.json\nzones + records]
end
A & B & C --> E
D --> E
E --> F --> G --> H
H --> I & J & K
The data store is immutable extract output — never edited directly.
Stage 2 — Render
bincio render merges any user edits, then runs an Astro build to produce a static site.
flowchart LR
subgraph DataStore ["BAS data store"]
A[activities/*.json]
B[edits/*.md\nsidecar files]
C[edits/images/]
end
subgraph Render ["bincio render (Astro/Node)"]
D[merge_all\napply sidecars]
E[Astro build\nSSG]
end
subgraph Site ["Static site dist/"]
F[HTML pages]
G[JS bundles]
H[data/\nBAS JSON]
end
A & B & C --> D --> E --> F & G & H
The rendered site is fully static: no server needed to serve it. GitHub Pages, Netlify, nginx — all work.
The edit flow
When bincio edit is running locally, an Edit button appears in the site. It opens a drawer that writes sidecar files without touching the immutable extract output.
sequenceDiagram
actor User
participant Site as Static site\n(Astro dev server)
participant EditServer as bincio edit server\n(FastAPI, port 4041)
participant DataStore as BAS data store
User->>Site: clicks Edit on activity
Site->>EditServer: GET /api/activity/{id}
EditServer->>DataStore: reads .json + sidecar .md
EditServer-->>Site: current values
User->>Site: edits title / description / sport
Site->>EditServer: POST /api/activity/{id}
EditServer->>DataStore: writes edits/{id}.md
EditServer->>DataStore: runs merge_all()
EditServer-->>Site: ok
Note over Site: feed reloads with merged data
The edit server is never public-facing — it only binds to 127.0.0.1 and is only enabled when PUBLIC_EDIT_URL is set in site/.env.
Data sources
There are three ways activities enter the data store:
flowchart TD
subgraph Sources
A[📁 Local files\nGPX / FIT / TCX]
B[🟠 Strava API\nOAuth sync]
C[📱 Convert page\nPyodide in-browser]
end
subgraph EditServer ["bincio edit server\n(when running)"]
D[POST /api/upload]
E[POST /api/strava/sync]
end
subgraph CLI ["bincio extract CLI"]
F[batch extract]
end
subgraph DataStore ["BAS data store"]
G[activities/*.json]
H[index.json]
end
A -->|bulk| F --> G & H
A -->|single file via UI| D --> G & H
B --> E --> G & H
C -->|download JSON| A
C -->|POST to edit server\nif configured| D
Local files → CLI is the primary path for bulk imports. Strava sync and file upload go through the edit server for single activities. Convert page runs the extract pipeline in-browser — output is either downloaded or sent to the edit server.
The convert page
/convert/ is a page in the static site that runs the full extract pipeline inside the browser using Pyodide (Python compiled to WebAssembly).
sequenceDiagram
actor User as User\n(on phone or desktop)
participant Page as /convert/ page\n(browser)
participant Pyodide as Pyodide runtime\n(Python in WASM)
participant EditServer as bincio edit server\n(optional)
User->>Page: opens /convert/
Page->>Pyodide: load Pyodide + packages\n(lxml, fitdecode, bincio wheel)
Note over Page,Pyodide: ~10MB, cached after first visit
User->>Page: selects GPX / FIT / TCX file
Page->>Pyodide: write file to virtual FS\nrun parse → metrics → write
Pyodide-->>Page: BAS JSON + GeoJSON strings
alt Download
Page->>User: download activity.json\n(+ activity.geojson if GPS)
else Save to bincio
Page->>EditServer: POST /api/upload
EditServer-->>Page: {id}
Page->>User: redirect to activity page
end
"Save to bincio" only appears when the page is served from an instance with PUBLIC_EDIT_URL set — the same flag that enables the edit drawer. For anyone else, downloading the JSON is the output.
Why the convert page belongs to the instance
The convert page is part of the static site build — it uses the same styling, the same Pyodide wheel, and (if enabled) the same edit server. It is a tool for the people who use this instance:
- The instance owner uses it to convert files on mobile without needing a computer
- (Future) other users of a multi-user instance use it to upload their own activities
It is not a standalone public tool (though technically anyone with the URL could use the download path, since Pyodide runs locally in their browser).
Federation
Federation is how users follow each other across different bincio instances without any central server.
flowchart TB
subgraph InstanceA ["Instance A — bincio.alice.com"]
A1[BAS data store]
A2[Static site]
A1 --> A2
end
subgraph InstanceB ["Instance B — bincio.bob.com"]
B1[BAS data store]
B2[Static site]
B1 --> B2
end
subgraph Browser ["Bob's browser"]
C[Feed]
end
A2 -->|BAS JSON URL| C
B2 -->|BAS JSON URL| C
%% Bob adds Alice's index.json URL to his followed feeds.
%% No accounts. No central server. Just a URL.
Federation is a planned feature — the data format (BAS JSON) is designed for it, but the site UI doesn't yet support adding followed feeds.
Mobile app (Capacitor)
The existing Astro/Svelte site is wrapped in a Capacitor native shell to produce iOS and Android apps. No code is rewritten — Capacitor provides the native APIs that a plain browser PWA cannot.
What Capacitor adds over a browser PWA
| Capability | Browser PWA | Capacitor app |
|---|---|---|
| Background GPS (iOS) | ✗ killed by OS | ✅ native entitlement |
| Background GPS (Android) | ⚠️ limited | ✅ foreground service |
| Filesystem access | ✗ sandboxed | ✅ full device storage |
| Install without App Store | ✅ (Android sideload) | iOS: App Store or TestFlight |
Architecture
flowchart TD
subgraph App ["Capacitor app (iOS / Android)"]
subgraph WebView ["WebView — existing Astro/Svelte site"]
A[/record/ — GPS recorder]
B[/convert/ — Pyodide converter]
C[/feed/, /activity/, etc.]
end
subgraph Native ["Native layer"]
D[Geolocation plugin\nbackground GPS]
E[Filesystem plugin\nread/write device storage]
end
end
subgraph Cloud ["Cloud instance (optional)"]
F[Static site]
G[bincio edit server]
end
A -->|JS bridge| D
B -->|JS bridge| E
B -->|POST /api/import-bas| G
C --> F
Mobile workflows
Workflow 1 — Record on phone, save to cloud
Phone app: /record/ → GPS recording (Capacitor Geolocation)
→ export GPX → /convert/
/convert/ → Pyodide runs extract pipeline in WebView
→ POST /api/import-bas to cloud edit server
Cloud instance → saves activity, site updates
Workflow 2 — Record on phone, save locally (offline)
Phone app: /record/ → GPS recording
→ export GPX → /convert/
/convert/ → Pyodide converts in WebView
→ download BAS JSON to device storage
(upload to cloud later when online)
Workflow 3 — Import from OsmAnd / Organic Maps
OsmAnd / Organic Maps → exports GPX to device storage
Phone app: /convert/ → user picks GPX file
→ Pyodide converts in WebView
→ save to cloud or download
Workflow 4 — Fully offline on phone (future)
Phone app: /record/ → GPS recording (native Geolocation)
→ export GPX → /convert/
/convert/ → Pyodide converts (loaded from local cache)
→ saveActivityLocally() writes to IndexedDB
Data loader → loadIndex() / loadActivity(id) read from IndexedDB
WebView → feed + activity pages populated from local data
→ edit drawer writes sidecars (merge logic in JS)
See "Fully offline — missing pieces" below for implementation status.
Setup
cd site
# First time: initialise native projects
npx cap add android # creates site/android/
npx cap add ios # creates site/ios/ — requires macOS + Xcode
# Daily workflow
npm run cap:sync # build Astro + sync to native projects
npm run cap:android # build + open Android Studio
npm run cap:ios # build + open Xcode
The bincio Python wheel
The /convert/ page loads the extract pipeline via Pyodide (Python compiled to WebAssembly). The pipeline is bundled as site/public/bincio.whl — a standard Python wheel that Pyodide loads via micropip.
To rebuild the wheel after changing the extract code:
# from the repo root
uv build --wheel
cp dist/bincio-*.whl site/public/bincio.whl
Pyodide loads these packages on first visit (cached by the browser after that):
lxml,pyyaml— prebuilt Pyodide packages (~2 MB)fitdecode,gpxpy,rdp— pure Python, installed via micropipbincio.whl— our wheel, ~150 KB
Total cold-start download: ~10 MB. Subsequent visits: instant (all cached).
Fully offline — missing pieces
1. Data access abstraction (do this first)
The prior design asked "how do we serve /data/* to the WebView from local storage?" — but that question skips a cleaner solution: eliminate the fetch('/data/*') calls from the hot path entirely.
The Svelte components (ActivityFeed, ActivityDetail, etc.) currently call fetch(...) directly. Replacing those with a thin loader module (loadIndex(), loadActivity(id)) with two implementations decouples the data source from the UI:
src/lib/dataloader.ts
loadIndex() → fetch('/data/index.json') [cloud build]
→ idbGet('/data/local-index') [app build]
loadActivity(id) → fetch('/data/activities/...') [cloud build]
→ idbGet('/data/activities/...') [app build]
The build variant is selected by an env var (PUBLIC_DATA_MODE=cloud|local) at Astro build time. No service worker required. No iOS WKWebView question. Useful regardless of offline support (testability, mocking, future federation transports).
This is the first thing to build. The service worker work already done (sw.js, localstore.ts) provides a working fallback and is useful for the browser, but the abstraction layer is cleaner and should land before investing more in the SW path.
Status: not yet implemented.
2. Pyodide available offline
Currently loads from CDN (~10 MB) on first use. A service worker can cache the CDN assets on first online visit so all subsequent visits work offline. Standard PWA pattern, no app size increase, no code changes to the convert page.
Status: not yet implemented.
3. Sidecar merge in JS
The edit drawer currently calls the edit server's POST /api/activity/{id} which triggers the Python merge_all(). For offline editing this needs to run locally. Port to JS (~150 lines) — do not use Pyodide for this.
Pyodide is lazy-loaded on /convert/. It is not warm just because the app is open. A user tapping Edit → change title → Save would trigger a full ~10 MB Pyodide cold start for a trivial one-line edit. The edit drawer must remain decoupled from the convert page's machinery.
Status: not yet implemented.
4. If service workers fail on iOS
If testing in a Capacitor iOS WebView reveals that service workers are blocked, fall back to one of:
- Data access abstraction only (§1 above) — if all
fetch('/data/*')calls are gone, the SW question is moot. - Native Swift/Kotlin micro-server — a small HTTP server embedded in the native layer, serving from device storage. No third-party plugins.
- Re-run
cap syncon import — bundle updated static data files and resync. Crude but reliable for low-frequency imports.
capacitor-nodejs is not a fallback option. It embeds a full Node runtime, has spotty iOS support, and carries significant long-term maintenance cost.
5. Open questions — data lifecycle on device
These don't need solutions today but will surface during implementation:
- Storage limits: iOS may evict IndexedDB under storage pressure.
@capacitor/filesystemstores in the app's Documents directory (not evictable) — may be preferable for activity data. - Uninstall / reinstall: IndexedDB is wiped on uninstall. Documents directory survives reinstall on iOS. Strategy TBD.
- Sync conflicts: if the same activity exists locally and on a cloud instance (e.g. uploaded via Strava, also recorded in-app), the merge strategy is undefined. Likely: server wins on pull, local wins on push, user resolves conflicts manually.
6. Implementation order
| Step | Effort | Status |
|---|---|---|
Data access abstraction (dataloader.ts) |
Medium | Not started — do first |
| Sidecar merge in JS | Medium | Not started |
| Pyodide service worker cache | Medium | Not started |
| Test SW in Capacitor Android WebView | Low | Not started |
| Test SW on iOS | Low | Not started |
| Native micro-server (iOS fallback, if needed) | Hard | Contingency only |
The /api/import-bas endpoint
The /convert/ page sends pre-converted BAS JSON directly to the edit server, avoiding the need to re-parse a file on the server side. The endpoint:
- Validates the
idfield - Writes
activities/{id}.json(and.geojsonif provided) - Rebuilds
index.json - Runs
merge_all()
Deployment
flowchart LR
subgraph Local ["Local machine"]
A[bincio extract]
B[bincio edit\noptional]
end
subgraph VPS ["VPS or static host"]
C[Static site\ndist/]
D[nginx]
end
subgraph Phone ["Phone"]
E[OsmAnd /\nOrganic Maps]
F[Browser:\n/convert/]
end
A -->|rsync / CI| C
C --> D
E -->|GPX export| F
F -->|upload| B
B --> A
The edit server is always local in the single-user setup. In a future multi-user deployment on a VPS, it would run as a service alongside nginx.