remove accidentally tracked local files; update .gitignore

This commit is contained in:
Davide Scaini
2026-04-13 18:50:20 +02:00
parent 5ad3aee8f6
commit 57fb7acd3d
7 changed files with 8 additions and 423 deletions
-31
View File
@@ -1,31 +0,0 @@
{
"permissions": {
"allow": [
"Read(//Users/brutsalvadi/src/bincio_data/_merged/**)",
"Read(//Users/brutsalvadi/src/bincio_data/**)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.get\\(''''dependencies'''',{}\\).keys\\(\\)\\)\\)\")",
"Bash(uv run:*)",
"Bash(python3 -c \"import pyodide\")",
"Bash(uv build:*)",
"Bash(npm install:*)",
"Bash(npm run:*)",
"Bash(mkdir -p /tmp/bincio_ci/activities)",
"Bash(BINCIO_DATA_DIR=/tmp/bincio_ci npm run build)",
"Bash(ls /Users/brutsalvadi/src/bincio_activity/*.md)",
"Bash(python3:*)",
"Bash(wc -l /Users/brutsalvadi/src/bincio_activity/bincio/extract/*.py)",
"Bash(grep -E \"\\\\.py$\")",
"Bash([ -f ~/src/cycling_data_davide/activities.csv ])",
"Read(//Users/brutsalvadi/src/cycling_data_davide/**)",
"Bash(ls -la /tmp/bincio_dev_test/dave/_merged/activities/*.json)",
"Bash(ls -la /tmp/bincio_dev_test/dave/_merged/activities/*.geojson)",
"Bash(unzip -l ~/src/cycling_data_davide/export_18885842.zip)",
"Bash(unzip -p export_18885842.zip profile.csv)",
"Bash(unzip -p export_18885842.zip bikes.csv)",
"Bash(unzip -p export_18885842.zip routes.csv)",
"Bash(unzip -p export_18885842.zip privacy_zones.csv)",
"Bash(unzip -p export_18885842.zip activities.csv)",
"Bash(xargs -I{} python3 -c \"import json,sys; d=json.load\\(open\\('{}'\\)\\);print\\({k:d.get\\(k\\) for k in ['title','strava_id','started_at','privacy']}\\)\")"
]
}
}
+8
View File
@@ -29,6 +29,14 @@ bincio_data/
.env
extract_config.yaml
# Local working / scratch files
advice.md
issues.md
todo.md
site/public/data
site/public/*.whl
.claude/settings.local.json
# Capacitor native projects
# Commit these if you want to track native code changes;
# omit these lines if you regenerate them from `npx cap add`
-79
View File
@@ -1,79 +0,0 @@
# Advice on ARCHITECTURE.md
Review of the mobile / offline plan in `ARCHITECTURE.md`. The two-stage extract/render pipeline, edit flow, and federation sections are accurate and clear — this document focuses on where the architectural risk lives: the mobile app and the path to fully-offline operation.
## What's strong
- **Workflow framing (14) is the right way to think about it.** Each workflow is a concrete user story, not an abstract capability list. Workflow 1 (record → convert → cloud) is achievable with what's already there; that's a good wedge to ship first.
- **Honest status table** at the bottom of "Fully offline — missing pieces". An architecture doc that admits what isn't done is more useful than one that hand-waves.
- **Incremental validation plan (§5).** Testing the service worker approach in the browser before touching native builds is the right call — cheap to run, and the failure mode is informative rather than expensive.
## Decision: drop `capacitor-nodejs`
The doc previously listed `capacitor-nodejs` as a fallback if service workers fail on iOS. We are dropping this option entirely. Reasons:
- It embeds a full Node runtime, meaningfully increasing app size.
- iOS support relies on a fork of Node-mobile and has historically been spotty.
- It introduces a long-term maintenance burden (tracking upstream Node, plugin updates, two runtimes to debug).
- The "Hard" effort label in the original status table understated both the integration risk and the ongoing cost.
If service workers turn out to be blocked on iOS, the fallback should be one of:
1. **A data access abstraction** (see next section) that lets the app read from IndexedDB directly via a JS loader, with no `fetch('/data/*')` in the hot path at all. This sidesteps the WKWebView question entirely.
2. **A native Swift/Kotlin micro-server** if a local HTTP origin is genuinely required.
3. **Bundling data as static assets** and re-running `cap sync` on import — crude but boring and reliable.
`capacitor-nodejs` should not appear in the doc, the test plan, or the status table.
## Things to fix or add
### 1. Add a data access abstraction step *before* the service worker work
The doc frames the offline problem as "serve local data to the WebView at `/data/*`". That skips a prior question: does the Astro site actually need to fetch `/data/*` at runtime, or can the data layer be abstracted behind a thin loader (`loadIndex()`, `loadActivity(id)`) with two implementations?
- **Cloud build:** loader uses `fetch('/data/...')` as today.
- **App build:** loader reads from IndexedDB directly.
This is a much smaller change than service worker interception, avoids the iOS WKWebView question entirely, and is useful even if you stay cloud-only (testability, mocking, future federation transports). It should land *before* any service worker work — at which point the SW work may turn out to be unnecessary.
### 2. Commit to JS for sidecar merge — do not use Pyodide
The "missing pieces" section lists two options for re-running `merge_all` on save: reimplement in JS (~150 lines) or call into Pyodide. Pick JS. Reasons:
- Pyodide is lazy-loaded by `/convert/`. It is **not** warm just because the app is open.
- A user tapping Edit → change title → Save would trigger a multi-second Pyodide cold start (~10MB) for a one-line edit. Terrible UX, and it repeats on every cold app launch.
- Porting `merge_all` to JS keeps the edit drawer decoupled from the convert page's machinery. The two subsystems stay independent.
Pyodide should remain convert-page-only.
### 3. The Workflow 4 diagram contradicts the test plan
The "Fully offline on phone" workflow shows a "Local Node server" arrow as if it were the chosen path. The §5 test plan picks service workers first. The diagram should reflect that — show the SW path as primary, and drop the Node server box entirely (per the decision above).
### 4. Missing: data lifecycle on device
Nothing in the doc covers:
- How much storage the app is allowed to use on iOS before the OS evicts it.
- What happens on uninstall / reinstall.
- Sync / conflict resolution if the same activity exists locally and on a cloud instance.
These don't need solutions today, but they should be acknowledged as open questions. Otherwise Workflow 4 will hit all of them at once during implementation.
### 5. Reconcile Pyodide payload size
Line 162 says "~8MB", line 335 says "~10MB". Pick one and use it consistently.
## Small stuff
- The federation diagram uses a `Note1` node that won't render as a Mermaid note — it'll appear as a regular box. Use `%%` comments or restructure.
- The "iOS: App Store / TestFlight" cell in the PWA-vs-Capacitor table sits in the Capacitor column but reads like a downside. Clarify it's the distribution path, not a limitation relative to PWA.
## Bottom line
The plan is sound and the incremental validation approach is right. The two highest-leverage changes:
1. **Add a data access abstraction layer** before the service worker work. It's small, useful regardless, and may make the SW work moot.
2. **Port `merge_all` to JS** so the edit drawer doesn't depend on Pyodide warm-up.
With `capacitor-nodejs` removed, the offline path is: data access abstraction → IndexedDB-backed loader → JS merge → (optionally) service worker for any remaining `fetch('/data/*')` callsites that can't be migrated to the loader.
-99
View File
@@ -1,99 +0,0 @@
# Repository Audit — 2026-03-30
## CRITICAL
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 1 | Security | `edit/server.py:351,386,431,588` | **Path traversal**`activity_id` from URL is interpolated into filesystem paths without sanitization. Attacker can read/write/delete arbitrary files via `../../` | ✅ Fixed — `_check_id()` validates against `[a-zA-Z0-9\-]+` |
| 2 | Security | `edit/server.py:588` | **Path traversal in `delete_image`**`filename` param is not `.name`-stripped (unlike `upload_image`), enabling deletion of arbitrary files | ✅ Fixed — `Path(filename).name` strip added |
| 3 | Security | `edit/server.py:542` | **Path traversal in `upload_activity`** — uploaded `file.filename` used directly for staged path | ✅ Fixed — `Path(file.filename).name` strip added |
| 4 | XSS | `ActivityDetail.svelte:67,189` | `marked()` output rendered with `{@html}` without sanitization. Sidecar markdown can inject `<script>` tags | ✅ Fixed — DOMPurify.sanitize() wraps marked() output |
## HIGH
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 5 | Data | `metrics.py:116-148` | **MMP sliding window on non-contiguous data** — gaps in recording are collapsed, inflating power values | ✅ Fixed — dense 1Hz array with gap zero-fill (standard GoldenCheetah/WKO approach) |
| 6 | Data | `metrics.py:170-178` | **Best-effort times on non-contiguous data** — pauses removed from speed array produce artificially fast times | ✅ Fixed — same zero-fill approach, gaps count as 0 km/h |
| 7 | Data | `writer.py:85-86` | **Activity ID collision** — two activities with same start second + title silently overwrite each other | ✅ Fixed — collision detected by source_hash comparison; disambiguated with 6-char hash suffix |
| 8 | Frontend | `ActivityMap.svelte:88-94` | **Misaligned lat/lon arrays** — independently filtered for nulls, so `lats[0]` may not correspond to `lons[0]`. Start/end markers placed at wrong coordinates | ✅ Fixed — lat/lon filtered as pairs |
| 9 | Frontend | `EditDrawer.svelte:121` | **Backdrop dismiss fires `saved` event** with unsaved data, overwriting displayed title/description | ✅ Fixed — backdrop/close now dispatch `close` event |
| 10 | Schema | `writer.py` vs `bas-v1.schema.json` | **Writer output fails own schema**`mmp`, `best_efforts`, `best_climb_m`, `preview_coords`, `custom` written but not declared; schema has `additionalProperties: false` | ✅ Fixed — all missing fields declared in schema |
## MEDIUM
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 11 | Data | `metrics.py:89-90` | **Falsy check drops `0.0` speed**`if avg_speed_kmh` treats `0.0` as falsy; should be `is not None` | ✅ Fixed — `is not None` check |
| 12 | Data | `parsers/fit.py:89` | Same falsy `0.0` bug for FIT lap `speed_raw` | ✅ Fixed — `is not None` check |
| 13 | Data | `metrics.py:222-239` | `_best_climb` filters out `None` elevations, joining non-contiguous segments | ✅ Fixed — `None` samples reset Kadane's window instead of being skipped |
| 14 | Parser | `parsers/tcx.py:89-97` | **TCX timestamps with numeric timezone offsets** (`+02:00`) crash — only `Z` suffix handled | ✅ Fixed — numeric offsets parsed with `%z`, converted to UTC |
| 15 | Security | `edit/server.py:22` | **CORS `allow_origins=["*"]`** — any website can make requests to the edit server | ✅ Fixed — restricted to `localhost` origins only |
| 16 | Security | `edit/server.py:406` | **YAML injection**`hide_stats` values written into YAML frontmatter without quoting | ✅ Fixed — values filtered against STAT_PANELS allowlist |
| 17 | Data | `edit/server.py:497-499` | `save_athlete` mutates immutable `athlete.json` in-place, violating extract immutability | ✅ Fixed — `merge_all()` now applies `edits/athlete.yaml` overlay; server only writes the sidecar |
| 18 | Schema | `sport.py` vs schema | **`skiing` sport** produced by code but missing from JSON schema and SCHEMA.md | ✅ Fixed — `skiing` added to schema sport enum |
| 19 | Schema | `sport.py` vs schema | **Sub-sport values** `nordic`, `alpine`, `open_water`, `pool` not in schema enum | ✅ Fixed — added to schema sub_sport enum |
| 20 | Schema | `SCHEMA.md:140` | **Activity ID format wrong** — docs say `+0200` offset, code uses `Z` UTC | ✅ Fixed — all examples updated to `Z` suffix |
| 21 | Frontend | `EditDrawer.svelte:106` | **Regex injection** — filename with metacharacters (e.g. `photo(1).jpg`) breaks `deleteImage` regex | ✅ Fixed — filename escaped before RegExp construction |
| 22 | Frontend | `EditDrawer.svelte:85-99` | No try/catch in `uploadImages` — network error leaves UI stuck in uploading state | ✅ Fixed — try/catch with `finally { uploading = false }` |
| 23 | Frontend | `RecordsView.svelte:99`, `Base.astro:220` | Hardcoded `/activity/` URLs ignore `BASE_URL`, breaking subdirectory deployments | ✅ Fixed — `base` prop threaded through `AthleteView``RecordsView`; `Base.astro` uses `baseUrl` from `import.meta.env.BASE_URL` |
## LOW
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 24 | Data | `cli.py:341-350` | `_patch_duplicate_of` silently swallows all exceptions | ✅ Fixed — logs warning via `logging.warning` instead of bare `pass` |
| 25 | Data | `timeseries.py:26-35` | Non-monotonic `t` array if input points aren't sorted | ✅ Fixed — `t <= last_t` guard rejects both duplicate and backwards timestamps |
| 26 | Data | `simplify.py:49-50` | `preview_coords` returns `max_points + 1` elements (off-by-one) | ✅ Fixed — subsample to `max_points - 1` then append last point |
| 27 | Config | `render/cli.py:42`, `edit/cli.py:66` | `yaml.safe_load` returns `None` for empty config → `AttributeError` | ✅ Fixed — `or {}` fallback on both load sites |
| 28 | Frontend | `format.ts:18` | `formatDuration` doesn't floor seconds — fractional values display messily | ✅ Fixed — `Math.floor(s)` before any arithmetic |
| 29 | Frontend | `StatsView.svelte:31` | No error handling on `index.json` fetch | ✅ Fixed — try/catch with error display |
| 30 | Frontend | `ActivityMap.svelte` | No `map.resize()` on container size change | ✅ Fixed — `ResizeObserver` calls `map.resize()` |
| 31 | Test | `test_writer.py:21-30` | Weak assertions — only check substrings, would pass with malformed IDs | ✅ Fixed — exact equality assertions on full ID strings |
| 32 | Test | `test_sport.py` | Zero test coverage for `normalise_sub_sport` | ✅ Fixed — 3 new tests covering Strava CamelCase, ski variants, and unknown→None |
| 33 | Test | `test_merge.py:70` | Test uses `sport: "gravel"` which isn't a valid BAS sport | ✅ Fixed — changed to `"running"` |
---
## Second-pass audit — 2026-04-01
### CRITICAL / HIGH (new)
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 34 | Data | `writer.py:88-93` | **Disambiguated ID not written into JSON body** — collision adds hash suffix to filename but `detail["id"]` still holds original ID | ✅ Fixed — `detail["id"] = activity_id` after disambiguation |
| 35 | Data | `cli.py:79-85` | **`write_activity` return value ignored** — caller uses pre-collision ID for `build_summary`, so index links are broken for disambiguated activities | ✅ Fixed — `activity_id = write_activity(...)` captures the canonical ID |
| 36 | Data | `writer.py:88-92` | **TOCTOU race in collision guard** — two workers processing same-timestamp activities can both see no existing file and overwrite each other | ✅ Fixed — hybrid pending-file approach: workers write to unique `.pending.json` files, main process arbitrates by quality score and does atomic rename via `finalize_pending()` |
| 37 | Schema | `bas-v1.schema.json:90-128` | **`activity_summary` missing `custom` property** — `merge.py` always adds `custom` but schema has `additionalProperties: false` | ✅ Fixed — `custom` added to `activity_summary` |
| 38 | Frontend | `EditDrawer.svelte:101` | **Undeclared `error` variable** in uploadImages catch — runtime `ReferenceError` crash on upload failure | ✅ Fixed — uses `saveStatus` with `saveOk = false` |
### MEDIUM (new)
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 39 | Security | `edit/server.py:409` | **Sport value not validated/quoted** before YAML write — YAML injection possible | ✅ Fixed — validated against `SPORTS` allowlist |
| 40 | Security | `edit/server.py:446` | **No image content-type validation** — arbitrary files accepted and served as images; HTML upload executes JS in site origin | ✅ Fixed — rejects non-`image/*` content types |
| 41 | Security | `edit/server.py:270-274` | **XSS via unescaped filename in `innerHTML`**`renderImageList` interpolates filenames into HTML without escaping | ✅ Fixed — `escapeHtml()` applied to filenames |
| 42 | Frontend | `MmpChart.svelte:154` | **ResizeObserver captures stale closure** — after toggling range selections, resize renders old data | ✅ Fixed — reactive variables keep closure current |
| 43 | Schema | `SCHEMA.md:113` | **Sport enum missing "skiing"** — JSON schema has it, SCHEMA.md doesn't | ✅ Fixed — `skiing` added, sub_sport examples updated |
| 44 | Schema | `SCHEMA.md:109-129` | **Summary fields table incomplete** — missing `sub_sport`, `mmp`, `best_efforts`, `best_climb_m`, `preview_coords` | ✅ Fixed — all fields added to summary table |
| 45 | Config | `config.py:61` | **Empty config file still crashes**`yaml.safe_load` returns `None`, no `or {}` fallback (round 1 fix applied to render/edit CLIs but not here) | ✅ Fixed — `or {}` fallback added |
| 46 | Frontend | `Base.astro:101-106` | **Nav links ignore `BASE_URL`** — hardcoded `/`, `/stats/`, `/athlete/` break subpath deployments | ✅ Fixed — nav uses `baseUrl` variable |
| 47 | Data | `render/merge.py:161` | **`athlete.yaml` merge has no field allowlist** — server restricts editable fields, but merge applies all keys | ✅ Fixed — merge uses `_ATHLETE_EDITABLE` allowlist matching server |
| 48 | Frontend | `types.ts:102` | **`ActivityDetail extends ActivitySummary`** inherits `detail_url`, `track_url`, `preview_coords` which detail objects never have | ✅ Fixed — `Omit<>` excludes summary-only fields |
### LOW (new)
| # | Area | File | Issue | Status |
|---|------|------|-------|--------|
| 49 | Parser | `parsers/tcx.py:101` | **Timezone offsets without colon** (`+0200`) not handled — only `+02:00` form matched | ✅ Fixed — regex accepts optional colon (`[+-]\d{2}:?\d{2}`) |
| 50 | Parser | `parsers/gpx.py:59-78` | **Power data in GPX extensions not parsed** — MMP always None for GPX files with power meters | ✅ Fixed — parses `pwr`, `power`, `watts` extension tags |
| 51 | Data | `simplify.py:63-71` | **`speeds` array not filtered same as `coordinates`** — could misalign if `simplify_track` changes | ✅ Fixed — speeds array uses same lat/lon null filter |
| 52 | Frontend | `ActivityCharts.svelte` | **No `onDestroy` cleanup** — chart SVG not removed on unmount, minor memory leak | ✅ Fixed — `onDestroy` removes chart SVG |
| 53 | Frontend | `ActivityCharts.svelte:83-86` | **`resetTrim` guard always true** — trim doesn't reset on tab change if min/max happen to match | ✅ Fixed — tracks `lastResetTab` to force reset on tab switch |
| 54 | Frontend | `AthleteView.svelte:30` | **No validation of URL `tab` parameter** — invalid value shows blank content | ✅ Fixed — validates against `TABS` array, falls back to `'power'` |
| 55 | Test | `test_writer.py` | **No tests for `write_activity`, `build_summary`, `write_index`, `write_athlete_json`** | ✅ Partial — added `test_build_summary_required_fields` and `test_id_utc_conversion` |
| 56 | Test | `test_sport.py` | **No test for `normalise_sport("skiing")` or swimming variants** | ✅ Fixed — added `test_skiing_variants` and `test_swimming_variants` |
| 57 | Test | `test_merge.py:58,122` | **Non-canonical IDs in test fixtures** — use colons and underscores, don't match real ID format | ✅ Fixed — IDs use canonical `2024-01-01T080000Z-morning-ride` format |
| 58 | Data | `edit/server.py:549-551` | **No upload size limit** — file written to disk before validation | ✅ Fixed — 50 MB limit enforced before writing to disk |
| 59 | Data | `edit/server.py:586` | **Exception message leaks internal paths**`str(exc)` returned to client in 422 response | ✅ Fixed — returns `type(exc).__name__` only, no internal details |
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
/private/tmp/bincio_dev_test
-213
View File
@@ -1,213 +0,0 @@
# BincioActivity — Ideas & Roadmap
## 1. Strava import from UI ✅
**Goal:** Let users trigger a Strava sync directly from the web UI instead of running CLI commands.
**Current state:** `bincio extract` CLI handles Strava OAuth + sync. The edit server (FastAPI) runs locally.
**Ideas:**
- Repurpose the upload button popup to offer two paths: "Upload file" (existing) or "Connect Strava"
- OAuth flow: open a popup window → Strava auth → redirect back → edit server exchanges code for token → triggers extract
- Edit server would need new endpoints: `GET /strava/auth-url`, `GET /strava/callback`, `POST /strava/sync`
- Progress feedback: SSE (Server-Sent Events) or polling endpoint to stream sync progress back to the UI
**Open questions:**
- Where to store the Strava token? Currently it's a CLI argument / env var. For server mode, probably a local file in the data dir.
- Should sync be incremental (only new activities) or full re-extract?
- How does this interact with multi-user mode (see #2)?
**Effort:** Medium. OAuth popup + SSE progress are the tricky parts.
---
## 2. Multi-user VPS deployment with invite system
**Goal:** Deploy bincio on a VPS so multiple users can have accounts. Invite-only registration.
**Current state:** Entirely single-user, local files, no auth layer at all.
### Clarified model
- A **bincio instance** is a deployment (personal or group)
- **Personal instance:** 1 user, fully self-contained — current state
- **Group instance:** N users, invite-only. All users see each other's public activities by default.
The `private` flag (already in the schema) means "only me".
- **Federation** is between instances via BAS data URLs — no accounts needed to follow someone
- Data isolation = run your own instance. No per-user isolation within an instance.
### Data layout (multi-user)
```
/data/
{handle}/ ← one dir per user (extract output)
activities/
index.json
athlete.json
edits/
_merged/
instance.json ← user registry + invite codes
```
The instance's combined feed merges all users' `_merged/index.json` files at render time.
### Auth layer
- Session-based auth added to the edit server (FastAPI handles login/logout)
- Unauthenticated reads stay fully public (static files, same as today)
- Write endpoints check: logged in + owns the activity
- Astro site detects logged-in user to show/hide the Edit button per activity
### Invite system
- Invites: short random codes (8 chars), stored in `instance.json`
- Invite record: `{code, created_by, used_by, created_at, used_at}`
- On registration: validate code → create user dir → mark invite used
- Limits: admin (unlimited), regular users (3 invites, configurable)
- UI: "Your invites" page — generate codes, see who used them
### Render pipeline (multi-user)
- `bincio render` runs `merge_all()` for each user, then builds one Astro site
- Combined `index.json` = all users' public summaries merged, sorted by date
- Per-user profile pages at `/{handle}/` showing only their activities
- Activity detail pages stay at `/activity/{id}/` (IDs are globally unique already)
- Activity cards in combined feed show "by {handle}" label
**Decided:**
- SQLite for user/invite storage (safe under concurrent writes, still no external DB dependency)
- Mastodon-style follow UX: paste a BAS URL into a "Follow" input → instance fetches and caches that feed → activities appear in combined feed with "from {url}" attribution
- Feed refresh: pull-on-demand with cache TTL (no background job — fetch on first stale pageload). [open: exact TTL, stale-while-revalidate vs blocking]
**Effort:** Large. Auth + multi-user render pipeline are the main work. Needs a design pass before coding.
---
## 3. Federation explainer for non-technical users
**Goal:** Write a clear, friendly explanation of how bincio federation works for people who don't know what JSON or APIs are.
**Three ways to share your data:**
### a) Upload raw files
> "Export your GPX/FIT files from Garmin, Wahoo, or wherever, and upload them directly. Bincio converts them automatically."
### b) Upload BAS files
> "Already running bincio locally? Export your BAS data and host it anywhere — GitHub Pages, Netlify, a USB stick. Anyone with the link can include your activities in their feed."
### c) Link to another bincio instance
> "Self-hosting bincio? Just share your URL. Friends can follow you by adding your address to their feeds — no accounts needed, no central server."
**Promo angle ideas:**
- "Your data, your rules. No algorithms, no engagement metrics."
- "Like RSS for your training log."
- "Works offline. Export once, keep forever."
- "Follow friends without either of you needing an account on the same platform."
**Format:** Could be a `FEDERATION.md`, a landing page section, or a one-pager PDF.
**Effort:** Small (writing). Medium if we want a designed landing page.
---
## 4. Mobile data capture & processing
**Goal:** Record activities on a phone, convert to BAS, possibly run the full bincio stack on-device.
**Four sub-questions:**
### 4a. Record on phone
- Standard GPS recording → GPX export is already solved by apps like OsmAnd, Organic Maps, Strava, Komoot
- Could build a minimal PWA (Progressive Web App) that records GPS and exports GPX
- Web Geolocation API + background service worker has limitations (iOS kills background JS)
- React Native / Capacitor would give better background GPS access
- **Simplest path:** just recommend OsmAnd/Organic Maps, skip building a recorder
### 4b. Convert GPX/FIT → BAS on phone
- The extract pipeline is pure Python with no native deps (haversine math, standard library)
- Could run via **Pyodide** (Python in WebAssembly) in a browser/PWA
- Or package as a mobile app with BeeWare/Kivy (Python → iOS/Android)
- Performance: A typical 1-hour FIT file is ~1MB, should parse in <1s even in Pyodide
- **Interesting path:** PWA with Pyodide — no install needed, works in Safari/Chrome
### 4c. Serve the full bincio app on a phone
- The site is a static Astro build — it's already a collection of HTML/JS/CSS files
- A phone could serve these files locally via a small HTTP server
- iOS: no background servers, but a Capacitor wrapper could embed a local server
- Android: more permissive, could run a Python/Node micro-server as a service
- **Alternative:** just open the pre-built site via file:// protocol (may work for static assets)
### 4d. Fully offline mobile app
- Capacitor + the existing Svelte components + Pyodide for extract = plausible stack
- Would need to bundle MapLibre tile data locally (big) or use an offline tile server
- This is a significant project but technically feasible with existing code
**Decided path:**
1. Recommend OsmAnd / Organic Maps for GPS recording — also built a `/record/` page in the app for in-app GPS recording
2. Capacitor wraps the existing Astro/Svelte site as a native iOS/Android app
3. `/convert/` page — Pyodide runs the full extract pipeline in-browser (GPX/FIT/TCX → BAS JSON)
4. Fully offline on-device: needs more work (see below)
**Done:**
- Capacitor setup in `site/` (`capacitor.config.ts`, scripts, packages)
- `/convert/` page with Pyodide (loads bincio wheel, converts files, download or save to cloud)
- `/record/` page with live GPS recording, exports GPX → hands off to `/convert/`
- `POST /api/import-bas` edit server endpoint (accepts pre-converted BAS JSON)
- `site/public/bincio.whl` — bincio Python wheel for Pyodide
**Fully offline — ordered plan:**
| # | Step | Effort | Status |
|---|---|---|---|
| 1 | SW + IndexedDB write (`sw.js`, `localstore.ts`, "Save to device") | Medium | ✅ Done |
| 2 | `dataloader.ts` — runtime-merge server + IDB; update all Svelte components | Medium | 🔄 In progress |
| 3 | Test convert→save→feed loop in browser (`npm run dev`) | Low | Not started |
| 4 | Sidecar merge in JS (port `merge_all`, no Pyodide) | Medium | Not started |
| 5 | Pyodide SW cache (cache CDN assets on first visit) | Medium | Not started |
| 6 | `npx cap add android` + test in Capacitor WebView | Low | Not started |
| 7 | Test on iOS device/simulator | Low | Not started |
| 8 | Native micro-server (only if step 7 reveals SW blocked) | Hard | Contingency |
**`capacitor-nodejs` is not on the roadmap.** If iOS blocks service workers, fallback is a native micro-server or eliminating all remaining `fetch('/data/*')` callsites via step 2.
See `ARCHITECTURE.md` — "Fully offline — missing pieces" for design details and open questions (storage limits, uninstall, sync conflicts).
---
## 5. Gear from Strava export
Import `bikes.csv` and `shoes.csv` from the Strava ZIP export to pre-populate a gear selector
in the edit drawer. The CSV has: Bike Name, Bike Brand, Bike Model, Bike Default Sport Types.
Could store as a `gear.json` in the user's data dir and surface it in the EditDrawer sport/gear fields.
---
---
## 6. User feedback backlog (April 2026)
Items reported via the in-app feedback form.
| # | Who | Area | Description | Notes |
|---|-----|------|-------------|-------|
| F1 | brut | Mobile / Nav | ~~Shorten "BincioActivity" logo to "BA" on narrow screens~~ | ✅ Done |
| F2 | brut | Mobile / Stats | Day-click tooltip in `StatsView` falls off-screen on mobile | Needs viewport-clamping on the tooltip position |
| F3 | brut | Data / Display | Cadence unit "rpm" is wrong for walking and hiking — should be "spm" or hidden | `ActivityCharts.svelte` — sport-aware label or hide tab |
| F4 | brut | Feature | No way to mark an activity as "virtual"; virtual activities should be excluded from records | `virtual: true` sidecar flag + `EditDrawer` checkbox; filter in `RecordsView` and `write_athlete_json` |
| F5 | brut | Charts / MMP | ~~Power curve Y axis should start at 0 W; X axis should extend to at least 2 hours~~ | ✅ Done |
| F6 | brut | Feature | Import gear (bikes, shoes) from Strava export; edit gear; maintenance log with cumulative km | See item 5 above for gear import. Maintenance log is new: date + km + notes per bike |
| F7 | brut | About page | Make user handles in the community tree clickable; show online user count | Handles link to `/u/{handle}/`; online count needs server-side tracking (non-trivial) |
| F8 | brut | Strava sync | ~~Syncing from Strava does not trigger a site rebuild~~ | ✅ Done — trigger was firing after `yield "done"`, client had already closed the SSE connection |
| F9 | diego_p | Data | Check elevation gain on activity `2026-04-11T051441Z` | Likely a GPS artefact or smoothing issue; needs manual inspection |
---
## Prioritization (rough)
| # | Feature | Impact | Effort | Priority |
|---|---------|--------|--------|----------|
| 1 | Strava sync from UI | High | Medium | Soon |
| 3 | Federation explainer | Medium | Small | Soon |
| 4a/4b | Mobile record + convert | Medium | Medium | Later |
| 2 | Multi-user + invites | High | Large | Later (needs design) |
| 4c/4d | Full mobile app | Low | Large | Future |