- _rebuild_athlete_json now applies sidecar edits (sub_sport, sport, etc.)
in-memory before passing summaries to write_athlete_json, so activities
marked indoor via sidecar are correctly excluded from records.
- _best_climb now runs Kadane's over cumulative distance (not 1Hz dense
time) so recording pauses don't create None gaps that falsely reset the
climbing window. Grappa: 811m→1603m; Nivolet: 311m→2009m.
- Add bincio render --recompute-climbs to backfill existing activities
from their stored timeseries.
- fit.py: map FIT sub_sport 'treadmill' and 'virtual' to 'indoor'
- writer.py: broaden _is_outdoor to catch all indoor sub_sport variants
- render/cli.py: rebuild athlete.json from index.json on every bake so
records never go stale when the exclusion logic changes
- bincio/explore.py: bake_tracks() simplifies GPS coords (RDP ε=0.0001),
strips to [lng,lat], groups by sport type, writes per-handle tracks.json
- bake-tracks CLI command; render CLI calls _bake_tracks() after each build;
strava_zip runs it once at end of batch
- /api/me/tracks endpoint serves the baked file; wipe_user cleans it up
- Explore.svelte: MapLibre full-screen map with sidebar — type pills,
year/month date filter, Lines / Heatmap (global or by-type) view modes
- AthleteView: Explore tab visible only to profile owner (checks __bincioMe)
- Base.astro: fullscreen prop + Planner nav link
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.
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.
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.
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.
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.
- "unlisted" = not shown in the public feed, but GPS track, timeseries
and detail JSON are all accessible by direct URL (security by obscurity)
- "private" accepted as legacy alias everywhere (backward compat with
existing data on disk)
- New writes from Strava sync / ZIP upload / sidecar use "unlisted"
- Only "no_gps" now suppresses the GPS track
- isUnlisted() helper in format.ts used by all Svelte/Astro components
- SCHEMA.md and CLAUDE.md document the privacy model and the distinction
between "unlisted" and "no_gps"
stripping them; privacy filtering is now done client-side
- ActivityFeed: detect logged-in user via bincio:me event; show private
activities only when viewing your own profile; private cards get a lock
badge
1. The build took minutes → 404 during that window
2. Even after the build, the output lands in site/dist/ — nginx serves from /var/www/bincio/ which is only updated by the rsync in the post-receive hook, not by the server process
Fixes applied:
1. bincio/render/cli.py: Added --no-build flag — merges sidecars and updates manifests but skips astro build. This is fast (~1 second).
2. bincio/serve/server.py _trigger_rebuild: Now passes --no-build. After an upload, _merged/ and root index.json are updated immediately, so the feed reflects the new activity. The static Astro pages are
only rebuilt on git push.
3. site/src/components/ActivityDetailLoader.svelte (new): Svelte component that reads the activity ID from the URL, calls loadIndex to resolve the shard tree, then renders ActivityDetail dynamically — no
pre-built page needed.
4. site/src/pages/activity/index.astro (new): Generic Astro shell page that renders ActivityDetailLoader. Gets compiled to dist/activity/index.html.
5. docs/deployment/vps.md: Added location /activity/ { try_files $uri $uri/ /activity/index.html; } to the nginx config. When a request arrives for /activity/2026-04-06T153345Z/ and no pre-built file
exists, nginx serves the shell, which loads the data dynamically from /data/ (which nginx already serves live from disk).
- writer.py: timeseries is now written to {id}.timeseries.json as a separate file. The detail JSON gets a timeseries_url field instead. finalize_pending and cleanup_pending handle the extra file.
- merge.py (merge_one): symlinks the .timeseries.json file alongside the detail JSON. merge_all already handles it transparently (the .timeseries.json stem doesn't match any activity
ID in to_merge, so it falls through to the symlink branch).
- types.ts: timeseries is now timeseries?: Timeseries | null, and timeseries_url?: string | null added.
- dataloader.ts: new loadTimeseries(url, detailUrl, base) function that resolves paths correctly in both single- and multi-user modes (uses the fetched detail URL's directory as the base).
- ActivityDetail.svelte: loads timeseries separately after detail loads; uses detail.timeseries for IDB activities (embedded) or fetches via detail.timeseries_url for server activities. Charts show a pulse placeholder while loading.
2 — GZip
- GZipMiddleware (min 1 KB) added to both bincio/serve/server.py and bincio/edit/server.py — all API JSON responses are now gzip-compressed.
- For static files (the big timeseries JSONs), nginx should be configured with gzip on; gzip_types application/json application/geo+json; — no code change needed on the server side.
Net effect: opening an activity page now fetches ~1.4 KB (detail) instead of ~586 KB. The timeseries fetches ~60–150 KB gzip-compressed shortly after (it loads concurrently with the map rendering).
- Add bincio/extract/ingest.py as a facade over the extract internals (ingest_parsed, strava_sync), reducing coupling from 6+ imports to one
- Add merge_one() to merge.py — fast single-activity path for interactive edits (rewrites one file + index, skips full directory rebuild)
- Rewrite edit/ops.py to delegate to the new facade; fix broken run_strava_sync return (was referencing undefined locals)
- Remove duplicated SPORTS, STAT_PANELS, VALID_ACTIVITY_ID from edit/server.py — now imported from ops.py
Auth wall (Base.astro): set data-auth-pending on <body> at SSG time and hide
it with inline CSS before any JS runs; remove the attribute after /api/me
resolves. Eliminates the flash of protected content on private instances.
Multi-user write API (serve/server.py): the previous _apply_sidecar_edit and
strava_sync imports from bincio.edit.server were broken (those names don't
exist as module-level exports) and the Strava sync mutated a global data_dir,
making concurrent requests from different users racy. Fix: extract both
operations into bincio/edit/ops.py as pure functions that take data_dir
explicitly. Both edit/server.py and serve/server.py now import from there.
Security: add rate limiting to POST /api/register (5 attempts / 15 min / IP,
separate bucket from login). Add _check_id() activity ID validation to both
GET and POST /api/activity/{id} in serve/server.py.
Single-user mode: _write_root_manifest now forces instance.private=false when
no instance.db exists, even if a previous run wrote true. Prevents the auth
wall from firing and redirecting to /login/ when bincio serve isn't running.
ActivityFeed: skip filterHandle when profileIndexUrl is set (per-user profile
pages load the right shard directly; activities have no handle tag at that
point, so the filter was producing an empty feed). Fix handle links to point
to /u/{handle}/ instead of /{handle}/. Fix <a>-inside-<a> Svelte warning by
converting the inner handle link to a <button>.
- bincio/render/merge.py: parse sidecar .md files (YAML frontmatter +
markdown body), produce data/_merged/ with symlinks for unmodified
activities and real merged files for overridden ones; filters private
activities from index.json; sorts highlighted activities first.
Keeps extracted data pristine — re-running extract never clobbers edits.
- bincio/edit/: FastAPI edit server (port 4041) with embedded HTML/JS
edit UI; GET/POST /api/activity/{id} reads/writes sidecars; multipart
image upload to edits/images/{id}/; DELETE for image cleanup.
- bincio render now calls merge_all() before build/serve and symlinks
public/data → data/_merged/ instead of data/ directly.
- ActivityDetail.svelte: edit button (links to edit server) when
PUBLIC_EDIT_URL env var is set; respects custom.hide_stats to suppress
stat panels; description supports whitespace-preserving rendering.
- 15 unit tests covering parse_sidecar, apply_sidecar, and merge_all.