Files
bincio-activity/ARCHITECTURE.md
T
2026-04-06 20:52:31 +02:00

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 micropip
  • bincio.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:

  1. Data access abstraction only (§1 above) — if all fetch('/data/*') calls are gone, the SW question is moot.
  2. Native Swift/Kotlin micro-server — a small HTTP server embedded in the native layer, serving from device storage. No third-party plugins.
  3. Re-run cap sync on 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/filesystem stores 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:

  1. Validates the id field
  2. Writes activities/{id}.json (and .geojson if provided)
  3. Rebuilds index.json
  4. 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.