When a user saves new Strava credentials with a different client_id,
auto-delete the existing token (it belongs to a different OAuth app
and will always fail on refresh). Add POST /api/strava/disconnect
endpoint and a "Disconnect from Strava" button in settings, visible
only when connected.
Immediate: deleted diego_p's stale token so he can reconnect.
Apple Watch and similar devices record exactly 0.0 for elevation while
waiting for barometric/GPS lock, then jump to the real altitude. The
hysteresis accumulator was seeding from 0.0, counting the full jump as
ascent. Fix: detect a leading near-zero run followed by a large jump
and seed the accumulator from the first real value instead.
Applied in both _elevation() (fresh extractions) and
recalculate_elevation_hysteresis() (recompute path). Added a bulk
admin endpoint POST /api/admin/users/{handle}/recompute-elevation and
corresponding button to fix existing stored activities.
BINCIO_DATA_DIR is already set in the build env, so manifest.ts reads
the data root directly at build time without needing public/data.
Moving _link_data() into the serve-only branch prevents Astro from
following the symlink and copying the full data dir into dist/. Any
leftover symlink from a previous dev session is removed before build.
Dev mode is unchanged.
Each sync run now writes _strava_sync_status.json per user (status,
imported count, error message). New admin endpoints expose this data
and allow triggering an on-demand sync. The admin page gains a Strava
Sync section showing per-user token/credentials state, total imported,
last sync time, and last-run status with inline error messages.
- Add bincio sync-strava command: headless multi-user Strava sync
designed for systemd timer. Discovers users via strava_token.json,
skips users without their own strava_credentials.json, respects
Strava visibility (only_me → unlisted). Treats 404 stream errors as
no-GPS activities rather than retrying every run.
- Add deploy/systemd/bincio-sync.{service,timer}: runs every 3 hours,
Persistent=true to catch up after downtime.
- Add POST /api/internal/rebuild: webhook for sync timer to trigger
site rebuild, authenticated via X-Sync-Secret header.
- Add suspended column to users table with auto-migration on open_db.
Suspended users are blocked at login and session lookup (covers both
activity site and wiki, which share instance.db).
- Add POST /api/admin/users/{handle}/suspend|unsuspend and
DELETE /api/admin/users/{handle}/account endpoints.
- Admin panel: Suspend/Unsuspend toggle, Del account button, suspended
badge on user row.
Replace per-upload Astro build threads with a single background worker
(_site_rebuild_worker) that waits on an event, sleeps 60 s to let upload
bursts settle, then runs one full build + rsync. 271 concurrent uploads now
produce one build instead of 271 serialised builds, eliminating the OOM kill.
--webroot is re-enabled; merge-only path still runs immediately per upload.
Also: date filter row added to ActivityFeed.svelte (sport + date presets
with dynamic year pills); deploy/vps gitignored for VPS config backups.
Date row now shows All time | 7d | 30d | 6mo | 2026 | 2025 | ... derived
from actual activity data. Year pills use a bounded [Jan 1, Jan 1+1) range
via a new dateTo field on ActivityFilter; rolling-window presets keep an
open upper bound.
Adds edits_json column (migration v3) to store user overrides separately
from detail_json so Option A server re-extraction never clobbers them.
- Tap the title in the detail screen to edit (local activities only, shown
with a ✎ hint). Saves on keyboard dismiss via onEndEditing.
- Cards and search display user_title ?? title.
- Raw upload: user_title sent to server -> sidecar written so web UI shows
the correct title (server re-extracts from FIT, which has Karoo's title).
- BAS upload: detail.title overridden before sending, no sidecar needed.
Adds a fourth tab visible only on Android API 29+ (full phone, not Karoo).
Filters by sport pill, date preset (7d/30d/6mo/year), and sort order
(newest/distance/elevation). Paginated FlatList with the same activity cards.
ActivityCard extracted to mobile/components/ActivityCard.tsx so both the
feed tab and the new search tab share the same component without duplication.
Four related issues made uploading 271+ activities unreliable:
1. merge_all/write_combined_feed were inside the extraction try/except —
any merge race returned 422 even though the file was on disk, causing
the mobile app to permanently mark the upload as failed. Fixed by
moving them to a separate best-effort try/except after the extraction
block. Switch to merge_one (single-activity symlink) instead of
merge_all (full rebuild) so each upload is O(1) FS ops, not O(N).
2. The dev watcher fired merge_all for every activity .json write AND the
upload endpoint also ran merge_all — O(N²) symlink operations during
bulk uploads. Watcher now skips activities/*.json changes (upload
endpoint handles those directly).
3. Vite/Chokidar followed the public/data symlink and opened a handle per
activity file; constant merge rebuilds exhausted file descriptors and
crashed the Astro dev server. Fixed with watch.ignored on public/data.
4. _write_year_shards and write_combined_feed used f.unlink() without
missing_ok=True — concurrent callers racing the same file threw
FileNotFoundError which propagated as a false extraction failure.
source_hash: BAS JSON import now computes SHA-256 via crypto.subtle.digest
instead of the '${id}-${length}' stub. No extra package — Hermes supports
Web Crypto API natively.
Feed pagination: useActivities(query, limit) accepts a LIMIT parameter.
The feed screen starts at 50, calls loadMore() via FlatList onEndReached
(threshold 0.3) to increment by 50 each time. useActivityCount(query)
drives the hasMore guard so loadMore is a no-op at the end of the list.
Feed search: compact TextInput below the header filters by title via
SQLite json_extract LIKE. Changing the query resets limit to PAGE_SIZE
so stale paginated results don't linger.
Docs: close the three resolved debt items; keep only the accepted
background-polling limitation as a known gap.
Settings → Sync → Upload format: "Original file" (default) / "Extracted JSON".
- raw (default): reads original_path as base64, POSTs to /api/upload/raw; after
success, overwrites local detail_json/timeseries_json/geojson/source_hash with
the server's DEM-corrected extraction (Option A). Falls back to bas if the file
is missing.
- bas: POSTs pre-extracted JSON to /api/upload/bas, faster, no DEM correction.
Switching modes is safe — the server deduplicates by activity id so a previous
raw upload will return status:"duplicate" on a subsequent bas attempt.
When original_path is set (i.e. the original FIT/GPX/TCX is still on disk),
upload via POST /api/upload/raw { filename, base64 } so the server re-extracts
with DEM elevation correction. Falls back to /api/upload/bas (pre-extracted
BAS JSON) when original_path is null or the file has been deleted.
When the dev file-watcher and an upload endpoint both trigger merge_all on the
same user directory at the same time, the shutil.rmtree/_merged/activities/
sequence collides: one thread deletes the directory while the other is mid-walk,
causing ENOTEMPTY or ENOENT crashes.
Added a per-user-dir threading.Lock (_merge_lock) at the top of merge.py so
all callers (upload endpoints, watcher, admin rebuild) are serialized without
changing any call site. Also made the rmtree ignore_errors=True and the
symlink_to conditional to survive any stale leftover from a prior crash.
Three bugs fixed:
- /api/upload/bas and /api/upload/raw never updated user_dir/index.json, so
merge_all couldn't include uploaded activities in year shards — they existed
on disk but were invisible to the browser feed. Fixed by _upsert_index_summary()
called before merge_all().
- Silent catch {} in uploadLocalActivities swallowed all per-activity errors;
replaced with console.warn so failures are visible in Expo logs.
- After a server wipe, synced_at flags on the device caused "Nothing to upload"
forever. uploadFeed() now reconciles against GET /api/feed at the start of each
upload: local activities not found on the server get synced_at cleared.
Also: live upload progress ("Uploading N / M…"), failed count in result message,
onProgress callback on uploadFeed(), countPendingUploads() helper.
merge_all(user_dir) updates the per-user _merged/ shard but the home page
loads feed.json first via loadCombinedFeed. write_combined_feed was only
called by the CLI render command, not by the API upload endpoints or the
dev watcher, leaving feed.json permanently stale after any runtime upload.
Add write_combined_feed(_get_data_dir()) after every merge_all call in
/api/upload/bas, /api/upload/raw, the dev.py file watcher, and dev startup.
- Skip MapLibre on Android <29 (Karoo): SELinux denies kgsl-3d0 access
from untrusted_app context, crashing the GPU driver on any OpenGL
surface. Replace with SvgRouteView — equirectangular SVG route trace
using react-native-svg, no native GL surface needed.
- Add +/- zoom buttons to full-screen MapLibre map on modern devices
via Camera ref and onRegionDidChange.
- Skip PyodideWebView on Android <29: same GPU driver conflict; set
_engineUnavailable at module init via API level gate (< 29).
- Add engine_unavailable fast path in PyodideWebView: post message
immediately if WebAssembly.Global is absent (Chrome <69) instead of
attempting 30 MB Pyodide download.
- Add server-side extraction fallback (extractServer.ts): when engine
unavailable, POST raw file as base64 to /api/upload/raw; server runs
full Python pipeline and returns extracted data.
- Add /api/upload/raw endpoint in server.py.
- Add pre-flight auth check (checkServerAuth) before batch import so
an expired token errors immediately rather than after N files.
- Fix uploadLocalActivities in sync.ts: was reading original_path as
JSON (binary FIT file, always threw), silently skipping every upload.
Now reads detail_json from DB directly.
- Redesign Feed header: replace single Sync button with Upload /
Download / Refresh. Pull-to-refresh and Refresh button are local-only.
Auto-refresh on tab focus via useFocusEffect.
- Replace ActivityIndicator with plain Text everywhere (native animation
also crashes Karoo GPU driver).
- Raise macOS open-file limit in dev_test.py to prevent EMFILE errors
from Astro file watcher.
- Document all Karoo hardware constraints in docs/mobile-app.md.
Root causes identified via logcat:
Chrome <71: globalThis not defined → ReferenceError in Pyodide factory,
loadPyodide stays as {} (set by UMD wrapper), "not a function"
Chrome <63: dynamic import() is a parse-time SyntaxError
Chrome <63: for-await-of is a parse-time SyntaxError
Fix: when Chrome <80 is detected, fetch v0.18.1 pyodide.js as text and apply
three split/join patches before injecting via Blob URL:
1. prepend globalThis polyfill
2. import( → __loadScript( (script-tag shim)
3. for await( → for( (getFsHandles is never called in our flow)
Chrome 80+ keeps the original clean <script>-tag path with v0.26.4.
The regex /\bimport\(/g inside a template literal corrupts \b into a
backspace and \( into (, producing an unterminated-group SyntaxError in the
WebView. Use split().join() instead — no escape sequences, no corruption.
Modern WebViews (Chrome 63+) keep the original clean <script>-tag path.
Old WebViews (Karoo Chrome 61) take the fetch+patch+Blob path.
Chrome 61 (Karoo WebView) cannot parse dynamic import() — a SyntaxError at
parse time prevents loadPyodide from ever being defined.
Fix: fetch pyodide.js as text, replace every import( with a __loadScript(
shim that uses <script> tag injection, then inject via Blob URL. The Blob
script is never pre-scanned for module syntax so the patch is invisible to
the parser.
Also: expose waitForEngine() from extractActivity so callers can await
engine readiness before batching files — manual scan now shows "Preparing
extraction engine…" instead of flooding with N individual failures.
Settings: watch directory field is hidden behind a warning if no instance
URL is saved yet, making the dependency explicit before the user sets a
path.
Import: runAutoScan silently skips (no errors) when instance URL is
missing; manualScan shows a single clear message instead of one failure
per file.
The Save button is anchored to the Instance section near the top. Moving
the auto_import_path write to onBlur on the field itself means the value
is persisted as soon as the user leaves the input — no need to scroll up
and hit Save.
- useFocusEffect re-reads auto_import_path from DB every time the Import
tab is focused, so the scan button appears immediately after saving in
Settings (was broken by conditional hook call which violated Rules of
Hooks and never re-fired on setting changes)
- Nested inner try/catch isolates DocumentPicker.getDocumentAsync so
ActivityNotFoundException (Karoo has no DocumentsUI) shows a friendly
message instead of crashing the tab
- Settings watch-directory placeholder updated to /sdcard/FitFiles
Import screen:
- Add "Scan for new rides" button (green) when auto_import_path is set;
shows the configured path, lets user trigger manually in addition to
the automatic scan on app open.
- Detect ActivityNotFoundException from DocumentPicker (Karoo and other
stripped Android devices have no DocumentsUI app) and show a friendly
message directing users to set a Watch directory instead.
- "No new rides found" feedback when manual scan finds nothing.
Docs (docs/mobile-app.md):
- Phase 1 marked complete with implementation notes (wheel delivery,
timeseries workaround, source_path dedup).
- Phase 2 updated to reflect what is actually implemented (on-open scan,
not background task) vs what remains (true background polling).
- Batch import moved from Phase 5 todo to done.
- Data model updated with source_path column.
- Known gaps section revised to remove fixed stubs.
- New Karoo sideloading section with debuggableVariants and armeabi-v7a
troubleshooting notes.
Two issues prevented the app from running on the Karoo 2 (armeabi-v7a, Android 8.1):
1. Debug builds loaded the JS bundle from Metro (localhost:8081) rather than
embedding it. Fixed by setting debuggableVariants = [] in the react {} block,
which tells the RN Gradle plugin to bundle JS for all variants including debug.
2. splits.abi only included arm64-v8a, so the CMake/NDK build (which generates
libappmodules.so — the TurboModule registry) never ran for armeabi-v7a.
Result: PlatformConstants TurboModule not found, app stuck on splash.
Fixed by adding armeabi-v7a to the splits.abi include list.
- Import tab now accepts multiple files at once (DocumentPicker multiple:true),
processes them sequentially through Pyodide, and shows a summary with per-file
errors on completion.
- DB migration v2 adds source_path column (original filesystem path before copy)
and an index on it, enabling O(1) deduplication for watch-folder imports.
- On Android, if auto_import_path is set, the Import tab scans the directory on
mount and on AppState 'active' (app foreground), then automatically imports any
FIT files not yet in the DB. Designed for Karoo: finish a ride, open the app,
new files import without any manual steps.
- insertActivity now accepts optional source_path; both importBasJson and
importNativeFile pass it through (null for files picked via DocumentPicker,
real path for watch-folder files).
Diagnosed via on-device debug: build_timeseries produces 3271 points
correctly, but the installed wheel's write_activity has a silent
exception path that skips writing the timeseries file. The workaround
calls build_timeseries directly and writes the file if missing.
Also moves useTheme import to @/ThemeContext across all tab screens.
Replace useSetting()-based useTheme() with a React context (ThemeProvider
+ useTheme/usePaletteControl). The context holds palette key in state so
pressing a palette button in Settings re-renders all screens instantly.
Persists to SQLite and reloads the stored value on mount.
Inline script in Base.astro sets --accent / --accent-dim CSS variables
before first paint based on the current date. Switches to pink (Giro),
yellow (Tour), or red (Vuelta) during each Grand Tour window; falls back
to the default blue. Also aligns default --accent with the mobile app
(#60a5fa instead of #00c8ff).
- theme.ts: useTheme() hook with race calendar (May–Sep windows),
auto-detects Giro/Tour/Vuelta by date; stores override in SQLite
- All screens (feed, import, activity, tab bar) now use accent/dim
from useTheme() instead of hardcoded #60a5fa
- Settings: Palette section with Auto/Default/Giro/Tour/Vuelta buttons
to override the auto-detected palette for testing
- Detail screen: Delete button (top-right, red) with confirmation alert;
deletes SQLite row and original file via expo-file-system
- Feed screen: long-press card to enter select mode; checkbox + blue
border on selected cards; bottom action bar with bulk Delete N button;
header switches to show count + Cancel
- db/queries: deleteActivity (returns original_path) and deleteActivities
(bulk, returns all original paths)