When --oidc-issuer is set, validate tokens as RS256 id_tokens fetched
against bincio-auth's JWKS endpoint (cached for 1h). Falls back to
HS256 if the RS256 check fails, so existing sessions keep working
during the transition. DB session lookup is the final fallback.
New --oidc-issuer flag reads BINCIO_OIDC_ISSUER env var.
- Select mode in ActivityFeed: toggle with Select button (logged-in only),
cards become clickable with checkmark indicator, action bar fixed at bottom
- Bulk delete: calls existing DELETE /api/activity/{id} for each selected,
removes from local feed state immediately
- Bulk merge: POST /api/merge sorts by started_at (earliest = primary),
sums distance/duration/elevation, weighted-averages HR/power, concatenates
geojson and timeseries; backs up originals to _merge_backup/ for recovery
- GET /api/merges returns per-user hidden list; feed filters secondaries
client-side on load so static shards don't need a rebuild to hide them
- POST /api/unmerge/{id} restores primary from backup, unhides secondaries
- ActivityDetail: shows "Merged (N)" badge + Unmerge button for owners
- Fix: edit button now works from personal profile feed (handle was missing
from year-shard activities; injected from filterHandle on sessionStorage write)
Pages (register, reset-password, invites) now redirect to bincio.org
like login already did. Admin user-state ops (reset-password-code,
suspend, unsuspend, delete account) are proxied to bincio-auth via
httpx so they write to the correct DB. Adds BINCIO_AUTH_API env var.
activity.bincio.org/login/ was issuing plain session tokens; bincio-activity
now validates JWTs, so that path silently broke. Auth wall and logout now
point to the central bincio-auth service instead.
When --jwt-secret / BINCIO_AUTH_JWT_SECRET is set, auth is validated
locally by decoding the bincio-auth-issued JWT — no DB session lookup.
Falls back to existing DB-based session lookup when the flag is absent,
so standalone deployments keep working without any config change.
Changes:
- deps.py: add jwt_secret global, _decode_jwt helper, wire into
_current_user and _require_auth
- cli.py: add --jwt-secret option; log active auth mode on startup
- pyproject.toml: add PyJWT>=2.8 to serve and dev extras
API (gear.py):
- POST /api/gear/{id}/parts
- PATCH /api/gear/{id}/parts/{pid}
- DELETE /api/gear/{id}/parts/{pid}
- POST /api/gear/{id}/parts/{pid}/replacements
- DELETE /api/gear/{id}/parts/{pid}/replacements/{rid}
UI (AthleteView.svelte):
- Gear rows are now accordion-expandable
- Collapsed row shows colored status dots (green/yellow/red) per part
- Expanded section: parts list with km-since-replacement colored by threshold,
Replaced button with date+note form, recent log entries, add-part form
- Contextual suggestion for first part (chain for bikes, shoes for running)
- Edit/delete gear moved into expanded section
Move gear backfill logic from the route handler into
import_garmin_gear(data_dir, user_dir) in garmin_sync.py so it can be
called both from the API and from the CLI script.
scripts/backfill_garmin_gear.py finds all users with Garmin credentials
and runs the backfill for each, printing a per-user summary.
Compute total distance from allActivities where gear name matches and
display it inline next to each gear item. Also add gear field to
ActivitySummary type so index shard gear data is accessible in the UI.
- garmin_sync_iter: sync gear registry from Garmin on every sync run and
resolve gear for each newly imported activity via get_activity_gear()
- POST /api/garmin/import-gear: one-time backfill that matches Garmin gear
activities to existing local activities by UTC timestamp (±60 s)
- New /api/gear CRUD endpoints (gear.json per user)
- Gear tab in AthleteView (owner-only): add, edit, retire items
- EditDrawer gear field becomes a dropdown when registry has items
- Strava API sync now resolves gear_id → name, adds to registry automatically
- Strava ZIP import reads Gear column from activities.csv
- POST /api/strava/import-gear for one-time backfill from stored originals
Previous thresholds (10 m GPS, 5 m barometric, 30 s MA) were calibrated for
raw noisy GPS. Strava-exported FIT files carry elevation already pre-processed
by Strava (smooth 1 m quantisation, no steps > 5 m), so the aggressive
filtering suppressed real climbing — avg −17 % error across 37 reference
activities.
New strategy, keyed on source + altitude_source:
strava_export → MA 5 s, threshold 1.0 m
fit_file / barometric → no MA, threshold 1.5 m
fit_file / gps → MA 5 s, threshold 2.0 m
unknown non-strava → MA 5 s, threshold 1.5 m
Result on 37 cross-referenced activities: avg −2.8 %, std 4.6 %,
37/37 within ±15 % (was 0/37).
Both paths — initial import (metrics._elevation) and bulk recalculate
(dem.recalculate_elevation_hysteresis) — now use the same elevation_params()
function from metrics.py.
Adds a List/Map toggle to the feed and @user profile pages. The map view
plots all filtered activities as sport-coloured tracks on a MapLibre map
with no extra requests (uses preview_coords already in memory). Clicking
a track or list row selects it: pans the map to fit, expands the list
item with key stats, and scrolls it into view.
Matches the Strava sync table layout. Accumulates total_imported in
garmin_sync.json state on each sync run; admin API exposes last_sync_at
and total_imported from that file.
Extract _haversine_m from the inline block in _gps_speed_kmh, add
_spatial_downsample (keep one sample per 10 m traveled, GPS haversine
primary / speed×Δt fallback, indoor activities unchanged), and wire it
into build_timeseries() after the 1 s dedup loop.
Add --downsample-timeseries migration flag to bincio render that applies
the same downsampling to existing stored timeseries files without
re-extracting from original FIT/GPX files.
Extract the synchronous segment-file scan into a plain function and
dispatch it via asyncio.to_thread so it runs in a thread pool instead
of blocking the event loop during concurrent fetches.
Write the activity summary to sessionStorage on click in ActivityFeed,
then read it synchronously at module init in ActivityDetailLoader so the
page renders immediately without the "Loading activity…" blank screen
or the 2 round-trip index fetch.
Direct URL / bookmark / shared link falls through to the existing slow
path unchanged.
explore lives at /u/{handle}/athlete/explore/ — was classified as
profile. Add path-contains check so it's detected correctly.
Add community (/community/) which was falling into the feed catchall.
Extend feature map tuples to (host, startswith, contains, label).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>