Commit Graph

320 Commits

Author SHA1 Message Date
Davide Scaini 02bb8a3dd7 feat: serve bincio wheel locally for mobile dev testing
- 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
2026-04-24 11:01:24 +02:00
Davide Scaini b37df88fe1 feat: Phase 0 mobile app scaffold — Expo 55, SQLite, Feed/Import/Settings screens 2026-04-24 10:39:06 +02:00
Davide Scaini 565f5a3ff1 docs: complete mobile app plan — phased roadmap, Android/iOS divergences, data model 2026-04-24 10:26:58 +02:00
Davide Scaini 61479fe554 docs: mobile app — Pyodide/hidden-WebView extraction model, algorithm-travels-to-data pattern 2026-04-24 10:18:49 +02:00
Davide Scaini e952d9bdc1 docs: expand mobile app design — hybrid extraction, Karoo integration, platform independence vision 2026-04-24 10:12:36 +02:00
Davide Scaini 81ed5e1b0b docs: add mobile app design document (local-first, Expo/React Native) 2026-04-24 10:04:13 +02:00
Davide Scaini 7329c8f5e8 Merge branch 'main' into mobile_app 2026-04-24 09:52:27 +02:00
Davide Scaini 0f1876a33c chore: untrack CLAUDE.md, publish.sh, docs/squash-for-github.md; gitignore dns/nginx scratch files 2026-04-22 17:22:31 +02:00
Davide Scaini 7c171c9e9d docs: replace VPS IP with placeholder in elevation.md 2026-04-22 17:14:10 +02:00
Davide Scaini a69a2f240a docs: mark changelog 0.1.0 public release (2026-04-22) 2026-04-22 17:09:44 +02:00
Davide Scaini 6dc1fb6f20 fix: prune dist/data before rsync in _trigger_rebuild and manual rebuild endpoint
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.
2026-04-22 11:01:01 +02:00
Davide Scaini df496a017f fix: refine hysteresis recalculation with MA pre-smoothing and lower thresholds
- 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)
2026-04-22 10:57:28 +02:00
Davide Scaini 88b24a6274 docs: update elevation docs and changelog for two-button recalculation and DEM fix 2026-04-20 21:43:28 +02:00
Davide Scaini ebac3f50f4 fix: DEM elevation overcounting and add hysteresis-only recalculation button
- 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)
2026-04-20 21:41:23 +02:00
Davide Scaini 2b7a37ed41 docs: update changelog, CLI reference, user guide, and elevation notes
- 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
2026-04-20 21:18:50 +02:00
Davide Scaini 0c659db6cb fix: default DEM URL to api.open-elevation.com
No configuration needed out of the box; --dem-url only required to
override the default with a self-hosted or alternative endpoint.
2026-04-20 21:17:03 +02:00
Davide Scaini 1940e2409b feat: DEM-based elevation recalculation via edit drawer button
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
2026-04-20 20:45:06 +02:00
Davide Scaini 872651f471 metrics: replace naive elevation accumulation with hysteresis dead-band
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
2026-04-20 20:29:20 +02:00
Davide Scaini 696f538f56 ci: add fastapi/bcrypt to dev dependency group so all tests pass
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.
2026-04-20 20:23:06 +02:00
Davide Scaini 6491e4fd8c fix: show total activity count in global feed counter
The counter now shows "50 of 16398 activities" using the total from
feed.json, matching the previous behaviour where all activities were
loaded upfront.
2026-04-20 17:12:50 +02:00
Davide Scaini 104328bc50 fix: stable y-axis range and sane dist_km in activity charts
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).
2026-04-20 17:06:56 +02:00
Davide Scaini 631e814d64 fix: load all year shards into combined feed, not just top 2 2026-04-20 16:14:40 +02:00
Davide Scaini db7047f210 perf: combined feed index for multi-user global feed
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.
2026-04-20 15:31:35 +02:00
Davide Scaini e8a5fbbaba docs: add nginx gzip configuration for JSON compression
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.
2026-04-20 15:24:16 +02:00
Davide Scaini d069716068 fix: clamp stats tooltip within viewport on mobile 2026-04-20 15:07:52 +02:00
Davide Scaini cea1dbc2fb ops: fix data/ triple-duplication costing ~24 GB on VPS
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/)
2026-04-19 23:34:55 +02:00
Davide Scaini 5227b30456 fix: EditDrawer correctly reads and labels unlisted privacy
- 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"
2026-04-19 22:58:09 +02:00
Davide Scaini 8a3e7831d3 fix: prevent double URL-rewrite in nested shard resolution
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".
2026-04-19 22:46:36 +02:00
Davide Scaini 8575a7015b fix: delete activity removes it from index.json; detail page uses lazy load
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.
2026-04-19 22:31:20 +02:00
Davide Scaini cada2bcb03 perf: year-shard index.json to cut initial load from MBs to ~1 year
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.
2026-04-19 22:21:10 +02:00
Davide Scaini bb253cc2c1 site: accept gzip MIME types in upload file picker
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.
2026-04-16 18:54:18 +02:00
Davide Scaini cd1cdca33b extract: auto-detect gzip by magic bytes, not just .gz extension
Files compressed with gzip but named without .gz (e.g. activity.gpx
containing gzip data) now decompress transparently.
2026-04-16 18:49:01 +02:00
Davide Scaini b22b5deb9e fix tests: update merge tests for private→unlisted rename and client-side filtering 2026-04-16 18:16:46 +02:00
Davide Scaini 219308bdb5 ci: install serve/edit/strava extras so bcrypt and fastapi are available for tests 2026-04-16 18:14:51 +02:00
Davide Scaini c68dfa9057 chore: update changelog, remove stale files, scrub VPS IP
- 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
2026-04-16 18:09:32 +02:00
Davide Scaini a78f6ee3bd fix: strip local image refs with spaces/parens in filenames before markdown render 2026-04-16 10:29:13 +02:00
Davide Scaini cfdd8d2744 fix: image refs in description and broken gallery URLs
- EditDrawer: stop auto-inserting ![filename](...) into description on
  upload — images are tracked via custom.images; the refs only cluttered
  the textarea. Strip any pre-existing refs on load so old sidecars are
  also cleaned up when the drawer is opened.
