- Add /api/activity/{id}/geojson and /api/activity/{id}/timeseries endpoints
(bearer-token-gated, falls back from _merged to raw activities dir)
- Rewrite activity detail screen with MapLibreGL v11 API (Map, Camera,
GeoJSONSource, Layer) and react-native-svg area chart with gradient fill
- On-demand fetch for remote activities that have no local geojson/timeseries
- Add react-native-svg dependency; requires dev build (npx expo run:android)
_merged/index.json is a shard manifest with activities:[] when the user
has >FEED_PAGE_SIZE activities. The endpoint now collects from all
index-{year}.json shard files before returning.
SyncResult gains a `total` field (activities received from server) so the
feed screen can distinguish "server returned nothing" from "all already
stored locally". Messages: "No activities on instance" / "Up to date (N)"
/ "X of N activities synced".
Server (bincio/serve/server.py):
- Add _require_auth: accepts session cookie OR Authorization: Bearer token
- POST /api/auth/token: same as /api/auth/login but returns token in body
(password used once, not stored; mobile stores only the session token)
- GET /api/feed: auth-gated; reads _merged/index.json for the user and
returns the activities array as JSON
Mobile:
- db/sync.ts: syncFeed(db) fetches /api/feed, upserts each summary into
local SQLite as origin='remote'; skips locally-imported activities
- db/queries.ts: add upsertRemoteActivity (INSERT ... ON CONFLICT DO UPDATE
WHERE origin='remote' — never overwrites local imports); fix feed sort
order to started_at DESC instead of insertion order
- settings.tsx: Connect section — password field (not persisted) + Connect
button calls POST /api/auth/token and stores token; Disconnect clears it
- index.tsx: ↓ Sync button + pull-to-refresh both trigger syncFeed; cloud
badge on remote activities; empty state updated
expo-router@6.0.23 declares react-dom as peerOptional; without an explicit
pin npm resolves react-dom@19.2.5 (latest) which conflicts with react@19.1.0.
The SDK 54 template pins both to 19.1.0 — mirroring that here.
react-dom@19.2.5 (pulled by @expo/router-server) requires react@^19.2.5;
@react-native/virtualized-lists@0.85.2 requires @types/react@^19.2.0.
npm install now completes without errors or warnings.
- Add GET /api/wheel/download to serve/server.py and edit/server.py:
serves dist/bincio-*.whl via FileResponse; in production nginx takes
the request before FastAPI, so this is a no-op there but works locally
- wheel_version response now includes api_url: "/api/wheel/download"
alongside the nginx-served url field
- Bundle mobile/assets/bincio.whl (built from dist/) as an offline
fallback for Pyodide testing before the first instance sync
- docs/mobile-app.md: document dev setup — bundled asset, local server
endpoint, and how to refresh the bundle with uv build + cp
Same fix as cea1dbc (post-receive hook) but missed in server.py: Astro resolves
the public/data symlink and copies all activity JSON into dist/; without pruning,
every Strava sync / upload / edit that triggers a full build + rsync duplicates
GBs of data into the nginx webroot.
Both rsync callsites now rm -rf dist/data + pass --exclude=data/ to rsync.
- dem.py: pre-smooth elevation with 30s moving average before hysteresis
in recalculate_elevation_hysteresis(); thresholds drop from 5m/10m to
1m (barometric) / 3m (GPS) — accurate after noise is smoothed out
- dem.py: widen DEM median-filter window 45s → 60s
- dem.py: rename response key source → altitude_source for consistency
- writer.py: write altitude_source into detail JSON at extract time
- tests/test_dem.py: 21 unit tests for pure functions and file-level hysteresis
- tests/test_edit_server.py: 11 TestClient API tests for both recalculate endpoints
- add httpx as dev dependency (required by FastAPI TestClient)
- dem.py: apply 45s median filter before hysteresis to suppress SRTM
tile-boundary steps that were accumulating through the 5m threshold;
raise DEM hysteresis threshold from 5m to 10m
- dem.py: back up elevation_m as elevation_m_original in timeseries
before the first DEM overwrite, so original sensor data is preserved
- dem.py: add recalculate_elevation_hysteresis() — recomputes gain/loss
from original recorded elevation (reads elevation_m_original if a DEM
run already replaced elevation_m) using source-aware thresholds
(5m barometric, 10m GPS/unknown); does not touch the elevation array
- edit/server.py, serve/server.py: split /recalculate-elevation into
two endpoints: /recalculate-elevation/dem and
/recalculate-elevation/hysteresis
- EditDrawer.svelte: replace single DEM button with two side-by-side
buttons — "Recalculate (hysteresis)" (fast, offline) and
"Recalculate (DEM)" (SRTM lookup)
- CHANGELOG: document hysteresis elevation fix and DEM recalculation feature
- docs/reference/cli.md: add --dem-url to bincio edit and bincio serve tables
- docs/user-guide.md: document "Recalculate from terrain map" button in edit drawer
- docs/elevation.md: mark both short-term and medium-term fixes as implemented
Adds a "Recalculate from terrain map (DEM)" button to the activity edit
drawer. On click it queries an Open-Elevation-compatible API to replace
GPS altitude with SRTM terrain data, applies 5m hysteresis, and updates
the activity's elevation stats and timeseries chart in place.
- bincio/extract/dem.py: lookup_elevations() (batched HTTP POST) +
recalculate_elevation() (subsample → DEM → interpolate → hysteresis →
patch activity JSON, timeseries JSON, index.json)
- POST /api/activity/{id}/recalculate-elevation on both serve and edit
servers; serve endpoint is auth-gated and triggers merge + rebuild
- --dem-url flag (also DEM_URL env var) on bincio serve and bincio edit;
logged at startup; missing URL returns a clear 503 with setup instructions
- /api/me response gains dem_configured bool
- EditDrawer: button with loading state, shows new ↑/↓ values on success
GPS jitter and barometric quantization noise caused systematic overestimation
of elevation gain — in extreme cases 100% of reported gain was sub-1m noise.
Implements source-aware hysteresis: elevation is only committed when it
deviates from the last committed value by ≥5m (barometric) or ≥10m (GPS/GPX/TCX).
- ParsedActivity gains `altitude_source` field ("barometric"/"gps"/"unknown")
- FIT parser sets "barometric" when enhanced_altitude is present, else "gps"
- GPX and TCX parsers always set "gps"
- metrics._elevation() uses the threshold matching the source
- 5 new parametric tests covering flat GPS noise, threshold differences, and real climbs
test_db.py and test_server_imports.py import bincio.serve.server and
bincio.serve.db which require fastapi and bcrypt. These were only in the
optional 'serve' extra so the default dev env was missing them, causing
4 test failures and 1 collection error in CI.
The counter now shows "50 of 16398 activities" using the total from
feed.json, matching the previous behaviour where all activities were
loaded upfront.
Three bugs in the time↔distance x-axis toggle:
1. GPS speed glitches (e.g. a 1-second spike at 222 km/h) were accumulated
into dist_km, pushing all subsequent points ~60 m too far right on the
distance axis and compressing the rest of the chart. Cap speed at 150 km/h
during dist_km integration; values above that are treated as 0 movement.
2. Observable Plot auto-infers the y domain from plottable points only.
When x-mode changes, which points are "plottable" changes too, so the
y axis range silently shifted between time and distance views. Fix by
computing lineDomainMin/Max once from the full dataset and passing an
explicit domain to Plot.
3. monotone-x curve requires strictly increasing x. In distance mode,
stopped segments produce consecutive points with identical dist_km,
causing NaN Bézier control points and visual artifacts. Use linear
curve for distance mode (data is dense enough that it looks smooth).
Instead of the browser resolving 20+ user shards recursively (~27 MB),
generate a pre-sorted feed.json at merge time with 50 activities per
page. The global feed loads one ~30 KB file on first paint; "Load more"
fetches subsequent pages (feed-2.json, feed-3.json, etc.).
Per-user profile pages still use year-sharded loadIndexPaged as before.
Activity index shards compress ~90% with gzip (130 KB → 14 KB).
The default nginx.conf has gzip on but gzip_types commented out,
so JSON was served uncompressed.
astro build resolves the public/data symlink and copies all activity JSON
into dist/; rsync then copied that to the webroot — but nginx already serves
/data/ directly from /var/bincio/data/ via alias, so both copies were dead
weight. Freed 36 GB → 14 GB on the live server.
- post-receive hook: prune dist/data/ before rsync, add --exclude=data/
- docs: update manual rebuild command and nginx comment to match
- serve/server.py: _mb() now uses lstat() to count symlinks at face value
rather than following them to targets, so admin storage panel no longer
double-counts _merged/ (which is mostly symlinks into activities/)
- serve/server.py GET adds private:bool to the response (true when
privacy is "unlisted" or legacy "private") so EditDrawer can read it
- edit/server.py GET: same fix for the single-user edit server
- EditDrawer: fall back to d.privacy if d.private is absent; rename
"Private" toggle label to "Unlisted"
rewriteActivityUrls now skips URLs that are already absolute (start with
/ or http). Before this fix, the new user→year two-level nesting caused
year-shard URLs (/data/brut/_merged/activities/X.json) to be prepended
again at the user-shard level, producing broken doubled paths and making
every activity show "Activity not found".
delete_activity now updates data_dir/index.json so merge_all no longer
re-adds the summary for a deleted activity, preventing the broken
"Activity not found" state after deletion.
ActivityDetailLoader switches from loadIndex (all year shards) to
loadIndexPaged (first year shard only) + direct file fallback, so
opening an activity detail page no longer downloads the entire history.
merge_all/_merged/index.json is now a shard manifest; activities are
split into index-{year}.json files. The feed loads only the most-recent
year on first paint (~200 activities instead of all of them). Older
years are fetched lazily when the user clicks "Load older activities".
Also strips best_efforts / best_climb_m / source from shard files —
these fields are aggregation inputs only, never read by the feed UI.
Add application/gzip, application/x-gzip, and .gz to the accept
attribute so browsers show .gpx.gz / .fit.gz / .tcx.gz in the picker.
Browsers often ignore double-extension filters (.gpx.gz) without the
matching MIME type.
- CHANGELOG.md: add [Unreleased] 2026-04-16 section covering settings
page, admin tools, password reset, re-extract, community page, SSE
upload progress, and all bug fixes since 2026-04-10
- Remove docs-proposal.md (internal planning doc, not user-facing)
- Remove publish/ directory (leftover artefacts from publish.sh, not
meant to be tracked)
- scripts/pull_feedback.sh: replace hardcoded default VPS IP with a
required positional argument to avoid leaking server address
- docs/squash-for-github.md: document the squash-for-github commit
strategy for future reference