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: ~8MB, 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
Note1["Bob adds Alice's index.json URL\nto his followed feeds.\nNo accounts. No central server.\nJust 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 | ✅ | iOS: App Store / TestFlight |
| Local HTTP server | ✗ | ✅ (future, via native plugin) |
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)
→ writes BAS JSON to device storage (Filesystem plugin)
Local Node server → serves BAS JSON from device storage to WebView
WebView → feed + activity pages read from local server
→ edit drawer saves sidecars to device storage
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
Five things need to be in place before Workflow 4 works end-to-end.
1. Pyodide available offline
Currently loads from CDN on first use. Two options:
- Service worker cache (recommended) — on first online visit the service worker caches all Pyodide assets; subsequent visits work offline. Standard PWA pattern, no app size increase.
- Bundle in app assets — copy
pyodide/intosite/public/at build time. Adds ~25 MB to the app but works with zero network dependency. Use if targeting environments with no initial connectivity.
Status: not yet implemented.
2. Write converted activities to device storage
@capacitor/filesystem is already installed. After Pyodide converts a file, instead of (or in addition to) POSTing to the edit server, the convert page should:
- Write
{id}.jsonand{id}.geojsonto a localactivities/directory via the Filesystem plugin - Append the summary to a local
index.json
This is self-contained JS work — no native changes needed.
Status: not yet implemented.
3. Serve local activity data to the WebView (the core problem)
The Astro site reads /data/index.json and /data/activities/*.json via fetch(). In the current setup these come from either bundled static assets (baked in at build time) or a remote server. There is no built-in mechanism for a WebView to dynamically serve locally-stored files at those URLs.
Two approaches:
Option A — Service worker interception (web-only, no native code)
A service worker intercepts fetch('/data/*') and responds with data from IndexedDB. No native plugin required. Limitation: iOS WKWebView has historically restricted service worker scope in Capacitor apps, though this has improved in recent iOS versions. Best option if cross-platform parity is not critical.
Option B — Local HTTP server via capacitor-nodejs (recommended for iOS reliability)
capacitor-nodejs embeds a Node.js runtime inside the Capacitor app. A small Express/Fastify server:
- Serves the static site assets (or proxies to the WebView asset loader)
- Serves BAS JSON from device storage at
/data/* - Handles the edit/save API (
/api/import-bas,/api/activity/{id}, etc.) The WebView points tohttp://localhost:PORT/. Reliable on both iOS and Android.
Status: not yet implemented. Option B is the recommended path.
4. Port edit server API to local JS
Once Option B above is in place, the Node.js server needs to implement the same API surface as the current FastAPI edit server — at minimum:
| Endpoint | Purpose |
|---|---|
POST /api/import-bas |
Save converted activity to device storage |
GET /api/activity/{id} |
Read activity + sidecar |
POST /api/activity/{id} |
Write sidecar to device storage |
GET /data/index.json |
Serve merged index from device storage |
GET /data/activities/* |
Serve individual activity files |
The sidecar merge logic (merge_all) can either be reimplemented in JS (~150 lines) or invoked via Pyodide (already present in the app).
Strava sync and image upload require network access by definition — they are out of scope for offline mode.
Status: not yet implemented.
5. Summary
| Step | Effort | Status |
|---|---|---|
| Pyodide service worker cache | Medium | Not started |
| Write to Filesystem after convert | Easy | Not started |
Local Node.js server (capacitor-nodejs) |
Hard | Not started |
| Port edit API to local JS | Medium | Not started |
| Sidecar merge in JS (or via Pyodide) | Medium | Not started |
The local Node.js server (step 3B) is the critical path item — it unblocks steps 4 and makes the feed dynamic. Steps 1 and 2 are independent and can be done in any order.
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.