- ActivityDetail: imageBase now treats detail_url that starts with '/'
  as already-absolute (same fix pattern as track_url / detail_url);
  was prepending ${base}data/ on top of /data/... → double path.
2026-04-16 10:19:32 +02:00
Davide Scaini 395182649b improve docs 2026-04-15 23:07:52 +02:00
Davide Scaini bfb6432666 fix: force black text in Plot tooltips (white bg, grey text was unreadable) 2026-04-15 22:53:48 +02:00
Davide Scaini 5205a41224 fix: theme-aware chart colors — readable axes and tooltips in light mode 2026-04-15 22:18:06 +02:00
Davide Scaini a95dd07e22 fix: remove TS type annotation from define:vars script (plain JS only) 2026-04-15 20:48:11 +02:00
Davide Scaini 7142ac8f2e settings: split danger zone into delete originals / delete all activities
Move "Delete original files" out of the Storage card and into the
Danger zone as a first, less-destructive step (simple confirm, no
password needed). "Delete all activity data" and "Delete account"
follow below it, both still password-gated. Descriptions clarify
exactly what each action does and does not remove.
2026-04-15 20:42:31 +02:00
Davide Scaini 87a69bcc8b settings: add nav visibility prefs and per-user Strava credentials
- user_prefs table in db.py with get/set helpers
- GET/PUT /api/me/prefs endpoints for bulk pref management
- GET/PUT/DELETE /api/me/strava-credentials; PUT preserves existing
  secret when client_secret field is left blank
- _strava_creds() helper resolves per-user → instance fallback across
  all five Strava endpoints
- Settings page: Navigation card (hide Feed/Community/About toggles)
  and Strava credentials card
- Base.astro: ids on feed/community/about nav links; applies
  nav_hide_* prefs after login
