Files
bincio-activity/CLAUDE.md
T

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:

  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

  • 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 of instance.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:// and https:// namespace URIs — parser handles both
  • Shard manifest for multi-user — no activity data duplication; root index.json lists user shard URLs; browser resolves all shards concurrently; same mechanism handles yearly pagination and remote federation
  • Iterative RDP implemented inline in simplify.py — no rdp PyPI 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 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:

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_data starts a FastAPI server on port 4041
  • Set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env to 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 render always runs merge_all() before build/serve and symlinks public/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_INVITES in db.py)
  • Instance privacy: instance.private=true in root index.jsonBase.astro injects a fetch('/api/me') auth wall; /login/ and /register/ have public={true} to skip it
  • Incremental rebuild: POST /api/activity/{id} triggers bincio render --handle {user} as a fire-and-forget subprocess (only if --site-dir was passed to bincio serve)

Password reset (no email — out-of-band code)

There is no email infrastructure. Password resets work via admin-generated one-time codes:

  1. 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.
  2. Admin sends the code out-of-band (Signal, Telegram, etc.).
  3. 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 serve delegates to bincio.edit.server._apply_sidecar_edit; the Strava sync delegates to bincio.edit.server.strava_sync with 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/mestore_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/stats on 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 --watch mode not yet implemented as a standalone command, but bincio dev now watches the data directory via watchfiles (bundled with uvicorn) and re-runs merge_all automatically when sidecars or activity files change
  • Activity IDs in older test data may use +0000 format (pre-fix); re-run extract to get Z format
  • 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/.env file is gitignored — copy from site/.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 --watch incremental rebuild on sidecar/data changes
  • Highlight badge in activity feed cards
  • Per-instance user limit (max_users setting, enforced at registration)
  • Original file storage option (per-upload checkbox + store_originals instance setting)
  • About page — multilingual (EN/IT/ES/CA), Ko-fi button, community invitation tree
  • GET /api/stats — public endpoint with member count and invitation tree
  • bincio.render.merge — sidecar parser, _merged/ output, private filter, highlight sort
  • bincio edit FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
  • EditDrawer.svelte — slide-in edit UI in the Astro site
  • PUBLIC_EDIT_URL feature 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)