2026-04-15 20:37:42 +02:00
Davide Scaini 4fd5ba428e settings: add self-service user settings page
API endpoints (all auth-gated to the logged-in user):
- GET  /api/me/storage        — per-category disk breakdown
- DELETE /api/me/originals    — free originals/ dir (post-extraction cleanup)
- DELETE /api/me/activities   — wipe all activity data (password confirm)
- DELETE /api/me              — delete account + all data (password confirm)
- PUT  /api/me/display-name   — update display name
- PUT  /api/me/password       — change password (requires current password)

Page at /settings/:
- Storage card: activities / originals / Strava originals / photos / total
  with one-click 'Delete original files' when originals exist
- Profile card: display name field with inline save
- Password card: change password form
- Danger zone: delete all activities or delete account (both require
  password confirmation in a modal before proceeding)

Nav: 'Settings' link appears in the top bar after login (same as Admin).
2026-04-15 20:24:04 +02:00
Davide Scaini 764da09130 upload: add overwrite option to replace existing activities
When 'Overwrite existing activities' is checked, duplicate activities are
re-extracted and replaced instead of silently skipped:
- Deletes {id}.json, .geojson, .timeseries.json from activities/ and _merged/
- Removes the stale index summary and dedup cache entry
- Ingests the new file fresh via ingest_parsed
- Reports 'overwritten' (↺) status in the SSE stream vs 'imported' (↓)
- done event includes 'overwritten' count; UI shows it alongside 'added'
2026-04-15 20:17:32 +02:00
Davide Scaini a33fea91cf admin: mark ghost users (no DB account) and add Delete dir button
- /api/admin/disk now includes in_db flag per user (true if account exists in DB)
- Ghost users (directory exists, no DB account) show amber 'ghost' badge and only
  Diag + Delete dir buttons (no Re-extract, Rebuild, Reset pwd, Reset data)
- DELETE /api/admin/users/{handle}/directory wipes the entire directory and updates
  the root manifest; refuses if the account still exists in the DB
- Wires up rmdir-btn with a window.confirm before calling the new endpoint
2026-04-15 14:58:54 +02:00
Davide Scaini dfd56e4448 fix: handle absolute track_url paths in ActivityDetail
resolveShards also rewrites track_url to absolute paths (/data/…).
The trackUrl reactive statement only handled http:// prefixes,
producing double /data//data/… for the GeoJSON fetch → map had no track.
2026-04-15 14:50:10 +02:00
Davide Scaini f376b24106 fix: handle absolute detail_url paths in loadActivity and loadTimeseries
resolveShards rewrites detail_url to absolute paths (e.g. /data/brut/_merged/activities/{id}.json)
when fetching from a user shard. loadActivity and loadTimeseries only checked for http:// prefixes
and treated /data/... paths as relative, producing double /data//data/... in the fetch URL → 404.

Fix: treat URLs starting with / as already absolute, same as http:// URLs.
2026-04-15 14:44:34 +02:00
Davide Scaini 290eef6c72 metrics: guard against corrupted time streams causing OOM
Strava originals with absolute Unix timestamps stored as elapsed-second
offsets produce a t_max of ~1.6 billion. compute_mmp and compute_best_efforts
both create dense 1Hz arrays via range(t_min, t_max+1), which for a 1.6B
span allocates 44+ GB and OOM-kills the process. Add a >1-week sanity
check and return None early for corrupt streams.

Root cause: old Strava activities (seen from 1970-epoch start_date)
where the time stream contains absolute Unix timestamps instead of
elapsed seconds.
2026-04-15 14:06:20 +02:00
Davide Scaini 25d80c8132 reextract: process in batches of 100 to bound subprocess memory
One Python process for 2015 activities exhausts all RAM + swap on a
cheap VPS. Split into sequential batches of 100: each subprocess handles
100 activities and exits, returning all memory to the OS before the
next batch starts. The server chains batches in the SSE event_stream
and triggers a single rebuild when all batches complete.
2026-04-15 10:08:55 +02:00