Compare commits

...

181 Commits

Author SHA1 Message Date
Davide Scaini 5287b98bc1 fix: admin reset-pwd button copies full bincio.org link instead of bare code 2026-06-03 09:42:24 +02:00
Davide Scaini 0e5044eb06 fix: close all bincio-auth migration holes
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.
2026-06-03 09:36:20 +02:00
Davide Scaini 75f7fa8810 refactoring mobile app location 2026-06-02 23:28:41 +02:00
Davide Scaini 5255e24184 feat: default chart x-axis to distance 2026-06-02 16:49:21 +02:00
Davide Scaini c59fc0073f feat: pace chart and spm cadence for running/hiking/walking/other
Speed tab becomes Pace tab (min/km, y-axis inverted so faster = top).
Cadence label switches to spm. Tooltip and reference lines use m:ss format.
2026-06-02 16:46:16 +02:00
Davide Scaini a142e8732f fix: redirect login to bincio.org (bincio-auth) when PUBLIC_AUTH_URL is set
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.
2026-06-02 16:31:46 +02:00
Davide Scaini 13859a34d3 feat: show pace (min/km) for running, hiking, walking, other activities
Cycling keeps km/h; pace sports show e.g. "5:30 /km" in the feed card,
activity stat panel (avg/max), and laps table.
2026-06-02 16:23:03 +02:00
Davide Scaini 1dca00d5e3 update stats script 2026-06-02 15:47:55 +02:00
Davide Scaini fa61801580 refactor: move mobile app to bincio-autarchive repo 2026-06-02 15:45:42 +02:00
Davide Scaini 2af29a460b serve: add JWT consumer shim for bincio-auth integration
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
2026-06-02 14:54:43 +02:00
Davide Scaini 0d6bf57932 fix: handle empty/invalid athlete.json in merge, API read, and writer encoding 2026-05-25 20:00:18 +02:00
Davide Scaini 447d56a960 fix: skip empty or unparseable athlete.json in merge_all 2026-05-25 19:55:08 +02:00
Davide Scaini 2f5251e9fe perf: run all background build/merge/rsync subprocesses at nice 19 2026-05-24 19:07:23 +02:00
Davide Scaini c9b544ab55 perf: throttle OG image generation — nice 19 + 50ms sleep between renders 2026-05-24 19:02:08 +02:00
Davide Scaini b827792d16 feat: export gear maintenance log as CSV 2026-05-24 14:07:37 +02:00
Davide Scaini 94cd3f7eb4 fix: show replacement dates as dd/mm/yyyy 2026-05-24 14:01:53 +02:00
Davide Scaini bdee036204 feat: part lifespan tracking in gear tab
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
2026-05-24 13:40:27 +02:00
Davide Scaini 7db7bf91e0 refactor: extract import_garmin_gear() + add backfill script
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.
2026-05-24 13:13:47 +02:00
Davide Scaini 801140ac51 feat: show accumulated distance per gear item in gear tab
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.
2026-05-24 13:06:37 +02:00
Davide Scaini 49feef66c5 feat: Garmin gear sync — registry + per-activity gear on sync and backfill
- 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)
2026-05-24 13:03:34 +02:00
Davide Scaini b23b3de1bb feat: include gear in activity index summaries; generate OG images in serve rebuild 2026-05-24 12:51:57 +02:00
Davide Scaini 5bf426df29 fix: use Strava gear ID prefix (b/g) to determine gear type, not missing primary_type field 2026-05-24 12:44:25 +02:00
Davide Scaini 40ccec0e2d fix: generate OG images in serve rebuild worker, not on every deploy 2026-05-24 12:39:38 +02:00
Davide Scaini e553e08663 feat: gear registry — manage bikes/shoes per athlete, set per activity
- 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
2026-05-24 12:33:41 +02:00
Davide Scaini aca9f79b46 fix: slope tooltip clipping — use clip:false + marginTop instead of dy function 2026-05-23 22:24:55 +02:00
Davide Scaini 40aa51be4d fix: flip slope tooltip below dot when near chart top edge 2026-05-23 22:06:02 +02:00
Davide Scaini e5c5383471 fix: move Pillow to base deps so generate_og_images.py can import it 2026-05-23 21:49:47 +02:00
Davide Scaini 693f720cbd feat: OG link previews — track image + meta tags for Telegram/WhatsApp
- bincio/render/ogimage.py: generate 400x400 elevation-coloured PNG with Pillow
- bincio/serve/routers/ogimage.py: /activity/{id}/ OG HTML stub for bot UAs;
  /og-image/{user}/{id}.png serves pre-generated images with on-demand fallback
- scripts/generate_og_images.py: batch pre-generation, incremental (mtime skip)
- scripts/strava_elevation_audit.py: add source/threshold/MA columns and pct stats
- pyproject.toml: add Pillow>=10 to serve extras
2026-05-23 21:44:19 +02:00
Davide Scaini 56932f7f25 perf: add patch_index flag to recalculate_elevation_hysteresis
Allows bulk callers to skip per-activity index.json rewrites and
batch the update themselves, reducing O(n²) index churn to O(n).
2026-05-23 21:05:00 +02:00
Davide Scaini 02edb0b0f9 fix: per-source elevation params — strava_export vs barometric vs raw GPS
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.
2026-05-23 20:12:11 +02:00
Davide Scaini df025873c6 Add map view toggle to activity feed
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.
2026-05-22 11:47:47 +02:00
Davide Scaini 7f2a751065 feat: power curve chart on activity page (single-activity MMP) 2026-05-21 21:29:29 +02:00
Davide Scaini 793b719983 fix: stable power curve colors — buttons and chart lines always match 2026-05-21 21:15:44 +02:00
Davide Scaini d4e5b11f71 admin: add Total imported and Last sync columns to Garmin sync table
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.
2026-05-21 20:34:25 +02:00
Davide Scaini 418e3a13e8 changelog: document 2026-05-19 performance improvements 2026-05-19 20:19:11 +02:00
Davide Scaini 84eff1f3b0 perf: spatial 10 m downsampling for timeseries
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.
2026-05-19 20:11:00 +02:00
Davide Scaini 835968e8fe perf: unblock event loop for segment_efforts scan
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.
2026-05-19 19:53:26 +02:00
Davide Scaini 29c6e399c0 perf: skip feed index fetch when navigating from activity feed
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.
2026-05-19 19:44:20 +02:00
Davide Scaini 1f6239e7d2 Fix feature detection for explore and add community
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>
2026-05-18 22:03:40 +02:00
Davide Scaini 5d2e2443a3 Add feature breakdown to stderr output for debugging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:55:14 +02:00
Davide Scaini 90283c45f4 usage_stats: fix reindex alignment bug, switch logins panel to weekly
resample("D") produces midnight-aligned bins; reindexing against a
range built from a raw timestamp (not midnight) caused all values to
be 0. Also switched the logins panel from daily to weekly to match the
feature usage panel — less noisy for a small app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:31:05 +02:00
Davide Scaini cd80b8e32e usage_stats: fix datetime.utcnow() deprecation warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:27:29 +02:00
Davide Scaini adaa075e6e Add usage stats script and /api/admin/stats endpoint
scripts/usage_stats.py: standalone script (PEP 723, runs via uv run)
that parses all nginx access.log files, filters bots, maps Referer
headers to feature labels, and produces a 3-panel matplotlib figure:
daily logins + 7-day rolling mean, hour×weekday API heatmap, and
weekly feature usage stacked area. Output saved to
/var/bincio/stats/latest.png. Intended for a weekly cron job.

bincio/serve/routers/admin.py: GET /api/admin/stats serves the PNG
via the existing _require_admin() check — no new auth logic or nginx
changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:54:17 +02:00
Davide Scaini bbfab72138 ActivityCharts: switch slope tab to distance-weighted smoothing
Replace time-based rolling mean with a 400 m distance-weighted sliding-
window average (O(n), two advancing pointers) matching the spec in
SLOPE_COLORING.md. Slope values are now spatially consistent regardless
of riding speed. Smoothing buttons are hidden when the slope tab is
active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:03:56 +02:00
Davide Scaini 6faf63c2cd ActivityCharts: add slope coloring tab to elevation profile
New "Slope" tab colours the filled area and stroke line with an SVG
linearGradient driven by per-point slope data (green→yellow→orange→red→
purple scale). Slope is computed from smoothed elevation + cumulative
distance, reusing the existing raw/10s/20s smoothing controls. The
hover tooltip shows slope % (in slope colour) and elevation. Tab is
enabled only when both elevation and distance data are present; the
X-mode and histogram toggles are hidden for this tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:57:55 +02:00
Davide Scaini c0f6c4da6d render: add --recompute-vam to backfill climbing_time_s into existing activities
Reads each activity's timeseries, re-runs the VAM algorithm (which now returns
both climbing_vam_mh and climbing_time_s), and patches activities/*.json and
index.json in-place. Run once after upgrading to the new schema so NerdCorner
can filter and opacity-encode existing data.
2026-05-17 10:15:29 +02:00
Davide Scaini 766da0320b NerdCorner VAM: filter short climbs, opacity-encode confidence, add climbing time to tooltip
- Exclude per-activity VAM contributions where climbing_time_s < 10 min; short
  punchy efforts don't represent aerobic fitness and were skewing monthly averages
- Store climbing_time_s alongside climbing_vam_mh in metrics, detail JSON, and
  summary JSON so the frontend has the data to reason about confidence
- Accumulate total climbing time per period; opacity scales from 0.25 (10 min,
  minimum threshold) to 1.0 (≥ 1 h) so thin-evidence months read as faint dots
- Render VAM as dots only (no lines) since each period is an independent average,
  not a cumulative — lines implied continuity that isn't there
- Tooltip now shows "1060 m/h · 38 min climbing"
2026-05-17 10:13:39 +02:00
Davide Scaini 7a44cbbef0 change segment width and opacity 2026-05-17 09:51:11 +02:00
Davide Scaini 979a6c527f Segment create: load existing segments in activity bbox as reference layer 2026-05-17 09:40:41 +02:00
Davide Scaini 6bc77486f1 Segments: color track green→red along direction of travel 2026-05-17 09:32:31 +02:00
Davide Scaini 9521a64da4 Activity stats: fixed-position pairs so optional values don't shift layout 2026-05-17 09:15:11 +02:00
Davide Scaini 7953e05241 Fix build: use class ternary instead of class: directive for Tailwind /opacity class 2026-05-17 09:06:04 +02:00
Davide Scaini db9b4ce32c Activity map: click stat to lock track color mode, hover still previews 2026-05-17 09:01:59 +02:00
Davide Scaini 14a4a0b994 Activity detail: layout refactor + GPS-derived speed for map coloring
Layout: map + charts stacked left, stats panel (2-col) on the right.
Cadence moved to last stat. Charts sit directly below the map.

Speed coloring: most FIT files don't record per-second speed, leaving
timeseries speed_kmh all-null and the hover link dead. Fix: derive speed
from consecutive GPS coordinates (haversine + 5-pt moving average) when
the device didn't record it. Add --backfill-speed render flag to retrofit
existing timeseries files.
2026-05-16 23:24:29 +02:00
Davide Scaini 0dc450ba30 Fix track coloring hover: inline reactive vars so Svelte tracks deps
statColorMode() hid hasSpeedTrack etc. from Svelte's compiler so the
{#each} block never re-rendered when timeseries loaded. Inline the
ternary directly so all reactive variables are visible to the tracker.
2026-05-16 22:56:20 +02:00
Davide Scaini 1cca485062 Activity map: extend track coloring to HR, power, elevation, cadence
Refactor the metric gradient code into shared _computeProgress +
_buildGradient helpers. Hovering any of speed/HR/power/elevation/cadence
stats switches the track to a blue→green→yellow→red gradient for that
metric. A small legend (min ·gradient bar· max + label) appears in the
bottom-left corner of the map while active. Absolute elevation used
(not slope), so blue=valleys, red=peaks.
2026-05-16 22:50:24 +02:00
Davide Scaini f71fe2ddf5 Activity map: color track by speed when hovering speed stats
Hovering avg/max speed in the stats panel switches the track from the
default gradient to a blue→green→yellow→red speed gradient. Progress
along the track is computed from cumulative distance (speed × time) so
fast sections appear proportionally long rather than time-weighted.
Reverts to default on mouse-leave. No-op when timeseries has no speed
data or the activity has no GPS track.
2026-05-16 22:43:12 +02:00
Davide Scaini 08e8e54c36 Power curve: show record holder in tooltip and add records table
Find the activity that holds each MMP record by scanning per-activity
mmp arrays. Activity title appears in the chart hover tooltip. A table
below the chart lists every duration with the record watts, activity
title (linked), and date. The table has its own all-time/365d/90d toggle
independent of the chart overlays.
2026-05-16 22:25:30 +02:00
Davide Scaini 003b540481 VAM: drop duration curve, show avg climbing VAM in Nerd Corner
Remove the per-duration VAM curve everywhere (metrics, summaries, detail
JSON, athlete.json, VamChart.svelte, AthleteView VAM tab). Keep only
climbing_vam_mh per activity. Add it to activity summaries so NerdCorner
can plot average climbing VAM per week/month year-over-year alongside
distance/elevation/time. Add --backfill-vam-summary flag to copy the
field from existing detail JSONs into index.json without re-extracting.
2026-05-16 22:03:40 +02:00
Davide Scaini 7cd8a6b030 VAM: add --recompute-vam flag and compute_vam_from_timeseries helper
Refactors core VAM logic into _vam_from_ele_1hz() and _build_ele_1hz()
so both the DataPoint-based extract path and the timeseries-based backfill
path share the same implementation.

render --recompute-vam reads stored *.timeseries.json files and updates
climbing_vam_mh + vam_curve in activities/*.json and index.json in-place,
without re-parsing the original FIT/GPX files.
2026-05-16 21:37:51 +02:00
Davide Scaini baf20b51ba Add VAM (climbing velocity) metric and per-duration curve
Extract pipeline now computes two VAM metrics per activity (cycling,
running, hiking, walking):
- climbing_vam_mh: VAM on ascending segments only, using 30 s forward
  lookahead to classify climbing vs. flat/descent (stored in detail JSON)
- vam_curve: [[duration_s, vam_mh], ...] best VAM per standard duration
  (60 s – 1 h), sliding window on 30 s smoothed elevation, only windows
  with ≥ 10 m net gain count (stored in summary + detail)

Athlete JSON aggregates vam_curve across all activities (all_time,
last_365d, last_90d), same structure as power_curve.

Frontend:
- ActivityDetail shows "Climbing VAM" stat (grouped with elevation)
- AthleteView adds a "VAM Curve" tab that appears only when the athlete
  has climbing data; renders VamChart (new component, mirrors MmpChart)

vam_curve stripped from combined global feed; kept in user year shards
for season-based on-the-fly aggregation in VamChart.

Requires bincio reextract to backfill existing activities.
2026-05-16 21:34:06 +02:00
Davide Scaini de602ff5d9 Settings: per-user default for download_disabled
New pref download_disabled_default (stored in user_prefs + mirrored to
_user_settings.json for the render pipeline). When true, apply_sidecar
marks all activities as download_disabled unless the sidecar explicitly
sets download_disabled: false (per-activity opt-in from the edit drawer).

Settings page gets an "Activity defaults" card with the toggle.
2026-05-16 20:51:23 +02:00
Davide Scaini 2d9620c6d1 Admin: add Garmin sync status panel
New /api/admin/garmin-sync (GET) and /api/admin/garmin-sync/run (POST)
endpoints mirror the Strava equivalents, reading _garmin_sync_status.json
per user and exposing a run-now button. Admin page shows the Garmin table
below the Strava one, with auth_error/api_error/ok badges and live polling
while a sync is running.
2026-05-16 20:31:02 +02:00
Davide Scaini 2c69e75842 Show orange upload button when Strava/Garmin auth fails
GET /api/me/sync-status reads _strava_sync_status.json and
_garmin_sync_status.json for the logged-in user. On page load the nav
script checks this endpoint and, if either service has status=auth_error,
turns the upload arrow orange with a tooltip naming the disconnected
service(s).
2026-05-16 20:27:43 +02:00
Davide Scaini 0eb25620ef Add headless Garmin background sync (systemd timer)
New `bincio sync-garmin` command mirrors sync-strava: discovers all users
with garmin_creds.json, refreshes cached garth OAuth2 session, imports new
activities, and optionally POSTs to the rebuild endpoint.

systemd timer fires every 3h offset by 1h30m from Strava to avoid
simultaneous rebuilds. Status written to _garmin_sync_status.json per user.
2026-05-16 20:13:12 +02:00
Davide Scaini 307f1fbbc1 download bas: embed timeseries into the JSON so the file is self-contained 2026-05-15 18:38:53 +02:00
Davide Scaini c465e518e5 Add activity file downloads with per-activity download_disabled flag
New endpoint: GET /api/activity/{id}/download/{bas|original|gpx}
- bas: streams the BAS detail JSON as an attachment
- original: streams the original FIT or GPX file from originals/
- gpx: generates a GPX from the timeseries (always available when GPS exists)

download_disabled flag stored in sidecar (edits/{id}.md), propagated to
the merged BAS detail JSON. When set, only the owner can download.

Backend: ops.py writes flag to sidecar; merge.py propagates it to detail
JSON; download.py implements the endpoint; server.py registers the router.
Frontend: EditDrawer gets a "No download" toggle button; ActivityDetail
shows a Download section (hidden when disabled and viewer is not the owner).
2026-05-15 18:35:40 +02:00
Davide Scaini fe437626e6 Global feed: switch from sequential pages to month-based BAS shards
feed.json is now a BAS shard index pointing to feed-YYYY-MM.json files
(~150 activities / ~25 KB gzip each) instead of 400+ sequential feed-N.json
pages. The frontend can now jump directly to a specific month when filtering
by year or date range, without loading all newer data first.

- merge.py: write_combined_feed groups by YYYY-MM and emits a shard index
- dataloader.ts: isYearShardUrl matches feed-YYYY-MM.json; loadCombinedFeed
  returns pendingShards; FeedPage interface and loadCombinedFeedPage removed
- ActivityFeed.svelte: _yearFromShard handles both index-YYYY and feed-YYYY-MM;
  feedNextPage/feedTotalPages/loadingAllFeedPages removed; infinite-loop bug
  fixed (toLoad.length guard before setting loadingAllShards); onMount uses
  pendingShards from loadCombinedFeed
2026-05-15 10:25:01 +02:00
Davide Scaini d3bce49445 Feed: eager-load only the year shards needed for the active date filter 2026-05-15 09:32:12 +02:00
Davide Scaini 8a06227243 Feed date filter: early-stop global feed load, fix cross-date validation, show Loading while fetching
- Stop fetching combined-feed pages once the oldest activity in a batch predates
  the from-date (feed is newest-first, so everything needed is already loaded)
- Show "Loading…" instead of "No activities found" while eager-load is in progress
- Constrain From max to customTo (or today) and To min to customFrom so the
  range can't be inverted via the date pickers
2026-05-15 09:24:02 +02:00
Davide Scaini 1f3f5b3d3b Feed: fix date range eager-load — use primary let-vars, cover feed pages
Previous attempt used dateFrom (a derived $: variable) as the trigger which
Svelte 5 doesn't reliably track as a dependency of a side-effect $: block.
Replace with the primary let-variables (customFrom, customTo, datePre) that
Svelte does track statically.

Also extend eager-loading to cover the global combined feed (feedNextPage)
so date/search filtering works on multi-user instances too, not just per-user
profile pages (pendingShards).
2026-05-15 09:14:56 +02:00
Davide Scaini d2151a4acf Ideas: add reopen button when awaiting; add /reopen endpoint 2026-05-15 09:07:49 +02:00
Davide Scaini 9cc70269f5 Feed: eager-load all year shards when a date filter is active
The initial page load only fetches the most recent year shard. Selecting a
date range or year preset that spans an older shard returned no results because
those shards were never loaded. Extend the existing search eager-load trigger
to also fire on any non-empty dateFrom, covering both custom date inputs and
year preset buttons.
2026-05-15 08:59:06 +02:00
Davide Scaini afbcaa5011 Feed: cap date range inputs at today to prevent selecting future dates 2026-05-15 08:38:58 +02:00
Davide Scaini 15e9969ca2 Ideas: add 'won't implement' status with decline/reopen button 2026-05-15 08:36:31 +02:00
Davide Scaini c905449114 Feed: add custom date range (From/To) inputs alongside search bar 2026-05-15 08:32:31 +02:00
Davide Scaini ed6a7ed39c Ideas: add 'awaiting feedback' status with amber section + admin comment
Status cycles open → awaiting → done → reopen.
Awaiting ideas float to the top in a 'Waiting for your feedback' section
with an amber border (#f59e0b).

Admin can attach an implementation note to any awaiting idea via
POST /api/ideas/{id}/comment. The note appears inside the same card
in a distinct sub-box with a subtle amber tint border, editable inline.
The sub-box is visible to all users once a note exists.
2026-05-15 08:18:44 +02:00
Davide Scaini 3b675a68b0 Elevation: skip near-zero dropout values mid-recording
Devices (Apple Watch, some GPS units) record 0.0 when they lose barometric/GPS
lock mid-activity. The old accumulation committed these as real sea-level points,
inflating both gain and loss by the current elevation (e.g. 792m dropout on the
Cosmo Walk added ~1584m of phantom gain+loss).

Fix: skip any elevation value < 1.0m when the current committed elevation is
significantly above zero (> threshold). Gradual legitimate descents to sea level
are unaffected because intermediate values are committed along the way.

Add --recompute-elevation flag to bincio render to backfill existing activities.
2026-05-15 01:21:34 +02:00
Davide Scaini c12f5336f5 AthleteView: listen for bincio:me event so owner tabs appear without hard refresh
In multi-user instances /api/me is async and usually hasn't returned by the
time onMount runs, leaving isOwner=false. Subscribe to the bincio:me event
(fired by Base.astro when /api/me resolves) so the reactive TABS filter
re-evaluates and Explore / Nerd Corner appear without needing Cmd+Shift+R.
2026-05-15 01:15:24 +02:00
Davide Scaini 4ea2292e2b Indoor detection: title-based inference in merge layer + fix _merge_all_locked
- Add _INDOOR_TITLE_RE / _infer_indoor_title() to writer.py (matches zwift,
  ftp-builder, turbo-trainer, rodillo); replaces the narrower zwift-only regex
  that was local to write_athlete_json
- _is_outdoor now delegates to _infer_indoor_title so all four keywords are
  excluded from records and MMP aggregation
- apply_sidecar and _apply_sidecar_summary both set sub_sport=indoor when the
  title matches and no explicit sub_sport is already present
- _merge_one_locked: detect title-inferred activities as needs_merge and call
  apply_sidecar({},{}) so the _merged copy gets sub_sport=indoor written
- _merge_all_locked: read index upfront to populate to_merge with title-inferred
  IDs; call apply_sidecar({},{}) for activities in to_merge without sidecars;
  apply _apply_sidecar_summary to ALL summary entries (not only sidecar ones)
2026-05-15 01:03:17 +02:00
Davide Scaini 0fbb7822df EditDrawer: show sub_sport badge immediately after save without page reload 2026-05-15 00:52:34 +02:00
Davide Scaini a863cdd663 Records: exclude Zwift activities by title; show Saved confirmation before closing drawer
- _is_outdoor now also excludes activities whose title matches /\bzwift\b/i,
  covering the ~50 Strava-imported Zwift rides that lack sub_sport metadata.

- EditDrawer waits 900ms after a successful save before dispatching 'saved'
  (which closes the drawer), so the green "Saved" confirmation is visible.
2026-05-15 00:49:46 +02:00
Davide Scaini 9f1e9e4d3b Records: apply sidecars before computing; fix best_climb_m for long mountain climbs
- _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.
2026-05-15 00:30:58 +02:00
Davide Scaini de07d8d4cf activities: trigger rebuild after edit so records update immediately 2026-05-15 00:09:51 +02:00
Davide Scaini 1ce94b8536 records: exclude indoor/treadmill/virtual sub_sport; rebuild athlete.json on bake
- 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
2026-05-14 23:37:54 +02:00
Davide Scaini b509db4940 nerd corner: cool-to-warm year color ramp (proposal, not pushed) 2026-05-14 18:47:08 +02:00
Davide Scaini 653db2428f nerd corner: add cumulative plot below the per-period chart 2026-05-14 18:43:05 +02:00
Davide Scaini 5167f2a988 explore: shard tracks into per-year files for progressive loading
bake_tracks now writes tracks_YYYY.json shards + tracks_index.json manifest
instead of a single monolithic tracks.json. API /api/me/tracks returns the
manifest; /api/me/tracks/{year} serves individual shards. Explore.svelte
fetches the two most recent years eagerly then streams the rest in the
background so the map renders immediately with recent data.
2026-05-14 18:34:53 +02:00
Davide Scaini 8af6b7b04e nav: always show upload button on all screen sizes 2026-05-14 18:24:44 +02:00
Davide Scaini 16965a7645 ActivityDetail: fetch timeseries in parallel with detail JSON to cut load time 2026-05-14 18:22:05 +02:00
Davide Scaini c36b95e041 segments detect: add --fresh flag to clear efforts before re-detecting 2026-05-14 17:11:11 +02:00
Davide Scaini 862226305a Fix segment avg_speed: derive from distance/time; tighten speed bounds to reject false matches 2026-05-14 17:09:41 +02:00
Davide Scaini 8ff781661e Fix feedback JSON encoding: use ensure_ascii=False to preserve accented characters 2026-05-14 17:04:44 +02:00
Davide Scaini 4d6859b927 NerdCorner: show per-period load instead of cumulative 2026-05-14 16:42:16 +02:00
Davide Scaini b32553b0b1 Fix NerdCorner: pass all activities, not only those with power data 2026-05-14 16:39:57 +02:00
Davide Scaini 8804bdec37 Add Nerd Corner tab with year-over-year cumulative progress chart 2026-05-14 16:37:01 +02:00
Davide Scaini 487ce42361 Explore: type pill dark/light theme split; freeze active pill on hover in dark theme 2026-05-14 16:24:00 +02:00
Davide Scaini 46445dd1cb Move Invites link from athlete page to settings; type pill active state contrast fix 2026-05-14 16:22:31 +02:00
Davide Scaini ab112788b4 Explore: grey pill background dark-theme only; transparent in light mode 2026-05-14 16:18:10 +02:00
Davide Scaini 8d799e8e64 Explore: active type pill solid color bg with auto black/white text contrast 2026-05-14 16:17:16 +02:00
Davide Scaini cfb7198d64 Explore: raise inactive type-pill background opacity to 70% for dark theme visibility 2026-05-14 16:11:48 +02:00
Davide Scaini 2b9e080b4c Explore: opacity slider heatmap-only; lines mode width-only at 100% opacity 2026-05-14 16:07:57 +02:00
Davide Scaini 20bb5bfb60 explore: grey background on inactive type pills for readability 2026-05-14 15:59:00 +02:00
Davide Scaini dc719a55d5 explore: show width/opacity sliders in lines mode too 2026-05-14 15:57:20 +02:00
Davide Scaini 5593764fdb explore: skip legacy bare-timestamp geojsons; type pill colors visible when inactive 2026-05-14 15:55:10 +02:00
Davide Scaini e7228c2be8 explore: plasma palette; width + opacity sliders for heatmap 2026-05-14 15:48:30 +02:00
Davide Scaini 298fe3ea39 explore: fix by-type layer visibility — only show selected types 2026-05-14 15:43:12 +02:00
Davide Scaini 4e32cf4f21 explore: grey map default; zoom-scaled heatmap lines; fix all/none type buttons 2026-05-14 15:35:40 +02:00
Davide Scaini a75fabecb9 explore: thicker heatmap lines (4px + blur) 2026-05-14 14:57:39 +02:00
Davide Scaini b3c41967f6 explore: replace gaussian heatmap with line-accumulation (strava-style) 2026-05-14 14:54:47 +02:00
Davide Scaini 6d13993f98 explore: default heatmap/by-type; month All button; bbox filtering on map move 2026-05-14 14:45:54 +02:00
Davide Scaini 537d1bb712 explore: exclude indoor/virtual activities from tracks.json 2026-05-14 14:34:44 +02:00
Davide Scaini 5307ae287c Explore: personal GPS heatmap tab under Athlete page
- 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
2026-05-14 14:31:21 +02:00
Davide Scaini 2daa66d7b0 about: add Satispay donation button + QR code; remove feedback button (all locales) 2026-05-14 11:11:34 +02:00
Davide Scaini 1a7d1dc8c3 serve: complete CurrentUserResponse model (add wiki_access, activity_access, dem_configured) 2026-05-14 11:06:35 +02:00
Davide Scaini e7c5af0d01 Nav: add Planner link for logged-in users (mirrors wiki link strategy) 2026-05-14 10:44:46 +02:00
Davide Scaini a10164b932 metrics: fall back to GPS-derived speed in compute_best_efforts when device speed absent
FIT files from older devices (and GPX/TCX files) often omit the speed field.
The sliding-window best-effort algorithm was treating all such points as speed=0,
so no records were ever produced for these activities.

Fix: when p.speed_kmh is None but consecutive lat/lon are available, compute
haversine segment speed and spread it evenly across the 1Hz interval slots.
This mirrors what _gps_stats already does for avg/max speed computation.
2026-05-14 09:16:01 +02:00
Davide Scaini c85d2edf39 dev: fix _start_serve to set deps.data_dir/site_dir (not srv.*) after Step 3 split 2026-05-14 00:09:41 +02:00
Davide Scaini 7ec91b0e6a refactoring.md: update Step 4 to reflect as-built (post-split routers) 2026-05-14 00:04:52 +02:00
Davide Scaini 27f6d141f7 Refactor step 4: narrow broad except Exception catches
Replaced 28 bare `except Exception` catches across 8 files with specific
exception types reflecting the actual failure modes:

- JSON file reads → (OSError, json.JSONDecodeError)
- datetime parsing → ValueError
- base64 decoding → ValueError
- YAML parsing → (OSError, yaml.YAMLError); import moved above try
- GeoJSON coord extraction → (TypeError, IndexError, AttributeError)
- Startup temp-file cleanup → OSError
- Single JSON line parsing (SSE batch) → json.JSONDecodeError

Kept broad catches only where intentional:
- Background thread top-level guards (tasks.py, admin.py) with log.exception
- SSE stream generator tops (strava.py, garmin.py, uploads.py)
- Per-item batch loops that must not abort the whole operation
- Explicitly non-fatal post-upload merge steps with log.warning
2026-05-13 23:58:14 +02:00
Davide Scaini 8380b1d2cc Refactor: split serve/server.py (3220 lines) into focused modules
serve/server.py is now 69 lines — app factory, middleware, and router
registration only.

New modules:
  deps.py    (168 lines) — module-level globals + auth dependency functions
  models.py   (85 lines) — all Pydantic request/response models
  tasks.py   (136 lines) — background workers and job tracker
  routers/               — one file per domain (10 routers, ~2750 lines total)
    auth.py, me.py, admin.py, activities.py, uploads.py,
    segments.py, strava.py, garmin.py, ideas.py, feed.py

cli.py updated to set globals on deps instead of server.

88 new regression tests in tests/serve/ cover auth guards and key
behaviours for every router; 294 total passing after the split.
2026-05-13 23:47:19 +02:00
Davide Scaini 2ec4d9157c Refactor: extract edit UI HTML into bincio/edit/templates/edit.html
The 285-line _HTML string literal in edit/server.py is replaced by a
template file loaded at request time. The route handler is unchanged in
behaviour — it still substitutes __SITE_URL__, __SPORT_OPTIONS__, and
__STAT_CHECKBOXES__ before returning the response.

Five new tests cover: 200 response, form presence, site_url injection,
no unresolved placeholders, and template file existence on disk.
2026-05-13 23:19:19 +02:00
Davide Scaini 9dd533825f Fix pre-existing test failures in test_writer and test_metrics
test_writer: _dummy_metrics() and test_build_summary_required_fields were
missing np_power_w=None after the field was added to ComputedMetrics.

test_metrics: the leading-zero elevation heuristic fired on a single 0.0
start value, incorrectly skipping the first legitimate elevation step.
Guard now requires at least 2 consecutive near-zero leading values before
activating the Apple Watch lock-acquisition workaround.
2026-05-13 23:15:26 +02:00
Davide Scaini e61d05fc41 Refactor: extract shared image upload utilities into bincio/shared/images.py
ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES, and unique_image_name() were
duplicated identically in both the edit and serve servers. Centralising
them means a single change point for any future extension (e.g. adding
image/avif support).

Tests added in tests/test_shared_images.py cover no-collision, single
and chained collisions, no-suffix filenames, and constant values.
2026-05-13 23:13:08 +02:00
Davide Scaini cd97e4cc87 CORS: allow all *.bincio.org origins (for planner.bincio.org) 2026-05-13 22:51:00 +02:00
Davide Scaini 58a5d5b450 Strava sync: skip import when a FIT-file upload already covers the same start time
Before importing each Strava activity, build a set of existing timestamp
prefixes (YYYY-MM-DDTHHMMSSZ) from the activities directory. If the incoming
Strava activity matches an existing prefix, record its Strava ID as done and
skip — preventing duplicate entries when a FIT file and a Strava sync both
cover the same ride.

Also reports skipped-existing count in the summary line.
2026-05-13 21:52:07 +02:00
Davide Scaini fb033e3da2 Search: auto-load all year shards when a query is entered so full history is searched 2026-05-13 21:17:55 +02:00
Davide Scaini e4b53dde44 Nav: move Wiki to right-side menu (desktop + hamburger); fix missing element when PUBLIC_WIKI_URL unset in rebuilds 2026-05-13 20:50:04 +02:00
Davide Scaini a4b4d11fc0 Nav: move Ideas and About to right-side menu (desktop + hamburger) 2026-05-13 20:44:54 +02:00
Davide Scaini fc012b5311 pull_feedback: highlight new submissions since last pull 2026-05-13 20:36:40 +02:00
Davide Scaini b5a1e881fb delete plan 2026-05-13 20:07:26 +02:00
Davide Scaini b9a21e8bcc ideas: add inline edit for own ideas (author + admin) 2026-05-13 19:52:25 +02:00
Davide Scaini aa1c0b38c0 ideas: add Feedback button to header 2026-05-13 19:48:34 +02:00
Davide Scaini c2c4cb9f3a segments: fit initial map view to all existing segments 2026-05-13 19:41:59 +02:00
Davide Scaini d82033fd84 ideas: update bug report link text 2026-05-13 19:34:02 +02:00
Davide Scaini c30a15d295 ideas: add done/reopen status toggle for admins
Admin-only POST /api/ideas/{id}/status toggles status between open and
done. Done ideas are greyed out (opacity 0.55), show a green checkmark,
and sink to the bottom of the list. Admins see done/reopen buttons on
each card.
2026-05-13 19:32:30 +02:00
Davide Scaini 38f2e51788 ideas: add Ideas page, nav link; remove feedback button from About
New /ideas/ page with Svelte component: card list sorted by votes,
inline submit form, optimistic vote toggling, delete for own/admin.
Bug report link moved to bottom of Ideas page. Feedback button removed
from About page.
2026-05-13 19:29:39 +02:00
Davide Scaini 9553ca5ce7 ideas: add JSON-file-backed ideas API (list, create, vote, delete)
Ideas and votes are stored as flat JSON files in /var/bincio/_ideas/,
following the same filesystem-first philosophy as segments and efforts.
Vote toggling uses fcntl exclusive locking to prevent concurrent writes.
2026-05-13 19:27:54 +02:00
Davide Scaini cf9817e853 segments: clear stored efforts before rescan so stale entries are removed
Both trigger_detect and me_segment_rescan were appending-only, so false
efforts recorded before the geometric speed check fix remained after
rescan. Now each rescan path clears the effort file first, making the
result authoritative.
2026-05-13 16:35:44 +02:00
Davide Scaini 6e92ea4fce segments: reject false efforts via geometric speed check
Long circuit rides were matching a segment START early and finding the
segment END hours later on a second pass, producing effort times of
~17000s on a 4.7km segment. The conformance check passed because the
full-circuit track covers all interior points within 50m over 5 hours.

Add a per-sport minimum geometric speed (segment_distance / elapsed_s):
cycling ≥ 1.0 m/s, running ≥ 0.5 m/s, default ≥ 0.2 m/s. When the
check fails, advance past the current start candidate and retry, so a
legitimate later match (e.g. a second lap done at real speed) is still
detected.
2026-05-13 16:31:00 +02:00
Davide Scaini 994f4287ef Dedup segment efforts by started_at to handle same activity stored under two IDs 2026-05-13 16:20:10 +02:00
Davide Scaini ad9e428b1e Fix UnicodeEncodeError: sanitize surrogate pairs before JSON writes in merge.py 2026-05-13 16:16:58 +02:00
Davide Scaini c837464a28 Exclude indoor/virtual activities from records and power curve 2026-05-13 16:05:26 +02:00
Davide Scaini 2395a6e566 Fix segment effort duplicates; auto-scan on segment creation
- detect.py: truncate started_at to seconds so dedup key survives JSON round-trip
- store.py: dedup by (activity_id, iso-started_at) string key, not object equality
- server.py: extract _scan_segment_for_user helper; trigger background scan
  for the creating user's activities when a new segment is saved
2026-05-13 15:58:57 +02:00
Davide Scaini cb3c9b6e41 Move search bar above sport/date filters, below page title 2026-05-13 11:54:37 +02:00
Davide Scaini 861748a059 ActivityFeed: add title search bar with URL sync 2026-05-13 11:51:14 +02:00
Davide Scaini f00e5e47b2 SegmentDetail: sort efforts by time by default, sortable column headers 2026-05-13 11:17:22 +02:00
Davide Scaini 0ff5473dfd Athlete segments tab: link best time to activity; expandable effort list
- best_activity_id now included in segment_summary API response
- Best time is a direct link to the activity that produced it
- Clicking a row expands an inline effort list (lazy-loaded from
  /api/segments/{id}/efforts): date linked to activity, time, Δ vs PR
- Clicking again collapses; ▲/▼ chevron shows state
2026-05-13 08:40:39 +02:00
Davide Scaini 59cf99f0af Fix stuck segments tab; add /segments/ dev fallback
AthleteView: use segmentsFetched flag to prevent infinite fetch loop when
there are no efforts (segmentSummary.length === 0 was re-triggering the
reactive statement after every empty response). Also improve empty state
message and reset flag after rescan so the table reloads.

astro.config.mjs: extend shell fallback plugin to cover /segments/{id}/
the same way /activity/{id}/ is handled, so segment detail pages work in
the dev server without nginx.
2026-05-13 08:35:00 +02:00
Davide Scaini b8fd4e4ded Move segment rescan button from segments list to athlete/segments tab 2026-05-13 08:20:05 +02:00
Davide Scaini d7fd585e77 Add global segment rescan: POST /api/me/segment-rescan + Rescan all button 2026-05-13 08:17:18 +02:00
Davide Scaini f2075e29d2 Segments Phase 4: detail page, activity efforts, athlete tab, new APIs
New API endpoints:
- GET /api/segments/{id} — single segment metadata
- GET /api/activities/{id}/segment_efforts — efforts for an activity (auth)
- GET /api/users/{handle}/segment_summary — public best time + count per segment

New components:
- SegmentDetail.svelte — map + metadata + effort table (with PR/Δ) + rescan button
- SegmentsPage.svelte — URL router: shows detail when /segments/{id}/, list otherwise

Updated:
- segments/index.astro — now uses SegmentsPage router
- nginx-activity.conf — add /segments/ try_files rule for client-side routing
- ActivityDetail.svelte — segment efforts block below laps
- AthleteView.svelte — Segments tab with best time + effort count per segment
- format.ts — add formatElapsed() for compact m:ss display
2026-05-13 08:09:24 +02:00
Davide Scaini c7f0013e57 SegmentCreate: prompt after save instead of immediate redirect; update plan
After saving, show "Saved! Add another from this activity?" with two
buttons: "Add another" (resets name/handles, keeps map loaded) and
"Done" (navigates to /segments/).
2026-05-13 01:03:34 +02:00
Davide Scaini 6c9de35426 Enforce 500 m minimum segment length in UI and API 2026-05-13 00:56:04 +02:00
Davide Scaini e9e7b5d0e7 SegmentCreate: add elevation profile that zooms to selected portion
Shows a dim area for the visible range around the selection (4% padding)
and a blue overlay for the selected segment, with a light stroke on the
top edge. Both the x-domain and y-domain track the selection, so the
chart zooms in as the handles narrow. Elevation min/max labels overlaid
at top-left and bottom-left.
2026-05-13 00:54:39 +02:00
Davide Scaini 4d2df860ce Segments Phase 3: detection algorithm, CLI, ingest hook, and efforts API
- detect.py: ActivityTrack + detect_one/detect_all (bbox pre-filter →
  start/end proximity 25m → path conformance 50m/30% → effort extraction
  with avg speed/HR/power and Coggan NP)
- cli.py: `bincio segments detect` for retroactive detection over stored
  timeseries JSONs, with optional --activity-id / --segment-id filters
- ingest.py: non-fatal hook at end of ingest_parsed runs detect_all
- server.py: GET /api/segments/{id}/efforts and POST /api/segments/{id}/detect
2026-05-13 00:50:39 +02:00
Davide Scaini 61db0734d2 Move segment shortcut next to Edit button, shorten to '+ segment' 2026-05-13 00:39:51 +02:00
Davide Scaini dd9f7a82dc Segments phase 2: /segments/ browse page, /segments/new/ creation flow, activity detail shortcut 2026-05-13 00:36:44 +02:00
Davide Scaini 79cad29ff1 Segments phase 1: models, store, and API endpoints (GET/POST/DELETE /api/segments) 2026-05-13 00:19:15 +02:00
Davide Scaini 6b2698c0c5 Mark fallback NP computation for future removal 2026-05-12 23:52:19 +02:00
Davide Scaini c46e91d0f5 Compute NP from timeseries in frontend for activities missing np_power_w in JSON 2026-05-12 23:51:22 +02:00
Davide Scaini bd0595ee79 Add avg power and NP to activity summary; NP uses Coggan 30s rolling-average method 2026-05-12 23:47:06 +02:00
Davide Scaini f1fec6d825 ActivityCharts: smoothing toggle (Raw/10s/20s) for all line chart metrics 2026-05-12 23:37:41 +02:00
Davide Scaini a5db6142b3 ActivityCharts: 10s rolling mean on cadence and power line charts (display only) 2026-05-12 23:32:33 +02:00
Davide Scaini 1298586a74 ActivityCharts: extend reference lines to HR; use high-contrast label styling 2026-05-12 23:29:09 +02:00
Davide Scaini 3231fdb4b7 ActivityCharts: add avg/P20/P80 reference lines to speed, cadence, and power line charts 2026-05-12 23:24:33 +02:00
Davide Scaini 0b266d208c Strip pre-2000 leading points to prevent epoch-zero start time and absurd duration 2026-05-12 23:11:33 +02:00
Davide Scaini 867da767eb Add sub_sport editing to activity edit drawer 2026-05-12 23:01:12 +02:00
Davide Scaini 93f6109028 Add hamburger menu for mobile nav 2026-05-11 11:37:33 +02:00
Davide Scaini 8fbbf460a9 Add PWA icons and manifest for iOS/Android home screen 2026-05-11 11:17:13 +02:00
Davide Scaini 14313ec59c Add Disconnect button to Strava section of upload modal 2026-05-10 17:12:55 +02:00
Davide Scaini 1eaf5c4e0b Remove TS annotation from define:vars script (caused parse error) 2026-05-10 17:10:38 +02:00
Davide Scaini 5be58f4e1c Fix Strava OAuth popup detection via postMessage (cross-origin safe) 2026-05-10 17:04:30 +02:00
Davide Scaini 695dc9fdce Fix Strava re-auth when credentials change; add disconnect button
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.
2026-05-10 16:33:52 +02:00
Davide Scaini 8f028101c7 Fix elevation gain inflation from device no-fix leading zeros
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.
2026-05-10 16:21:24 +02:00
Davide Scaini 55d59112ad Fix: don't copy 9 GB data dir into dist/ during production builds
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.
2026-05-08 13:56:31 +02:00
Davide Scaini 2287d6e2ee Add Strava sync status report and manual trigger to admin panel
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.
2026-05-08 13:44:23 +02:00
Davide Scaini 12693dbd60 feat: scheduled Strava sync + admin suspend/delete account
- 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.
2026-05-08 10:36:21 +02:00
Davide Scaini 680ef9d440 Hide edit controls on activities the logged-in user does not own 2026-05-03 18:51:52 +02:00
Davide Scaini 48ffc5be8e Hub: add SW cleanup on load; add self-unregistering sw.js to kill stale Astro SW 2026-05-03 18:47:30 +02:00
Davide Scaini 8c10ff5574 fix card hub not being showed 2026-05-02 23:10:28 +02:00
159 changed files with 15241 additions and 17152 deletions
+50
View File
@@ -1,5 +1,55 @@
# Changelog
## [unreleased] — 2026-05-19
### Performance — activity detail page
Four targeted fixes that together eliminate the blank loading screen and
reduce timeseries payload size for the dominant use case.
**sessionStorage summary passthrough** (`ActivityFeed.svelte`,
`ActivityDetailLoader.svelte`): when the user clicks an activity from the
feed, the summary object is written to sessionStorage before navigation and
read back synchronously at module init on the detail page — before the first
render. The "Loading activity…" screen and the two sequential index-fetch
round trips are eliminated entirely for this path. Direct URLs and bookmarks
fall through to the existing slow path unchanged.
**Spatial 10 m downsampling** (`bincio/extract/timeseries.py`): timeseries
are now downsampled to one sample per 10 m of distance traveled (GPS
haversine primary, speed × Δt fallback) instead of one per second. Indoor
activities with neither GPS nor speed data are left at 1 s resolution.
Running activities see ~67 % fewer points; long cycling rides ~30 %. A
`bincio render --downsample-timeseries` migration flag retroactively
downsamples all existing stored files without re-extracting from FIT/GPX.
**nginx timeseries caching** (`deploy/vps/nginx-activity.conf`): a regex
location block before the generic `/data/` handler serves `*.timeseries.json`
with `Cache-Control: public, max-age=3600, stale-while-revalidate=3600`.
Previously every page view triggered a conditional GET even when nothing had
changed.
**asyncio.to_thread for segment_efforts** (`bincio/serve/routers/activities.py`):
the synchronous file scan in `GET /api/activities/{id}/segment_efforts` is
now dispatched via `asyncio.to_thread` so it runs in a thread pool instead of
blocking the event loop during concurrent fetches.
### Performance — static asset caching
**Immutable JS/CSS caching** (`deploy/vps/nginx-activity.conf`): Astro
content-hashes all `/_astro/*.js` and `/_astro/*.css` filenames at build time.
A new nginx location block serves them with `max-age=31536000, immutable` so
browsers never revalidate until the hash changes. HTML pages get an explicit
`no-cache, must-revalidate` header so the latest asset URLs are always fetched
after a deploy.
### Tooling
**VPS backup script** (`deploy/vps/backup-vps.sh`): extended to pull
`nginx-wiki.conf` and `nginx-planner.conf` in addition to the existing files.
---
## [0.1.0] — 2026-04-22
### Improvement — DEM & hysteresis algorithm refinements
+24
View File
@@ -19,6 +19,27 @@ from bincio.serve.init_cmd import init # noqa: E402
from bincio.serve.cli import serve # noqa: E402
from bincio.dev import dev # noqa: E402
from bincio.reextract_cmd import reextract_originals # noqa: E402
from bincio.sync_strava import sync_strava_cmd # noqa: E402
from bincio.sync_garmin import sync_garmin_cmd # noqa: E402
from bincio.segments.cli import segments_group # noqa: E402
@main.command("bake-tracks")
@click.option("--data-dir", required=True, help="BAS data store directory.")
@click.option("--handle", default=None, help="Bake one user only (default: all).")
def bake_tracks_cmd(data_dir: str, handle: str | None) -> None:
"""Pre-bake GPS tracks.json for the Explore heatmap page."""
from pathlib import Path
from bincio.explore import bake_tracks
from bincio.render.cli import _user_dirs
from rich.console import Console
console = Console()
data = Path(data_dir).expanduser().resolve()
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
n = bake_tracks(user_dir.name, data)
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
main.add_command(extract)
main.add_command(render)
@@ -28,3 +49,6 @@ main.add_command(init)
main.add_command(serve)
main.add_command(dev)
main.add_command(reextract_originals)
main.add_command(sync_strava_cmd)
main.add_command(sync_garmin_cmd)
main.add_command(segments_group)
+3 -2
View File
@@ -87,9 +87,10 @@ def _start_serve(data: Path, api_port: int, site: Path, api_host: str = "127.0.0
"""Start bincio serve in a background thread."""
import uvicorn
import bincio.serve.server as srv
from bincio.serve import deps
srv.data_dir = data
srv.site_dir = site
deps.data_dir = data
deps.site_dir = site
config = uvicorn.Config(
srv.app,
+11
View File
@@ -13,7 +13,10 @@ from typing import Any, Optional
# ── Shared constants (imported by edit/server.py and serve/server.py) ─────────
from bincio.extract.sport import SUB_SPORTS as _SUB_SPORTS
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
_VALID_SUB_SPORTS = {v for vs in _SUB_SPORTS.values() for v in vs}
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$')
@@ -36,6 +39,8 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
lines.append(f"title: {json.dumps(payload['title'])}")
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
lines.append(f"sport: {payload['sport']}")
if payload.get("sub_sport") and payload["sub_sport"] in _VALID_SUB_SPORTS:
lines.append(f"sub_sport: {payload['sub_sport']}")
if payload.get("gear"):
lines.append(f"gear: {json.dumps(payload['gear'])}")
if payload.get("highlight"):
@@ -45,6 +50,12 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS]
if hide:
lines.append(f"hide_stats: [{', '.join(hide)}]")
dd = payload.get("download_disabled")
if dd is True:
lines.append("download_disabled: true")
elif dd is False:
# Explicit false: allows per-activity opt-in against a user-level default
lines.append("download_disabled: false")
description = (payload.get("description") or "").strip()
+6 -303
View File
@@ -43,308 +43,12 @@ def _check_id(activity_id: str) -> str:
return activity_id
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
from bincio.shared.images import unique_image_name as _unique_image_name
def _unique_image_name(directory: Path, filename: str) -> str:
"""Return a filename that does not collide with existing files in directory."""
stem, suffix = Path(filename).stem, Path(filename).suffix
candidate = filename
counter = 1
while (directory / candidate).exists():
candidate = f"{stem}_{counter}{suffix}"
counter += 1
return candidate
# ── HTML UI ───────────────────────────────────────────────────────────────────
_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Activity</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090b; --surface: #18181b; --border: #27272a;
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
--accent-dim: #1d3461; --danger: #ef4444;
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body { background: var(--bg); color: var(--text); font-family: var(--font);
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.25rem; font-weight: 700; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
outline: none; transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 140px; }
.field { margin-bottom: 16px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
user-select: none; transition: border-color .15s, background .15s; }
.check-item:hover { border-color: var(--muted); }
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle-row { display: flex; gap: 16px; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: border-color .15s, background .15s; }
.toggle:hover { border-color: var(--muted); }
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle input { width: auto; accent-color: var(--accent); }
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
transition: border-color .15s; margin-top: 4px; }
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; }
.image-chip button { background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
.image-chip button:hover { color: var(--danger); }
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
cursor: pointer; border: none; transition: opacity .15s; }
.btn:disabled { opacity: .4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { opacity: .85; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
.status { font-size: 13px; }
.status.ok { color: #4ade80; }
.status.err { color: var(--danger); }
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
.back { font-size: 13px; color: var(--muted); }
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
.card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
border-bottom: 1px solid var(--border); }
</style>
</head>
<body>
<div style="max-width:780px;margin:0 auto">
<div class="header">
<a class="back" href="__SITE_URL__">← Back to site</a>
<h1 id="page-title">Edit Activity</h1>
</div>
<p id="meta" class="meta" style="margin-bottom:16px"></p>
<div class="card">
<form id="form" autocomplete="off">
<p class="section-title">Identity</p>
<div class="row">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
</div>
<div class="field">
<label for="sport">Sport</label>
<select id="sport" name="sport">
__SPORT_OPTIONS__
</select>
</div>
</div>
<div class="field">
<label for="gear">Gear</label>
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
</div>
<p class="section-title" style="margin-top:20px">Description</p>
<div class="field">
<label for="description">Markdown supported</label>
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
</div>
<p class="section-title" style="margin-top:20px">Display</p>
<div class="field">
<label>Hide stat panels</label>
<div class="check-group" id="hide-stats-group">
__STAT_CHECKBOXES__
</div>
</div>
<div class="field" style="margin-top:12px">
<label>Flags</label>
<div class="toggle-row">
<label class="toggle" id="toggle-highlight">
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
</label>
<label class="toggle" id="toggle-private">
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
</label>
</div>
</div>
<p class="section-title" style="margin-top:20px">Images</p>
<div class="field">
<label>Drag & drop images or click to browse</label>
<div class="drop-zone" id="drop-zone">
<span id="drop-label">Drop images here or click to upload</span>
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
</div>
<div class="image-list" id="image-list"></div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
<span class="status" id="status"></span>
</div>
</form>
</div>
</div>
<script>
const id = location.pathname.split('/edit/')[1];
const api = '/api/activity/' + id;
let uploadedImages = [];
// Fetch current data
fetch(api).then(r => r.json()).then(data => {
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
document.getElementById('meta').textContent = data.started_at
? new Date(data.started_at).toLocaleString() : '';
document.getElementById('title').value = data.title || '';
document.getElementById('sport').value = data.sport || 'other';
document.getElementById('gear').value = data.gear || '';
document.getElementById('description').value = data.description || '';
if (data.highlight) setToggle('highlight', true);
if (data.private) setToggle('private', true);
(data.hide_stats || []).forEach(s => {
const cb = document.querySelector(`input[data-stat="${s}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
});
uploadedImages = data.images || [];
renderImageList();
}).catch(() => {
document.getElementById('status').textContent = 'Could not load activity data.';
document.getElementById('status').className = 'status err';
});
// Toggle active class on check items
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.check-item').classList.toggle('active', cb.checked);
});
});
function setToggle(name, val) {
const cb = document.getElementById(name);
cb.checked = val;
document.getElementById('toggle-' + name).classList.toggle('active', val);
}
document.getElementById('highlight').addEventListener('change', e => {
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
});
document.getElementById('private').addEventListener('change', e => {
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
});
// Image upload
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
uploadFiles([...e.dataTransfer.files]);
});
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
async function uploadFiles(files) {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(api + '/images', { method: 'POST', body: fd });
if (r.ok) {
const d = await r.json();
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
renderImageList();
// Insert markdown image reference at end of description
const ta = document.getElementById('description');
const ref = '\\n![' + d.filename.replace(/\\.[^.]+$/, '') + '](' + d.filename + ')';
ta.value = ta.value.trimEnd() + ref;
}
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function renderImageList() {
const list = document.getElementById('image-list');
list.innerHTML = uploadedImages.map(f =>
`<span class="image-chip">${escapeHtml(f)}
<button type="button" onclick="removeImage('${escapeHtml(f)}')" title="Remove">×</button>
</span>`
).join('');
}
async function removeImage(filename) {
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
uploadedImages = uploadedImages.filter(f => f !== filename);
renderImageList();
}
// Save
document.getElementById('form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('save-btn');
const status = document.getElementById('status');
btn.disabled = true;
status.textContent = 'Saving…';
status.className = 'status';
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
.map(cb => cb.dataset.stat);
const payload = {
title: document.getElementById('title').value.trim(),
sport: document.getElementById('sport').value,
gear: document.getElementById('gear').value.trim(),
description: document.getElementById('description').value.trim(),
highlight: document.getElementById('highlight').checked,
private: document.getElementById('private').checked,
hide_stats: hideStats,
};
try {
const r = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
status.className = 'status ok';
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.className = 'status err';
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>
"""
_TEMPLATE_PATH = Path(__file__).parent / "templates" / "edit.html"
# ── Routes ────────────────────────────────────────────────────────────────────
@@ -369,13 +73,12 @@ async def edit_page(activity_id: str) -> str:
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
for s in STAT_PANELS
)
html = (
_HTML
return (
_TEMPLATE_PATH.read_text(encoding="utf-8")
.replace("__SITE_URL__", site_url)
.replace("__SPORT_OPTIONS__", sport_opts)
.replace("__STAT_CHECKBOXES__", stat_cbs)
)
return html
@app.get("/api/activity/{activity_id}")
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Activity</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090b; --surface: #18181b; --border: #27272a;
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
--accent-dim: #1d3461; --danger: #ef4444;
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body { background: var(--bg); color: var(--text); font-family: var(--font);
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.25rem; font-weight: 700; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
outline: none; transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 140px; }
.field { margin-bottom: 16px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
user-select: none; transition: border-color .15s, background .15s; }
.check-item:hover { border-color: var(--muted); }
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle-row { display: flex; gap: 16px; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: border-color .15s, background .15s; }
.toggle:hover { border-color: var(--muted); }
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle input { width: auto; accent-color: var(--accent); }
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
transition: border-color .15s; margin-top: 4px; }
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; }
.image-chip button { background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
.image-chip button:hover { color: var(--danger); }
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
cursor: pointer; border: none; transition: opacity .15s; }
.btn:disabled { opacity: .4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { opacity: .85; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
.status { font-size: 13px; }
.status.ok { color: #4ade80; }
.status.err { color: var(--danger); }
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
.back { font-size: 13px; color: var(--muted); }
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
.card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
border-bottom: 1px solid var(--border); }
</style>
</head>
<body>
<div style="max-width:780px;margin:0 auto">
<div class="header">
<a class="back" href="__SITE_URL__">← Back to site</a>
<h1 id="page-title">Edit Activity</h1>
</div>
<p id="meta" class="meta" style="margin-bottom:16px"></p>
<div class="card">
<form id="form" autocomplete="off">
<p class="section-title">Identity</p>
<div class="row">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
</div>
<div class="field">
<label for="sport">Sport</label>
<select id="sport" name="sport">
__SPORT_OPTIONS__
</select>
</div>
</div>
<div class="field">
<label for="gear">Gear</label>
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
</div>
<p class="section-title" style="margin-top:20px">Description</p>
<div class="field">
<label for="description">Markdown supported</label>
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
</div>
<p class="section-title" style="margin-top:20px">Display</p>
<div class="field">
<label>Hide stat panels</label>
<div class="check-group" id="hide-stats-group">
__STAT_CHECKBOXES__
</div>
</div>
<div class="field" style="margin-top:12px">
<label>Flags</label>
<div class="toggle-row">
<label class="toggle" id="toggle-highlight">
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
</label>
<label class="toggle" id="toggle-private">
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
</label>
</div>
</div>
<p class="section-title" style="margin-top:20px">Images</p>
<div class="field">
<label>Drag & drop images or click to browse</label>
<div class="drop-zone" id="drop-zone">
<span id="drop-label">Drop images here or click to upload</span>
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
</div>
<div class="image-list" id="image-list"></div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
<span class="status" id="status"></span>
</div>
</form>
</div>
</div>
<script>
const id = location.pathname.split('/edit/')[1];
const api = '/api/activity/' + id;
let uploadedImages = [];
// Fetch current data
fetch(api).then(r => r.json()).then(data => {
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
document.getElementById('meta').textContent = data.started_at
? new Date(data.started_at).toLocaleString() : '';
document.getElementById('title').value = data.title || '';
document.getElementById('sport').value = data.sport || 'other';
document.getElementById('gear').value = data.gear || '';
document.getElementById('description').value = data.description || '';
if (data.highlight) setToggle('highlight', true);
if (data.private) setToggle('private', true);
(data.hide_stats || []).forEach(s => {
const cb = document.querySelector(`input[data-stat="${s}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
});
uploadedImages = data.images || [];
renderImageList();
}).catch(() => {
document.getElementById('status').textContent = 'Could not load activity data.';
document.getElementById('status').className = 'status err';
});
// Toggle active class on check items
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.check-item').classList.toggle('active', cb.checked);
});
});
function setToggle(name, val) {
const cb = document.getElementById(name);
cb.checked = val;
document.getElementById('toggle-' + name).classList.toggle('active', val);
}
document.getElementById('highlight').addEventListener('change', e => {
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
});
document.getElementById('private').addEventListener('change', e => {
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
});
// Image upload
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
uploadFiles([...e.dataTransfer.files]);
});
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
async function uploadFiles(files) {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(api + '/images', { method: 'POST', body: fd });
if (r.ok) {
const d = await r.json();
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
renderImageList();
// Insert markdown image reference at end of description
const ta = document.getElementById('description');
const ref = '\n![' + d.filename.replace(/\.[^.]+$/, '') + '](' + d.filename + ')';
ta.value = ta.value.trimEnd() + ref;
}
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function renderImageList() {
const list = document.getElementById('image-list');
list.innerHTML = uploadedImages.map(f =>
`<span class="image-chip">${escapeHtml(f)}
<button type="button" onclick="removeImage('${escapeHtml(f)}')" title="Remove">×</button>
</span>`
).join('');
}
async function removeImage(filename) {
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
uploadedImages = uploadedImages.filter(f => f !== filename);
renderImageList();
}
// Save
document.getElementById('form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('save-btn');
const status = document.getElementById('status');
btn.disabled = true;
status.textContent = 'Saving…';
status.className = 'status';
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
.map(cb => cb.dataset.stat);
const payload = {
title: document.getElementById('title').value.trim(),
sport: document.getElementById('sport').value,
gear: document.getElementById('gear').value.trim(),
description: document.getElementById('description').value.trim(),
highlight: document.getElementById('highlight').checked,
private: document.getElementById('private').checked,
hide_stats: hideStats,
};
try {
const r = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
status.className = 'status ok';
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.className = 'status err';
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
"""Pre-bake per-handle GPS tracks for the Explore page.
Reads all activity GeoJSON files for a handle, applies RDP simplification,
and writes per-year tracks_YYYY.json shards plus a tracks_index.json manifest
for progressive client-side loading.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from bincio.extract.simplify import _rdp_mask
_VERSION = 2
_RDP_EPSILON = 0.0001 # ~10 m on the ground
_SPORT_MAP: dict[str, str] = {
"cycling": "cycling", "road_cycling": "cycling", "gravel_cycling": "cycling",
"mountain_biking": "cycling", "e_biking": "cycling", "indoor_cycling": "cycling",
"biking": "cycling", "bike": "cycling", "ride": "cycling",
"running": "running", "trail_running": "running", "treadmill_running": "running",
"jogging": "running",
"hiking": "hiking", "walking": "hiking", "trekking": "hiking",
"mountaineering": "hiking",
"skiing": "skiing", "cross_country_skiing": "skiing", "alpine_skiing": "skiing",
"snowboarding": "skiing",
}
def _sport_to_type(sport: str | None) -> str:
if not sport:
return "other"
return _SPORT_MAP.get(sport.lower(), "other")
def bake_tracks(handle: str, data_dir: Path) -> int:
"""Build tracks.json for handle. Returns number of tracks included."""
acts_dir = data_dir / handle / "activities"
if not acts_dir.exists():
return 0
tracks = []
for gj_path in sorted(acts_dir.glob("*.geojson")):
act_id = gj_path.stem
meta: dict = {}
meta_path = acts_dir / f"{act_id}.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
else:
# bare-timestamp geojson with no metadata — superseded by a slug version
if list(acts_dir.glob(f"{act_id}-*.geojson")):
continue
if meta.get("virtual") or meta.get("sub_sport") == "indoor":
continue
try:
gj = json.loads(gj_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
raw_coords = gj.get("geometry", {}).get("coordinates") or []
if len(raw_coords) < 2:
continue
lng_lat = [[float(c[0]), float(c[1])] for c in raw_coords if len(c) >= 2]
if len(lng_lat) < 2:
continue
mask = _rdp_mask(lng_lat, epsilon=_RDP_EPSILON)
simplified = [pt for pt, keep in zip(lng_lat, mask) if keep]
if len(simplified) < 2:
continue
tracks.append({
"id": act_id,
"date": (meta.get("started_at") or "")[:10],
"type": _sport_to_type(meta.get("sport")),
"name": meta.get("title") or act_id,
"dist": int(meta.get("distance_m") or 0),
"coords": simplified,
})
tracks.sort(key=lambda t: t["date"], reverse=True)
user_dir = data_dir / handle
now = int(time.time())
# Group into per-year buckets
by_year: dict[str, list] = {}
for t in tracks:
year = t["date"][:4] or "0000"
by_year.setdefault(year, []).append(t)
# Remove stale year shards that no longer have data
for old in user_dir.glob("tracks_*.json"):
stem = old.stem # e.g. "tracks_2024" or "tracks_index"
if stem == "tracks_index":
continue
year_part = stem[len("tracks_"):]
if year_part not in by_year:
old.unlink(missing_ok=True)
# Write per-year shards
for year, year_tracks in by_year.items():
shard_path = user_dir / f"tracks_{year}.json"
shard_path.write_text(
json.dumps({
"v": _VERSION,
"handle": handle,
"year": year,
"generated_at": now,
"tracks": year_tracks,
}),
encoding="utf-8",
)
# Write manifest
years_sorted = sorted(by_year.keys(), reverse=True)
index_path = user_dir / "tracks_index.json"
index_path.write_text(
json.dumps({
"v": _VERSION,
"handle": handle,
"generated_at": now,
"total": len(tracks),
"years": years_sorted,
}),
encoding="utf-8",
)
# Remove legacy monolithic file if present
legacy = user_dir / "tracks.json"
legacy.unlink(missing_ok=True)
return len(tracks)
+29 -16
View File
@@ -19,6 +19,8 @@ import urllib.request
from pathlib import Path
from typing import Optional
from bincio.extract.metrics import elevation_params
# Sample one GPS point per N seconds when building the DEM query.
# SRTM30 resolution is ~30 m; at 30 km/h cycling that's ~3 s per tile —
# sampling every 10 s is more than enough.
@@ -297,7 +299,9 @@ def recalculate_elevation(
}
def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
def recalculate_elevation_hysteresis(
user_dir: Path, activity_id: str, *, patch_index: bool = True
) -> dict:
"""Recompute elevation gain/loss from the original recorded elevation data.
Algorithm
@@ -346,13 +350,19 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
if len(elevations) < 2:
raise ValueError("Not enough elevation data to compute gain/loss")
# Determine source-aware threshold
detail = json.loads(json_path.read_text(encoding="utf-8"))
altitude_source = detail.get("altitude_source", "unknown")
threshold = 1.0 if altitude_source == "barometric" else 3.0
source = detail.get("source") or ""
ma_window, threshold = elevation_params(altitude_source, source)
# Pre-smooth to suppress noise, then accumulate with low dead-band
smoothed = _moving_average(elevations, _MA_WINDOW_S)
# Strip leading no-fix zeros (same logic as metrics._elevation)
if elevations and abs(elevations[0]) < 0.5:
for i, e in enumerate(elevations):
if abs(e) > threshold:
elevations = elevations[i:]
break
smoothed = _moving_average(elevations, ma_window) if ma_window > 1 else elevations
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
gain_r = round(gain, 1)
loss_r = round(loss, 1)
@@ -362,21 +372,24 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
detail["elevation_loss_m"] = loss_r
json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8")
# Patch index.json summary
index_path = user_dir / "index.json"
if index_path.exists():
index = json.loads(index_path.read_text(encoding="utf-8"))
for s in index.get("activities", []):
if s.get("id") == activity_id:
s["elevation_gain_m"] = gain_r
break
index_path.write_text(
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
)
# Patch index.json summary (skip for bulk callers who batch this themselves)
if patch_index:
index_path = user_dir / "index.json"
if index_path.exists():
index = json.loads(index_path.read_text(encoding="utf-8"))
for s in index.get("activities", []):
if s.get("id") == activity_id:
s["elevation_gain_m"] = gain_r
break
index_path.write_text(
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
)
return {
"elevation_gain_m": gain_r,
"elevation_loss_m": loss_r,
"threshold_m": threshold,
"ma_window_s": ma_window,
"altitude_source": altitude_source,
"source": source,
}
+195 -3
View File
@@ -22,9 +22,9 @@ from __future__ import annotations
import io
import json
import zipfile
from datetime import datetime, timedelta, timezone
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Generator
_SYNC_FILE = "garmin_sync.json"
@@ -73,9 +73,13 @@ def garmin_sync_iter(
data_dir: Root data directory (used for encryption key lookup).
user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.).
"""
import uuid as _uuid
from bincio.extract.garmin_api import GarminError, get_client
from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.fit import FitParser
from bincio.serve.routers.gear import _load as _gear_load
from bincio.serve.routers.gear import _save as _gear_save
# ── Login ──────────────────────────────────────────────────────────────────
try:
@@ -86,6 +90,41 @@ def garmin_sync_iter(
yield {"type": "fetching"}
# ── Sync gear registry ─────────────────────────────────────────────────────
_garmin_uuid_to_name: dict[str, str] = {}
try:
prof = client.connectapi("/userprofile-service/socialProfile")
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
if profile_id:
garmin_gear = client.get_gear(profile_id)
if isinstance(garmin_gear, list):
registry = _gear_load(user_dir)
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
for g in garmin_gear:
guuid = g.get("uuid") or ""
name = (g.get("customMakeModel") or g.get("displayName") or
f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip())
if not name or not guuid:
continue
_garmin_uuid_to_name[guuid] = name
if guuid not in known:
gear_type = g.get("gearTypeName", "").lower()
if gear_type not in ("bike", "shoes", "skis"):
gear_type = "other"
retired = g.get("gearStatusName") == "retired"
registry.append({"id": str(_uuid.uuid4()), "name": name,
"type": gear_type, "retired": retired,
"garmin_id": guuid})
known.add(guuid)
else:
# Update name in case it changed
for item in registry:
if item.get("garmin_id") == guuid:
item["name"] = name
_gear_save(user_dir, registry)
except Exception:
pass # gear sync is best-effort; don't abort activity sync
# ── Determine date range ───────────────────────────────────────────────────
state = _load_sync_state(user_dir)
last = state.get("last_sync_at")
@@ -144,6 +183,16 @@ def garmin_sync_iter(
except Exception as exc:
raise RuntimeError(f"FIT parse error: {exc}") from exc
# Resolve gear for this activity
if garmin_id and _garmin_uuid_to_name:
try:
act_gear = client.get_activity_gear(garmin_id)
if isinstance(act_gear, list) and act_gear:
guuid = act_gear[0].get("uuid") or ""
parsed.gear = _garmin_uuid_to_name.get(guuid) or None
except Exception:
pass
# Ingest — raises FileExistsError if already present (dedup)
ingest_parsed(parsed, user_dir)
imported += 1
@@ -173,7 +222,8 @@ def garmin_sync_iter(
}
# ── Persist sync state ─────────────────────────────────────────────────────
state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
state["last_sync_at"] = datetime.now(UTC).strftime("%Y-%m-%d")
state["total_imported"] = state.get("total_imported", 0) + imported
_save_sync_state(user_dir, state)
yield {
@@ -194,3 +244,145 @@ def run_garmin_sync(data_dir: Path, user_dir: Path) -> dict:
elif event["type"] == "error":
raise RuntimeError(event["message"])
return result
def import_garmin_gear(data_dir: Path, user_dir: Path) -> dict:
"""Backfill gear for all existing activities by querying Garmin's gear-activities API.
For each gear item, fetches the list of activities from Garmin and matches them
to local activities by UTC start timestamp (±60 s). Writes a sidecar and calls
merge_one for each match that doesn't already have gear set.
Returns {"gear_added": int, "activities_updated": int}.
"""
import contextlib
import re
import uuid
import yaml
from bincio.extract.garmin_api import GarminError, get_client
from bincio.render.merge import merge_one
from bincio.serve.routers.gear import _load as _gear_load
from bincio.serve.routers.gear import _save as _gear_save
client = get_client(data_dir, user_dir)
# Fetch gear list from Garmin
prof = client.connectapi("/userprofile-service/socialProfile")
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
if not profile_id:
raise GarminError("Could not read Garmin profile ID")
garmin_gear = client.get_gear(profile_id)
if not isinstance(garmin_gear, list) or not garmin_gear:
return {"gear_added": 0, "activities_updated": 0}
# Build / update local gear registry
registry = _gear_load(user_dir)
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
uuid_to_name: dict[str, str] = {}
gear_added = 0
for g in garmin_gear:
guuid = g.get("uuid") or ""
name = (g.get("customMakeModel") or g.get("displayName") or
f"{g.get('gearMakeName', '')} {g.get('gearModelName', '')}".strip())
if not name or not guuid:
continue
uuid_to_name[guuid] = name
if guuid not in known:
gear_type = g.get("gearTypeName", "").lower()
if gear_type not in ("bike", "shoes", "skis"):
gear_type = "other"
retired = g.get("gearStatusName") == "retired"
registry.append({"id": str(uuid.uuid4()), "name": name,
"type": gear_type, "retired": retired, "garmin_id": guuid})
known.add(guuid)
gear_added += 1
else:
for item in registry:
if item.get("garmin_id") == guuid:
item["name"] = name
_gear_save(user_dir, registry)
# Build timestamp → activity_id map from index shards
ts_to_id: dict[int, str] = {}
merged_dir = user_dir / "_merged"
shard_dir = merged_dir if merged_dir.exists() else user_dir
for shard_path in sorted(shard_dir.glob("index*.json")):
try:
idx = json.loads(shard_path.read_text(encoding="utf-8"))
for a in idx.get("activities", []):
started = a.get("started_at") or ""
if started and a.get("id"):
dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
ts_to_id[int(dt.astimezone(UTC).timestamp())] = a["id"]
except (OSError, json.JSONDecodeError, KeyError):
continue
edits_dir = user_dir / "edits"
edits_dir.mkdir(exist_ok=True)
activities_updated = 0
for guuid, gear_name in uuid_to_name.items():
try:
gear_acts = client.get_gear_activities(guuid, limit=10000)
except Exception:
continue
if not isinstance(gear_acts, list):
continue
for ga in gear_acts:
gmt = ga.get("startTimeGMT") or ""
if not gmt:
continue
try:
dt = datetime.strptime(gmt, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
ts = int(dt.timestamp())
except ValueError:
continue
act_id = None
for delta in range(0, 61):
act_id = ts_to_id.get(ts + delta) or ts_to_id.get(ts - delta)
if act_id:
break
if not act_id:
continue
# Skip if activity already has gear set
act_json = user_dir / "activities" / f"{act_id}.json"
if act_json.exists():
try:
if json.loads(act_json.read_text(encoding="utf-8")).get("gear"):
continue
except (OSError, json.JSONDecodeError):
pass
sidecar = edits_dir / f"{act_id}.md"
fm, body = {}, ""
if sidecar.exists():
try:
text = sidecar.read_text(encoding="utf-8")
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
if len(parts) >= 3:
fm = yaml.safe_load(parts[1]) or {}
body = parts[2].strip()
except Exception:
pass
if fm.get("gear"):
continue
fm["gear"] = gear_name
fm_text = yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
content = f"---\n{fm_text}\n---\n"
if body:
content += f"\n{body}\n"
sidecar.write_text(content, encoding="utf-8")
with contextlib.suppress(Exception):
merge_one(user_dir, act_id)
activities_updated += 1
return {"gear_added": gear_added, "activities_updated": activities_updated}
+47
View File
@@ -74,6 +74,15 @@ def ingest_parsed(
pass
write_athlete_json(list(summaries.values()), data_dir, athlete_config)
# Detect segment efforts for this activity (non-fatal if it fails).
try:
from bincio.segments.detect import track_from_parsed, detect_all
track = track_from_parsed(parsed, activity_id)
if track is not None:
detect_all(track, data_dir.name, data_dir.parent)
except Exception:
pass
return activity_id
@@ -91,18 +100,22 @@ def strava_sync_iter(
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
- ``"error"`` — fatal error before processing started; key: message
"""
import contextlib
import time
import uuid
from bincio.extract.strava_api import (
StravaError,
ensure_fresh,
fetch_activities,
fetch_gear,
fetch_streams,
save_token,
strava_meta_to_partial,
strava_to_parsed,
)
from bincio.extract.writer import make_activity_id
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
if not client_id or not client_secret:
yield {"type": "error", "message": "Strava not configured"}
@@ -128,6 +141,35 @@ def strava_sync_iter(
skipped = 0
errors: list[str] = []
# Cache: strava gear_id → gear name (avoid duplicate API calls within one sync)
_gear_name_cache: dict[str, str] = {}
def _resolve_gear(gear_id: str) -> str:
"""Return gear name for a Strava gear_id, adding to registry if new."""
if gear_id in _gear_name_cache:
return _gear_name_cache[gear_id]
# Check registry first
registry = _gear_load(data_dir)
existing = next((g for g in registry if g.get("strava_id") == gear_id), None)
if existing:
name = existing["name"]
_gear_name_cache[gear_id] = name
return name
# Fetch from Strava
details = fetch_gear(token["access_token"], gear_id)
name = details.get("name") or ""
if not name:
_gear_name_cache[gear_id] = ""
return ""
# Strava gear IDs: "b" prefix = bike, "g" prefix = shoes
gear_type = "shoes" if gear_id.startswith("g") else "bike"
# Add to registry
new_item: dict = {"id": str(uuid.uuid4()), "name": name, "type": gear_type, "retired": False, "strava_id": gear_id}
registry.append(new_item)
_gear_save(data_dir, registry)
_gear_name_cache[gear_id] = name
return name
for n, meta in enumerate(activities, 1):
name = meta.get("name", "Untitled")
try:
@@ -137,6 +179,11 @@ def strava_sync_iter(
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
continue
streams = fetch_streams(token["access_token"], meta["id"])
# Resolve gear name before converting
gear_id = meta.get("gear_id") or ""
if gear_id:
with contextlib.suppress(Exception):
meta["_gear_name"] = _resolve_gear(gear_id)
if originals_dir is not None:
orig_path = originals_dir / f"{activity_id}.json"
orig_path.write_text(
+268 -36
View File
@@ -14,6 +14,8 @@ from bincio.extract.models import DataPoint, ParsedActivity
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"})
# Standard best-effort distances (km) per sport.
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
"running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195],
@@ -53,6 +55,7 @@ class ComputedMetrics:
max_hr_bpm: Optional[int]
avg_cadence_rpm: Optional[int]
avg_power_w: Optional[int]
np_power_w: Optional[int]
max_power_w: Optional[int]
bbox: Optional[tuple[float, float, float, float]] # min_lon, min_lat, max_lon, max_lat
start_latlng: Optional[tuple[float, float]]
@@ -61,6 +64,8 @@ class ComputedMetrics:
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
best_efforts: Optional[list[list[float]]]
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
climbing_vam_mh: Optional[int] # average VAM on ascending segments only (m/h)
climbing_time_s: Optional[int] # total ascending seconds used to compute VAM
def compute(activity: ParsedActivity) -> ComputedMetrics:
@@ -70,15 +75,19 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
duration_s = _duration(pts)
distance_m, moving_time_s, avg_speed_kmh, max_speed_kmh = _gps_stats(pts)
gain, loss = _elevation(pts, activity.altitude_source)
inferred_source = "strava_export" if activity.strava_id else ""
gain, loss = _elevation(pts, activity.altitude_source, inferred_source)
avg_hr, max_hr = _hr_stats(pts)
avg_cad = _avg_nonnull([p.cadence_rpm for p in pts])
avg_pow = _avg_nonnull([p.power_w for p in pts])
np_pow = _np_power(pts, activity.started_at)
max_pow = _max_nonnull([p.power_w for p in pts])
bbox = _bbox(pts)
start_ll, end_ll = _endpoints(pts)
mmp = compute_mmp(pts, activity.started_at)
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
_vam = compute_vam(pts, activity.started_at, activity.sport)
climbing_vam_mh, climbing_time_s = _vam if _vam else (None, None)
return ComputedMetrics(
distance_m=distance_m,
@@ -92,6 +101,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
max_hr_bpm=max_hr,
avg_cadence_rpm=avg_cad,
avg_power_w=avg_pow,
np_power_w=np_pow,
max_power_w=max_pow,
bbox=bbox,
start_latlng=start_ll,
@@ -99,6 +109,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
mmp=mmp,
best_efforts=best_efforts,
best_climb_m=best_climb_m,
climbing_vam_mh=climbing_vam_mh,
climbing_time_s=climbing_time_s,
)
@@ -158,6 +170,97 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis
return results if results else None
# ── VAM (Velocità Ascensionale Media) ────────────────────────────────────────
def _rolling_mean_ele(data: list[float], win: int) -> list[float]:
"""O(n) rolling mean via prefix sums."""
n = len(data)
prefix = [0.0] * (n + 1)
for i, v in enumerate(data):
prefix[i + 1] = prefix[i] + v
half = win // 2
result = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
result.append((prefix[hi] - prefix[lo]) / (hi - lo))
return result
def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[tuple[int, int]]:
"""Climbing VAM from a dense 1 Hz elevation array.
Accumulates gain and time only on ascending seconds, identified by a 30 s
forward-lookahead on the smoothed elevation signal.
Returns (climbing_vam_mh, climbing_time_s), or None when there is too little
climbing data.
"""
n = len(ele_1hz)
if n < 60:
return None
ele_smooth = _rolling_mean_ele(ele_1hz, 30)
climbing_gain = 0.0
climbing_time = 0
for i in range(n - 1):
look = min(i + 30, n - 1)
if ele_smooth[look] - ele_smooth[i] >= 2.0:
inst = ele_smooth[i + 1] - ele_smooth[i]
if inst > 0:
climbing_gain += inst
climbing_time += 1
if climbing_time >= 60 and climbing_gain >= 5.0:
return round(climbing_gain * 3600.0 / climbing_time), climbing_time
return None
def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
"""Build a dense 1 Hz elevation array from a {t: ele} sparse dict, forward-filling gaps."""
if not sparse:
return None
t_min = min(sparse)
t_max = max(sparse)
if t_max - t_min > 7 * 24 * 3600:
return None
ele_raw: list[Optional[float]] = []
last_known: Optional[float] = None
for t in range(t_min, t_max + 1):
v = sparse.get(t)
if v is not None:
last_known = v
ele_raw.append(last_known)
if sum(1 for e in ele_raw if e is not None) < 60:
return None
first_valid = next((e for e in ele_raw if e is not None), None)
if first_valid is None:
return None
return [e if e is not None else first_valid for e in ele_raw]
def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[tuple[int, int]]:
"""Compute average climbing VAM (m/h) from DataPoints.
Only computed for cycling, running, hiking, walking.
Returns (climbing_vam_mh, climbing_time_s), or None when there is insufficient
climbing data.
"""
if sport not in _VAM_SPORTS:
return None
sparse: dict[int, Optional[float]] = {}
last_t = -1
for p in pts:
t = int((p.timestamp - started_at).total_seconds())
if t < 0 or t == last_t:
continue
sparse[t] = p.elevation_m
last_t = t
ele_1hz = _build_ele_1hz(sparse)
if ele_1hz is None:
return None
return _vam_from_ele_1hz(ele_1hz)
# ── best efforts & best climb ─────────────────────────────────────────────────
def compute_best_efforts(
@@ -178,16 +281,32 @@ def compute_best_efforts(
# Build dense 1 Hz speed (km/h) and elevation (m) arrays with gap zero-filling.
# Zero-filling speed gaps (0 km/h) prevents best-effort windows from spanning
# recording pauses and producing artificially fast times.
# When the device didn't record speed (common in older FIT files), fall back to
# GPS-derived speed: spread the haversine segment speed evenly across the interval
# so the sliding window accumulates the correct distance.
sparse_speed: dict[int, float] = {}
sparse_ele: dict[int, Optional[float]] = {}
last_t = -1
_prev: Optional[DataPoint] = None
for p in pts:
t = int((p.timestamp - started_at).total_seconds())
if t < 0 or t == last_t:
continue
last_t = t
sparse_speed[t] = p.speed_kmh if p.speed_kmh is not None else 0.0
sparse_ele[t] = p.elevation_m
if p.speed_kmh is not None:
sparse_speed[t] = p.speed_kmh
elif (_prev is not None
and _prev.lat is not None and _prev.lon is not None
and p.lat is not None and p.lon is not None):
dt_s = t - last_t
seg_m = _haversine_m(_prev.lat, _prev.lon, p.lat, p.lon)
seg_kmh = (seg_m / dt_s) * 3.6
for slot in range(last_t, t):
sparse_speed[slot] = seg_kmh
else:
sparse_speed[t] = 0.0
last_t = t
_prev = p
if not sparse_speed:
return None, None
@@ -212,7 +331,23 @@ def compute_best_efforts(
best_climb_m: Optional[float] = None
if sport == "cycling":
best_climb_m = _best_climb(ele_1hz)
# Use cumulative device distance as the x-axis so recording pauses
# (where distance doesn't increase) don't create gaps that reset the window.
# Fall back to elapsed-time ordering when no device distance is recorded.
dist_ele = sorted(
(p.distance_m, p.elevation_m)
for p in pts
if p.distance_m is not None and p.elevation_m is not None
)
if not dist_ele:
dist_ele = sorted(
(int((p.timestamp - started_at).total_seconds()), p.elevation_m)
for p in pts
if p.elevation_m is not None
and int((p.timestamp - started_at).total_seconds()) >= 0
)
if len(dist_ele) >= 2:
best_climb_m = _best_climb(dist_ele)
return best_efforts, best_climb_m
@@ -242,32 +377,26 @@ def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Opti
return best_s
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
def _best_climb(pts_sorted: list[tuple[float, float]]) -> Optional[float]:
"""Maximum net elevation gain over any contiguous uphill window (Kadane's).
None samples are treated as breaks between segments — the Kadane window is
reset to 0 at each gap so non-contiguous elevation data is never joined.
Returns None if fewer than two non-None samples exist.
pts_sorted: list of (x, elevation_m) pairs sorted by x, where x is
cumulative distance (m) or elapsed time (s). Using cumulative distance
means recording pauses (x doesn't increase while stopped) don't create
gaps that falsely reset the climbing window.
"""
non_null = sum(1 for e in ele_1hz if e is not None)
if non_null < 2:
if len(pts_sorted) < 2:
return None
max_gain = 0.0
current = 0.0
prev: Optional[float] = None
prev_e = pts_sorted[0][1]
for e in ele_1hz:
if e is None:
# Gap — reset window so we don't bridge the discontinuity
current = 0.0
prev = None
continue
if prev is not None:
current = max(0.0, current + (e - prev))
if current > max_gain:
max_gain = current
prev = e
for _, e in pts_sorted[1:]:
current = max(0.0, current + (e - prev_e))
if current > max_gain:
max_gain = current
prev_e = e
return round(max_gain, 1) if max_gain > 0 else None
@@ -347,33 +476,91 @@ def _duration(pts: list[DataPoint]) -> Optional[int]:
return int((pts[-1].timestamp - pts[0].timestamp).total_seconds())
# Hysteresis thresholds per altitude source.
# Only commit a new elevation when it differs from the last committed value by
# at least this amount, filtering out GPS noise and barometric quantization steps.
_ELEVATION_THRESHOLD: dict[str, float] = {
"barometric": 5.0, # barometric altimeter: smaller steps are real
"gps": 10.0, # GPS altitude: noisier, needs wider dead-band
"unknown": 10.0, # treat unknown as GPS to be conservative
}
def elevation_params(altitude_source: str, source: str = "") -> tuple[int, float]:
"""Return (ma_window_s, threshold_m) for elevation gain/loss computation.
Tuned on 37 activities cross-referenced against Strava-reported elevation:
strava_export — elevation already pre-processed by Strava (smooth 1 m
quantisation, 0 steps > 5 m). Light 5 s MA + 1.0 m
threshold gives avg 2.8 %, std 4.8 %, 34/37 within ±10 %.
barometric — raw barometric altimeter from a FIT file. No smoothing
needed; 1.5 m threshold gives ~0 % error on available data.
gps / unknown — raw GPS or unidentified non-Strava source. Light 5 s MA
+ 1.52.0 m threshold suppresses GPS jitter while keeping
real terrain changes.
"""
if source == "strava_export":
return (5, 1.0)
if altitude_source == "barometric":
return (0, 1.5)
if altitude_source == "gps":
return (5, 2.0)
return (5, 1.5) # unknown non-strava: conservative middle ground
def _ele_moving_average(values: list[float], window: int) -> list[float]:
if window <= 1:
return list(values)
half = window // 2
n = len(values)
cumsum = [0.0] * (n + 1)
for i, v in enumerate(values):
cumsum[i + 1] = cumsum[i] + v
return [
(cumsum[min(n, i + half + 1)] - cumsum[max(0, i - half)])
/ (min(n, i + half + 1) - max(0, i - half))
for i in range(n)
]
def _elevation(
pts: list[DataPoint],
altitude_source: str = "unknown",
source: str = "",
) -> tuple[Optional[float], Optional[float]]:
"""Hysteresis-based elevation accumulation.
Only commits a new elevation when it differs from the last committed value
by at least the source-specific threshold, filtering GPS jitter and
barometric quantization noise that would otherwise inflate the gain figure.
Applies a short moving-average pre-smoothing then commits a new elevation
level only when it differs from the last committed value by at least the
source-specific threshold. Parameters are chosen per data source via
:func:`elevation_params`.
"""
elevations = [p.elevation_m for p in pts if p.elevation_m is not None]
if len(elevations) < 2:
return None, None
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
ma_window, threshold = elevation_params(altitude_source, source)
# Some devices (e.g. Apple Watch) record exactly 0.0 for the initial samples
# while waiting for barometric/GPS lock, then jump to the real altitude.
# Only activate when there are at least 2 consecutive near-zero leading
# values — a single 0.0 is a legitimate sea-level starting point.
start = 0
if abs(elevations[0]) < 0.5:
n_leading = 0
for e in elevations:
if abs(e) < 0.5:
n_leading += 1
else:
break
if n_leading > 1:
for i, e in enumerate(elevations):
if abs(e) > threshold:
start = i
break
elevations = _ele_moving_average(elevations[start:], ma_window)
gain = loss = 0.0
committed = elevations[0]
for e in elevations[1:]:
# Skip near-zero values that appear mid-recording while we are at a
# significant elevation — these are sensor dropouts (device lost GPS/
# barometric lock), not genuine sea-level crossings.
if abs(e) < 1.0 and abs(committed) > threshold:
continue
diff = e - committed
if abs(diff) >= threshold:
if diff > 0:
@@ -401,6 +588,50 @@ def _max_nonnull(values: list) -> Optional[int]:
return max(v) if v else None
def _np_power(pts: list[DataPoint], started_at: datetime) -> Optional[int]:
"""Normalized power (Coggan method): 30 s rolling average → 4th power → mean → 4th root.
Uses a dense 1 Hz series (gaps zero-filled) identical to the MMP pipeline.
Returns None when the activity has no power data or is shorter than 30 s.
"""
sparse: dict[int, int] = {}
last_t = -1
for p in pts:
t = int((p.timestamp - started_at).total_seconds())
if t < 0 or t == last_t:
continue
last_t = t
if p.power_w is not None:
sparse[t] = p.power_w
if len(sparse) < 2:
return None
t_min, t_max = min(sparse), max(sparse)
if t_max - t_min > 7 * 24 * 3600:
return None
power_1hz = [sparse.get(t, 0) for t in range(t_min, t_max + 1)]
n = len(power_1hz)
win = 30
if n < win:
return None
# 30 s centred rolling mean, then raise to 4th power
half = win // 2
total = sum(power_1hz[:win])
fourth_powers: list[float] = []
for i in range(half, n - half):
avg = total / win
fourth_powers.append(avg ** 4)
if i + half + 1 < n:
total += power_1hz[i + half + 1] - power_1hz[i - half]
if not fourth_powers:
return None
return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25))
def _bbox(pts: list[DataPoint]) -> Optional[tuple[float, float, float, float]]:
lats = [p.lat for p in pts if p.lat is not None]
lons = [p.lon for p in pts if p.lon is not None]
@@ -424,7 +655,8 @@ def _empty() -> ComputedMetrics:
elevation_gain_m=None, elevation_loss_m=None,
avg_speed_kmh=None, max_speed_kmh=None,
avg_hr_bpm=None, max_hr_bpm=None,
avg_cadence_rpm=None, avg_power_w=None, max_power_w=None,
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
bbox=None, start_latlng=None, end_latlng=None,
mmp=None, best_efforts=None, best_climb_m=None,
climbing_vam_mh=None, climbing_time_s=None,
)
+19 -1
View File
@@ -5,9 +5,27 @@ It gets fed into metrics computation and the BAS JSON writer.
"""
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
# Any timestamp before this is almost certainly an uninitialised sensor value
# (epoch 0, FIT "no-data" sentinel, RTC not yet synced, etc.).
_MIN_TIMESTAMP = datetime(2000, 1, 1, tzinfo=timezone.utc)
def strip_bogus_leading_points(points: list["DataPoint"]) -> list["DataPoint"]:
"""Drop leading points whose timestamp predates the year 2000.
FIT files occasionally emit a record with timestamp=0 (or another
pre-2000 value) as an uninitialised sentinel before the real data
begins. Keeping such a point as points[0] produces a 1970 start
time and an absurdly large duration_s.
"""
i = 0
while i < len(points) and points[i].timestamp < _MIN_TIMESTAMP:
i += 1
return points[i:]
@dataclass
class DataPoint:
+4 -1
View File
@@ -6,7 +6,7 @@ from typing import Any
import fitdecode
from bincio.extract.models import DataPoint, LapData, ParsedActivity
from bincio.extract.models import DataPoint, LapData, ParsedActivity, strip_bogus_leading_points
from bincio.extract.sport import normalise_sport
@@ -101,6 +101,7 @@ class FitParser:
)
)
points = strip_bogus_leading_points(points)
if not points:
raise ValueError(f"No record messages found in {path.name}")
@@ -146,11 +147,13 @@ def _normalise_sub_sport(value: Any) -> str | None:
mapping = {
"generic": None, # FIT default — unspecified
"virtual_activity": "indoor",
"virtual": "indoor",
"road": "road",
"mountain": "mountain",
"gravel_cycling": "gravel",
"cyclocross": "gravel",
"indoor_cycling": "indoor",
"treadmill": "indoor",
"trail": "trail",
"track": "track",
"cross_country_skiing": "nordic",
+2 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import gpxpy
import gpxpy.gpx
from bincio.extract.models import DataPoint, ParsedActivity
from bincio.extract.models import DataPoint, ParsedActivity, strip_bogus_leading_points
from bincio.extract.parsers.base import BaseParser
from bincio.extract.sport import normalise_sport, normalise_sub_sport
@@ -38,6 +38,7 @@ class GpxParser(BaseParser):
_apply_extensions(pt, dp)
points.append(dp)
points = strip_bogus_leading_points(points)
if not points:
raise ValueError(f"No trackpoints found in {path.name}")
+2 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from lxml import etree
from bincio.extract.models import DataPoint, ParsedActivity
from bincio.extract.models import DataPoint, ParsedActivity, strip_bogus_leading_points
from bincio.extract.sport import normalise_sport, normalise_sub_sport
_NS_HTTP = {
@@ -73,6 +73,7 @@ class TcxParser:
)
points.append(dp)
points = strip_bogus_leading_points(points)
if not points:
raise ValueError(f"No trackpoints found in {path.name}")
+8
View File
@@ -99,6 +99,14 @@ _SUB_SPORT_MAPPING: dict[str, str] = {
BAS_SPORTS = {"cycling", "running", "hiking", "walking", "swimming", "skiing", "other"}
# Valid sub_sport values per sport, in display order.
SUB_SPORTS: dict[str, list[str]] = {
"cycling": ["road", "mountain", "gravel", "indoor"],
"running": ["trail", "track", "indoor"],
"swimming": ["open_water", "pool"],
"skiing": ["nordic", "alpine"],
}
def _normalise_key(raw: object) -> str:
key = str(raw).strip()
+10
View File
@@ -150,6 +150,15 @@ def fetch_streams(access_token: str, activity_id: int) -> dict:
return result if isinstance(result, dict) else {}
def fetch_gear(access_token: str, gear_id: str) -> dict:
"""Fetch gear details for a single gear item. Returns {} on error."""
try:
result = _api_get(f"{_API_BASE}/gear/{gear_id}", access_token)
return result if isinstance(result, dict) else {}
except StravaError:
return {}
# ── Model conversion ───────────────────────────────────────────────────────────
def strava_meta_to_partial(meta: dict) -> ParsedActivity:
@@ -215,4 +224,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
description=meta.get("description") or None,
strava_id=str(meta["id"]),
privacy="unlisted" if is_private else "public",
gear=meta.get("_gear_name") or None,
)
+2
View File
@@ -115,6 +115,8 @@ def strava_zip_iter(
parsed.description = meta_row["Activity Description"].strip()
if not parsed.strava_id and meta_row.get("Activity ID"):
parsed.strava_id = meta_row["Activity ID"].strip()
if not parsed.gear and meta_row.get("Gear"):
parsed.gear = meta_row["Gear"].strip()
if originals_dir is not None:
import shutil
+100
View File
@@ -2,11 +2,104 @@
the BAS timeseries object (parallel arrays)."""
from datetime import datetime
from math import atan2, cos, radians, sin, sqrt
from typing import Optional
from bincio.extract.models import DataPoint
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in metres between two GPS points."""
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
return 2 * 6_371_000.0 * atan2(sqrt(a), sqrt(1 - a))
_SPATIAL_RESOLUTION_M = 10.0
def _spatial_downsample(
sampled: list[DataPoint],
resolution_m: float = _SPATIAL_RESOLUTION_M,
) -> list[DataPoint]:
"""Keep one sample per `resolution_m` of cumulative distance traveled.
Distance source priority:
1. GPS haversine (lat/lon present on both consecutive points)
2. speed_kmh × Δt (fallback when GPS absent or gapped)
If neither source is available (indoor, no speed data), returns `sampled`
unchanged. Always retains the first and last points.
"""
if len(sampled) < 2:
return sampled
has_gps = any(p.lat is not None and p.lon is not None for p in sampled)
has_speed = any(p.speed_kmh is not None for p in sampled)
if not has_gps and not has_speed:
return sampled
result: list[DataPoint] = [sampled[0]]
cum_dist = 0.0
last_kept = 0.0
prev_speed = 0.0
for i in range(1, len(sampled)):
prev, cur = sampled[i - 1], sampled[i]
dt = (cur.timestamp - prev.timestamp).total_seconds()
if (has_gps
and prev.lat is not None and prev.lon is not None
and cur.lat is not None and cur.lon is not None):
dist_m = _haversine_m(prev.lat, prev.lon, cur.lat, cur.lon)
else:
spd = cur.speed_kmh if cur.speed_kmh is not None else prev_speed
dist_m = (spd / 3.6) * max(dt, 0)
if cur.speed_kmh is not None:
prev_speed = cur.speed_kmh
cum_dist += dist_m
if cum_dist - last_kept >= resolution_m:
result.append(cur)
last_kept = cum_dist
if result[-1] is not sampled[-1]:
result.append(sampled[-1])
return result
def _gps_speed_kmh(
lat_vals: list[Optional[float]],
lon_vals: list[Optional[float]],
ts_vals: list[int],
) -> list[Optional[float]]:
"""Compute speed (km/h) from consecutive GPS coordinates via haversine.
Applies a 5-point centred moving-average to reduce GPS noise.
"""
n = len(ts_vals)
raw: list[Optional[float]] = [None] * n
for i in range(1, n):
la0, lo0 = lat_vals[i - 1], lon_vals[i - 1]
la1, lo1 = lat_vals[i], lon_vals[i]
dt = ts_vals[i] - ts_vals[i - 1]
if la0 is None or lo0 is None or la1 is None or lo1 is None or dt <= 0:
continue
d_km = _haversine_m(la0, lo0, la1, lo1) / 1000.0
raw[i] = d_km / dt * 3600.0
# 5-point centred moving average (skip None anchors)
half = 2
smoothed: list[Optional[float]] = [None] * n
for i in range(n):
vals = [raw[j] for j in range(max(0, i - half), min(n, i + half + 1)) if raw[j] is not None]
if vals:
smoothed[i] = round(sum(vals) / len(vals), 2)
return smoothed
def build_timeseries(
points: list[DataPoint],
started_at: datetime,
@@ -35,11 +128,18 @@ def build_timeseries(
sampled.append(p)
last_t = t
sampled = _spatial_downsample(sampled)
ts_vals = [int((p.timestamp - started_at).total_seconds()) for p in sampled]
lat_vals = [round(p.lat, 7) if p.lat is not None else None for p in sampled] if include_gps else None
lon_vals = [round(p.lon, 7) if p.lon is not None else None for p in sampled] if include_gps else None
ele_vals = [round(p.elevation_m, 1) if p.elevation_m is not None else None for p in sampled]
spd_vals = [round(p.speed_kmh, 2) if p.speed_kmh is not None else None for p in sampled]
# Derive speed from GPS when the device didn't record per-second speed.
if include_gps and lat_vals and lon_vals and all(v is None for v in spd_vals):
spd_vals = _gps_speed_kmh(lat_vals, lon_vals, ts_vals)
hr_vals = [p.hr_bpm for p in sampled]
cad_vals = [p.cadence_rpm for p in sampled]
pwr_vals = [p.power_w for p in sampled]
+33 -4
View File
@@ -10,6 +10,18 @@ from bincio.extract.models import LapData, ParsedActivity
from bincio.extract.simplify import build_geojson, preview_coords
from bincio.extract.timeseries import build_timeseries
# Titles that reliably identify indoor/virtual activities regardless of sub_sport metadata.
# Strava imports from Zwift and FTP-builder platforms lose sub_sport on export.
_INDOOR_TITLE_RE = re.compile(
r'\b(zwift|ftp[\s\-]builder|turbo[\s\-]?trainer|rodillo)\b',
re.IGNORECASE,
)
def _infer_indoor_title(title: str) -> bool:
"""Return True if the title reliably identifies an indoor/virtual activity."""
return bool(_INDOOR_TITLE_RE.search(title))
def make_activity_id(activity: ParsedActivity) -> str:
"""Generate a BAS activity ID from started_at + optional title slug.
@@ -79,6 +91,7 @@ def write_activity(
"max_hr_bpm": metrics.max_hr_bpm,
"avg_cadence_rpm": metrics.avg_cadence_rpm,
"avg_power_w": metrics.avg_power_w,
"np_power_w": metrics.np_power_w,
"max_power_w": metrics.max_power_w,
"gear": activity.gear,
"device": activity.device,
@@ -88,6 +101,8 @@ def write_activity(
"mmp": metrics.mmp,
"best_efforts": metrics.best_efforts,
"best_climb_m": metrics.best_climb_m,
"climbing_vam_mh": metrics.climbing_vam_mh,
"climbing_time_s": metrics.climbing_time_s,
"laps": [_serialise_lap(lap) for lap in activity.laps],
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
"source": source,
@@ -244,6 +259,9 @@ def build_summary(
"mmp": metrics.mmp,
"best_efforts": metrics.best_efforts,
"best_climb_m": metrics.best_climb_m,
"climbing_vam_mh": metrics.climbing_vam_mh,
"climbing_time_s": metrics.climbing_time_s,
"gear": activity.gear,
"source": _infer_source(activity),
"privacy": privacy,
"detail_url": f"activities/{activity_id}.json",
@@ -276,9 +294,16 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
best[d] = w
return [[d, w] for d, w in sorted(best.items())]
all_mmps = [s["mmp"] for s in summaries if s.get("mmp")]
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_365]
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_90]
_INDOOR_SUB_SPORTS = {"indoor", "treadmill", "virtual"}
def _is_outdoor(s: dict) -> bool:
if s.get("sub_sport") in _INDOOR_SUB_SPORTS:
return False
return not _infer_indoor_title(s.get("title") or "")
all_mmps = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s)]
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
# ── Personal records aggregation ──────────────────────────────────────────
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
@@ -289,6 +314,8 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
best_climb: list[dict] = [] # top 10 best climbs for cycling
for s in summaries:
if not _is_outdoor(s):
continue
sport = s.get("sport", "other")
act_id = s.get("id", "")
started = s.get("started_at", "")
@@ -355,7 +382,9 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
**athlete_config,
}
(output_dir / "athlete.json").write_text(
json.dumps(athlete, indent=2, ensure_ascii=False)
json.dumps(athlete, indent=2, ensure_ascii=False),
encoding="utf-8",
errors="replace",
)
+27 -1
View File
@@ -343,9 +343,24 @@ def sync(
owner = index_data.get("owner", {})
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
# ── build timestamp-prefix index of existing activities ──────────────────
# Maps "YYYY-MM-DDTHHMMSSZ" → first matching activity filename (stem).
# Used to detect when a FIT-file upload already covers a Strava activity.
acts_dir = output_dir / "activities"
existing_ts: set[str] = set()
if acts_dir.is_dir():
for p in acts_dir.iterdir():
if p.suffix == ".json" and not p.name.endswith(".timeseries.json"):
stem = p.stem
# ID format: YYYY-MM-DDTHHMMSSZ[-optional-slug]
z_pos = stem.find("Z")
if z_pos != -1:
existing_ts.add(stem[: z_pos + 1])
# ── import loop ───────────────────────────────────────────────────────────
errors: list[tuple[str, str]] = []
imported = 0
skipped_existing = 0
with Progress(
TextColumn("[progress.description]{task.description}"),
@@ -362,11 +377,20 @@ def sync(
try:
streams = client.get_streams(act["id"])
parsed = _strava_to_parsed(act, streams)
# Skip if any activity already exists for the same start time
ts_part = parsed.started_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
if ts_part in existing_ts:
imported_ids.add(strava_id)
skipped_existing += 1
continue
metrics = compute(parsed)
metrics = _patch_from_summary(metrics, act)
act_id = make_activity_id(parsed)
write_activity(parsed, metrics, output_dir, privacy="public")
summaries[act_id] = build_summary(parsed, metrics, act_id, "public")
existing_ts.add(ts_part)
imported_ids.add(strava_id)
imported += 1
except Exception as exc:
@@ -384,9 +408,11 @@ def sync(
from bincio.render.merge import merge_all
merge_all(output_dir)
skipped_msg = f", skipped [bold]{skipped_existing}[/bold] already covered by local uploads" if skipped_existing else ""
console.print(
f"\n[green]Done.[/green] "
f"Imported [bold]{imported}[/bold] activities, "
f"Imported [bold]{imported}[/bold] activities"
f"{skipped_msg}, "
f"errors [bold]{len(errors)}[/bold]."
)
if errors:
+508 -1
View File
@@ -92,6 +92,225 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
console.print("No sidecars found — _merged/ dirs mirror extracted data.")
def _bake_tracks(data: Path, handle: str | None = None) -> None:
"""Bake tracks.json for one user or all users."""
from bincio.explore import bake_tracks
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
try:
n = bake_tracks(user_dir.name, data)
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
except Exception as exc:
console.print(f" [yellow]{user_dir.name}[/yellow]: bake_tracks failed: {exc}")
def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None:
"""Rebuild athlete.json for one user or all users.
Reads raw index.json summaries, applies any sidecar edits in-memory (so
overrides like sub_sport: indoor are respected), then calls write_athlete_json.
"""
import json
from bincio.extract.writer import write_athlete_json
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
targets = [data / handle] if handle else _user_dirs(data)
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
for user_dir in targets:
index_path = user_dir / "index.json"
if not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
summaries = index_data.get("activities", [])
if not summaries:
continue
# Apply sidecar edits so overrides (e.g. sub_sport: indoor) are visible
# to write_athlete_json without stripping best_efforts/best_climb_m.
edits_dir = user_dir / "edits"
if edits_dir.exists():
sidecars: dict[str, dict] = {}
for sc_path in edits_dir.glob("*.md"):
try:
fm, _ = parse_sidecar(sc_path)
sidecars[sc_path.stem] = fm
except Exception:
pass
if sidecars:
summaries = [
_apply_sidecar_summary(s, sidecars[s["id"]])
if s.get("id") in sidecars else s
for s in summaries
]
athlete_config: dict = {}
athlete_path = user_dir / "athlete.json"
if athlete_path.exists():
try:
existing = json.loads(athlete_path.read_text(encoding="utf-8"))
athlete_config = {k: v for k, v in existing.items() if k not in _COMPUTED}
except Exception:
pass
write_athlete_json(summaries, user_dir, athlete_config)
except Exception as exc:
console.print(f" [yellow]{user_dir.name}[/yellow]: rebuild_athlete failed: {exc}")
def _recompute_best_climbs(data: Path, handle: str | None = None) -> None:
"""Recompute best_climb_m for all cycling activities from their stored timeseries.
Updates activities/*.json and index.json in-place. Run this once after
upgrading the climb algorithm to fix values computed by the old code.
"""
import json
from bincio.extract.metrics import _best_climb
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
index_path = user_dir / "index.json"
if not acts_dir.exists() or not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except Exception:
continue
updated = 0
for act_path in acts_dir.glob("*.json"):
if act_path.stem.endswith((".timeseries", ".geojson")):
continue
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
if not ts_path.exists():
continue
try:
detail = json.loads(act_path.read_text(encoding="utf-8"))
if detail.get("sport") != "cycling":
continue
ts = json.loads(ts_path.read_text(encoding="utf-8"))
t_vals = ts.get("t", [])
e_vals = ts.get("elevation_m", [])
pairs = sorted(
(t, e) for t, e in zip(t_vals, e_vals) if e is not None
)
if len(pairs) < 2:
continue
new_val = _best_climb(pairs)
if new_val == detail.get("best_climb_m"):
continue
detail["best_climb_m"] = new_val
act_path.write_text(
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
)
act_id = act_path.stem
for s in index_data.get("activities", []):
if s.get("id") == act_id:
s["best_climb_m"] = new_val
break
updated += 1
except Exception:
pass
if updated:
index_path.write_text(
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
)
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} climb(s) recomputed")
def _recompute_elevation(data: Path, handle: str | None = None) -> None:
"""Recompute elevation_gain_m / elevation_loss_m for all activities.
Applies the dropout-skip fix (near-zero values mid-recording) so stored
values computed by older code are corrected. Updates activities/*.json
and index.json in-place.
"""
import json
from bincio.extract.metrics import _ELEVATION_THRESHOLD
def _accumulate(elevations: list[float], altitude_source: str) -> tuple[float, float]:
if len(elevations) < 2:
return 0.0, 0.0
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
# Skip leading near-zeros (device acquiring lock)
start = 0
if abs(elevations[0]) < 0.5:
n_leading = sum(1 for e in elevations if abs(e) < 0.5)
if n_leading > 1:
for i, e in enumerate(elevations):
if abs(e) > threshold:
start = i
break
gain = loss = 0.0
committed = elevations[start]
for e in elevations[start + 1:]:
if abs(e) < 1.0 and abs(committed) > threshold:
continue
diff = e - committed
if abs(diff) >= threshold:
if diff > 0:
gain += diff
else:
loss += diff
committed = e
return gain, loss
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
index_path = user_dir / "index.json"
if not acts_dir.exists() or not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except Exception:
continue
updated = 0
for act_path in acts_dir.glob("*.json"):
if act_path.stem.endswith((".timeseries", ".geojson")):
continue
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
if not ts_path.exists():
continue
try:
detail = json.loads(act_path.read_text(encoding="utf-8"))
ts = json.loads(ts_path.read_text(encoding="utf-8"))
raw = ts.get("elevation_m", [])
elevations = [e for e in raw if e is not None]
if len(elevations) < 2:
continue
alt_src = detail.get("altitude_source", "unknown")
new_gain, new_loss = _accumulate(elevations, alt_src)
new_gain_r = round(new_gain, 1) if new_gain else None
new_loss_r = round(abs(new_loss), 1) if new_loss else None
if (new_gain_r == detail.get("elevation_gain_m") and
new_loss_r == detail.get("elevation_loss_m")):
continue
detail["elevation_gain_m"] = new_gain_r
detail["elevation_loss_m"] = new_loss_r
act_path.write_text(
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
)
act_id = act_path.stem
for s in index_data.get("activities", []):
if s.get("id") == act_id:
s["elevation_gain_m"] = new_gain_r
s["elevation_loss_m"] = new_loss_r
break
updated += 1
except Exception:
pass
if updated:
index_path.write_text(
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
)
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} elevation(s) recomputed")
def _write_root_manifest(data: Path) -> None:
"""Rewrite the root index.json shard manifest from current user dirs."""
import json
@@ -158,6 +377,236 @@ def _link_data(site: Path, data: Path) -> None:
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
def _recompute_vam(data: Path, handle: str | None = None) -> None:
"""Recompute climbing_vam_mh and climbing_time_s for all activities.
Reads the stored timeseries, re-runs the VAM algorithm, and patches both
activities/*.json and index.json in-place. Run once after adding
climbing_time_s to the schema so the NerdCorner VAM chart can filter short
climbs and opacity-encode confidence.
"""
import json
from bincio.extract.metrics import _VAM_SPORTS, _build_ele_1hz, _vam_from_ele_1hz
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
index_path = user_dir / "index.json"
if not acts_dir.exists() or not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except Exception:
continue
index_by_id = {s["id"]: s for s in index_data.get("activities", [])}
updated = 0
for act_path in sorted(acts_dir.glob("*.json")):
if act_path.stem.endswith((".timeseries", ".geojson")):
continue
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
if not ts_path.exists():
continue
try:
detail = json.loads(act_path.read_text(encoding="utf-8"))
if detail.get("sport") not in _VAM_SPORTS:
continue
ts = json.loads(ts_path.read_text(encoding="utf-8"))
t_vals = ts.get("t", [])
e_vals = ts.get("elevation_m", [])
sparse: dict[int, float | None] = {int(t): e for t, e in zip(t_vals, e_vals)}
ele_1hz = _build_ele_1hz(sparse)
result = _vam_from_ele_1hz(ele_1hz) if ele_1hz else None
new_vam, new_climb_t = result if result else (None, None)
if (new_vam == detail.get("climbing_vam_mh") and
new_climb_t == detail.get("climbing_time_s")):
continue
detail["climbing_vam_mh"] = new_vam
detail["climbing_time_s"] = new_climb_t
act_path.write_text(
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
)
summary = index_by_id.get(act_path.stem)
if summary is not None:
summary["climbing_vam_mh"] = new_vam
summary["climbing_time_s"] = new_climb_t
updated += 1
except Exception:
pass
if updated:
index_path.write_text(
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
)
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} VAM(s) recomputed")
def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
"""Copy climbing_vam_mh from detail JSONs into index.json summaries.
Needed once after the vam_curve→climbing_vam_mh-in-summary migration.
"""
import json
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
index_path = user_dir / "index.json"
if not acts_dir.exists() or not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except Exception:
continue
updated = 0
for s in index_data.get("activities", []):
if "climbing_vam_mh" in s:
continue # already backfilled
act_path = acts_dir / f"{s['id']}.json"
if not act_path.exists():
continue
try:
detail = json.loads(act_path.read_text(encoding="utf-8"))
vam = detail.get("climbing_vam_mh")
if vam is not None:
s["climbing_vam_mh"] = vam
updated += 1
except Exception:
pass
if updated:
index_path.write_text(
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
)
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated")
def _backfill_speed(data: Path, handle: str | None = None) -> None:
"""Compute GPS-derived speed for timeseries files where speed_kmh is all null.
Reads each *.timeseries.json, fills speed_kmh from haversine distances when
the device did not record per-second speed, and writes the file back.
"""
import json
from bincio.extract.timeseries import _gps_speed_kmh
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
if not acts_dir.exists():
continue
updated = 0
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
try:
ts = json.loads(ts_path.read_text(encoding="utf-8"))
except Exception:
continue
spd = ts.get("speed_kmh", [])
if not spd or any(v is not None for v in spd):
continue # already has speed data
lat_vals = ts.get("lat") or []
lon_vals = ts.get("lon") or []
t_vals = ts.get("t") or []
if not lat_vals or not lon_vals or not t_vals:
continue
ts["speed_kmh"] = _gps_speed_kmh(lat_vals, lon_vals, t_vals)
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
updated += 1
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} timeseries updated with GPS speed")
def _downsample_timeseries(data: Path, handle: str | None = None) -> None:
"""Apply 10 m spatial downsampling to all stored timeseries files in activities/.
Reads the parallel JSON arrays, computes which indices to keep using the
same distance logic as _spatial_downsample, slices every channel, and
writes the file back. Run bincio render --no-build afterward so _merge_edits
regenerates _merged/ from the smaller source files.
"""
import json
from bincio.extract.timeseries import _haversine_m, _SPATIAL_RESOLUTION_M
_CHANNELS = ("t", "lat", "lon", "elevation_m", "speed_kmh",
"hr_bpm", "cadence_rpm", "power_w", "temperature_c")
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
if not acts_dir.exists():
continue
updated = skipped = 0
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
try:
ts = json.loads(ts_path.read_text(encoding="utf-8"))
except Exception:
continue
t_vals = ts.get("t") or []
lat_vals = ts.get("lat") or []
lon_vals = ts.get("lon") or []
spd_vals = ts.get("speed_kmh") or []
n = len(t_vals)
if n < 2:
skipped += 1
continue
has_gps = any(v is not None for v in lat_vals)
has_speed = any(v is not None for v in spd_vals)
if not has_gps and not has_speed:
skipped += 1
continue
kept: list[int] = [0]
cum_dist = last_kept = prev_speed = 0.0
for i in range(1, n):
dt = t_vals[i] - t_vals[i - 1]
la0 = lat_vals[i - 1] if lat_vals else None
lo0 = lon_vals[i - 1] if lon_vals else None
la1 = lat_vals[i] if lat_vals else None
lo1 = lon_vals[i] if lon_vals else None
if (has_gps and la0 is not None and lo0 is not None
and la1 is not None and lo1 is not None):
dist_m = _haversine_m(la0, lo0, la1, lo1)
else:
spd = (spd_vals[i] if spd_vals and spd_vals[i] is not None
else prev_speed)
dist_m = (spd / 3.6) * max(dt, 0)
if spd_vals and spd_vals[i] is not None:
prev_speed = spd_vals[i]
cum_dist += dist_m
if cum_dist - last_kept >= _SPATIAL_RESOLUTION_M:
kept.append(i)
last_kept = cum_dist
if kept[-1] != n - 1:
kept.append(n - 1)
if len(kept) >= n:
skipped += 1
continue # already sparse (very short / indoor / rest-stop heavy)
for key in _CHANNELS:
ch = ts.get(key)
if ch:
ts[key] = [ch[i] for i in kept]
ts_path.write_text(
json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8"
)
updated += 1
console.print(
f" [cyan]{user_dir.name}[/cyan]: "
f"{updated} downsampled, {skipped} skipped (indoor / short / already sparse)"
)
@click.command()
@click.option("--config", "config_path", default=None,
help="Path to extract_config.yaml (reads output.dir from it).")
@@ -175,6 +624,24 @@ def _link_data(site: Path, data: Path) -> None:
help="(Multi-user) Incrementally re-merge one user's shard only.")
@click.option("--no-build", "no_build", is_flag=True,
help="Skip the Astro build step (just merge sidecars and update manifests).")
@click.option("--recompute-climbs", "recompute_climbs", is_flag=True,
help="Recompute best_climb_m for all cycling activities from stored timeseries "
"(run once after upgrading the climb algorithm).")
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
"(run once after upgrading the dropout-skip fix).")
@click.option("--recompute-vam", "recompute_vam", is_flag=True,
help="Recompute climbing_vam_mh and climbing_time_s for all activities from stored "
"timeseries (run once after adding climbing_time_s to the schema).")
@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True,
help="Copy climbing_vam_mh from detail JSONs into index.json summaries "
"(run once after the VAM curve → summary migration).")
@click.option("--backfill-speed", "backfill_speed", is_flag=True,
help="Compute GPS-derived speed for timeseries where the device didn't record "
"per-second speed (run once to enable speed map coloring on older activities).")
@click.option("--downsample-timeseries", "downsample_timeseries", is_flag=True,
help="Apply 10 m spatial downsampling to all stored timeseries files "
"(run once after deploying the downsampling code).")
def render(
config_path: Optional[str],
data_dir: Optional[str],
@@ -184,6 +651,12 @@ def render(
deploy: Optional[str],
handle: Optional[str],
no_build: bool,
recompute_climbs: bool,
recompute_elevation: bool,
recompute_vam: bool,
backfill_vam_summary: bool,
backfill_speed: bool,
downsample_timeseries: bool,
) -> None:
"""Build (or serve) the BincioActivity static site from a BAS data store."""
@@ -193,7 +666,33 @@ def render(
console.print(f"Site: [cyan]{site}[/cyan]")
console.print(f"Data: [cyan]{data}[/cyan]")
if recompute_climbs:
console.print("Recomputing best climbs from timeseries…")
_recompute_best_climbs(data, handle=handle)
if recompute_elevation:
console.print("Recomputing elevation gain/loss from timeseries…")
_recompute_elevation(data, handle=handle)
if recompute_vam:
console.print("Recomputing VAM and climbing time from timeseries…")
_recompute_vam(data, handle=handle)
if backfill_vam_summary:
console.print("Backfilling climbing_vam_mh into summaries…")
_backfill_vam_summary(data, handle=handle)
if backfill_speed:
console.print("Backfilling GPS-derived speed into timeseries…")
_backfill_speed(data, handle=handle)
if downsample_timeseries:
console.print("Applying spatial downsampling to timeseries…")
_downsample_timeseries(data, handle=handle)
_merge_edits(data, handle=handle)
_rebuild_athlete_json(data, handle=handle)
_bake_tracks(data, handle=handle)
_write_root_manifest(data)
if no_build:
@@ -201,15 +700,23 @@ def render(
return
_ensure_npm(site)
_link_data(site, data)
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
if serve:
# Dev server needs to serve /data/ files at runtime from public/
_link_data(site, data)
console.print("Starting [cyan]astro dev[/cyan]…")
subprocess.run(["npm", "run", "dev"], cwd=site, env=env)
return
# Production build: BINCIO_DATA_DIR is already set so manifest.ts reads
# data directly; remove any leftover public/data symlink so Astro doesn't
# copy the full data directory (9+ GB) into dist/.
public_data = site / "public" / "data"
if public_data.is_symlink():
public_data.unlink()
# Build
cmd = ["npm", "run", "build"]
if out_dir:
+121 -34
View File
@@ -21,6 +21,31 @@ import yaml
# Per-user-directory lock so concurrent upload requests and the dev file-watcher
# cannot run merge_all simultaneously on the same directory.
def _fix_surrogates(obj: object) -> object:
"""Recursively replace surrogate pairs in strings with proper Unicode code points.
Surrogate pairs (U+D800U+DFFF) are valid in Python str but not in UTF-8.
They typically arise when emoji from UTF-16-encoded sources (Strava, some FIT
devices) are decoded incorrectly. encode/decode via utf-16 with surrogatepass
reconstructs the intended characters.
"""
if isinstance(obj, str):
try:
obj.encode("utf-8")
return obj
except UnicodeEncodeError:
return obj.encode("utf-16", "surrogatepass").decode("utf-16")
if isinstance(obj, dict):
return {k: _fix_surrogates(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_fix_surrogates(v) for v in obj]
return obj
def _dumps(obj: object) -> str:
return json.dumps(_fix_surrogates(obj), indent=2, ensure_ascii=False)
_merge_locks: dict[str, threading.Lock] = {}
_merge_locks_mu = threading.Lock()
@@ -44,8 +69,9 @@ def parse_sidecar(path: Path) -> tuple[dict, str]:
return {}, text.strip()
def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
def apply_sidecar(detail: dict, fm: dict, body: str, *, download_disabled_default: bool = False) -> dict:
"""Apply sidecar overrides to a detail JSON dict. Returns a modified copy."""
from bincio.extract.writer import _infer_indoor_title
d = dict(detail)
d.setdefault("custom", {})
d["custom"] = dict(d["custom"]) # don't mutate original
@@ -54,6 +80,11 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
d["title"] = str(fm["title"])
if "sport" in fm:
d["sport"] = str(fm["sport"])
if "sub_sport" in fm:
d["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None
# Infer indoor from title when sub_sport is still absent after sidecar
if not d.get("sub_sport") and _infer_indoor_title(d.get("title") or ""):
d["sub_sport"] = "indoor"
if "gear" in fm:
d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear")
if body:
@@ -66,12 +97,19 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
d["privacy"] = "unlisted" if fm["private"] else detail.get("privacy", "public")
if "hide_stats" in fm:
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])]
dd = fm.get("download_disabled") # True, False, or None (absent)
if dd is True:
d["download_disabled"] = True
elif dd is None and download_disabled_default:
d["download_disabled"] = True
# dd is False → explicit per-activity opt-in, leave unset
return d
def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
"""Apply sidecar overrides to an index summary entry."""
from bincio.extract.writer import _infer_indoor_title
s = dict(summary)
s.setdefault("custom", {})
s["custom"] = dict(s["custom"])
@@ -80,10 +118,17 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
s["title"] = str(fm["title"])
if "sport" in fm:
s["sport"] = str(fm["sport"])
if "sub_sport" in fm:
s["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None
if "gear" in fm:
s["gear"] = str(fm["gear"]) if fm["gear"] else s.get("gear")
if "highlight" in fm:
s["custom"]["highlight"] = bool(fm["highlight"])
if "private" in fm:
s["privacy"] = "unlisted" if fm["private"] else summary.get("privacy", "public")
# Infer indoor from title when sub_sport is still absent
if not s.get("sub_sport") and _infer_indoor_title(s.get("title") or ""):
s["sub_sport"] = "indoor"
return s
@@ -127,6 +172,12 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
)
needs_merge = has_sidecar or bool(image_files)
# Also need a real file (not symlink) when title inference would change sub_sport
if not needs_merge and not has_sidecar:
from bincio.extract.writer import _infer_indoor_title
_peek = json.loads(src.read_text(encoding="utf-8"))
if not _peek.get("sub_sport") and _infer_indoor_title(_peek.get("title") or ""):
needs_merge = True
# Symlink the timeseries file (never merged — always points to the extract output)
ts_src = acts_dir / f"{activity_id}.timeseries.json"
@@ -141,14 +192,17 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
dest.unlink()
if needs_merge:
detail = json.loads(src.read_text(encoding="utf-8"))
detail = locals().get("_peek") or json.loads(src.read_text(encoding="utf-8"))
if has_sidecar:
fm, body = parse_sidecar(sidecar_path) # type: ignore[arg-type]
detail = apply_sidecar(detail, fm, body)
else:
# No sidecar — still apply title inference
detail = apply_sidecar(detail, {}, "")
if image_files:
detail["custom"] = dict(detail.get("custom") or {})
detail["custom"]["images"] = image_files
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
dest.write_text(_dumps(detail))
else:
dest.symlink_to(src.resolve())
@@ -166,9 +220,8 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
activities = []
for s in index.get("activities", []):
aid = s.get("id", "")
if aid in all_sidecars:
fm, _ = all_sidecars[aid]
s = _apply_sidecar_summary(s, fm)
fm, _ = all_sidecars[aid] if aid in all_sidecars else ({}, "")
s = _apply_sidecar_summary(s, fm)
activities.append(s)
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
@@ -192,6 +245,13 @@ def _merge_all_locked(data_dir: Path) -> int:
merged_dir = data_dir / "_merged"
merged_acts = merged_dir / "activities"
_settings_path = data_dir / "_user_settings.json"
try:
_user_settings = json.loads(_settings_path.read_text(encoding="utf-8")) if _settings_path.exists() else {}
except (OSError, json.JSONDecodeError):
_user_settings = {}
_dl_default: bool = bool(_user_settings.get("download_disabled_default", False))
# Collect sidecars upfront
sidecars: dict[str, tuple[dict, str]] = {}
if edits_dir.exists():
@@ -214,6 +274,17 @@ def _merge_all_locked(data_dir: Path) -> int:
to_merge = set(sidecars) | set(image_lists)
# Also include activities whose title implies indoor (no sidecar required)
_index_path = data_dir / "index.json"
_cached_index: dict | None = None
if _index_path.exists():
from bincio.extract.writer import _infer_indoor_title
_cached_index = json.loads(_index_path.read_text(encoding="utf-8"))
for _s in _cached_index.get("activities", []):
_aid = _s.get("id", "")
if _aid and not _s.get("sub_sport") and _infer_indoor_title(_s.get("title") or ""):
to_merge.add(_aid)
# Wipe and recreate _merged/activities/
shutil.rmtree(merged_acts, ignore_errors=True)
merged_acts.mkdir(parents=True, exist_ok=True)
@@ -229,11 +300,13 @@ def _merge_all_locked(data_dir: Path) -> int:
detail = json.loads(src.read_text(encoding="utf-8"))
if activity_id in sidecars:
fm, body = sidecars[activity_id]
detail = apply_sidecar(detail, fm, body)
detail = apply_sidecar(detail, fm, body, download_disabled_default=_dl_default)
else:
detail = apply_sidecar(detail, {}, "", download_disabled_default=_dl_default)
if activity_id in image_lists:
detail["custom"] = dict(detail.get("custom") or {})
detail["custom"]["images"] = image_lists[activity_id]
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
dest.write_text(_dumps(detail))
else:
if not dest.exists() and not dest.is_symlink():
dest.symlink_to(src.resolve())
@@ -253,7 +326,7 @@ def _merge_all_locked(data_dir: Path) -> int:
athlete_dest = merged_dir / "athlete.json"
if athlete_dest.exists() or athlete_dest.is_symlink():
athlete_dest.unlink()
if athlete_src.exists():
if athlete_src.exists() and athlete_src.stat().st_size > 0:
athlete_edits_path = data_dir / "edits" / "athlete.yaml"
if athlete_edits_path.exists():
try:
@@ -265,22 +338,24 @@ def _merge_all_locked(data_dir: Path) -> int:
edits = {}
_ATHLETE_EDITABLE = {"max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"}
if edits:
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
try:
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
athlete_dest.write_text(_dumps(athlete_data))
except (json.JSONDecodeError, OSError):
pass
else:
athlete_dest.symlink_to(athlete_src.resolve())
# Write merged index.json (private filtered, highlight sorted)
index_path = data_dir / "index.json"
if index_path.exists():
index = json.loads(index_path.read_text(encoding="utf-8"))
index = _cached_index or json.loads(index_path.read_text(encoding="utf-8"))
activities = []
for s in index.get("activities", []):
aid = s.get("id", "")
if aid in sidecars:
fm, _ = sidecars[aid]
s = _apply_sidecar_summary(s, fm)
fm, _ = sidecars[aid] if aid in sidecars else ({}, "")
s = _apply_sidecar_summary(s, fm)
activities.append(s)
# "unlisted" (and legacy "private") activities are kept in the index so
@@ -334,7 +409,7 @@ def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dic
"activities": by_year[year],
}
fname = f"index-{year}.json"
(merged_dir / fname).write_text(json.dumps(shard_doc, indent=2, ensure_ascii=False))
(merged_dir / fname).write_text(_dumps(shard_doc))
shards.append({"url": fname, "year": int(year) if year.isdigit() else 0,
"count": len(by_year[year])})
@@ -343,7 +418,7 @@ def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dic
"shards": shards,
"activities": [],
}
(merged_dir / "index.json").write_text(json.dumps(root_doc, indent=2, ensure_ascii=False))
(merged_dir / "index.json").write_text(_dumps(root_doc))
FEED_PAGE_SIZE = 50
@@ -355,10 +430,11 @@ _COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
def write_combined_feed(data_dir: Path) -> int:
"""Build data_dir/feed.json — the N most recent activities across all users.
"""Build data_dir/feed.json and per-month data_dir/feed-YYYY-MM.json shards.
The global feed page loads this single file instead of resolving 20+ user
shards recursively. Returns the number of activities written.
feed.json is a BAS shard index (same format as per-user index.json).
Each feed-YYYY-MM.json contains all activities for that month across all users,
sorted newest-first. Returns the number of activities written.
"""
user_dirs = sorted(
p for p in data_dir.iterdir()
@@ -401,24 +477,35 @@ def write_combined_feed(data_dir: Path) -> int:
all_activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
# Remove stale feed pages
# Remove stale feed files (sequential pages and old year shards)
for f in data_dir.glob("feed*.json"):
f.unlink(missing_ok=True)
if not all_activities:
return 0
pages = [all_activities[i:i + FEED_PAGE_SIZE] for i in range(0, len(all_activities), FEED_PAGE_SIZE)]
for page_num, page in enumerate(pages):
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in page]
fname = "feed.json" if page_num == 0 else f"feed-{page_num + 1}.json"
doc = {
"bas_version": "1.0",
"page": page_num + 1,
"total_pages": len(pages),
"total_activities": len(all_activities),
"activities": slim,
}
(data_dir / fname).write_text(json.dumps(doc, indent=2, ensure_ascii=False))
# Group by YYYY-MM (month), preserving newest-first order within each bucket
by_month: dict[str, list[dict]] = {}
for a in all_activities:
ym = (a.get("started_at") or "")[:7] # "YYYY-MM"
if len(ym) == 7 and ym[4] == "-":
by_month.setdefault(ym, []).append(a)
months_desc = sorted(by_month.keys(), reverse=True)
# Write per-month shard files (~150-200 acts each → ~25 KB gzip)
for ym, acts in by_month.items():
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in acts]
doc: dict = {"bas_version": "1.0", "activities": slim}
(data_dir / f"feed-{ym}.json").write_text(_dumps(doc))
# Write feed.json as a BAS shard index (same pattern as per-user index.json)
index_doc: dict = {
"bas_version": "1.0",
"total_activities": len(all_activities),
"shards": [{"url": f"feed-{ym}.json"} for ym in months_desc],
"activities": [],
}
(data_dir / "feed.json").write_text(_dumps(index_doc))
return len(all_activities)
+121
View File
@@ -0,0 +1,121 @@
"""OG image generation — 400×400 track-on-dark PNG for social link previews."""
from __future__ import annotations
import io
import math
from pathlib import Path
from typing import Optional
# Colour stops matching ActivityMap.svelte _linearColor stops
_STOPS: list[tuple[float, tuple[int, int, int]]] = [
(0.00, (59, 130, 246)), # blue-500 (low)
(0.33, (74, 222, 128)), # green-400
(0.66, (250, 204, 21)), # yellow-400
(1.00, (239, 68, 68)), # red-500 (high)
]
_BG = (9, 9, 11) # zinc-950
_SIZE = 400
_PAD = 28
_WIDTH = 5 # logical line width; rendered at 2× then downscaled
def _lerp_color(t: float) -> tuple[int, int, int]:
t = max(0.0, min(1.0, t))
for i in range(len(_STOPS) - 1):
t0, c0 = _STOPS[i]
t1, c1 = _STOPS[i + 1]
if t <= t1:
f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
return (
round(c0[0] + f * (c1[0] - c0[0])),
round(c0[1] + f * (c1[1] - c0[1])),
round(c0[2] + f * (c1[2] - c0[2])),
)
return _STOPS[-1][1]
def generate(
lat_arr: list[Optional[float]],
lon_arr: list[Optional[float]],
ele_arr: list[Optional[float]],
) -> bytes:
"""Return PNG bytes for a 400×400 elevation-coloured track image.
Any of the three arrays may have None gaps (no-GPS seconds).
Returns a plain dark square if there are fewer than 2 valid GPS points.
"""
try:
from PIL import Image, ImageDraw # type: ignore[import]
except ImportError as e:
raise RuntimeError("Pillow is required for OG image generation") from e
# Collect valid GPS points paired with elevation (None → 0 for colouring)
pts: list[tuple[float, float, float]] = []
for lat, lon, ele in zip(lat_arr, lon_arr, ele_arr):
if lat is not None and lon is not None:
pts.append((float(lat), float(lon), float(ele) if ele is not None else 0.0))
if len(pts) < 2:
img = Image.new("RGB", (_SIZE, _SIZE), _BG)
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
return buf.getvalue()
lats = [p[0] for p in pts]
lons = [p[1] for p in pts]
eles = [p[2] for p in pts]
min_lat, max_lat = min(lats), max(lats)
min_lon, max_lon = min(lons), max(lons)
min_ele, max_ele = min(eles), max(eles)
ele_range = max_ele - min_ele or 1.0
# Mercator correction: compress longitude range by cos(mid_lat) so the
# track doesn't look stretched horizontally at higher latitudes.
cos_lat = math.cos(math.radians((min_lat + max_lat) / 2))
usable = _SIZE - 2 * _PAD
lat_span = max_lat - min_lat or 1e-6
lon_span = (max_lon - min_lon) * cos_lat or 1e-6
scale = min(usable / lat_span, usable / lon_span)
# Centre the track within the canvas
x_off = _PAD + (usable - (max_lon - min_lon) * cos_lat * scale) / 2
y_off = _PAD + (usable - lat_span * scale) / 2
def project(lat: float, lon: float) -> tuple[float, float]:
x = x_off + (lon - min_lon) * cos_lat * scale
y = _SIZE - (y_off + (lat - min_lat) * scale)
return x, y
# Render at 2× resolution then downscale for cheap anti-aliasing
S = _SIZE * 2
lw = _WIDTH * 2
img = Image.new("RGB", (S, S), _BG)
draw = ImageDraw.Draw(img)
for i in range(len(pts) - 1):
x0, y0 = project(pts[i][0], pts[i][1])
x1, y1 = project(pts[i+1][0], pts[i+1][1])
t = (eles[i] - min_ele) / ele_range
color = _lerp_color(t)
draw.line([(x0 * 2, y0 * 2), (x1 * 2, y1 * 2)], fill=color, width=lw)
img = img.resize((_SIZE, _SIZE), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
return buf.getvalue()
def generate_for_activity(ts_path: Path) -> bytes:
"""Convenience wrapper: read a .timeseries.json file and call generate()."""
import json
ts = json.loads(ts_path.read_text(encoding="utf-8"))
return generate(
ts.get("lat") or [],
ts.get("lon") or [],
ts.get("elevation_m") or [],
)
View File
+120
View File
@@ -0,0 +1,120 @@
"""bincio segments — segment management CLI commands."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
import click
def _dt(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
@click.group("segments")
def segments_group() -> None:
"""Manage segments and detect efforts."""
@segments_group.command("detect")
@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory (e.g. /var/bincio)")
@click.option("--handle", required=True, help="User handle to run detection for")
@click.option("--activity-id", default=None, help="Limit to a single activity ID (optional)")
@click.option("--segment-id", default=None, help="Limit to a single segment ID (optional)")
@click.option("--fresh", is_flag=True, default=False, help="Clear existing efforts before detecting")
def detect_cmd(data_dir: str, handle: str, activity_id: str | None, segment_id: str | None, fresh: bool) -> None:
"""Retroactively detect segment efforts for stored activities.
Walks every activity with GPS data, runs the detection algorithm against
all (or a single) segment, and persists any new efforts found.
"""
from bincio.segments.detect import track_from_timeseries_json, detect_one, detect_all
from bincio.segments import store as _store
dd = Path(data_dir).expanduser().resolve()
user_dir = dd / handle
acts_dir = user_dir / "activities"
if not acts_dir.exists():
click.echo(f"No activities directory at {acts_dir}", err=True)
sys.exit(1)
# Choose which segments to check.
if segment_id:
seg = _store.load_segment(dd, segment_id)
if seg is None:
click.echo(f"Segment not found: {segment_id}", err=True)
sys.exit(1)
segments = [seg]
else:
segments = _store.list_segments(dd)
if not segments:
click.echo("No segments defined.", err=True)
sys.exit(0)
if fresh:
for seg in segments:
_store.save_efforts(dd, handle, seg.id, [])
click.echo(f"Cleared existing efforts for {len(segments)} segment(s).")
# Choose which activities to process.
if activity_id:
detail_files = [acts_dir / f"{activity_id}.json"]
else:
detail_files = sorted(acts_dir.glob("*.json"))
# Exclude timeseries files.
detail_files = [f for f in detail_files if ".timeseries." not in f.name]
total_efforts = 0
processed = 0
for detail_path in detail_files:
try:
detail = json.loads(detail_path.read_text(encoding="utf-8"))
except Exception:
continue
ts_url = detail.get("timeseries_url")
if not ts_url:
continue
act_id = detail.get("id", detail_path.stem)
sport = detail.get("sport", "other")
started = detail.get("started_at")
if not started:
continue
try:
started_at = _dt(started)
except Exception:
continue
ts_path = user_dir / ts_url
if not ts_path.exists():
continue
try:
ts = json.loads(ts_path.read_text(encoding="utf-8"))
except Exception:
continue
track = track_from_timeseries_json(ts, act_id, sport, started_at)
if track is None:
continue
processed += 1
for seg in segments:
from bincio.segments.detect import detect_one
efforts = detect_one(track, seg)
for effort in efforts:
_store.add_effort(dd, handle, seg.id, effort)
if efforts:
click.echo(
f" {act_id}: {len(efforts)} effort(s) on '{seg.name}' "
f"({', '.join(str(e.elapsed_s) + 's' for e in efforts)})"
)
total_efforts += len(efforts)
click.echo(f"\nProcessed {processed} activities, found {total_efforts} effort(s).")
+307
View File
@@ -0,0 +1,307 @@
"""Segment effort detection.
Matches GPS tracks against stored segment polylines and produces SegmentEffort
records. Works from either a live ParsedActivity (ingest path) or from a
stored timeseries JSON (retroactive path).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
from bincio.segments.models import Segment, SegmentEffort
# ── tuning constants ──────────────────────────────────────────────────────────
MATCH_RADIUS_M = 25 # max distance to segment start/end to open/close an effort
CONFORMANCE_MAX_DEV_M = 50 # max allowed deviation for each interior segment point
CONFORMANCE_MAX_FRAC = 0.30 # max fraction of interior points allowed to deviate
# Minimum geometric speed (segment_distance / elapsed_s) per sport, in m/s.
# Rejects false matches from long circuit rides where the track passes the
# segment start early and the segment end hours later.
_MIN_SPEED_MS: dict[str, float] = {
'cycling': 2.0, # ~7.2 km/h — below any realistic cyclist even on brutal climbs
'running': 0.8, # ~2.9 km/h
}
_MIN_SPEED_DEFAULT = 0.3 # hiking / walking / unknown
# Maximum geometric speed per sport in m/s — rejects GPS glitch matches.
_MAX_SPEED_MS: dict[str, float] = {
'cycling': 30.0, # ~108 km/h
'running': 12.0, # ~43 km/h
}
_MAX_SPEED_DEFAULT = 20.0
# ── fast distance approximation ───────────────────────────────────────────────
_R = 6_371_000.0 # Earth radius in metres
def _dist(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Equirectangular approximation — fast, accurate to <0.1% within 100 km."""
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
mlat = math.radians((lat1 + lat2) / 2.0)
return math.hypot(dlat * _R, dlon * _R * math.cos(mlat))
# ── activity track representation ────────────────────────────────────────────
@dataclass
class ActivityTrack:
"""Common internal representation for detection, independent of source format."""
activity_id: str
sport: str
started_at: datetime
# Parallel arrays — all same length, GPS-only points (lat/lon not None).
lats: list[float]
lons: list[float]
times: list[int] # seconds from started_at
speeds: list[Optional[float]]
hrs: list[Optional[int]]
powers: list[Optional[int]]
bbox: list[float] = field(default_factory=list) # [lon_min, lat_min, lon_max, lat_max]
def __post_init__(self) -> None:
if self.lats and not self.bbox:
self.bbox = [
min(self.lons), min(self.lats),
max(self.lons), max(self.lats),
]
def track_from_parsed(parsed: "ParsedActivity", activity_id: str) -> Optional[ActivityTrack]: # noqa: F821
"""Build an ActivityTrack from a ParsedActivity (used during ingest)."""
lats, lons, times, speeds, hrs, powers = [], [], [], [], [], []
last_t = -1
for p in parsed.points:
if p.lat is None or p.lon is None:
continue
t = int((p.timestamp - parsed.started_at).total_seconds())
if t < 0 or t == last_t:
continue
last_t = t
lats.append(p.lat)
lons.append(p.lon)
times.append(t)
speeds.append(p.speed_kmh)
hrs.append(p.hr_bpm)
powers.append(p.power_w)
if len(lats) < 2:
return None
return ActivityTrack(
activity_id=activity_id,
sport=parsed.sport,
started_at=parsed.started_at,
lats=lats, lons=lons, times=times,
speeds=speeds, hrs=hrs, powers=powers,
)
def track_from_timeseries_json(
ts: dict,
activity_id: str,
sport: str,
started_at: datetime,
) -> Optional[ActivityTrack]:
"""Build an ActivityTrack from a stored timeseries JSON dict."""
raw_lats = ts.get("lat") or []
raw_lons = ts.get("lon") or []
raw_t = ts.get("t") or []
raw_spd = ts.get("speed_kmh") or []
raw_hr = ts.get("hr_bpm") or []
raw_pwr = ts.get("power_w") or []
n = len(raw_t)
if n < 2 or not raw_lats or len(raw_lats) != n:
return None
def _pad(arr: list, length: int) -> list:
return arr + [None] * (length - len(arr))
raw_spd = _pad(raw_spd, n)
raw_hr = _pad(raw_hr, n)
raw_pwr = _pad(raw_pwr, n)
lats, lons, times, speeds, hrs, powers = [], [], [], [], [], []
for i in range(n):
if raw_lats[i] is None or raw_lons[i] is None:
continue
lats.append(float(raw_lats[i]))
lons.append(float(raw_lons[i]))
times.append(int(raw_t[i]))
speeds.append(raw_spd[i])
hrs.append(raw_hr[i])
powers.append(raw_pwr[i])
if len(lats) < 2:
return None
return ActivityTrack(
activity_id=activity_id,
sport=sport,
started_at=started_at,
lats=lats, lons=lons, times=times,
speeds=speeds, hrs=hrs, powers=powers,
)
# ── effort metric helpers ─────────────────────────────────────────────────────
def _avg_nonnull(vals: list, lo: int, hi: int) -> Optional[float]:
nums = [v for v in vals[lo:hi + 1] if v is not None]
return sum(nums) / len(nums) if nums else None
def _np_power(powers: list[Optional[int]], lo: int, hi: int) -> Optional[int]:
"""Coggan NP from a slice of 1Hz power data (may have gaps/nulls)."""
WIN = 30
chunk = powers[lo:hi + 1]
filled = [v if v is not None else 0 for v in chunk]
n = len(filled)
if n < WIN:
# Too short for rolling average — just return avg power.
non_null = [v for v in chunk if v is not None]
return int(round(sum(non_null) / len(non_null))) if non_null else None
half = WIN // 2
window_sum = sum(filled[:WIN])
fourth_powers = []
for i in range(half, n - half):
fourth_powers.append((window_sum / WIN) ** 4)
if i + half + 1 < n:
window_sum += filled[i + half + 1] - filled[i - half]
if not fourth_powers:
return None
return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25))
# ── detection algorithm ───────────────────────────────────────────────────────
def _bboxes_overlap(a: list[float], b: list[float]) -> bool:
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
def _conformance_ok(
track: ActivityTrack,
seg: Segment,
i: int,
j: int,
) -> bool:
"""Check that the track slice [i..j] follows the segment polyline."""
interior = seg.polyline[1:-1]
if not interior:
return True # trivial 2-point segment
failing = 0
for sp in interior:
slat, slon = sp[0], sp[1]
min_d = min(
_dist(slat, slon, track.lats[k], track.lons[k])
for k in range(i, j + 1)
)
if min_d > CONFORMANCE_MAX_DEV_M:
failing += 1
return (failing / len(interior)) <= CONFORMANCE_MAX_FRAC
def _extract_effort(
track: ActivityTrack,
seg: Segment,
i: int,
j: int,
) -> SegmentEffort:
elapsed_s = track.times[j] - track.times[i]
started_at = (track.started_at + timedelta(seconds=track.times[i])).replace(microsecond=0)
# Always derive avg speed from segment distance / elapsed time. Device-recorded
# speed is unreliable across formats (m/s vs km/h in older FIT files) and
# averaging instantaneous GPS speed over a slice gives different results anyway.
avg_speed = (seg.distance_m / elapsed_s * 3.6) if elapsed_s > 0 else None
avg_hr_raw = _avg_nonnull(track.hrs, i, j)
avg_hr = int(round(avg_hr_raw)) if avg_hr_raw is not None else None
avg_pwr_raw = _avg_nonnull(track.powers, i, j)
avg_pwr = int(round(avg_pwr_raw)) if avg_pwr_raw is not None else None
np_pwr = _np_power(track.powers, i, j) if any(v is not None for v in track.powers[i:j + 1]) else None
return SegmentEffort(
activity_id=track.activity_id,
started_at=started_at,
elapsed_s=max(1, elapsed_s),
avg_speed_kmh=round(avg_speed, 2) if avg_speed is not None else None,
avg_hr_bpm=avg_hr,
avg_power_w=avg_pwr,
np_power_w=np_pwr,
detected_at=datetime.now(timezone.utc),
)
def detect_one(track: ActivityTrack, seg: Segment) -> list[SegmentEffort]:
"""Return all matching efforts for a single segment against a track."""
if not track.bbox or not _bboxes_overlap(track.bbox, seg.bbox):
return []
if seg.sport and seg.sport != track.sport:
return []
seg_start_lat, seg_start_lon = seg.polyline[0][0], seg.polyline[0][1]
seg_end_lat, seg_end_lon = seg.polyline[-1][0], seg.polyline[-1][1]
n = len(track.lats)
efforts: list[SegmentEffort] = []
search_from = 0
while search_from < n - 1:
# Find next start candidate from search_from.
start_idx = None
for i in range(search_from, n):
if _dist(seg_start_lat, seg_start_lon, track.lats[i], track.lons[i]) <= MATCH_RADIUS_M:
start_idx = i
break
if start_idx is None:
break
# Scan forward from start_idx for an end candidate.
end_idx = None
for j in range(start_idx + 1, n):
if _dist(seg_end_lat, seg_end_lon, track.lats[j], track.lons[j]) <= MATCH_RADIUS_M:
end_idx = j
break
if end_idx is None:
# No end found — no more efforts possible starting at or after start_idx.
break
# Reject implausibly slow or fast matches.
elapsed = track.times[end_idx] - track.times[start_idx]
if elapsed > 0:
geo_speed = seg.distance_m / elapsed
min_speed = _MIN_SPEED_MS.get(track.sport, _MIN_SPEED_DEFAULT)
max_speed = _MAX_SPEED_MS.get(track.sport, _MAX_SPEED_DEFAULT)
if geo_speed < min_speed or geo_speed > max_speed:
search_from = start_idx + 1
continue
if _conformance_ok(track, seg, start_idx, end_idx):
efforts.append(_extract_effort(track, seg, start_idx, end_idx))
search_from = end_idx + 1
else:
# Conformance failed; try next start candidate after start_idx.
search_from = start_idx + 1
return efforts
def detect_all(
track: ActivityTrack,
handle: str,
data_dir: Path,
) -> int:
"""Detect efforts for all segments and persist them. Returns effort count."""
from bincio.segments import store as _store
segments = _store.list_segments(data_dir)
total = 0
for seg in segments:
efforts = detect_one(track, seg)
for effort in efforts:
_store.add_effort(data_dir, handle, seg.id, effort)
total += len(efforts)
return total
+29
View File
@@ -0,0 +1,29 @@
"""Segment and SegmentEffort data models."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
@dataclass
class Segment:
id: str
name: str
polyline: list[list[float]] # [[lat, lon], ...]
distance_m: float
bbox: list[float] # [lon_min, lat_min, lon_max, lat_max]
created_by: str
created_at: datetime
sport: Optional[str] = None # None = any sport
@dataclass
class SegmentEffort:
activity_id: str
started_at: datetime
elapsed_s: int
detected_at: datetime
avg_speed_kmh: Optional[float] = None
avg_hr_bpm: Optional[int] = None
avg_power_w: Optional[int] = None
np_power_w: Optional[int] = None
+186
View File
@@ -0,0 +1,186 @@
"""Read/write segments and segment efforts to/from /var/bincio."""
from __future__ import annotations
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from bincio.segments.models import Segment, SegmentEffort
# /var/bincio/segments/{id}.json
_SEGMENTS_DIR = "segments"
# /var/bincio/data/{handle}/segment_efforts/{segment_id}.json
_EFFORTS_SUBDIR = "segment_efforts"
# ── helpers ───────────────────────────────────────────────────────────────────
def _segments_dir(data_dir: Path) -> Path:
d = data_dir / _SEGMENTS_DIR
d.mkdir(parents=True, exist_ok=True)
return d
def _efforts_dir(data_dir: Path, handle: str) -> Path:
d = data_dir / handle / _EFFORTS_SUBDIR
d.mkdir(parents=True, exist_ok=True)
return d
def _slugify(name: str) -> str:
s = name.lower().strip()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")[:48]
def _make_id(name: str) -> str:
slug = _slugify(name)
suffix = hashlib.sha256(name.encode()).hexdigest()[:4]
return f"{slug}-{suffix}"
def _dt(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def _iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ── serialisation ─────────────────────────────────────────────────────────────
def _segment_to_dict(seg: Segment) -> dict:
return {
"id": seg.id,
"name": seg.name,
"sport": seg.sport,
"polyline": seg.polyline,
"distance_m": round(seg.distance_m, 1),
"bbox": [round(v, 6) for v in seg.bbox],
"created_by": seg.created_by,
"created_at": _iso(seg.created_at),
}
def _segment_from_dict(d: dict) -> Segment:
return Segment(
id=d["id"],
name=d["name"],
sport=d.get("sport"),
polyline=d["polyline"],
distance_m=float(d["distance_m"]),
bbox=d["bbox"],
created_by=d["created_by"],
created_at=_dt(d["created_at"]),
)
def _effort_to_dict(e: SegmentEffort) -> dict:
return {
"activity_id": e.activity_id,
"started_at": _iso(e.started_at),
"elapsed_s": e.elapsed_s,
"avg_speed_kmh": e.avg_speed_kmh,
"avg_hr_bpm": e.avg_hr_bpm,
"avg_power_w": e.avg_power_w,
"np_power_w": e.np_power_w,
"detected_at": _iso(e.detected_at),
}
def _effort_from_dict(d: dict) -> SegmentEffort:
return SegmentEffort(
activity_id=d["activity_id"],
started_at=_dt(d["started_at"]),
elapsed_s=int(d["elapsed_s"]),
avg_speed_kmh=d.get("avg_speed_kmh"),
avg_hr_bpm=d.get("avg_hr_bpm"),
avg_power_w=d.get("avg_power_w"),
np_power_w=d.get("np_power_w"),
detected_at=_dt(d["detected_at"]),
)
# ── public API ────────────────────────────────────────────────────────────────
def make_segment_id(name: str) -> str:
return _make_id(name)
def save_segment(data_dir: Path, seg: Segment) -> None:
path = _segments_dir(data_dir) / f"{seg.id}.json"
path.write_text(json.dumps(_segment_to_dict(seg), ensure_ascii=False, indent=2), encoding="utf-8")
def load_segment(data_dir: Path, segment_id: str) -> Optional[Segment]:
path = _segments_dir(data_dir) / f"{segment_id}.json"
if not path.exists():
return None
return _segment_from_dict(json.loads(path.read_text(encoding="utf-8")))
def delete_segment(data_dir: Path, segment_id: str) -> bool:
path = _segments_dir(data_dir) / f"{segment_id}.json"
if not path.exists():
return False
path.unlink()
return True
def list_segments(data_dir: Path, bbox: Optional[list[float]] = None) -> list[Segment]:
"""Return all segments, optionally filtered to those overlapping bbox.
bbox = [lon_min, lat_min, lon_max, lat_max]
"""
segs = []
for path in sorted(_segments_dir(data_dir).glob("*.json")):
try:
seg = _segment_from_dict(json.loads(path.read_text(encoding="utf-8")))
except Exception:
continue
if bbox is not None and not _bboxes_overlap(seg.bbox, bbox):
continue
segs.append(seg)
return segs
def _bboxes_overlap(a: list[float], b: list[float]) -> bool:
"""True if two [lon_min, lat_min, lon_max, lat_max] boxes overlap."""
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
# ── efforts ───────────────────────────────────────────────────────────────────
def load_efforts(data_dir: Path, handle: str, segment_id: str) -> list[SegmentEffort]:
path = _efforts_dir(data_dir, handle) / f"{segment_id}.json"
if not path.exists():
return []
try:
return [_effort_from_dict(d) for d in json.loads(path.read_text(encoding="utf-8"))]
except Exception:
return []
def save_efforts(data_dir: Path, handle: str, segment_id: str, efforts: list[SegmentEffort]) -> None:
path = _efforts_dir(data_dir, handle) / f"{segment_id}.json"
data = [_effort_to_dict(e) for e in efforts]
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def add_effort(data_dir: Path, handle: str, segment_id: str, effort: SegmentEffort) -> None:
"""Append an effort, replacing any existing effort at the same start time.
Deduplicating by started_at (not activity_id) handles the case where the
same ride is stored under two activity IDs (e.g. re-imported with a different
source hash), which would otherwise produce two identical-time efforts.
"""
efforts = load_efforts(data_dir, handle, segment_id)
key = _iso(effort.started_at)
efforts = [e for e in efforts if _iso(e.started_at) != key]
efforts.append(effort)
efforts.sort(key=lambda e: e.started_at, reverse=True)
save_efforts(data_dir, handle, segment_id, efforts)
+35 -20
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from rich.console import Console
@@ -22,10 +21,14 @@ console = Console()
@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.")
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).")
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
strava_client_id: Optional[str], strava_client_secret: Optional[str],
max_users: Optional[int], public_url: Optional[str],
webroot: Optional[str], dem_url: Optional[str]) -> None:
@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).")
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.")
@click.option("--auth-api", default=None, envvar="BINCIO_AUTH_API", help="Internal URL of the bincio-auth API (e.g. http://127.0.0.1:4040). When set, admin user-state operations are proxied to bincio-auth.")
def serve(data_dir: str, site_dir: str | None, host: str, port: int,
strava_client_id: str | None, strava_client_secret: str | None,
max_users: int | None, public_url: str | None,
webroot: str | None, dem_url: str | None,
sync_secret: str | None, jwt_secret: str | None, auth_api: str | None) -> None:
"""Start the bincio multi-user application server.
Handles auth, user management, and write operations.
@@ -34,8 +37,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
Requires a data directory initialised with `bincio init`.
"""
import uvicorn
import bincio.serve.server as srv
from bincio.serve.db import open_db, set_setting, get_setting
from bincio.serve import deps
from bincio.serve.db import get_setting, open_db, set_setting
dd = Path(data_dir).expanduser().resolve()
if not (dd / "instance.db").exists():
@@ -48,36 +53,46 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
set_setting(db, "max_users", str(max_users))
db.close()
srv.data_dir = dd
deps.data_dir = dd
if site_dir:
srv.site_dir = Path(site_dir).expanduser().resolve()
deps.site_dir = Path(site_dir).expanduser().resolve()
if strava_client_id:
srv.strava_client_id = strava_client_id
deps.strava_client_id = strava_client_id
if strava_client_secret:
srv.strava_client_secret = strava_client_secret
deps.strava_client_secret = strava_client_secret
if public_url:
srv.public_url = public_url
deps.public_url = public_url
if webroot and site_dir:
srv.webroot = Path(webroot).expanduser().resolve()
deps.webroot = Path(webroot).expanduser().resolve()
if dem_url:
srv.dem_url = dem_url
deps.dem_url = dem_url
if sync_secret:
deps.sync_secret = sync_secret
if jwt_secret:
deps.jwt_secret = jwt_secret
if auth_api:
deps.auth_api = auth_api.rstrip("/")
db = open_db(dd)
current_limit = get_setting(db, "max_users")
db.close()
console.print(f"[bold]bincio serve[/bold]")
console.print("[bold]bincio serve[/bold]")
console.print(f" Data: [cyan]{dd}[/cyan]")
if srv.site_dir:
console.print(f" Site: [cyan]{srv.site_dir}[/cyan]")
if srv.webroot:
console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)")
if deps.site_dir:
console.print(f" Site: [cyan]{deps.site_dir}[/cyan]")
if deps.webroot:
console.print(f" Web: [cyan]{deps.webroot}[/cyan] (auto-rebuild on upload)")
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
if current_limit and int(current_limit) > 0:
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
else:
console.print(f" Users: [dim]unlimited[/dim]")
console.print(f" DEM: [cyan]{srv.dem_url}[/cyan]")
console.print(" Users: [dim]unlimited[/dim]")
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
if deps.jwt_secret:
console.print(" Auth: [green]JWT (bincio-auth)[/green]")
else:
console.print(" Auth: [dim]local DB sessions[/dim]")
console.print()
log_config = uvicorn.config.LOGGING_CONFIG.copy()
+24 -4
View File
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER NOT NULL DEFAULT 0,
wiki_access INTEGER NOT NULL DEFAULT 1,
activity_access INTEGER NOT NULL DEFAULT 0,
suspended INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
@@ -89,6 +90,7 @@ class User:
is_admin: bool
wiki_access: bool
activity_access: bool
suspended: bool
created_at: int
@@ -115,6 +117,10 @@ def open_db(data_dir: Path) -> sqlite3.Connection:
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON")
db.executescript(_SCHEMA)
# Migration: add suspended column to pre-existing databases
cols = {r[1] for r in db.execute("PRAGMA table_info(users)")}
if "suspended" not in cols:
db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0")
db.commit()
return db
@@ -140,7 +146,8 @@ def create_user(
)
db.commit()
return User(handle=handle, display_name=display_name, is_admin=is_admin,
wiki_access=wiki_access, activity_access=activity_access, created_at=now)
wiki_access=wiki_access, activity_access=activity_access,
suspended=False, created_at=now)
def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]:
@@ -153,12 +160,13 @@ def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]:
is_admin=bool(row["is_admin"]),
wiki_access=bool(row["wiki_access"]),
activity_access=bool(row["activity_access"]),
suspended=bool(row["suspended"]),
created_at=row["created_at"],
)
def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional[User]:
"""Return the User if credentials are valid, else None."""
"""Return the User if credentials are valid and account is not suspended, else None."""
row = db.execute(
"SELECT * FROM users WHERE handle = ?", (handle,)
).fetchone()
@@ -166,12 +174,15 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional
return None
if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
return None
if row["suspended"]:
return None
return User(
handle=row["handle"],
display_name=row["display_name"],
is_admin=bool(row["is_admin"]),
wiki_access=bool(row["wiki_access"]),
activity_access=bool(row["activity_access"]),
suspended=False,
created_at=row["created_at"],
)
@@ -188,6 +199,7 @@ def list_users(db: sqlite3.Connection) -> list[User]:
return [User(handle=r["handle"], display_name=r["display_name"],
is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]),
activity_access=bool(r["activity_access"]),
suspended=bool(r["suspended"]),
created_at=r["created_at"]) for r in rows]
@@ -196,6 +208,11 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None:
db.commit()
def set_suspended(db: sqlite3.Connection, handle: str, suspended: bool) -> None:
db.execute("UPDATE users SET suspended = ? WHERE handle = ?", (int(suspended), handle))
db.commit()
def get_member_tree(db: sqlite3.Connection) -> list[dict]:
"""Return users with their inviter handle and join timestamp.
@@ -271,10 +288,10 @@ def create_session(db: sqlite3.Connection, handle: str) -> str:
def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
"""Return the User owning this session, or None if expired/invalid."""
"""Return the User owning this session, or None if expired/invalid/suspended."""
row = db.execute(
"SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
"u.wiki_access, u.activity_access, u.created_at "
"u.wiki_access, u.activity_access, u.suspended, u.created_at "
"FROM sessions s JOIN users u ON s.handle = u.handle "
"WHERE s.token = ?",
(token,),
@@ -284,12 +301,15 @@ def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
if row["expires_at"] < int(time.time()):
delete_session(db, token)
return None
if row["suspended"]:
return None
return User(
handle=row["handle"],
display_name=row["display_name"],
is_admin=bool(row["is_admin"]),
wiki_access=bool(row["wiki_access"]),
activity_access=bool(row["activity_access"]),
suspended=False,
created_at=row["created_at"],
)
+190
View File
@@ -0,0 +1,190 @@
"""Shared state and FastAPI dependency functions for bincio.serve.
All module-level globals live here so routers can import them without
creating circular dependencies through server.py.
The CLI sets these before uvicorn starts.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from pathlib import Path
import jwt as _jwt
from fastapi import Cookie, HTTPException, Request, Response
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
from bincio.serve.db import (
User,
get_session,
open_db,
)
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401
from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401
# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────
data_dir: Path | None = None
site_dir: Path | None = None
webroot: Path | None = None
strava_client_id: str = ""
strava_client_secret: str = ""
public_url: str = ""
dem_url: str = "https://api.open-elevation.com"
sync_secret: str = ""
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
auth_api: str = "" # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040)
_db = None
_strava_sync_running = False
_strava_sync_lock = threading.Lock()
_garmin_sync_running = False
_garmin_sync_lock = threading.Lock()
# ── Constants ─────────────────────────────────────────────────────────────────
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
_SESSION_COOKIE = "bincio_session"
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
_STRAVA_CREDS_FILE = "strava_credentials.json"
_login_attempts: dict[str, list[float]] = {}
_register_attempts: dict[str, list[float]] = {}
_RATE_WINDOW = 900 # 15 minutes
_LOGIN_RATE_LIMIT = 10
_REGISTER_RATE_LIMIT = 5
# ── Core helpers ──────────────────────────────────────────────────────────────
def _get_data_dir() -> Path:
if data_dir is None:
raise HTTPException(500, "Server not configured")
return data_dir
def _get_db():
global _db
if _db is None:
_db = open_db(_get_data_dir())
return _db
def _strava_creds(handle: str) -> tuple[str, str]:
"""Return (client_id, client_secret) for a user.
Per-user credentials take precedence over the instance-level globals.
Returns ("", "") when neither is configured.
"""
creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
if creds_path.exists():
try:
d = json.loads(creds_path.read_text(encoding="utf-8"))
cid = str(d.get("client_id", "")).strip()
csec = str(d.get("client_secret", "")).strip()
if cid and csec:
return cid, csec
except (OSError, json.JSONDecodeError, KeyError, ValueError):
pass
return strava_client_id, strava_client_secret
def _check_id(activity_id: str) -> str:
if not _VALID_ACTIVITY_ID.match(activity_id):
raise HTTPException(400, "Invalid activity ID")
return activity_id
# ── Rate limiting ─────────────────────────────────────────────────────────────
def _check_rate_limit(
ip: str,
store: dict[str, list[float]],
limit: int,
msg: str = "Too many attempts. Try again later.",
) -> None:
now = time.time()
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
store[ip] = attempts
if len(attempts) >= limit:
raise HTTPException(429, msg)
attempts.append(now)
store[ip] = attempts
# ── Auth dependency functions ─────────────────────────────────────────────────
def _decode_jwt(token: str) -> User | None:
"""Decode a bincio-auth JWT and return a User. Returns None on any failure."""
try:
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
except _jwt.PyJWTError:
return None
handle = payload.get("sub")
if not handle:
return None
return User(
handle=handle,
display_name=payload.get("display_name", ""),
is_admin=bool(payload.get("is_admin", False)),
wiki_access=bool(payload.get("wiki_access", True)),
activity_access=bool(payload.get("activity_access", False)),
suspended=False,
created_at=0,
)
def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
if not bincio_session:
return None
if jwt_secret:
return _decode_jwt(bincio_session)
return get_session(_get_db(), bincio_session)
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
user = _current_user(bincio_session)
if not user:
raise HTTPException(401, "Not authenticated")
return user
def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User:
user = _require_user(bincio_session)
if not user.is_admin:
raise HTTPException(403, "Admin required")
return user
def _require_auth(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> User:
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
token = bincio_session
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
if not token:
raise HTTPException(401, "Not authenticated")
user = _decode_jwt(token) if jwt_secret else get_session(_get_db(), token)
if not user:
raise HTTPException(401, "Invalid or expired session")
return user
def _set_session_cookie(response: Response, token: str) -> None:
kwargs: dict = dict(
key=_SESSION_COOKIE,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=False,
)
if _SESSION_DOMAIN:
kwargs["domain"] = _SESSION_DOMAIN
response.set_cookie(**kwargs)
+93
View File
@@ -0,0 +1,93 @@
"""Pydantic request/response models for bincio.serve."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
handle: str = Field(..., description="User handle (username)")
password: str = Field(..., description="User password")
class LoginResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
handle: str = Field(..., description="User handle")
display_name: str = Field(..., description="User's display name")
class ResetPasswordRequest(BaseModel):
handle: str = Field(..., description="User handle")
code: str = Field(..., description="Reset code (24 hours valid)")
password: str = Field(..., description="New password (min 8 chars)")
class RegisterRequest(BaseModel):
code: str = Field(..., description="Invite code")
handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)")
password: str = Field(..., description="Password (min 8 characters)")
display_name: str = Field(default="", description="Full name (optional, defaults to handle)")
class RegisterResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
handle: str = Field(..., description="New user's handle")
class CurrentUserResponse(BaseModel):
handle: str = Field(..., description="User handle")
display_name: str = Field(..., description="User's display name")
is_admin: bool = Field(..., description="Whether user is an admin")
wiki_access: bool = Field(default=True, description="Whether user has wiki access")
activity_access: bool = Field(default=False, description="Whether user has activity access")
store_originals_default: bool = Field(
default=True,
description="Instance-wide default for storing original files"
)
dem_configured: bool = Field(default=False, description="Whether DEM elevation lookup is configured")
class ActivityEditRequest(BaseModel):
title: str | None = Field(default=None, description="Activity title")
description: str | None = Field(default=None, description="Activity description (markdown)")
sport: str | None = Field(default=None, description="Sport type")
sub_sport: str | None = Field(default=None, description="Sport sub-category")
private: bool | None = Field(default=None, description="Hide from public feed")
highlight: bool | None = Field(default=None, description="Mark as favorite")
gear: str | None = Field(default=None, description="Gear used")
download_disabled: bool | None = Field(default=None, description="Prevent others from downloading files")
class ActivityEditResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
class ResetPasswordCodeResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
code: str = Field(..., description="One-time reset code")
expires_in_hours: int = Field(24, description="Code validity period in hours")
class GenericResponse(BaseModel):
ok: bool = Field(True, description="Success flag")
class CreateSegmentRequest(BaseModel):
name: str = Field(..., description="Segment name")
sport: Optional[str] = Field(default=None, description="Sport filter")
polyline: list[list[float]] = Field(..., description="[[lat, lon], ...] GPS points")
distance_m: float = Field(..., description="Segment length in metres")
class CreateInviteRequest(BaseModel):
grants_activity: bool = Field(default=False)
class IdeaBody(BaseModel):
title: str
body: str = ""
class IdeaCommentBody(BaseModel):
comment: str = ""
View File
+388
View File
@@ -0,0 +1,388 @@
"""Activity CRUD and athlete endpoints."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from bincio.serve import deps, tasks
from bincio.serve.models import ActivityEditRequest, ActivityEditResponse, GenericResponse
from bincio.serve.db import User
from bincio.shared.images import (
ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES,
MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES,
unique_image_name as _unique_image_name,
)
router = APIRouter()
def _user_data_dir(handle: str) -> Path:
"""Return the merged data dir for a user, for reading activity files."""
dd = deps._get_data_dir()
merged = dd / handle / "_merged"
return merged if merged.exists() else dd / handle
def _require_owns(activity_id: str, user: User) -> Path:
"""Verify the user owns this activity (it lives in their data dir)."""
activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json"
if not activity_path.exists():
raise HTTPException(404, "Activity not found")
return activity_path
@router.get("/api/activity/{activity_id}/geojson")
async def get_activity_geojson(
activity_id: str,
user: User = Depends(deps._require_auth),
) -> JSONResponse:
"""Return GeoJSON track for an activity (mobile detail screen)."""
deps._check_id(activity_id)
dd = deps._get_data_dir()
user_dir = dd / user.handle
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
p = base / f"{activity_id}.geojson"
if p.exists():
return JSONResponse(json.loads(p.read_text()))
raise HTTPException(404, "GeoJSON not found")
@router.get("/api/activity/{activity_id}/timeseries")
async def get_activity_timeseries(
activity_id: str,
user: User = Depends(deps._require_auth),
) -> JSONResponse:
"""Return timeseries for an activity (mobile detail screen)."""
deps._check_id(activity_id)
dd = deps._get_data_dir()
user_dir = dd / user.handle
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
p = base / f"{activity_id}.timeseries.json"
if p.exists():
return JSONResponse(json.loads(p.read_text()))
raise HTTPException(404, "Timeseries not found")
@router.get("/api/activity/{activity_id}")
async def get_activity(
activity_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
path = _require_owns(activity_id, user)
detail = json.loads(path.read_text())
# Normalise for EditDrawer: add `private` bool so the drawer works regardless
# of whether the raw JSON uses the old "private" or the new "unlisted" value.
detail["private"] = detail.get("privacy") in ("private", "unlisted")
return JSONResponse(detail)
@router.post("/api/activity/{activity_id}", response_model=ActivityEditResponse)
async def post_activity(
activity_id: str,
edit_req: ActivityEditRequest,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
dd = deps._get_data_dir() / user.handle
# Verify the activity belongs to this user before writing
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, "Activity not found")
from bincio.edit.ops import apply_sidecar_edit
body = edit_req.model_dump(exclude_none=True)
apply_sidecar_edit(activity_id, body, dd)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True})
@router.post("/api/activity/{activity_id}/recalculate-elevation/dem")
async def recalculate_elevation_dem_endpoint(
activity_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
Requires --dem-url to be set when starting bincio serve.
"""
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
if not deps.dem_url:
raise HTTPException(503, "DEM URL not configured.")
dd = deps._get_data_dir() / user.handle
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, "Activity not found")
try:
from bincio.extract.dem import recalculate_elevation
from bincio.render.merge import merge_one
result = recalculate_elevation(dd, activity_id, deps.dem_url)
merge_one(dd, activity_id)
tasks._trigger_rebuild(user.handle)
return JSONResponse(result)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(422, str(e))
@router.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis")
async def recalculate_elevation_hysteresis_endpoint(
activity_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Recompute gain/loss from original recorded elevation using source-aware hysteresis."""
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
dd = deps._get_data_dir() / user.handle
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, "Activity not found")
try:
from bincio.extract.dem import recalculate_elevation_hysteresis
from bincio.render.merge import merge_one
result = recalculate_elevation_hysteresis(dd, activity_id)
merge_one(dd, activity_id)
tasks._trigger_rebuild(user.handle)
return JSONResponse(result)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(422, str(e))
@router.delete("/api/activity/{activity_id}", response_model=GenericResponse)
async def delete_activity(
activity_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Delete a single activity and all associated files for the logged-in user."""
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
dd = deps._get_data_dir() / user.handle
acts_dir = dd / "activities"
json_path = acts_dir / f"{activity_id}.json"
if not json_path.exists():
raise HTTPException(404, "Activity not found")
import shutil
# Remove the source files (activities dir)
for suffix in (".json", ".geojson", ".timeseries.json"):
p = acts_dir / f"{activity_id}{suffix}"
p.unlink(missing_ok=True)
# Remove sidecar edit and images
sidecar = dd / "edits" / f"{activity_id}.md"
sidecar.unlink(missing_ok=True)
images_dir = dd / "edits" / "images" / activity_id
if images_dir.exists():
shutil.rmtree(images_dir)
# Remove from the extract-level flat index so merge_all doesn't re-add
# the summary even though the detail file is gone.
index_path = dd / "index.json"
if index_path.exists():
try:
idx = json.loads(index_path.read_text(encoding="utf-8"))
idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
except (OSError, json.JSONDecodeError):
pass # corrupt index — merge_all will clean up on next run
# Remove from dedup cache so the file can be re-uploaded if needed
cache_path = dd / ".bincio_cache.json"
if cache_path.exists():
try:
cache = json.loads(cache_path.read_text(encoding="utf-8"))
if isinstance(cache, dict) and "activities" in cache:
cache["activities"] = [
a for a in cache["activities"] if a.get("id") != activity_id
]
cache_path.write_text(json.dumps(cache, indent=2, ensure_ascii=False))
except (OSError, json.JSONDecodeError):
pass # corrupt cache — leave it; next extract will rebuild
# Full merge needed: activity removed from index
from bincio.render.merge import merge_all
merge_all(dd)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True})
@router.get("/api/activity/{activity_id}/images")
async def list_images(
activity_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
dd = deps._get_data_dir() / user.handle
images_dir = dd / "edits" / "images" / activity_id
images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
return JSONResponse({"images": images})
@router.post("/api/activity/{activity_id}/images")
async def upload_image(
activity_id: str,
file: UploadFile = File(...),
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
dd = deps._get_data_dir() / user.handle
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, "Activity not found")
if not file.filename:
raise HTTPException(400, "No filename")
ct = file.content_type or ""
if ct not in _ALLOWED_IMAGE_TYPES:
raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted")
contents = await file.read()
if len(contents) > _MAX_IMAGE_BYTES:
raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)")
images_dir = dd / "edits" / "images" / activity_id
images_dir.mkdir(parents=True, exist_ok=True)
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
(images_dir / safe_name).write_bytes(contents)
from bincio.render.merge import merge_one
merge_one(dd, activity_id)
return JSONResponse({"ok": True, "filename": safe_name})
@router.delete("/api/activity/{activity_id}/images/{filename}")
async def delete_image(
activity_id: str,
filename: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
deps._check_id(activity_id)
dd = deps._get_data_dir() / user.handle
import shutil
safe_name = Path(filename).name
target = dd / "edits" / "images" / activity_id / safe_name
if target.exists() and target.is_file():
target.unlink()
if target.parent.exists() and not any(target.parent.iterdir()):
shutil.rmtree(target.parent)
from bincio.render.merge import merge_one
merge_one(dd, activity_id)
return JSONResponse({"ok": True})
@router.get("/api/athlete")
async def get_athlete(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
athlete_path = dd / "athlete.json"
data: dict = {}
if athlete_path.exists():
try:
data = json.loads(athlete_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
# Layer edits/athlete.yaml on top
edits_path = dd / "edits" / "athlete.yaml"
if edits_path.exists():
import yaml
try:
edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {}
for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
if k in edits:
data[k] = edits[k]
except (OSError, yaml.YAMLError):
pass
return JSONResponse(data)
@router.post("/api/athlete")
async def save_athlete(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
athlete_path = dd / "athlete.json"
if not athlete_path.exists():
from datetime import datetime, timezone
athlete_path.write_text(json.dumps({
"bas_version": "1.0",
"generated_at": datetime.now(timezone.utc).isoformat(),
"power_curve": {},
}), encoding="utf-8")
payload = await request.json()
edits_dir = dd / "edits"
edits_dir.mkdir(exist_ok=True)
overrides: dict[str, Any] = {}
if payload.get("max_hr") is not None:
overrides["max_hr"] = int(payload["max_hr"])
if payload.get("ftp_w") is not None:
overrides["ftp_w"] = int(payload["ftp_w"])
if payload.get("hr_zones") is not None:
overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
if payload.get("power_zones") is not None:
overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
if payload.get("seasons") is not None:
overrides["seasons"] = [
{"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
for s in payload["seasons"]
]
if payload.get("gear") is not None:
overrides["gear"] = payload["gear"]
import yaml
(edits_dir / "athlete.yaml").write_text(
yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
encoding="utf-8",
)
from bincio.render.merge import merge_all
merge_all(dd)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True})
@router.get("/api/activities/{activity_id}/segment_efforts")
async def activity_segment_efforts(
activity_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return segment efforts that belong to a specific activity for the logged-in user."""
import asyncio
from bincio.segments import store as _seg_store
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
def _collect() -> list[dict]:
efforts_dir = dd / user.handle / "segment_efforts"
result: list[dict] = []
if not efforts_dir.exists():
return result
for ef_file in sorted(efforts_dir.glob("*.json")):
seg_id = ef_file.stem
all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id)
matching = [e for e in all_efforts if e.activity_id == activity_id]
if not matching:
continue
seg = _seg_store.load_segment(dd, seg_id)
if not seg:
continue
pr_elapsed = min(e.elapsed_s for e in all_efforts)
for eff in matching:
result.append({
"segment_id": seg.id,
"segment_name": seg.name,
"segment_distance_m": seg.distance_m,
"elapsed_s": eff.elapsed_s,
"pr_elapsed_s": pr_elapsed,
"started_at": _seg_store._iso(eff.started_at),
})
return result
return JSONResponse(await asyncio.to_thread(_collect))
+710
View File
@@ -0,0 +1,710 @@
"""Admin endpoints (/api/admin/*)."""
from __future__ import annotations
import json
import logging
import shutil
import subprocess
import threading
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from bincio.serve import deps, tasks
async def _auth_proxy(method: str, path: str, cookie: str | None) -> JSONResponse:
"""Forward a user-state admin request to bincio-auth and relay the response."""
if not deps.auth_api:
raise HTTPException(503, "User management is handled by bincio-auth but BINCIO_AUTH_API is not configured.")
url = f"{deps.auth_api}{path}"
cookies = {"bincio_session": cookie} if cookie else {}
async with httpx.AsyncClient() as client:
r = await client.request(method, url, cookies=cookies)
return JSONResponse(r.json(), status_code=r.status_code)
from bincio.serve.models import ResetPasswordCodeResponse
from bincio.serve.db import (
User,
get_user,
list_users,
)
log = logging.getLogger("bincio.serve")
router = APIRouter()
def _wipe_user_activities(user_dir: Path) -> int:
"""Delete all extracted activity files and caches for a user.
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
_merged/, index.json, athlete.json, and the dedup cache.
Leaves the user directory itself intact (account remains in the DB).
Returns the number of files deleted.
"""
import shutil
deleted = 0
for subdir in ("activities", "edits", "originals"):
d = user_dir / subdir
if d.exists():
for f in d.rglob("*"):
if f.is_file():
deleted += 1
shutil.rmtree(d)
for name in ("_merged", ):
d = user_dir / name
if d.exists():
shutil.rmtree(d)
for name in ("index.json", "athlete.json", ".bincio_cache.json"):
f = user_dir / name
if f.exists():
f.unlink()
deleted += 1
return deleted
@router.get("/api/admin/stats")
async def admin_stats(bincio_session: str | None = Cookie(default=None)) -> FileResponse:
"""Serve the latest usage stats figure. Admin only."""
deps._require_admin(bincio_session)
path = deps._get_data_dir().parent / "stats" / "latest.png"
if not path.exists():
raise HTTPException(404, "Stats not yet generated — run scripts/usage_stats.py first")
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache, no-store"})
@router.get("/api/admin/users")
async def admin_users(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
deps._require_admin(bincio_session)
users = list_users(deps._get_db())
return JSONResponse([{
"handle": u.handle,
"display_name": u.display_name,
"is_admin": u.is_admin,
"suspended": u.suspended,
"created_at": u.created_at,
} for u in users])
@router.get("/api/admin/jobs")
async def admin_jobs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return currently active upload/processing jobs. Admin only."""
deps._require_admin(bincio_session)
with tasks._jobs_lock:
jobs = list(tasks._active_jobs.values())
return JSONResponse(jobs)
@router.get("/api/admin/disk")
async def admin_disk(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Per-user disk usage breakdown. Admin only."""
deps._require_admin(bincio_session)
import shutil
data_dir = deps._get_data_dir()
def _mb(path: Path) -> float:
if not path.exists():
return 0.0
# Use lstat to count symlink entries (few bytes each) rather than following
# the link to the target — prevents _merged/ from double-counting activities/.
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
return round(total / 1_048_576, 1)
def _count(path: Path, pattern: str = "*") -> int:
if not path.exists():
return 0
return sum(1 for f in path.glob(pattern) if f.is_file())
db = deps._get_db()
from bincio.serve.db import get_user as _get_user
users = []
for user_dir in sorted(data_dir.iterdir()):
if not user_dir.is_dir() or user_dir.name.startswith("_"):
continue
# leaked tmp zips
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
db_user = _get_user(db, user_dir.name)
users.append({
"handle": user_dir.name,
"in_db": db_user is not None,
"suspended": db_user.suspended if db_user else False,
"total_mb": _mb(user_dir),
"activities_mb": _mb(user_dir / "activities"),
"activities_count": _count(user_dir / "activities", "*.json"),
"merged_mb": _mb(user_dir / "_merged"),
"originals_mb": _mb(user_dir / "originals"),
"originals_strava_mb": _mb(user_dir / "originals" / "strava"),
"images_mb": _mb(user_dir / "edits" / "images"),
"leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1),
"leaked_zips_count": len(leaked),
})
disk = shutil.disk_usage("/")
return JSONResponse({
"disk": {
"total_gb": round(disk.total / 1_073_741_824, 1),
"used_gb": round(disk.used / 1_073_741_824, 1),
"free_gb": round(disk.free / 1_073_741_824, 1),
"percent": round(disk.used / disk.total * 100, 1),
},
"users": users,
})
@router.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse)
async def admin_reset_password_code(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Generate a one-time password reset code for a user. Proxied to bincio-auth."""
return await _auth_proxy("POST", f"/api/admin/users/{handle}/reset-password-code", bincio_session)
@router.post("/api/admin/users/{handle}/suspend")
async def admin_suspend(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Suspend a user account. Proxied to bincio-auth."""
return await _auth_proxy("POST", f"/api/admin/users/{handle}/suspend", bincio_session)
@router.post("/api/admin/users/{handle}/unsuspend")
async def admin_unsuspend(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Re-enable a suspended user account. Proxied to bincio-auth."""
return await _auth_proxy("POST", f"/api/admin/users/{handle}/unsuspend", bincio_session)
@router.delete("/api/admin/users/{handle}/account")
async def admin_delete_account(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Delete a user account. Proxied to bincio-auth."""
return await _auth_proxy("DELETE", f"/api/admin/users/{handle}/account", bincio_session)
@router.post("/api/admin/users/{handle}/rebuild")
async def admin_rebuild(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Trigger a merge_all + site rebuild for a user. Admin only."""
deps._require_admin(bincio_session)
user_dir = deps._get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No data directory for user '{handle}'")
tasks._trigger_rebuild(handle)
return JSONResponse({"ok": True})
@router.post("/api/admin/users/{handle}/rebuild-sync")
async def admin_rebuild_sync(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Run merge+rebuild synchronously and return full output. Admin only.
Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr.
Use for debugging when you need to see what went wrong.
"""
deps._require_admin(bincio_session)
user_dir = deps._get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No data directory for user '{handle}'")
if deps.site_dir is None:
raise HTTPException(503, "Server has no --site-dir configured; rebuild not available")
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
cmd = [uv, "run", "bincio", "render",
"--data-dir", str(deps.data_dir),
"--site-dir", str(deps.site_dir),
"--handle", handle,
"--no-build"]
if deps.webroot:
cmd = [uv, "run", "bincio", "render",
"--data-dir", str(deps.data_dir),
"--site-dir", str(deps.site_dir),
"--handle", handle]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
resp: dict[str, Any] = {
"ok": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
if result.returncode == 0 and deps.webroot:
dist_data = deps.site_dir / "dist" / "data"
if dist_data.exists():
shutil.rmtree(dist_data)
rsync = subprocess.run(
["rsync", "-a", "--delete", "--exclude=data/",
f"{deps.site_dir}/dist/", str(deps.webroot) + "/"],
capture_output=True, text=True, timeout=120,
)
resp["rsync_returncode"] = rsync.returncode
resp["rsync_stdout"] = rsync.stdout
resp["rsync_stderr"] = rsync.stderr
resp["ok"] = rsync.returncode == 0
return JSONResponse(resp)
@router.post("/api/admin/users/{handle}/reextract-originals")
async def admin_reextract_originals(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> StreamingResponse:
"""Re-extract activities from stored Strava originals without hitting the API.
Spawns `bincio reextract-originals` as a subprocess so heavy memory use
is isolated from the server process. Streams its JSON-lines output as SSE.
Triggers a full rebuild on completion.
"""
import asyncio
deps._require_admin(bincio_session)
user_dir = deps._get_data_dir() / handle
originals_dir = user_dir / "originals" / "strava"
if not originals_dir.exists():
raise HTTPException(404, f"No Strava originals directory for '{handle}'")
# Use the bincio script from the same venv bin dir as the running Python.
# This is reliable in systemd environments where PATH may not include uv.
import sys as _sys
bincio_exe = str(Path(_sys.executable).parent / "bincio")
data_dir = str(deps._get_data_dir())
# Count originals so we can split into memory-safe batches.
total_originals = len(list(originals_dir.glob("*.json")))
# Each activity can briefly peak at ~1030 MB; 100 per batch keeps RSS
# well under 3 GB even on a cheap VPS.
_BATCH = 100
log.info("reextract[%s]: %d originals, batch size %d, via %s",
handle, total_originals, _BATCH, bincio_exe)
async def event_stream():
total_imported = total_skipped = total_errors = 0
offset = 0
while offset < total_originals:
limit = min(_BATCH, total_originals - offset)
proc = await asyncio.create_subprocess_exec(
bincio_exe, "reextract-originals",
"--data-dir", data_dir,
"--handle", handle,
"--offset", str(offset),
"--limit", str(limit),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.decode(errors="replace").strip()
if not line:
continue
yield f"data: {line}\n\n"
try:
evt = json.loads(line)
if evt.get("type") == "done":
total_imported += evt.get("imported", 0)
total_skipped += evt.get("skipped", 0)
total_errors += evt.get("errors", 0)
except json.JSONDecodeError:
pass
await proc.wait()
if proc.returncode != 0:
stderr_out = await proc.stderr.read() if proc.stderr else b""
log.error("reextract[%s]: batch offset=%d exited %d — stderr: %s",
handle, offset, proc.returncode,
stderr_out.decode(errors="replace")[:500])
yield f"data: {json.dumps({'type': 'error', 'message': f'Batch {offset}{offset+limit} exited with code {proc.returncode}'})}\n\n"
return # stop on batch failure
offset += limit
# All batches complete
log.info("reextract[%s]: all batches done — imported=%d skipped=%d errors=%d; triggering rebuild",
handle, total_imported, total_skipped, total_errors)
tasks._trigger_rebuild(handle)
yield f"data: {json.dumps({'type': 'done', 'imported': total_imported, 'skipped': total_skipped, 'errors': total_errors})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/api/admin/users/{handle}/diag")
async def admin_diag(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return a diagnostic snapshot of a user's data directory. Admin only."""
deps._require_admin(bincio_session)
user_dir = deps._get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No data directory for user '{handle}'")
def _count(path: Path, glob: str = "*") -> int:
return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0
def _size_mb(path: Path) -> float:
if not path.exists():
return 0.0
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576
activities_dir = user_dir / "activities"
merged_dir = user_dir / "_merged"
originals_dir = user_dir / "originals"
uploads_dir = user_dir / "_uploads"
merged_index = merged_dir / "index.json"
root_index = user_dir / "index.json"
merged_activity_count: int | None = None
if merged_index.exists():
try:
idx = json.loads(merged_index.read_text())
merged_activity_count = len(idx.get("activities", []))
except (OSError, json.JSONDecodeError):
merged_activity_count = -1
root_activity_count: int | None = None
if root_index.exists():
try:
idx = json.loads(root_index.read_text())
root_activity_count = len(idx.get("activities", []))
except (OSError, json.JSONDecodeError):
root_activity_count = -1
# Peek at a few filenames in activities/ to understand the actual state
acts_sample: list[str] = []
acts_symlinks = 0
if activities_dir.exists():
for f in sorted(activities_dir.iterdir())[:10]:
acts_sample.append(f.name + (" → symlink" if f.is_symlink() else ""))
if f.is_symlink():
acts_symlinks += 1
# Check _merged/activities/ separately
merged_acts_dir = merged_dir / "activities"
merged_acts_json = _count(merged_acts_dir, "*.json")
merged_acts_geojson = _count(merged_acts_dir, "*.geojson")
# List pending files
pending_files: list[str] = []
if uploads_dir.exists():
pending_files = [f.name for f in uploads_dir.iterdir() if f.is_file()]
return JSONResponse({
"handle": handle,
"user_dir": str(user_dir),
"activities": {
"json_files": _count(activities_dir, "*.json"),
"geojson_files": _count(activities_dir, "*.geojson"),
"size_mb": round(_size_mb(activities_dir), 2),
"sample": acts_sample,
"symlink_count": acts_symlinks,
},
"originals": {
"exists": originals_dir.exists(),
"size_mb": round(_size_mb(originals_dir), 2),
"strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0,
},
"merged": {
"exists": merged_dir.exists(),
"activity_count_in_index": merged_activity_count,
"size_mb": round(_size_mb(merged_dir), 2),
"activities_json": merged_acts_json,
"activities_geojson": merged_acts_geojson,
},
"root_index": {
"exists": root_index.exists(),
"activity_count": root_activity_count,
},
"pending_uploads": len(pending_files),
"pending_files": pending_files,
"dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(),
"athlete_json_exists": (user_dir / "athlete.json").exists(),
})
@router.delete("/api/admin/users/{handle}/activities")
async def admin_delete_activities(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Delete all activity data for a user and wipe the merged cache."""
deps._require_admin(bincio_session)
user_dir = deps._get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No data directory for user '{handle}'")
deleted = _wipe_user_activities(user_dir)
tasks._trigger_rebuild(handle)
return JSONResponse({"ok": True, "deleted": deleted})
@router.delete("/api/admin/users/{handle}/directory")
async def admin_delete_user_directory(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Delete the entire user directory from disk (for ghost users not in the DB).
Refuses if the handle exists as an account in the database — use
DELETE /api/admin/users/{handle}/activities for registered users.
"""
import shutil
deps._require_admin(bincio_session)
db = deps._get_db()
from bincio.serve.db import get_user as _get_user
if _get_user(db, handle) is not None:
raise HTTPException(
400,
f"User '{handle}' is still in the database. Remove the account first, "
"or use 'Reset data' to wipe only activity files.",
)
user_dir = deps._get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No directory for '{handle}'")
shutil.rmtree(user_dir)
# Rebuild root manifest so the ghost shard disappears from the site
from bincio.render.cli import _write_root_manifest
try:
_write_root_manifest(deps._get_data_dir())
except (OSError, json.JSONDecodeError):
pass
return JSONResponse({"ok": True})
@router.get("/api/admin/strava-sync")
async def admin_strava_sync_status(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return per-user Strava sync status for the admin panel."""
deps._require_admin(bincio_session)
root = deps._get_data_dir()
users = []
for tf in sorted(root.glob("*/strava_token.json")):
user_dir = tf.parent
handle = user_dir.name
has_creds = (user_dir / "strava_credentials.json").exists()
last_sync: str | None = None
total_imported = 0
sync_path = user_dir / "_strava_sync.json"
if sync_path.exists():
try:
sc = json.loads(sync_path.read_text(encoding="utf-8"))
last_sync = sc.get("last_sync")
total_imported = len(sc.get("imported_ids", []))
except (OSError, json.JSONDecodeError):
pass
run_status: str | None = None
run_imported = 0
run_errors = 0
run_error_message: str | None = None
last_run: str | None = None
status_path = user_dir / "_strava_sync_status.json"
if status_path.exists():
try:
ss = json.loads(status_path.read_text(encoding="utf-8"))
run_status = ss.get("status")
run_imported = ss.get("imported", 0)
run_errors = ss.get("errors", 0)
run_error_message = ss.get("error_message")
last_run = ss.get("last_run")
except (OSError, json.JSONDecodeError):
pass
users.append({
"handle": handle,
"has_credentials": has_creds,
"last_sync": last_sync,
"total_imported": total_imported,
"run_status": run_status,
"run_imported": run_imported,
"run_errors": run_errors,
"run_error_message": run_error_message,
"last_run": last_run,
})
return JSONResponse({"running": deps._strava_sync_running, "users": users})
@router.post("/api/admin/strava-sync/run")
async def admin_strava_sync_run(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Trigger an immediate Strava sync for all users (admin only)."""
deps._require_admin(bincio_session)
with deps._strava_sync_lock:
if deps._strava_sync_running:
raise HTTPException(409, "Sync already running")
deps._strava_sync_running = True
def _run() -> None:
try:
from bincio.sync_strava import sync_all
results = sync_all(deps._get_data_dir())
total_new = sum(n for n, _ in results.values())
if total_new > 0:
tasks._site_rebuild_event.set()
except Exception:
log.exception("admin_strava_sync_run: unexpected error")
finally:
deps._strava_sync_running = False
threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start()
return JSONResponse({"ok": True}, status_code=202)
@router.get("/api/admin/garmin-sync")
async def admin_garmin_sync_status(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return per-user Garmin sync status for the admin panel."""
deps._require_admin(bincio_session)
root = deps._get_data_dir()
users = []
for cf in sorted(root.glob("*/garmin_creds.json")):
user_dir = cf.parent
handle = user_dir.name
last_sync: str | None = None
total_imported = 0
sync_path = user_dir / "garmin_sync.json"
if sync_path.exists():
try:
sc = json.loads(sync_path.read_text(encoding="utf-8"))
last_sync = sc.get("last_sync_at")
total_imported = sc.get("total_imported", 0)
except (OSError, json.JSONDecodeError):
pass
run_status: str | None = None
run_imported = 0
run_errors = 0
run_error_message: str | None = None
last_run: str | None = None
status_path = user_dir / "_garmin_sync_status.json"
if status_path.exists():
try:
ss = json.loads(status_path.read_text(encoding="utf-8"))
run_status = ss.get("status")
run_imported = ss.get("imported", 0)
run_errors = ss.get("errors", 0)
run_error_message = ss.get("error_message")
last_run = ss.get("last_run")
except (OSError, json.JSONDecodeError):
pass
users.append({
"handle": handle,
"last_sync": last_sync,
"total_imported": total_imported,
"run_status": run_status,
"run_imported": run_imported,
"run_errors": run_errors,
"run_error_message": run_error_message,
"last_run": last_run,
})
return JSONResponse({"running": deps._garmin_sync_running, "users": users})
@router.post("/api/admin/garmin-sync/run")
async def admin_garmin_sync_run(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Trigger an immediate Garmin sync for all users (admin only)."""
deps._require_admin(bincio_session)
with deps._garmin_sync_lock:
if deps._garmin_sync_running:
raise HTTPException(409, "Sync already running")
deps._garmin_sync_running = True
def _run() -> None:
try:
from bincio.sync_garmin import sync_all
results = sync_all(deps._get_data_dir())
total_new = sum(n for n, _ in results.values())
if total_new > 0:
tasks._site_rebuild_event.set()
except Exception:
log.exception("admin_garmin_sync_run: unexpected error")
finally:
deps._garmin_sync_running = False
threading.Thread(target=_run, daemon=True, name="admin-garmin-sync").start()
return JSONResponse({"ok": True}, status_code=202)
@router.post("/api/admin/users/{handle}/recompute-elevation")
async def admin_recompute_elevation(
handle: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Recompute elevation gain/loss for all activities of a user from stored timeseries.
Skips activities with altitude_source == 'dem' (already DEM-corrected).
Applies the leading-zero no-fix fix and source-aware hysteresis.
Returns patched/skipped/error counts.
"""
deps._require_admin(bincio_session)
user_dir = deps._get_data_dir() / handle
if not user_dir.is_dir():
raise HTTPException(404, f"No data directory for '{handle}'")
from bincio.extract.dem import recalculate_elevation_hysteresis
from bincio.render.merge import merge_one
patched = skipped = errors = 0
acts_dir = user_dir / "activities"
for json_path in sorted(acts_dir.glob("*.json")):
if json_path.name.endswith(".timeseries.json"):
continue
activity_id = json_path.stem
try:
detail = json.loads(json_path.read_text(encoding="utf-8"))
if detail.get("altitude_source") == "dem":
skipped += 1
continue
ts_path = acts_dir / f"{activity_id}.timeseries.json"
if not ts_path.exists():
skipped += 1
continue
ts = json.loads(ts_path.read_text(encoding="utf-8"))
ele_arr = ts.get("elevation_m") or []
if not any(e for e in ele_arr if e is not None):
skipped += 1
continue
recalculate_elevation_hysteresis(user_dir, activity_id)
merge_one(user_dir, activity_id)
patched += 1
except Exception as exc:
log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc)
errors += 1
if patched > 0:
tasks._trigger_rebuild(handle)
return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors})
+204
View File
@@ -0,0 +1,204 @@
"""Authentication and registration endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse
from bincio.serve import deps, tasks
from bincio.serve.models import (
CreateInviteRequest,
GenericResponse,
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
ResetPasswordRequest,
)
from bincio.serve.db import (
authenticate,
count_activity_users,
count_wiki_users,
create_invite,
create_session,
create_user,
delete_session,
get_invite,
get_setting,
get_user,
list_invites,
use_invite,
)
router = APIRouter()
@router.post("/api/auth/login", response_model=LoginResponse)
async def login(
login_req: LoginRequest,
request: Request,
) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
handle = login_req.handle.strip().lower()
password = login_req.password
user = authenticate(deps._get_db(), handle, password)
if not user:
raise HTTPException(401, "Invalid credentials")
token = create_session(deps._get_db(), handle)
resp = JSONResponse({
"ok": True,
"handle": user.handle,
"display_name": user.display_name,
"wiki_access": user.wiki_access,
"activity_access": user.activity_access,
})
deps._set_session_cookie(resp, token)
return resp
@router.post("/api/auth/logout", response_model=GenericResponse)
async def logout(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
if bincio_session:
delete_session(deps._get_db(), bincio_session)
resp = JSONResponse({"ok": True})
kwargs: dict = dict(key=deps._SESSION_COOKIE)
if deps._SESSION_DOMAIN:
kwargs["domain"] = deps._SESSION_DOMAIN
resp.delete_cookie(**kwargs)
return resp
@router.post("/api/auth/token")
async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse:
"""Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie."""
ip = request.client.host if request.client else "unknown"
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
handle = login_req.handle.strip().lower()
user = authenticate(deps._get_db(), handle, login_req.password)
if not user:
raise HTTPException(401, "Invalid credentials")
token = create_session(deps._get_db(), handle)
return JSONResponse({
"ok": True,
"token": token,
"handle": user.handle,
"display_name": user.display_name,
})
@router.post("/api/auth/reset-password", response_model=GenericResponse)
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
"""Validate a reset code and set a new password. Public endpoint."""
from bincio.serve.db import use_reset_code, change_password
handle = reset_req.handle.strip().lower()
code = reset_req.code.strip().upper()
new_pw = reset_req.password
if len(new_pw) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
db = deps._get_db()
if not use_reset_code(db, code, handle):
raise HTTPException(400, "Invalid or expired reset code")
change_password(db, handle, new_pw)
return JSONResponse({"ok": True})
# ── Registration ──────────────────────────────────────────────────────────────
@router.post("/api/register", response_model=RegisterResponse)
async def register(
register_req: RegisterRequest,
request: Request,
) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
deps._check_rate_limit(ip, deps._register_attempts, deps._REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
code = register_req.code.strip().upper()
handle = register_req.handle.strip().lower()
password = register_req.password
display = register_req.display_name.strip() or handle
if not deps._VALID_HANDLE.match(handle):
raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
if len(password) < 8:
raise HTTPException(400, "Password must be at least 8 characters")
invite = get_invite(deps._get_db(), code)
if not invite or invite.used:
raise HTTPException(400, "Invalid or already-used invite code")
if get_user(deps._get_db(), handle):
raise HTTPException(409, "Handle already taken")
db = deps._get_db()
max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users")
if max_wiki_val is not None:
limit = int(max_wiki_val)
if limit > 0 and count_wiki_users(db) >= limit:
raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})")
if invite.grants_activity:
max_act_val = get_setting(db, "max_activity_users")
if max_act_val is not None:
limit = int(max_act_val)
if limit > 0 and count_activity_users(db) >= limit:
raise HTTPException(403, f"This instance has reached its activity user limit ({limit})")
create_user(deps._get_db(), handle, display, password, is_admin=False,
wiki_access=True, activity_access=invite.grants_activity)
use_invite(deps._get_db(), code, handle)
# Create per-user directories
dd = deps._get_data_dir()
user_dir = dd / handle
(user_dir / "activities").mkdir(parents=True, exist_ok=True)
(user_dir / "edits").mkdir(parents=True, exist_ok=True)
# Write an empty index.json so the shard URL resolves immediately,
# even before the user uploads any activities.
from bincio.extract.writer import write_index
index_path = user_dir / "index.json"
if not index_path.exists():
write_index([], user_dir, {"handle": handle, "display_name": display or handle})
# Update root manifest so the new user's shard is discoverable immediately
from bincio.render.cli import _write_root_manifest
_write_root_manifest(dd)
# Rebuild site so the new user's profile pages exist immediately
tasks._trigger_rebuild(handle)
token = create_session(deps._get_db(), handle)
resp = JSONResponse({"ok": True, "handle": handle})
deps._set_session_cookie(resp, token)
return resp
# ── Invites ───────────────────────────────────────────────────────────────────
@router.get("/api/invites")
async def get_invites(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
invites = list_invites(deps._get_db(), user.handle)
return JSONResponse([{
"code": i.code,
"used": i.used,
"used_by": i.used_by,
"created_at": i.created_at,
"used_at": i.used_at,
"grants_activity": i.grants_activity,
} for i in invites])
@router.post("/api/invites")
async def post_invite(
body: CreateInviteRequest = CreateInviteRequest(),
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
try:
code = create_invite(deps._get_db(), user.handle, grants_activity=body.grants_activity)
except ValueError as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity})
+190
View File
@@ -0,0 +1,190 @@
"""Activity file download endpoint.
GET /api/activity/{activity_id}/download/{fmt}
fmt: bas | original | gpx
Permission:
- If activity.download_disabled is true: only the owner (authenticated) may download.
- Otherwise: no auth required — anyone who can see the activity can download.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Cookie, HTTPException
from fastapi.responses import FileResponse, Response
from bincio.serve import deps
router = APIRouter()
def _find_activity(activity_id: str) -> tuple[str, Path] | None:
"""Return (handle, detail_path) for whichever user owns this activity."""
data_dir = deps._get_data_dir()
for user_dir in sorted(data_dir.iterdir()):
if not user_dir.is_dir() or user_dir.name.startswith(("_", ".")):
continue
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
p = base / f"{activity_id}.json"
if p.exists():
return user_dir.name, p
return None
def _check_download_permission(
detail: dict, handle: str, bincio_session: Optional[str]
) -> None:
if not detail.get("download_disabled"):
return
try:
user = deps._require_user(bincio_session)
except HTTPException:
raise HTTPException(403, "Downloads are disabled for this activity")
if user.handle != handle:
raise HTTPException(403, "Downloads are disabled for this activity")
def _generate_gpx(detail: dict, ts: dict) -> str:
t_vals = ts.get("t") or []
lat_vals = ts.get("lat") or []
lon_vals = ts.get("lon") or []
ele_vals = ts.get("elevation_m") or []
hr_vals = ts.get("hr_bpm") or []
title = (detail.get("title") or "Activity").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
started = detail.get("started_at") or "1970-01-01T00:00:00+00:00"
try:
t0 = datetime.fromisoformat(started)
except ValueError:
t0 = datetime(1970, 1, 1, tzinfo=timezone.utc)
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<gpx version="1.1" creator="bincio"'
' xmlns="http://www.topografix.com/GPX/1/1"'
' xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">',
f' <trk><name>{title}</name><trkseg>',
]
for i, t in enumerate(t_vals):
lat = lat_vals[i] if i < len(lat_vals) else None
lon = lon_vals[i] if i < len(lon_vals) else None
if lat is None or lon is None:
continue
ele = ele_vals[i] if i < len(ele_vals) else None
hr = hr_vals[i] if i < len(hr_vals) else None
ts_str = (t0 + timedelta(seconds=t)).strftime("%Y-%m-%dT%H:%M:%SZ")
trkpt = f' <trkpt lat="{lat}" lon="{lon}">'
if ele is not None:
trkpt += f"<ele>{ele}</ele>"
trkpt += f"<time>{ts_str}</time>"
if hr is not None:
trkpt += (
f"<extensions><gpxtpx:TrackPointExtension>"
f"<gpxtpx:hr>{hr}</gpxtpx:hr>"
f"</gpxtpx:TrackPointExtension></extensions>"
)
trkpt += "</trkpt>"
lines.append(trkpt)
lines += [" </trkseg></trk>", "</gpx>"]
return "\n".join(lines)
@router.get("/api/activity/{activity_id}/download/{fmt}")
async def download_activity(
activity_id: str,
fmt: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> Response:
deps._check_id(activity_id)
if fmt not in ("bas", "original", "gpx"):
raise HTTPException(400, "fmt must be bas, original, or gpx")
result = _find_activity(activity_id)
if result is None:
raise HTTPException(404, "Activity not found")
handle, detail_path = result
detail = json.loads(detail_path.read_text(encoding="utf-8"))
_check_download_permission(detail, handle, bincio_session)
if fmt == "bas":
# Embed the timeseries so the downloaded file is self-contained.
ts_path: Path | None = None
data_dir = deps._get_data_dir()
for base in (
data_dir / handle / "_merged" / "activities",
data_dir / handle / "activities",
):
p = base / f"{activity_id}.timeseries.json"
if p.exists():
ts_path = p
break
if ts_path:
try:
detail["timeseries"] = json.loads(ts_path.read_text(encoding="utf-8"))
detail.pop("timeseries_url", None)
except (OSError, json.JSONDecodeError):
pass
content = json.dumps(detail, ensure_ascii=False, indent=2)
return Response(
content=content,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{activity_id}.json"'},
)
if fmt == "original":
source = detail.get("source") or ""
source_file = detail.get("source_file") or ""
if source not in ("fit_file", "gpx_file") or not source_file:
raise HTTPException(404, "No original file available for this activity")
safe_name = Path(source_file).name # strip any directory traversal
orig_path = deps._get_data_dir() / handle / "originals" / safe_name
if not orig_path.exists():
raise HTTPException(404, "Original file not found on disk")
media_type = "application/octet-stream"
if safe_name.endswith(".fit"):
media_type = "application/vnd.ant.fit"
elif safe_name.endswith(".gpx"):
media_type = "application/gpx+xml"
return FileResponse(
orig_path,
media_type=media_type,
filename=safe_name,
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)
# fmt == "gpx"
data_dir = deps._get_data_dir()
ts_path: Path | None = None
for base in (
data_dir / handle / "_merged" / "activities",
data_dir / handle / "activities",
):
p = base / f"{activity_id}.timeseries.json"
if p.exists():
ts_path = p
break
if ts_path is None:
raise HTTPException(404, "No GPS data available for this activity")
ts = json.loads(ts_path.read_text(encoding="utf-8"))
lat_vals = ts.get("lat") or []
if not any(v is not None for v in lat_vals):
raise HTTPException(404, "No GPS data available for this activity")
gpx_content = _generate_gpx(detail, ts)
raw_title = detail.get("title") or activity_id
safe_title = "".join(c for c in raw_title if c.isalnum() or c in " -_")[:50].strip()
filename = f"{safe_title or activity_id}.gpx"
return Response(
content=gpx_content,
media_type="application/gpx+xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+129
View File
@@ -0,0 +1,129 @@
"""Feed and wheel endpoints."""
from __future__ import annotations
import json
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from bincio.serve import deps, tasks
from bincio.serve.models import CurrentUserResponse
from bincio.serve.db import (
User,
get_member_tree,
get_setting,
)
router = APIRouter()
@router.get("/api/me", response_model=CurrentUserResponse)
async def me(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
user = deps._current_user(bincio_session)
if not user:
raise HTTPException(401, "Not authenticated")
store_orig = get_setting(deps._get_db(), "store_originals")
return JSONResponse({
"handle": user.handle,
"display_name": user.display_name,
"is_admin": user.is_admin,
"wiki_access": user.wiki_access,
"activity_access": user.activity_access,
"store_originals_default": store_orig != "false",
"dem_configured": bool(deps.dem_url),
})
@router.get("/api/stats")
async def stats() -> JSONResponse:
"""Public endpoint: member count, join dates, and invitation tree."""
import time as _time
now = int(_time.time())
members = get_member_tree(deps._get_db())
return JSONResponse({
"user_count": len(members),
"members": [
{
"handle": m["handle"],
"display_name": m["display_name"],
"member_since": m["created_at"],
"member_for_days": (now - m["created_at"]) // 86400,
"invited_by": m["invited_by"],
}
for m in members
],
})
@router.post("/api/internal/rebuild")
async def internal_rebuild(request: Request) -> JSONResponse:
"""Trigger a site rebuild. Authenticated via X-Sync-Secret header.
Called by the bincio sync-strava systemd timer after syncing new activities.
Returns 503 if webroot is not configured (rebuild not possible).
Returns 403 if the secret is missing or wrong.
"""
if not deps.sync_secret:
raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)")
if request.headers.get("X-Sync-Secret") != deps.sync_secret:
raise HTTPException(403, "Forbidden")
if deps.site_dir is None:
raise HTTPException(503, "No site dir configured")
tasks._site_rebuild_event.set()
return JSONResponse({"status": "rebuild queued"})
@router.get("/api/wheel/version")
async def wheel_version() -> JSONResponse:
"""Public endpoint: current bincio wheel version for mobile app update checks."""
import importlib.metadata
try:
version = importlib.metadata.version("bincio")
except importlib.metadata.PackageNotFoundError:
version = "0.1.0"
return JSONResponse({
"version": version,
"url": f"/bincio-{version}-py3-none-any.whl",
"api_url": f"/api/wheel/download",
})
@router.get("/api/wheel/download")
async def wheel_download() -> FileResponse:
"""Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
import importlib.metadata
from pathlib import Path
try:
version = importlib.metadata.version("bincio")
except importlib.metadata.PackageNotFoundError:
version = "0.1.0"
wheel_name = f"bincio-{version}-py3-none-any.whl"
# Look in dist/ relative to repo root (two levels up from this file)
dist_dir = Path(__file__).parent.parent.parent.parent / "dist"
wheel_path = dist_dir / wheel_name
if not wheel_path.exists():
raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)
@router.get("/api/feed")
async def get_feed(user: User = Depends(deps._require_auth)) -> JSONResponse:
"""Return the authenticated user's activity summaries (mobile feed sync).
_merged/index.json is a shard manifest (activities: []) when the user has
more than FEED_PAGE_SIZE activities. Collect from all shard files.
"""
dd = deps._get_data_dir()
user_dir = dd / user.handle
for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"):
if not index_path.exists():
continue
index = json.loads(index_path.read_text())
activities: list[dict] = index.get("activities", [])
for shard in index.get("shards", []):
shard_path = index_path.parent / shard["url"]
if shard_path.exists():
shard_doc = json.loads(shard_path.read_text())
activities.extend(shard_doc.get("activities", []))
return JSONResponse({"activities": activities})
return JSONResponse({"activities": []})
+140
View File
@@ -0,0 +1,140 @@
"""Garmin Connect endpoints (/api/garmin/*)."""
from __future__ import annotations
import json
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from bincio.serve import deps, tasks
router = APIRouter()
def _garmin_user_message(exc: Exception) -> str:
"""Return a human-friendly error message for common Garmin login failures."""
msg = str(exc)
fallback = (
" In the meantime, you can export your activities from Garmin Connect "
"(garmin.com → Activities → Export) or Garmin Express as FIT files "
"and upload them directly."
)
if "429" in msg or "rate limit" in msg.lower():
return (
"Garmin is rate-limiting this server's IP address (HTTP 429). "
"Wait a few hours and try again." + fallback
)
if "403" in msg:
return (
"Cloudflare is blocking the login request (HTTP 403). "
"This is a known upstream issue — try again later or update garminconnect "
"(uv sync --extra garmin)." + fallback
)
if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower():
return (
"Garmin's login page returned a CAPTCHA or MFA challenge that "
"cannot be completed automatically. Try again later, or disable "
"two-factor authentication on your Garmin account." + fallback
)
return f"Login failed: {exc}" + fallback
@router.get("/api/garmin/status")
async def garmin_status(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return whether Garmin credentials are stored for the current user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
from bincio.extract.garmin_api import has_credentials
from bincio.extract.garmin_sync import _load_sync_state
connected = has_credentials(dd)
last_sync = None
if connected:
state = _load_sync_state(dd)
last_sync = state.get("last_sync_at")
return JSONResponse({"connected": connected, "last_sync": last_sync})
@router.post("/api/garmin/connect")
async def garmin_connect(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Test Garmin login with the supplied credentials and save them on success."""
user = deps._require_user(bincio_session)
body = await request.json()
email = (body.get("email") or "").strip()
password = body.get("password") or ""
if not email or not password:
raise HTTPException(400, "email and password are required")
data_dir = deps._get_data_dir()
user_dir = data_dir / user.handle
from bincio.extract.garmin_api import GarminError, test_login
try:
info = test_login(data_dir, user_dir, email, password)
except GarminError as exc:
raise HTTPException(400, _garmin_user_message(exc))
return JSONResponse({"ok": True, **info})
@router.post("/api/garmin/disconnect")
async def garmin_disconnect(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Remove stored Garmin credentials and session for the current user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
from bincio.extract.garmin_api import delete_credentials
delete_credentials(dd)
return JSONResponse({"ok": True})
@router.get("/api/garmin/sync/stream")
async def garmin_sync_stream(bincio_session: str | None = Cookie(default=None)) -> StreamingResponse:
"""SSE endpoint — streams per-activity Garmin sync progress."""
user = deps._require_user(bincio_session)
data_dir = deps._get_data_dir()
user_dir = data_dir / user.handle
from bincio.extract.garmin_api import GarminError, has_credentials
if not has_credentials(user_dir):
raise HTTPException(400, "No Garmin credentials stored — connect first")
from bincio.extract.garmin_sync import garmin_sync_iter
def event_stream():
try:
for event in garmin_sync_iter(data_dir, user_dir):
if event["type"] == "done":
tasks._trigger_rebuild(user.handle)
yield f"data: {json.dumps(event)}\n\n"
except GarminError as exc:
yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/api/garmin/import-gear")
async def garmin_import_gear(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""One-time backfill: fetch gear registry from Garmin and match to existing activities by timestamp."""
from bincio.extract.garmin_api import GarminError, has_credentials
from bincio.extract.garmin_sync import import_garmin_gear
user = deps._require_user(bincio_session)
data_dir = deps._get_data_dir()
user_dir = data_dir / user.handle
if not has_credentials(user_dir):
raise HTTPException(400, "No Garmin credentials stored — connect first")
try:
result = import_garmin_gear(data_dir, user_dir)
except GarminError as exc:
raise HTTPException(502, _garmin_user_message(exc))
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, **result})
+292
View File
@@ -0,0 +1,292 @@
"""Gear registry endpoints (/api/gear)."""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse
from bincio.serve import deps
router = APIRouter()
_GEAR_TYPES = {"bike", "shoes", "skis", "other"}
def _gear_path(user_dir: Path) -> Path:
return user_dir / "gear.json"
def _load(user_dir: Path) -> list[dict]:
p = _gear_path(user_dir)
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
return data.get("items", [])
except (OSError, json.JSONDecodeError):
return []
def _save(user_dir: Path, items: list[dict]) -> None:
_gear_path(user_dir).write_text(
json.dumps({"items": items}, indent=2, ensure_ascii=False),
encoding="utf-8",
)
@router.get("/api/gear")
async def gear_list(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
items = _load(deps._get_data_dir() / user.handle)
return JSONResponse({"items": items})
@router.post("/api/gear")
async def gear_add(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
body = await request.json()
name = str(body.get("name", "")).strip()
if not name:
raise HTTPException(400, "name is required")
gear_type = str(body.get("type", "other")).strip()
if gear_type not in _GEAR_TYPES:
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
strava_id = str(body.get("strava_id", "")).strip() or None
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
# Deduplicate by strava_id if provided
if strava_id and any(i.get("strava_id") == strava_id for i in items):
existing = next(i for i in items if i.get("strava_id") == strava_id)
return JSONResponse({"ok": True, "item": existing, "created": False})
item: dict = {
"id": str(uuid.uuid4()),
"name": name,
"type": gear_type,
"retired": False,
}
if strava_id:
item["strava_id"] = strava_id
items.append(item)
_save(user_dir, items)
return JSONResponse({"ok": True, "item": item, "created": True}, status_code=201)
@router.patch("/api/gear/{item_id}")
async def gear_update(
item_id: str,
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
if idx is None:
raise HTTPException(404, "Gear item not found")
body = await request.json()
item = dict(items[idx])
if "name" in body:
name = str(body["name"]).strip()
if not name:
raise HTTPException(400, "name cannot be empty")
item["name"] = name
if "type" in body:
gear_type = str(body["type"]).strip()
if gear_type not in _GEAR_TYPES:
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
item["type"] = gear_type
if "retired" in body:
item["retired"] = bool(body["retired"])
items[idx] = item
_save(user_dir, items)
return JSONResponse({"ok": True, "item": item})
@router.delete("/api/gear/{item_id}")
async def gear_delete(
item_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
before = len(items)
items = [g for g in items if g["id"] != item_id]
if len(items) == before:
raise HTTPException(404, "Gear item not found")
_save(user_dir, items)
return JSONResponse({"ok": True})
# ── Parts ─────────────────────────────────────────────────────────────────────
def _find_item(items: list[dict], item_id: str) -> tuple[int, dict]:
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
if idx is None:
raise HTTPException(404, "Gear item not found")
return idx, items[idx]
@router.post("/api/gear/{item_id}/parts")
async def part_add(
item_id: str,
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
idx, item = _find_item(items, item_id)
body = await request.json()
name = str(body.get("name", "")).strip()
if not name:
raise HTTPException(400, "name is required")
threshold_km = body.get("threshold_km")
if threshold_km is not None:
try:
threshold_km = float(threshold_km)
except (TypeError, ValueError):
raise HTTPException(400, "threshold_km must be a number")
part: dict = {"id": str(uuid.uuid4()), "name": name, "replacements": []}
if threshold_km is not None:
part["threshold_km"] = threshold_km
item = dict(item)
item.setdefault("parts", [])
item["parts"] = [*item["parts"], part]
items[idx] = item
_save(user_dir, items)
return JSONResponse({"ok": True, "part": part}, status_code=201)
@router.patch("/api/gear/{item_id}/parts/{part_id}")
async def part_update(
item_id: str,
part_id: str,
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
idx, item = _find_item(items, item_id)
parts = list(item.get("parts", []))
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
if pidx is None:
raise HTTPException(404, "Part not found")
body = await request.json()
part = dict(parts[pidx])
if "name" in body:
name = str(body["name"]).strip()
if not name:
raise HTTPException(400, "name cannot be empty")
part["name"] = name
if "threshold_km" in body:
try:
part["threshold_km"] = float(body["threshold_km"])
except (TypeError, ValueError):
raise HTTPException(400, "threshold_km must be a number")
parts[pidx] = part
item = {**item, "parts": parts}
items[idx] = item
_save(user_dir, items)
return JSONResponse({"ok": True, "part": part})
@router.delete("/api/gear/{item_id}/parts/{part_id}")
async def part_delete(
item_id: str,
part_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
idx, item = _find_item(items, item_id)
parts = [p for p in item.get("parts", []) if p["id"] != part_id]
items[idx] = {**item, "parts": parts}
_save(user_dir, items)
return JSONResponse({"ok": True})
@router.post("/api/gear/{item_id}/parts/{part_id}/replacements")
async def replacement_add(
item_id: str,
part_id: str,
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Log a replacement event for a part. date defaults to today (UTC)."""
from datetime import UTC, datetime
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
idx, item = _find_item(items, item_id)
parts = list(item.get("parts", []))
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
if pidx is None:
raise HTTPException(404, "Part not found")
body = await request.json()
date = str(body.get("date", "")).strip() or datetime.now(UTC).strftime("%Y-%m-%d")
note = str(body.get("note", "")).strip() or None
entry: dict = {"id": str(uuid.uuid4()), "date": date}
if note:
entry["note"] = note
part = dict(parts[pidx])
part["replacements"] = [*part.get("replacements", []), entry]
parts[pidx] = part
items[idx] = {**item, "parts": parts}
_save(user_dir, items)
return JSONResponse({"ok": True, "replacement": entry}, status_code=201)
@router.delete("/api/gear/{item_id}/parts/{part_id}/replacements/{replacement_id}")
async def replacement_delete(
item_id: str,
part_id: str,
replacement_id: str,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
items = _load(user_dir)
idx, item = _find_item(items, item_id)
parts = list(item.get("parts", []))
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
if pidx is None:
raise HTTPException(404, "Part not found")
part = dict(parts[pidx])
part["replacements"] = [r for r in part.get("replacements", []) if r["id"] != replacement_id]
parts[pidx] = part
items[idx] = {**item, "parts": parts}
_save(user_dir, items)
return JSONResponse({"ok": True})
+305
View File
@@ -0,0 +1,305 @@
"""Ideas and feedback endpoints (/api/ideas/*, /api/feedback)."""
from __future__ import annotations
import fcntl as _fcntl
import json
import secrets
import time
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from bincio.serve import deps
from bincio.serve.models import IdeaBody, IdeaCommentBody
router = APIRouter()
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
_FEEDBACK_MAX_IMAGES = 3
_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB
def _ideas_dir(data_dir: Path) -> Path:
d = data_dir / "_ideas"
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/ideas")
async def list_ideas(
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
ideas = []
for path in sorted(_ideas_dir(dd).glob("*.json")):
try:
idea = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
votes = idea.get("votes", [])
idea["vote_count"] = len(votes)
idea["my_vote"] = user.handle in votes
ideas.append(idea)
def _sort_key(x: dict):
s = x.get("status") or "open"
order = {"awaiting": 0, "open": 1, "done": 2, "declined": 3}
return (order.get(s, 1), -x["vote_count"], -x["created_at"])
ideas.sort(key=_sort_key)
return JSONResponse({"ideas": ideas})
@router.post("/api/ideas")
async def create_idea(
data: IdeaBody,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
title = data.title.strip()[:200]
body = data.body.strip()[:2000]
if not title:
raise HTTPException(400, "Title required")
dd = deps._get_data_dir()
idea_id = secrets.token_hex(8)
idea = {
"id": idea_id,
"title": title,
"body": body,
"author": user.handle,
"created_at": int(time.time()),
"votes": [],
}
path = _ideas_dir(dd) / f"{idea_id}.json"
path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8")
return JSONResponse({"id": idea_id})
@router.post("/api/ideas/{idea_id}/vote")
async def toggle_idea_vote(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
votes: list = idea.get("votes", [])
if user.handle in votes:
votes.remove(user.handle)
voted = False
else:
votes.append(user.handle)
voted = True
idea["votes"] = votes
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"voted": voted, "votes": len(votes)})
@router.post("/api/ideas/{idea_id}/status")
async def toggle_idea_status(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
if not user.is_admin:
raise HTTPException(403, "Forbidden")
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
cycle = {"open": "awaiting", "awaiting": "done", "done": "open"}
idea["status"] = cycle.get(idea.get("status") or "open", "awaiting")
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"status": idea["status"]})
@router.post("/api/ideas/{idea_id}/reopen")
async def reopen_idea(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
if not user.is_admin:
raise HTTPException(403, "Forbidden")
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
idea["status"] = "open"
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"status": "open"})
@router.post("/api/ideas/{idea_id}/decline")
async def decline_idea(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
if not user.is_admin:
raise HTTPException(403, "Forbidden")
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
idea["status"] = "open" if idea.get("status") == "declined" else "declined"
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"status": idea["status"]})
@router.post("/api/ideas/{idea_id}/comment")
async def set_idea_comment(
idea_id: str,
data: IdeaCommentBody,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
if not user.is_admin:
raise HTTPException(403, "Forbidden")
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
comment = data.comment.strip()[:1000]
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
if comment:
idea["admin_comment"] = comment
else:
idea.pop("admin_comment", None)
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"ok": True, "admin_comment": comment or None})
@router.patch("/api/ideas/{idea_id}")
async def edit_idea(
idea_id: str,
data: IdeaBody,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
title = data.title.strip()[:200]
body = data.body.strip()[:2000]
if not title:
raise HTTPException(400, "Title required")
with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f)
if not user.is_admin and idea.get("author") != user.handle:
raise HTTPException(403, "Forbidden")
idea["title"] = title
idea["body"] = body
f.seek(0)
f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"ok": True, "title": title, "body": body})
@router.delete("/api/ideas/{idea_id}")
async def delete_idea(
idea_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
path = _ideas_dir(dd) / f"{idea_id}.json"
if not path.exists():
raise HTTPException(404, "Not found")
try:
idea = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raise HTTPException(500, "Could not read idea")
if not user.is_admin and idea.get("author") != user.handle:
raise HTTPException(403, "Forbidden")
path.unlink()
return JSONResponse({"ok": True})
# ── Feedback ──────────────────────────────────────────────────────────────────
@router.post("/api/feedback")
async def submit_feedback(
text: str = Form(""),
images: list[UploadFile] = File(default=[]),
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
text = text.strip()
if not text and not any(f.filename for f in images):
raise HTTPException(400, "Feedback must include text or at least one image")
if len(images) > _FEEDBACK_MAX_IMAGES:
raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission")
feedback_dir = deps._get_data_dir() / "_feedback"
feedback_dir.mkdir(exist_ok=True)
images_dir = feedback_dir / user.handle
images_dir.mkdir(exist_ok=True)
now = int(time.time())
submission_id = f"{now}_{secrets.token_hex(4)}"
saved_images: list[str] = []
for img in images:
if not img.filename:
continue
suffix = Path(img.filename).suffix.lower()
if suffix not in _FEEDBACK_IMAGE_SUFFIXES:
raise HTTPException(400, f"Unsupported image type '{suffix}'")
contents = await img.read()
if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES:
raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit")
safe_name = f"{submission_id}_{Path(img.filename).name}"
(images_dir / safe_name).write_bytes(contents)
saved_images.append(safe_name)
from datetime import datetime, timezone
entry = {
"id": submission_id,
"handle": user.handle,
"submitted_at": datetime.now(timezone.utc).isoformat(),
"text": text,
"images": saved_images,
}
log_file = feedback_dir / f"{user.handle}.json"
existing: list[dict] = []
if log_file.exists():
try:
existing = json.loads(log_file.read_text())
except (OSError, json.JSONDecodeError):
existing = []
existing.append(entry)
log_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
return JSONResponse({"ok": True, "id": submission_id})
+354
View File
@@ -0,0 +1,354 @@
"""Self-service user settings endpoints (/api/me/*)."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from bincio.serve import deps, tasks
from bincio.serve.db import (
authenticate,
get_user_prefs,
set_user_prefs,
)
router = APIRouter()
def _wipe_user_activities(user_dir: Path) -> int:
"""Delete all extracted activity files and caches for a user.
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
_merged/, index.json, athlete.json, and the dedup cache.
Leaves the user directory itself intact (account remains in the DB).
Returns the number of files deleted.
"""
import shutil
deleted = 0
for subdir in ("activities", "edits", "originals"):
d = user_dir / subdir
if d.exists():
for f in d.rglob("*"):
if f.is_file():
deleted += 1
shutil.rmtree(d)
for name in ("_merged", ):
d = user_dir / name
if d.exists():
shutil.rmtree(d)
for name in ("index.json", "athlete.json", ".bincio_cache.json", "tracks.json", "tracks_index.json"):
f = user_dir / name
if f.exists():
f.unlink()
deleted += 1
for shard in user_dir.glob("tracks_*.json"):
shard.unlink(missing_ok=True)
deleted += 1
return deleted
@router.get("/api/me/tracks")
async def me_tracks(bincio_session: str | None = Cookie(default=None)) -> Response:
"""Return the tracks manifest (years list + total) for the logged-in user."""
user = deps._require_user(bincio_session)
index_path = deps._get_data_dir() / user.handle / "tracks_index.json"
if not index_path.exists():
raise HTTPException(404, "Tracks not yet baked — upload an activity first")
return Response(content=index_path.read_bytes(), media_type="application/json")
@router.get("/api/me/tracks/{year}")
async def me_tracks_year(year: str, bincio_session: str | None = Cookie(default=None)) -> Response:
"""Return the pre-baked tracks shard for a specific year."""
user = deps._require_user(bincio_session)
if not year.isdigit() or len(year) != 4:
raise HTTPException(400, "year must be a 4-digit string")
shard_path = deps._get_data_dir() / user.handle / f"tracks_{year}.json"
if not shard_path.exists():
raise HTTPException(404, f"No tracks shard for year {year}")
return Response(content=shard_path.read_bytes(), media_type="application/json")
@router.get("/api/me/storage")
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return per-category disk usage for the logged-in user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
def _mb(path: Path) -> float:
if not path.exists():
return 0.0
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
return round(total / 1_048_576, 2)
def _count(path: Path, pattern: str = "*") -> int:
if not path.exists():
return 0
return sum(1 for f in path.glob(pattern) if f.is_file())
activities_mb = _mb(dd / "activities")
originals_mb = _mb(dd / "originals")
strava_mb = _mb(dd / "originals" / "strava")
images_mb = _mb(dd / "edits" / "images")
total_mb = _mb(dd)
return JSONResponse({
"total_mb": total_mb,
"activities_mb": activities_mb,
"activities_count": _count(dd / "activities", "*.json"),
"originals_mb": originals_mb,
"strava_originals_mb": strava_mb,
"strava_originals_count": _count(dd / "originals" / "strava", "*.json"),
"images_mb": images_mb,
})
@router.delete("/api/me/originals")
async def me_delete_originals(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Delete the user's originals/ directory (frees space after re-extraction)."""
user = deps._require_user(bincio_session)
originals = deps._get_data_dir() / user.handle / "originals"
if not originals.exists():
return JSONResponse({"ok": True, "freed_mb": 0.0})
freed = round(
sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2
)
shutil.rmtree(originals)
return JSONResponse({"ok": True, "freed_mb": freed})
@router.delete("/api/me/activities")
async def me_delete_activities(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON).
Requires the user's current password in the request body for confirmation.
"""
user = deps._require_user(bincio_session)
body = await request.json()
password = body.get("password", "")
if not authenticate(deps._get_db(), user.handle, password):
raise HTTPException(401, "Wrong password")
user_dir = deps._get_data_dir() / user.handle
deleted = _wipe_user_activities(user_dir)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "deleted": deleted})
@router.delete("/api/me")
async def me_delete_account(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Delete the account and all data permanently.
Requires the user's current password. Deletes the DB row, all sessions,
and the entire user data directory. The root shard manifest is updated.
"""
user = deps._require_user(bincio_session)
body = await request.json()
password = body.get("password", "")
if not authenticate(deps._get_db(), user.handle, password):
raise HTTPException(401, "Wrong password")
# Wipe data directory
user_dir = deps._get_data_dir() / user.handle
if user_dir.is_dir():
shutil.rmtree(user_dir)
# Remove from DB (cascades to sessions, invites, reset_codes)
from bincio.serve.db import delete_user as _delete_user
_delete_user(deps._get_db(), user.handle)
# Update root manifest so the shard disappears
from bincio.render.cli import _write_root_manifest
try:
_write_root_manifest(deps._get_data_dir())
except (OSError, json.JSONDecodeError):
pass
resp = JSONResponse({"ok": True})
resp.delete_cookie(deps._SESSION_COOKIE)
return resp
@router.put("/api/me/display-name")
async def me_update_display_name(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Update the logged-in user's display name."""
user = deps._require_user(bincio_session)
body = await request.json()
display_name = str(body.get("display_name", "")).strip()
if len(display_name) > 60:
raise HTTPException(400, "Display name too long (max 60 characters)")
db = deps._get_db()
db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle))
db.commit()
return JSONResponse({"ok": True, "display_name": display_name})
@router.get("/api/me/prefs")
async def me_get_prefs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return all user preferences as a key→value dict."""
user = deps._require_user(bincio_session)
return JSONResponse(get_user_prefs(deps._get_db(), user.handle))
@router.put("/api/me/prefs")
async def me_set_prefs(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Upsert one or more user preferences. Body: {key: value, ...} (all strings)."""
user = deps._require_user(bincio_session)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Body must be a JSON object")
# Coerce all values to strings; ignore unknown keys silently
prefs = {str(k): str(v) for k, v in body.items()}
set_user_prefs(deps._get_db(), user.handle, prefs)
# Mirror download_disabled_default to a file so the render pipeline can read it
if "download_disabled_default" in prefs:
user_dir = deps._get_data_dir() / user.handle
settings_path = user_dir / "_user_settings.json"
try:
current = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
except (OSError, json.JSONDecodeError):
current = {}
current["download_disabled_default"] = prefs["download_disabled_default"] == "true"
settings_path.write_text(json.dumps(current, indent=2), encoding="utf-8")
return JSONResponse({"ok": True})
@router.get("/api/me/strava-credentials")
async def me_get_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return whether per-user Strava credentials are configured (never returns the secret)."""
user = deps._require_user(bincio_session)
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
has_user_creds = False
client_id_hint = ""
if creds_path.exists():
try:
d = json.loads(creds_path.read_text(encoding="utf-8"))
cid = str(d.get("client_id", "")).strip()
csec = str(d.get("client_secret", "")).strip()
if cid and csec:
has_user_creds = True
client_id_hint = cid
except (OSError, json.JSONDecodeError):
pass
return JSONResponse({
"has_user_creds": has_user_creds,
"client_id": client_id_hint,
"instance_configured": bool(deps.strava_client_id),
})
@router.put("/api/me/strava-credentials")
async def me_set_strava_credentials(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Save per-user Strava credentials. Body: {client_id, client_secret}."""
user = deps._require_user(bincio_session)
body = await request.json()
cid = str(body.get("client_id", "")).strip()
csec = str(body.get("client_secret", "")).strip()
if not cid:
raise HTTPException(400, "client_id is required")
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
# If client_secret is omitted, preserve existing secret (if any)
if not csec:
if creds_path.exists():
try:
existing = json.loads(creds_path.read_text(encoding="utf-8"))
csec = str(existing.get("client_secret", "")).strip()
except (OSError, json.JSONDecodeError):
pass
if not csec:
raise HTTPException(400, "client_secret is required (no existing secret to preserve)")
# If the client_id changed, the existing token belongs to a different OAuth
# app and will fail on refresh — delete it so the user must re-authenticate.
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
if creds_path.exists() and token_path.exists():
try:
old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip()
if old_cid and old_cid != cid:
token_path.unlink(missing_ok=True)
except (OSError, json.JSONDecodeError):
pass
creds_path.write_text(
json.dumps({"client_id": cid, "client_secret": csec}, indent=2),
encoding="utf-8",
)
return JSONResponse({"ok": True})
@router.delete("/api/me/strava-credentials")
async def me_delete_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Remove per-user Strava credentials (falls back to instance credentials)."""
user = deps._require_user(bincio_session)
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
creds_path.unlink(missing_ok=True)
return JSONResponse({"ok": True})
@router.put("/api/me/password")
async def me_change_password(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Change the logged-in user's password. Requires current password."""
from bincio.serve.db import change_password as _change_password
user = deps._require_user(bincio_session)
body = await request.json()
current = body.get("current_password", "")
new_pw = body.get("new_password", "")
if not authenticate(deps._get_db(), user.handle, current):
raise HTTPException(401, "Current password is wrong")
if len(new_pw) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
_change_password(deps._get_db(), user.handle, new_pw)
return JSONResponse({"ok": True})
@router.get("/api/me/sync-status")
async def get_sync_status(
bincio_session: str | None = Cookie(default=None),
) -> JSONResponse:
"""Return the last sync status for Strava and Garmin for the logged-in user."""
user = deps._require_user(bincio_session)
user_dir = deps._get_data_dir() / user.handle
def _read_status(filename: str) -> dict | None:
p = user_dir / filename
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
strava = _read_status("_strava_sync_status.json")
garmin = _read_status("_garmin_sync_status.json")
return JSONResponse({
"strava": strava,
"garmin": garmin,
})
+161
View File
@@ -0,0 +1,161 @@
"""OG preview endpoints.
GET /activity/{activity_id}
Returns a minimal HTML page with Open Graph meta tags for social link
previews (Telegram, WhatsApp, Slack, ). nginx proxies only bot
User-Agents here; regular browsers still get the static SPA shell.
GET /api/og-image/{user_handle}/{activity_id}.png
Returns the pre-generated 400×400 track PNG. Falls back to generating
on the fly if the static file doesn't exist yet (e.g. a brand-new import
before the next deploy-time generation run).
"""
from __future__ import annotations
import html
import json
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from bincio.serve import deps
router = APIRouter()
# Sport → emoji map (extend as needed)
_SPORT_EMOJI: dict[str, str] = {
"cycling": "🚴",
"running": "🏃",
"swimming": "🏊",
"hiking": "🥾",
"walking": "🚶",
"skiing": "⛷️",
"rowing": "🚣",
"triathlon": "🏊",
"e_cycling": "🚴",
"gravel": "🚴",
}
def _find_user(data_dir: Path, activity_id: str) -> str | None:
"""Return the user handle that owns *activity_id*, or None."""
for user_dir in sorted(data_dir.iterdir()):
if not user_dir.is_dir() or user_dir.name.startswith("_") or user_dir.name == "segments":
continue
if (user_dir / "activities" / f"{activity_id}.json").exists():
return user_dir.name
return None
def _fmt_description(detail: dict, handle: str) -> str:
parts: list[str] = []
sport = (detail.get("sport") or "").lower()
emoji = _SPORT_EMOJI.get(sport, "🏅")
parts.append(emoji)
dist_m = detail.get("distance_m")
if dist_m:
parts.append(f"{dist_m / 1000:.1f} km")
gain = detail.get("elevation_gain_m")
if gain:
parts.append(f"{gain:.0f} m ↑")
dur = detail.get("moving_time_s") or detail.get("duration_s")
if dur:
h, rem = divmod(int(dur), 3600)
m = rem // 60
parts.append(f"{h}h {m:02d}m" if h else f"{m}m")
started = detail.get("started_at")
if started:
try:
dt = datetime.fromisoformat(started).astimezone(timezone.utc)
parts.append(dt.strftime("%-d %b %Y"))
except ValueError:
pass
parts.append(f"@{handle}")
return " · ".join(parts)
@router.get("/activity/{activity_id}", response_class=HTMLResponse, include_in_schema=False)
@router.get("/activity/{activity_id}/", response_class=HTMLResponse, include_in_schema=False)
async def og_preview(activity_id: str, request: Request) -> HTMLResponse:
data_dir = deps._get_data_dir()
handle = _find_user(data_dir, activity_id)
if handle is None:
raise HTTPException(404)
json_path = data_dir / handle / "activities" / f"{activity_id}.json"
detail = json.loads(json_path.read_text(encoding="utf-8"))
title = detail.get("title") or activity_id
desc = _fmt_description(detail, handle)
base = str(request.base_url).rstrip("/")
img_url = f"{base}/og-image/{handle}/{activity_id}.png"
act_url = f"{base}/activity/{activity_id}/"
h_title = html.escape(title)
h_desc = html.escape(desc)
h_img = html.escape(img_url)
h_url = html.escape(act_url)
content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{h_title} BincioActivity</title>
<meta property="og:title" content="{h_title}" />
<meta property="og:description" content="{h_desc}" />
<meta property="og:image" content="{h_img}" />
<meta property="og:image:width" content="400" />
<meta property="og:image:height" content="400" />
<meta property="og:url" content="{h_url}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="BincioActivity" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{h_title}" />
<meta name="twitter:description" content="{h_desc}" />
<meta name="twitter:image" content="{h_img}" />
<script>window.location.replace("{act_url}");</script>
</head>
<body></body>
</html>"""
return HTMLResponse(content=content)
@router.get("/og-image/{user_handle}/{activity_id}.png", include_in_schema=False)
async def og_image(user_handle: str, activity_id: str) -> Response:
data_dir = deps._get_data_dir()
www_root = Path("/var/www/activity")
img_path = www_root / "og-image" / user_handle / f"{activity_id}.png"
if img_path.exists():
return Response(
content=img_path.read_bytes(),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
# Fallback: generate on the fly (e.g. new activity before next deploy run)
ts_path = data_dir / user_handle / "activities" / f"{activity_id}.timeseries.json"
if not ts_path.exists():
raise HTTPException(404)
try:
from bincio.render.ogimage import generate_for_activity
png = generate_for_activity(ts_path)
img_path.parent.mkdir(parents=True, exist_ok=True)
img_path.write_bytes(png)
except Exception:
raise HTTPException(500, "Image generation failed")
return Response(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
+293
View File
@@ -0,0 +1,293 @@
"""Segments endpoints (/api/segments/*)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Cookie, HTTPException
from fastapi.responses import JSONResponse
from bincio.serve import deps
from bincio.serve.models import CreateSegmentRequest
from bincio.segments import models as _seg_models
from bincio.segments import store as _seg_store
router = APIRouter()
def _scan_segment_for_user(dd: Path, handle: str, segment_id: str) -> int:
"""Scan all of a user's activities against one segment. Returns effort count."""
from datetime import datetime as _datetime
from bincio.segments.detect import track_from_timeseries_json, detect_one
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
return 0
user_dir = dd / handle
acts_dir = user_dir / "activities"
total = 0
for detail_path in sorted(acts_dir.glob("*.json")):
if ".timeseries." in detail_path.name:
continue
try:
detail = json.loads(detail_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
ts_url = detail.get("timeseries_url")
if not ts_url:
continue
ts_path = user_dir / ts_url
if not ts_path.exists():
continue
try:
ts = json.loads(ts_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
started_raw = detail.get("started_at")
if not started_raw:
continue
try:
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
except ValueError:
continue
track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem),
detail.get("sport", "other"), started_at)
if track is None:
continue
efforts = detect_one(track, seg)
for effort in efforts:
_seg_store.add_effort(dd, handle, segment_id, effort)
total += len(efforts)
return total
@router.get("/api/segments")
async def get_segments(
bbox: Optional[str] = None,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""List segments, optionally filtered to a map viewport bbox (lon_min,lat_min,lon_max,lat_max)."""
deps._require_user(bincio_session)
parsed_bbox: Optional[list[float]] = None
if bbox:
try:
parts = [float(x) for x in bbox.split(",")]
if len(parts) == 4:
parsed_bbox = parts
except ValueError:
raise HTTPException(400, "bbox must be four comma-separated floats")
dd = deps._get_data_dir()
segs = _seg_store.list_segments(dd, parsed_bbox)
return JSONResponse([{
"id": s.id,
"name": s.name,
"sport": s.sport,
"distance_m": s.distance_m,
"bbox": s.bbox,
"polyline": s.polyline,
"created_by": s.created_by,
"created_at": _seg_store._iso(s.created_at),
} for s in segs])
@router.get("/api/segments/{segment_id}")
async def get_segment(segment_id: str) -> JSONResponse:
"""Return metadata for a single segment."""
dd = deps._get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
return JSONResponse({
"id": seg.id,
"name": seg.name,
"sport": seg.sport,
"polyline": seg.polyline,
"distance_m": seg.distance_m,
"bbox": seg.bbox,
"created_by": seg.created_by,
"created_at": _seg_store._iso(seg.created_at),
})
@router.post("/api/segments")
async def create_segment(
body: CreateSegmentRequest,
background_tasks: BackgroundTasks,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
if len(body.polyline) < 2:
raise HTTPException(400, "polyline must have at least 2 points")
if body.distance_m < 500:
raise HTTPException(400, "segment must be at least 500 m long")
lats = [p[0] for p in body.polyline]
lons = [p[1] for p in body.polyline]
bbox = [min(lons), min(lats), max(lons), max(lats)]
seg_id = _seg_store.make_segment_id(body.name)
from datetime import datetime, timezone as _tz
seg = _seg_models.Segment(
id=seg_id,
name=body.name,
sport=body.sport or None,
polyline=body.polyline,
distance_m=body.distance_m,
bbox=bbox,
created_by=user.handle,
created_at=datetime.now(_tz.utc),
)
dd = deps._get_data_dir()
_seg_store.save_segment(dd, seg)
background_tasks.add_task(_scan_segment_for_user, dd, user.handle, seg_id)
return JSONResponse({"id": seg_id}, status_code=201)
@router.delete("/api/segments/{segment_id}")
async def delete_segment(
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
if seg.created_by != user.handle and not user.is_admin:
raise HTTPException(403, "Not allowed")
_seg_store.delete_segment(dd, segment_id)
return JSONResponse({"ok": True})
@router.get("/api/segments/{segment_id}/efforts")
async def get_segment_efforts(
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Return all efforts on a segment for the logged-in user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
efforts = _seg_store.load_efforts(dd, user.handle, segment_id)
return JSONResponse([
{
"activity_id": e.activity_id,
"started_at": _seg_store._iso(e.started_at),
"elapsed_s": e.elapsed_s,
"avg_speed_kmh": e.avg_speed_kmh,
"avg_hr_bpm": e.avg_hr_bpm,
"avg_power_w": e.avg_power_w,
"np_power_w": e.np_power_w,
}
for e in efforts
])
@router.post("/api/segments/{segment_id}/detect")
async def trigger_detect(
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Retroactively detect efforts on a segment for the logged-in user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
if _seg_store.load_segment(dd, segment_id) is None:
raise HTTPException(404, "Segment not found")
_seg_store.save_efforts(dd, user.handle, segment_id, [])
total = _scan_segment_for_user(dd, user.handle, segment_id)
return JSONResponse({"ok": True, "efforts_found": total})
@router.post("/api/me/segment-rescan")
async def me_segment_rescan(
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Retroactively detect efforts for ALL segments across ALL activities for the logged-in user."""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir()
user_dir = dd / user.handle
acts_dir = user_dir / "activities"
from datetime import datetime as _datetime
from bincio.segments.detect import track_from_timeseries_json, detect_one
import json as _json
segments = _seg_store.list_segments(dd)
if not segments:
return JSONResponse({"ok": True, "efforts_found": 0})
for seg in segments:
_seg_store.save_efforts(dd, user.handle, seg.id, [])
total = 0
for detail_path in sorted(acts_dir.glob("*.json")):
if ".timeseries." in detail_path.name:
continue
try:
detail = _json.loads(detail_path.read_text(encoding="utf-8"))
except (OSError, _json.JSONDecodeError, ValueError):
continue
ts_url = detail.get("timeseries_url")
if not ts_url:
continue
ts_path = user_dir / ts_url
if not ts_path.exists():
continue
try:
ts = _json.loads(ts_path.read_text(encoding="utf-8"))
except (OSError, _json.JSONDecodeError, ValueError):
continue
started_raw = detail.get("started_at")
if not started_raw:
continue
try:
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
except ValueError:
continue
track = track_from_timeseries_json(
ts, detail.get("id", detail_path.stem),
detail.get("sport", "other"), started_at,
)
if track is None:
continue
for seg in segments:
efforts = detect_one(track, seg)
for effort in efforts:
_seg_store.add_effort(dd, user.handle, seg.id, effort)
total += len(efforts)
return JSONResponse({"ok": True, "efforts_found": total})
@router.get("/api/users/{handle}/segment_summary")
async def user_segment_summary(handle: str) -> JSONResponse:
"""Public endpoint: segments where this user has efforts, with best time and count."""
dd = deps._get_data_dir()
efforts_dir = dd / handle / "segment_efforts"
result = []
if efforts_dir.exists():
for ef_file in sorted(efforts_dir.glob("*.json")):
seg_id = ef_file.stem
efforts = _seg_store.load_efforts(dd, handle, seg_id)
if not efforts:
continue
seg = _seg_store.load_segment(dd, seg_id)
if not seg:
continue
best = min(efforts, key=lambda e: e.elapsed_s)
result.append({
"segment": {
"id": seg.id,
"name": seg.name,
"sport": seg.sport,
"distance_m": seg.distance_m,
},
"best_elapsed_s": best.elapsed_s,
"best_activity_id": best.activity_id,
"effort_count": len(efforts),
})
result.sort(key=lambda x: x["segment"]["name"].lower())
return JSONResponse(result)
+291
View File
@@ -0,0 +1,291 @@
"""Strava integration endpoints (/api/strava/*)."""
from __future__ import annotations
import json
import secrets
from typing import Optional
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from bincio.serve import deps, tasks
from bincio.serve.db import get_setting
router = APIRouter()
_strava_oauth_states: set[str] = set()
@router.get("/api/strava/status")
async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
cid, _ = deps._strava_creds(user.handle)
if not cid:
return JSONResponse({"configured": False, "connected": False, "last_sync": None})
dd = deps._get_data_dir() / user.handle
from bincio.extract.strava_api import load_token
token = load_token(dd)
return JSONResponse({
"configured": True,
"connected": token is not None,
"last_sync": token.get("last_sync_at") if token else None,
})
@router.post("/api/strava/disconnect")
async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
"""Remove the stored Strava token, forcing a fresh OAuth on next connect."""
user = deps._require_user(bincio_session)
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
token_path.unlink(missing_ok=True)
return JSONResponse({"ok": True})
@router.post("/api/strava/reset")
async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
"""Reset last_sync_at so the next sync re-fetches from a chosen point.
mode=soft set to the started_at of the most recent activity on disk
(next sync only fetches activities newer than the last known one)
mode=hard clear last_sync_at entirely
(next sync re-downloads full Strava history, skipping existing files)
"""
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
from bincio.extract.strava_api import load_token, save_token
token = load_token(dd)
if token is None:
raise HTTPException(400, "Not connected to Strava")
body = await request.json()
mode = body.get("mode", "soft")
if mode == "hard":
token.pop("last_sync_at", None)
save_token(dd, token)
return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None})
# soft: find the most recent started_at across the user's merged index
from datetime import datetime, timezone
last_ts: int | None = None
for index_path in [dd / "_merged" / "index.json", dd / "index.json"]:
if not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
started_ats = [
a.get("started_at") for a in index_data.get("activities", [])
if a.get("started_at")
]
if started_ats:
latest = max(started_ats)
dt = datetime.fromisoformat(latest.replace("Z", "+00:00"))
last_ts = int(dt.astimezone(timezone.utc).timestamp())
break
except (OSError, json.JSONDecodeError, ValueError):
continue
if last_ts is None:
token.pop("last_sync_at", None)
else:
token["last_sync_at"] = last_ts
save_token(dd, token)
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
@router.get("/api/strava/auth-url")
async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
cid, _ = deps._strava_creds(user.handle)
if not cid:
raise HTTPException(400, "Strava client ID not configured on this server")
state = secrets.token_urlsafe(16)
_strava_oauth_states.add(state)
if deps.public_url:
redirect_uri = deps.public_url.rstrip("/") + "/api/strava/callback"
else:
redirect_uri = str(request.url_for("strava_callback"))
from bincio.extract.strava_api import auth_url
return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)})
@router.get("/api/strava/callback", name="strava_callback")
async def strava_callback(
request: Request,
code: str = "",
error: str = "",
state: str = "",
bincio_session: Optional[str] = Cookie(default=None),
) -> RedirectResponse:
site_origin = deps.public_url.rstrip("/") if deps.public_url else str(request.base_url).rstrip("/")
if error or not code:
return RedirectResponse(f"{site_origin}/?strava=error")
if state not in _strava_oauth_states:
return RedirectResponse(f"{site_origin}/?strava=error")
_strava_oauth_states.discard(state)
user = deps._current_user(bincio_session)
if not user:
return RedirectResponse(f"{site_origin}/?strava=error")
cid, csec = deps._strava_creds(user.handle)
if not cid or not csec:
return RedirectResponse(f"{site_origin}/?strava=error")
dd = deps._get_data_dir() / user.handle
from bincio.extract.strava_api import StravaError, exchange_code, save_token
try:
token = exchange_code(cid, csec, code)
except StravaError:
return RedirectResponse(f"{site_origin}/?strava=error")
save_token(dd, token)
return RedirectResponse(f"{site_origin}/?strava=connected")
@router.get("/api/strava/sync/stream")
async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
"""SSE endpoint — streams per-activity progress then a final summary event."""
user = deps._require_user(bincio_session)
cid, csec = deps._strava_creds(user.handle)
if not cid or not csec:
raise HTTPException(400, "Strava not configured on this server")
dd = deps._get_data_dir() / user.handle
store_orig_setting = get_setting(deps._get_db(), "store_originals")
store_orig = store_orig_setting == "true"
originals_dir = (dd / "originals" / "strava") if store_orig else None
if originals_dir:
originals_dir.mkdir(parents=True, exist_ok=True)
from bincio.extract.ingest import strava_sync_iter
def event_stream():
try:
for event in strava_sync_iter(dd, cid, csec, originals_dir):
if event["type"] == "done":
tasks._trigger_rebuild(user.handle) # start before client closes connection
yield f"data: {json.dumps(event)}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/api/strava/import-gear")
async def serve_strava_import_gear(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
"""One-time backfill: scan stored Strava originals for gear_ids, fetch names, populate gear registry."""
user = deps._require_user(bincio_session)
cid, csec = deps._strava_creds(user.handle)
if not cid or not csec:
raise HTTPException(400, "Strava not configured on this server")
dd = deps._get_data_dir() / user.handle
originals_dir = dd / "originals" / "strava"
if not originals_dir.exists():
return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0, "message": "No stored originals found"})
import contextlib
import uuid
from bincio.extract.strava_api import StravaError, ensure_fresh, fetch_gear
from bincio.render.merge import merge_one
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
try:
token = ensure_fresh(dd, cid, csec)
except StravaError as e:
raise HTTPException(502, str(e))
registry = _gear_load(dd)
known_strava_ids = {g.get("strava_id") for g in registry if g.get("strava_id")}
# Collect all unique gear_ids from originals
gear_id_to_activities: dict[str, list[str]] = {}
for orig_path in originals_dir.glob("*.json"):
try:
data = json.loads(orig_path.read_text(encoding="utf-8"))
gear_id = (data.get("meta") or {}).get("gear_id") or ""
if gear_id:
gear_id_to_activities.setdefault(gear_id, []).append(orig_path.stem)
except (OSError, json.JSONDecodeError):
continue
gear_added = 0
activities_updated = 0
for gear_id, activity_ids in gear_id_to_activities.items():
if gear_id in known_strava_ids:
gear_name = next(g["name"] for g in registry if g.get("strava_id") == gear_id)
else:
details = fetch_gear(token["access_token"], gear_id)
gear_name = details.get("name") or ""
if not gear_name:
continue
gear_type = "shoes" if gear_id.startswith("g") else "bike"
new_item: dict = {"id": str(uuid.uuid4()), "name": gear_name, "type": gear_type, "retired": False, "strava_id": gear_id}
registry.append(new_item)
known_strava_ids.add(gear_id)
gear_added += 1
# Backfill: write sidecar for each activity that has no gear set yet
import yaml as _yaml
edits_dir = dd / "edits"
edits_dir.mkdir(exist_ok=True)
for activity_id in activity_ids:
activity_json = dd / "activities" / f"{activity_id}.json"
if not activity_json.exists():
continue
try:
act = json.loads(activity_json.read_text(encoding="utf-8"))
if act.get("gear"):
continue # already has gear
except (OSError, json.JSONDecodeError):
continue
sidecar = edits_dir / f"{activity_id}.md"
fm: dict = {}
body = ""
if sidecar.exists():
try:
text = sidecar.read_text(encoding="utf-8")
import re as _re
parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE)
if len(parts) >= 3:
fm = _yaml.safe_load(parts[1]) or {}
body = parts[2].strip()
except Exception:
pass
if fm.get("gear"):
continue # sidecar already sets gear
fm["gear"] = gear_name
fm_text = _yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
content = f"---\n{fm_text}\n---\n"
if body:
content += f"\n{body}\n"
sidecar.write_text(content, encoding="utf-8")
with contextlib.suppress(Exception):
merge_one(dd, activity_id)
activities_updated += 1
_gear_save(dd, registry)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated})
@router.post("/api/strava/sync")
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = deps._require_user(bincio_session)
cid, csec = deps._strava_creds(user.handle)
if not cid or not csec:
raise HTTPException(400, "Strava not configured on this server")
dd = deps._get_data_dir() / user.handle
store_orig_setting = get_setting(deps._get_db(), "store_originals")
store_orig = store_orig_setting == "true"
originals_dir = (dd / "originals" / "strava") if store_orig else None
if originals_dir:
originals_dir.mkdir(parents=True, exist_ok=True)
from bincio.edit.ops import run_strava_sync
try:
result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir)
except RuntimeError as e:
raise HTTPException(502, str(e))
tasks._trigger_rebuild(user.handle)
return JSONResponse(result)
+506
View File
@@ -0,0 +1,506 @@
"""File upload endpoints (/api/upload/*)."""
from __future__ import annotations
import json
import logging
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Cookie, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from bincio.serve import deps, tasks
log = logging.getLogger("bincio.serve")
router = APIRouter()
_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
def _file_suffix(name: str) -> str:
"""Return the effective suffix, including .gz double-extension."""
p = Path(name.lower())
if p.suffix == ".gz":
return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz"
return p.suffix
def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None:
"""Add or update an activity summary in user_dir/index.json.
Called after writing BAS activity files so that merge_all can include the
activity in year shards. Without this, uploaded activities exist on disk
but never appear in the browser feed.
"""
# Build preview coords from geojson if available ([lat, lng] order)
preview: Optional[list] = None
if geojson:
try:
coords = geojson.get("geometry", {}).get("coordinates", [])
if coords:
step = max(1, len(coords) // 9)
preview = [[c[1], c[0]] for c in coords[::step]][:9]
except (TypeError, IndexError, AttributeError):
pass
has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists()
summary = {
"id": activity_id,
"title": activity.get("title", activity_id),
"sport": activity.get("sport"),
"sub_sport": activity.get("sub_sport"),
"started_at": activity.get("started_at"),
"distance_m": activity.get("distance_m"),
"duration_s": activity.get("duration_s"),
"moving_time_s": activity.get("moving_time_s"),
"elevation_gain_m": activity.get("elevation_gain_m"),
"avg_speed_kmh": activity.get("avg_speed_kmh"),
"max_speed_kmh": activity.get("max_speed_kmh"),
"avg_hr_bpm": activity.get("avg_hr_bpm"),
"max_hr_bpm": activity.get("max_hr_bpm"),
"avg_cadence_rpm": activity.get("avg_cadence_rpm"),
"avg_power_w": activity.get("avg_power_w"),
"mmp": activity.get("mmp"),
"best_efforts": activity.get("best_efforts"),
"best_climb_m": activity.get("best_climb_m"),
"source": activity.get("source"),
"privacy": activity.get("privacy", "public"),
"detail_url": f"activities/{activity_id}.json",
"track_url": f"activities/{activity_id}.geojson" if has_track else None,
"preview_coords": preview,
}
index_path = user_dir / "index.json"
if index_path.exists():
index_data = json.loads(index_path.read_text(encoding="utf-8"))
else:
index_data = {
"bas_version": "1.0",
"owner": {"handle": user_dir.name},
"generated_at": None,
"activities": [],
}
existing = {a["id"]: a for a in index_data.get("activities", [])}
existing[activity_id] = summary
index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True)
index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8")
@router.post("/api/upload/bas")
async def upload_bas_activity(
request: Request,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Accept a pre-extracted BAS activity JSON from the mobile app.
Body (JSON):
activity full BAS activity dict (required, must have 'id')
timeseries timeseries dict (optional)
geojson GeoJSON dict (optional)
Returns:
{"ok": true, "id": "...", "status": "imported" | "duplicate"}
"""
user = deps._require_auth(request, bincio_session)
body = await request.json()
activity = body.get("activity")
if not activity or not activity.get("id"):
raise HTTPException(400, "Missing activity.id")
activity_id = str(activity["id"])
deps._check_id(activity_id)
user_dir = deps._get_data_dir() / user.handle
acts_dir = user_dir / "activities"
acts_dir.mkdir(parents=True, exist_ok=True)
out = acts_dir / f"{activity_id}.json"
if out.exists():
return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"})
out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8")
if body.get("timeseries"):
ts_path = acts_dir / f"{activity_id}.timeseries.json"
if not ts_path.exists():
ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8")
geojson_body: Optional[dict] = body.get("geojson") or None
if geojson_body:
gj_path = acts_dir / f"{activity_id}.geojson"
if not gj_path.exists():
gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8")
_upsert_index_summary(user_dir, activity_id, activity, geojson_body)
try:
from bincio.render.merge import merge_one, write_combined_feed
merge_one(user_dir, activity_id)
write_combined_feed(deps._get_data_dir())
except Exception as exc:
log.warning("upload/bas[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)
log.info("upload/bas[%s]: imported %s", user.handle, activity_id)
return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
@router.post("/api/upload/raw")
async def upload_raw_activity(
request: Request,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it
server-side, store it in the user's activity library, and return the full
extracted data so the mobile can cache it locally.
Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69).
Body (JSON):
filename original filename (used only to determine file extension)
base64 base64-encoded raw file bytes
Auth: Authorization: Bearer <token>
Returns:
{"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
"geojson": {...}|null, "source_hash": "<sha256-hex>"}
"""
import base64 as _b64
import hashlib
import shutil
user = deps._require_auth(request, bincio_session)
body = await request.json()
filename_hint: str = body.get("filename") or "activity.fit"
b64: str = body.get("base64") or ""
user_title: Optional[str] = body.get("user_title") or None
if not b64:
raise HTTPException(400, "Missing base64 field")
try:
raw = _b64.b64decode(b64)
except ValueError:
raise HTTPException(400, "Invalid base64 encoding")
source_hash = hashlib.sha256(raw).hexdigest()
suffix = Path(filename_hint).suffix or ".fit"
tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}")
tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}")
try:
tmp_in.write_bytes(raw)
tmp_out.mkdir()
from bincio.extract.parsers.factory import parse_file
from bincio.extract.metrics import compute
from bincio.extract.writer import make_activity_id, write_activity
from bincio.extract.timeseries import build_timeseries
activity = parse_file(tmp_in)
metrics = compute(activity)
write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001)
act_id = make_activity_id(activity)
acts_tmp = tmp_out / "activities"
detail_path = acts_tmp / f"{act_id}.json"
ts_path = acts_tmp / f"{act_id}.timeseries.json"
geojson_path = acts_tmp / f"{act_id}.geojson"
if not ts_path.exists():
ts_data = build_timeseries(activity.points, activity.started_at, "public")
if ts_data.get("t"):
ts_path.write_text(json.dumps(ts_data))
detail = json.loads(detail_path.read_text())
timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
# Also store on the server so the activity appears in the user's feed.
user_dir = deps._get_data_dir() / user.handle
acts_dir = user_dir / "activities"
acts_dir.mkdir(parents=True, exist_ok=True)
out = acts_dir / f"{act_id}.json"
if not out.exists():
out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists():
(acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8")
if geojson and not (acts_dir / f"{act_id}.geojson").exists():
(acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8")
_upsert_index_summary(user_dir, act_id, detail, geojson)
if user_title:
import yaml as _yaml
edits_dir = user_dir / "edits"
edits_dir.mkdir(parents=True, exist_ok=True)
(edits_dir / f"{act_id}.md").write_text(
f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n",
encoding="utf-8",
)
except Exception as exc:
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
finally:
tmp_in.unlink(missing_ok=True)
shutil.rmtree(tmp_out, ignore_errors=True)
# Merge and update feed — best effort; a race or transient FS error here must
# not turn a successful extraction into a 422 (the file is on disk; the mobile
# would retry indefinitely and the activity would never be marked synced).
try:
from bincio.render.merge import merge_one, write_combined_feed
merge_one(user_dir, act_id)
write_combined_feed(deps._get_data_dir())
except Exception as exc:
log.warning("upload/raw[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)
log.info("upload/raw[%s]: imported %s", user.handle, act_id)
return JSONResponse({
"ok": True,
"id": act_id,
"detail": detail,
"timeseries": timeseries,
"geojson": geojson,
"source_hash": source_hash,
})
@router.post("/api/upload")
async def upload_activity(
files: list[UploadFile] = File(...),
store_original: bool = Form(False),
overwrite: bool = Form(False),
bincio_session: Optional[str] = Cookie(default=None),
) -> StreamingResponse:
"""Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.
activities.csv (Strava export format) can be included in the batch to:
- Enrich activity files in the same batch (matched by filename)
- Retroactively update sidecars for existing activities (matched by strava_id)
SSE events:
{"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"overwritten"|"duplicate"|"error"}
{"type": "csv", "updates": N} -- only when CSV was included
{"type": "done", "added": N, "csv_updates": N, "duplicates": N, "overwritten": N, "errors": N}
"""
from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.factory import parse_file
from bincio.extract.writer import make_activity_id
from bincio.render.merge import merge_all
user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle
staging = dd / "_uploads"
staging.mkdir(exist_ok=True)
# Read all files into memory now (async), then process synchronously in the generator
csv_bytes_list: list[bytes] = []
activity_items: list[tuple[str, bytes]] = [] # (original_filename, bytes)
for f in files:
fname = Path(f.filename or "").name
raw = await f.read()
if fname.lower().endswith(".csv"):
csv_bytes_list.append(raw)
else:
activity_items.append((fname, raw))
# Build metadata from the first CSV
metadata = None
if csv_bytes_list:
from bincio.extract.strava_csv import StravaMetadata
import tempfile
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
tmp.write(csv_bytes_list[0])
tmp_path = Path(tmp.name)
try:
metadata = StravaMetadata(tmp_path)
finally:
tmp_path.unlink(missing_ok=True)
total_files = len(activity_items)
job_id = tasks._job_start(user.handle, total_files) if total_files > 0 else None
def event_stream():
added = 0
overwritten = 0
duplicates = 0
errors = 0
any_added = False
for n, (name, contents) in enumerate(activity_items, 1):
if job_id:
tasks._job_update(job_id, n - 1, name)
suffix = _file_suffix(name)
if suffix not in _SUPPORTED_SUFFIXES:
errors += 1
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
continue
if len(contents) > 50 * 1024 * 1024:
errors += 1
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
continue
staged = staging / name
staged.write_bytes(contents)
kept = False
try:
activity = parse_file(staged)
if metadata is not None:
metadata.enrich(name, activity)
activity_id = make_activity_id(activity)
was_overwrite = False
if (dd / "activities" / f"{activity_id}.json").exists():
if not overwrite:
duplicates += 1
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
continue
# Overwrite: delete existing files before re-ingesting.
for ext in (".json", ".geojson", ".timeseries.json"):
(dd / "activities" / f"{activity_id}{ext}").unlink(missing_ok=True)
# Remove stale summary from index so ingest_parsed writes a clean one
index_path = dd / "index.json"
if index_path.exists():
idx = json.loads(index_path.read_text(encoding="utf-8"))
idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
# Remove from dedup hash cache so the new file isn't blocked
cache_path = dd / ".bincio_cache.json"
if cache_path.exists():
try:
cache = json.loads(cache_path.read_text(encoding="utf-8"))
cache.pop(activity_id, None)
cache_path.write_text(json.dumps(cache, ensure_ascii=False))
except (OSError, json.JSONDecodeError):
pass
# Remove merged copies (merge_all will regenerate them after ingest)
merged_acts = dd / "_merged" / "activities"
if merged_acts.exists():
for ext in (".json", ".geojson", ".timeseries.json"):
p = merged_acts / f"{activity_id}{ext}"
if p.exists() or p.is_symlink():
p.unlink(missing_ok=True)
was_overwrite = True
ingest_parsed(activity, dd, privacy="public")
if store_original:
originals_dir = dd / "originals"
originals_dir.mkdir(exist_ok=True)
staged.rename(originals_dir / name)
kept = True
if was_overwrite:
overwritten += 1
else:
added += 1
any_added = True
status = 'overwritten' if was_overwrite else 'imported'
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': status})}\n\n"
except Exception as exc:
errors += 1
log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True)
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n"
finally:
if not kept:
staged.unlink(missing_ok=True)
# Retroactively apply CSV metadata to existing activities
csv_updates = 0
if metadata is not None:
from bincio.extract.strava_csv import apply_csv_to_data_dir
csv_updates = apply_csv_to_data_dir(dd, metadata)
if csv_updates:
yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"
if any_added or csv_updates:
merge_all(dd)
if any_added:
tasks._trigger_rebuild(user.handle)
yield f"data: {json.dumps({'type': 'done', 'added': added, 'overwritten': overwritten, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
if job_id:
tasks._job_finish(job_id)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/api/upload/strava-zip")
async def upload_strava_zip(
file: UploadFile = File(...),
private: str = Form(default="false"),
bincio_session: Optional[str] = Cookie(default=None),
) -> StreamingResponse:
"""Accept a Strava bulk export ZIP and stream SSE progress while processing.
The ZIP is written to a temp file, processed activity-by-activity, then deleted.
Originals are never kept the UI informs the user of this upfront.
"""
user = deps._require_user(bincio_session)
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(400, "Please upload a .zip file")
privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public"
dd = deps._get_data_dir() / user.handle
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
zip_path = Path(tmp.name)
try:
while chunk := await file.read(1024 * 1024): # 1 MB chunks
tmp.write(chunk)
finally:
tmp.close()
from bincio.extract.strava_zip import strava_zip_iter
from bincio.render.merge import merge_all
log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy)
def event_stream():
any_imported = False
imported_count = 0
error_count = 0
try:
for event in strava_zip_iter(zip_path, dd, privacy=privacy):
yield f"data: {json.dumps(event)}\n\n"
if event.get("type") == "progress":
status = event.get("status")
if status == "imported":
any_imported = True
imported_count += 1
elif status == "error":
error_count += 1
log.warning("strava-zip[%s]: error on %s: %s",
user.handle, event.get("name"), event.get("detail", ""))
if event.get("type") == "done":
log.info("strava-zip[%s]: done — imported=%d errors=%d",
user.handle, imported_count, error_count)
if any_imported:
merge_all(dd)
try:
from bincio.explore import bake_tracks
bake_tracks(user.handle, deps._get_data_dir())
except Exception as exc:
log.warning("strava-zip[%s]: bake_tracks failed (non-fatal): %s", user.handle, exc)
tasks._trigger_rebuild(user.handle)
except Exception as exc:
log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
finally:
zip_path.unlink(missing_ok=True)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+38 -2480
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
"""Background workers and job tracker for bincio.serve."""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import threading
import time
import uuid
from pathlib import Path
from bincio.serve import deps
log = logging.getLogger("bincio.serve")
# ── Job tracker ───────────────────────────────────────────────────────────────
_jobs_lock = threading.Lock()
_active_jobs: dict[str, dict] = {}
def _job_start(user_handle: str, total_files: int) -> str:
job_id = uuid.uuid4().hex[:8]
with _jobs_lock:
_active_jobs[job_id] = {
"id": job_id,
"user": user_handle,
"started_at": int(time.time()),
"total": total_files,
"done": 0,
"current": "",
}
return job_id
def _job_update(job_id: str, done: int, current: str) -> None:
with _jobs_lock:
if job_id in _active_jobs:
_active_jobs[job_id]["done"] = done
_active_jobs[job_id]["current"] = current
def _job_finish(job_id: str) -> None:
with _jobs_lock:
_active_jobs.pop(job_id, None)
# ── Post-write rebuild ────────────────────────────────────────────────────────
_rebuild_lock = threading.Lock()
_site_rebuild_event = threading.Event()
_low_priority = {"preexec_fn": lambda: os.nice(19)}
def _site_rebuild_worker() -> None:
"""Single background thread: debounced Astro build + rsync after uploads.
Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle,
then runs one full build. Uploads that arrive during the build set the
event again, so a follow-up build starts after the current one finishes.
"""
_webroot = str(deps.webroot)
_data_dir = str(deps.data_dir)
_site_dir = str(deps.site_dir)
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
while True:
_site_rebuild_event.wait()
_site_rebuild_event.clear()
time.sleep(60)
_site_rebuild_event.clear()
log.info("site-rebuild: starting full build + rsync to %s", _webroot)
try:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir],
capture_output=True,
text=True,
**_low_priority,
)
if result.returncode != 0:
log.error("site-rebuild: build failed (rc=%d):\n%s\n%s",
result.returncode, result.stdout, result.stderr)
continue
dist_data = Path(_site_dir) / "dist" / "data"
if dist_data.exists():
shutil.rmtree(dist_data)
rsync = subprocess.run(
["rsync", "-a", "--delete", "--exclude=data/",
f"{_site_dir}/dist/", _webroot + "/"],
capture_output=True,
text=True,
**_low_priority,
)
if rsync.returncode != 0:
log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s",
rsync.returncode, rsync.stdout, rsync.stderr)
else:
log.info("site-rebuild: rsync done, generating OG images")
og_script = Path(_site_dir).parent / "scripts" / "generate_og_images.py"
if og_script.exists() and deps.webroot is not None:
og = subprocess.run(
[uv, "run", "python3", str(og_script),
"--data-dir", _data_dir,
"--www-root", _webroot],
capture_output=True,
text=True,
preexec_fn=lambda: os.nice(19),
)
if og.returncode != 0:
log.error("site-rebuild: og-images failed (rc=%d):\n%s\n%s",
og.returncode, og.stdout, og.stderr)
else:
log.info("site-rebuild: done")
except Exception:
log.exception("site-rebuild: unexpected error")
def _trigger_rebuild(handle: str) -> None:
"""Merge sidecars for handle asynchronously; signal the site-rebuild worker."""
if deps.site_dir is None:
return
if not deps._VALID_HANDLE.match(handle):
return
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
_data_dir = str(deps.data_dir)
_site_dir = str(deps.site_dir)
_handle = handle
def _run() -> None:
try:
log.info("rebuild[%s]: merge-only", _handle)
with _rebuild_lock:
result = subprocess.run(
[uv, "run", "bincio", "render",
"--data-dir", _data_dir,
"--site-dir", _site_dir,
"--handle", _handle,
"--no-build"],
capture_output=True,
text=True,
**_low_priority,
)
if result.returncode != 0:
log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
_handle, result.returncode, result.stdout, result.stderr)
else:
log.info("rebuild[%s]: merge done", _handle)
if deps.webroot is not None:
_site_rebuild_event.set()
except Exception:
log.exception("rebuild[%s]: unexpected error", _handle)
threading.Thread(target=_run, daemon=True).start()
View File
+23
View File
@@ -0,0 +1,23 @@
"""Shared image upload utilities used by both the edit and serve servers."""
from __future__ import annotations
from pathlib import Path
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset({
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
})
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 # 10 MB
def unique_image_name(directory: Path, filename: str) -> str:
"""Return a filename that does not collide with existing files in directory."""
stem, suffix = Path(filename).stem, Path(filename).suffix
candidate = filename
counter = 1
while (directory / candidate).exists():
candidate = f"{stem}_{counter}{suffix}"
counter += 1
return candidate
+169
View File
@@ -0,0 +1,169 @@
"""Headless multi-user Garmin sync — designed to run as a systemd timer.
For each user directory that contains garmin_creds.json, tries to refresh
the cached garth OAuth2 session (fast, no full login), falls back to a full
email/password re-login only when the session has expired, then fetches and
ingests new activities via garmin_sync_iter.
After all users are synced, optionally POSTs to a server endpoint to trigger
an Astro rebuild + rsync.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import click
_STATUS_FILE = "_garmin_sync_status.json"
log = logging.getLogger("bincio.sync_garmin")
def _write_status(
user_dir: Path,
status: str,
imported: int,
errors: int,
error_message: str | None = None,
) -> None:
payload: dict = {
"status": status,
"imported": imported,
"errors": errors,
"last_run": datetime.now(timezone.utc).isoformat(),
}
if error_message is not None:
payload["error_message"] = error_message
try:
(user_dir / _STATUS_FILE).write_text(
json.dumps(payload, indent=2), encoding="utf-8"
)
except Exception:
pass
def _post_rebuild(url: str, secret: str | None) -> None:
headers: dict[str, str] = {"Content-Type": "application/json"}
if secret:
headers["X-Sync-Secret"] = secret
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
log.info("rebuild triggered: HTTP %d", resp.status)
except urllib.error.HTTPError as exc:
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
except Exception as exc:
log.error("rebuild trigger failed: %s", exc)
def sync_user(data_dir: Path, user_dir: Path) -> tuple[int, int]:
"""Sync one user's Garmin activities.
Returns (imported_count, error_count). Skips silently if no credentials.
"""
from bincio.extract.garmin_api import GarminError, has_credentials, get_client
from bincio.extract.garmin_sync import run_garmin_sync
handle = user_dir.name
if not has_credentials(user_dir):
log.debug("sync[%s]: no garmin_creds.json — skipped", handle)
_write_status(user_dir, "no_credentials", 0, 0)
return 0, 0
# Explicit auth step so we can distinguish auth failures from API failures.
try:
get_client(data_dir, user_dir)
except GarminError as exc:
log.error("sync[%s]: auth failed: %s", handle, exc)
_write_status(user_dir, "auth_error", 0, 1, str(exc))
return 0, 1
try:
result = run_garmin_sync(data_dir, user_dir)
except RuntimeError as exc:
log.error("sync[%s]: sync failed: %s", handle, exc)
_write_status(user_dir, "api_error", 0, 1, str(exc))
return 0, 1
imported = result.get("imported", 0)
error_count = result.get("error_count", 0)
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, error_count)
_write_status(user_dir, "ok", imported, error_count)
return imported, error_count
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
"""Sync all users that have garmin_creds.json. Returns {handle: (imported, errors)}."""
results: dict[str, tuple[int, int]] = {}
cred_files = sorted(root_data_dir.glob("*/garmin_creds.json"))
if not cred_files:
log.info("sync_all: no users with garmin_creds.json found in %s", root_data_dir)
return results
log.info("sync_all: %d user(s) with Garmin credentials", len(cred_files))
for cf in cred_files:
user_dir = cf.parent
handle = user_dir.name
try:
results[handle] = sync_user(root_data_dir, user_dir)
except Exception as exc:
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
results[handle] = (0, -1)
return results
@click.command("sync-garmin")
@click.option("--data-dir", "data_dir_str", required=True,
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
@click.option("--user", "only_user", default=None,
help="Sync only this handle instead of all users.")
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
help="POST here after a successful sync to trigger a site rebuild.")
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
def sync_garmin_cmd(
data_dir_str: str,
only_user: str | None,
rebuild_url: str | None,
rebuild_secret: str | None,
) -> None:
"""Headless Garmin sync for all users (designed for systemd timer).
Discovers every user directory that has garmin_creds.json, tries to
resume the cached garth session (no full re-login if the token is still
valid), fetches new activities, and optionally triggers a site rebuild.
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s%(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
root = Path(data_dir_str).expanduser().resolve()
if not root.is_dir():
raise click.ClickException(f"Data dir not found: {root}")
if only_user:
user_dir = root / only_user
if not user_dir.is_dir():
raise click.ClickException(f"User dir not found: {user_dir}")
new_count, err_count = sync_user(root, user_dir)
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
total_new = new_count
else:
results = sync_all(root)
total_new = sum(n for n, _ in results.values())
total_err = sum(e for _, e in results.values())
click.echo(
f"Sync complete: {len(results)} users, "
f"{total_new} new activities, {total_err} errors"
)
if total_new > 0 and rebuild_url:
_post_rebuild(rebuild_url, rebuild_secret)
+272
View File
@@ -0,0 +1,272 @@
"""Headless multi-user Strava sync — designed to run as a systemd timer.
For each user directory that contains both strava_token.json and
strava_credentials.json, refreshes the token, fetches new activities,
writes them to the user's data dir, merges sidecars, and updates the
_strava_sync.json checkpoint.
After all users are synced, optionally POSTs to a server endpoint
to trigger an Astro rebuild + rsync.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
import click
_TOKEN_FILE = "strava_token.json"
_CREDS_FILE = "strava_credentials.json"
_SYNC_FILE = "_strava_sync.json"
_STATUS_FILE = "_strava_sync_status.json"
log = logging.getLogger("bincio.sync_strava")
def _write_status(
user_dir: Path,
status: str,
imported: int,
errors: int,
error_message: str | None = None,
) -> None:
payload: dict = {
"status": status,
"imported": imported,
"errors": errors,
"last_run": datetime.now(timezone.utc).isoformat(),
}
if error_message is not None:
payload["error_message"] = error_message
try:
(user_dir / _STATUS_FILE).write_text(json.dumps(payload, indent=2), encoding="utf-8")
except Exception:
pass
def _load_creds(user_dir: Path) -> tuple[str, str] | None:
"""Return (client_id, client_secret) from strava_credentials.json, or None."""
p = user_dir / _CREDS_FILE
if not p.exists():
return None
try:
d = json.loads(p.read_text(encoding="utf-8"))
cid = str(d.get("client_id", "")).strip()
csec = str(d.get("client_secret", "")).strip()
if cid and csec:
return cid, csec
except Exception:
pass
return None
def sync_user(user_dir: Path) -> tuple[int, int]:
"""Sync one user's Strava activities.
Returns (new_count, error_count). Skips silently if no credentials.
"""
from bincio.extract.strava_api import ensure_fresh, fetch_activities, fetch_streams, StravaError
from bincio.extract.metrics import compute
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
from bincio.import_.strava import _strava_to_parsed, _patch_from_summary
from bincio.render.merge import merge_all
handle = user_dir.name
creds = _load_creds(user_dir)
if creds is None:
log.debug("sync[%s]: no strava_credentials.json — skipped", handle)
_write_status(user_dir, "no_credentials", 0, 0)
return 0, 0
client_id, client_secret = creds
try:
token = ensure_fresh(user_dir, client_id, client_secret)
except StravaError as exc:
log.error("sync[%s]: token refresh failed: %s", handle, exc)
_write_status(user_dir, "token_error", 0, 1, str(exc))
return 0, 1
access_token = token["access_token"]
# Load incremental sync state
sync_path = user_dir / _SYNC_FILE
sync_state: dict = (
json.loads(sync_path.read_text(encoding="utf-8"))
if sync_path.exists() else {}
)
imported_ids: set[str] = set(sync_state.get("imported_ids", []))
after_ts: int | None = None
if sync_state.get("last_sync"):
last = datetime.fromisoformat(sync_state["last_sync"])
# 1-hour overlap to catch activities saved late to Strava
after_ts = int((last - timedelta(hours=1)).timestamp())
try:
all_acts = fetch_activities(access_token, after=after_ts)
except StravaError as exc:
log.error("sync[%s]: fetch_activities failed: %s", handle, exc)
_write_status(user_dir, "api_error", 0, 1, str(exc))
return 0, 1
new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids]
log.info(
"sync[%s]: %d new, %d already imported",
handle, len(new_acts), len(all_acts) - len(new_acts),
)
if not new_acts:
_write_status(user_dir, "ok", 0, 0)
return 0, 0
# Load existing index so we can update it in place
index_path = user_dir / "index.json"
if index_path.exists():
index_data = json.loads(index_path.read_text(encoding="utf-8"))
else:
index_data = {"owner": {"handle": handle}, "activities": []}
owner = index_data.get("owner", {})
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
imported = 0
errors = 0
for act in new_acts:
strava_id = str(act["id"])
try:
try:
streams = fetch_streams(access_token, int(strava_id))
except StravaError as exc:
if "404" in str(exc):
# Activity exists in list but has no accessible streams (old/deleted GPS).
# Still import it using summary-only stats via _patch_from_summary.
streams = {}
else:
raise
# strava_api.fetch_streams returns {type: {"data": [...], ...}};
# _strava_to_parsed (from import_/strava.py) expects {type: [...]}
flat_streams = {
k: v["data"] for k, v in streams.items()
if isinstance(v, dict) and "data" in v
}
parsed = _strava_to_parsed(act, flat_streams)
metrics = compute(parsed)
metrics = _patch_from_summary(metrics, act)
act_id = make_activity_id(parsed)
# Respect Strava visibility: only_me → unlisted
visibility = act.get("visibility") or ""
privacy = "unlisted" if (act.get("private") or visibility == "only_me") else "public"
write_activity(parsed, metrics, user_dir, privacy=privacy)
summaries[act_id] = build_summary(parsed, metrics, act_id, privacy)
imported_ids.add(strava_id)
imported += 1
except Exception as exc:
log.error("sync[%s]: activity %s failed: %s", handle, strava_id, exc)
errors += 1
# Persist index and sync checkpoint
write_index(list(summaries.values()), user_dir, owner)
sync_state["imported_ids"] = sorted(imported_ids)
sync_state["last_sync"] = datetime.now(timezone.utc).isoformat()
sync_path.write_text(json.dumps(sync_state, indent=2), encoding="utf-8")
# Merge sidecars so _merged/ reflects any edits
merge_all(user_dir)
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors)
_write_status(user_dir, "ok", imported, errors)
return imported, errors
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
"""Sync all users that have a strava_token.json. Returns {handle: (new, errors)}."""
results: dict[str, tuple[int, int]] = {}
token_files = sorted(root_data_dir.glob("*/strava_token.json"))
if not token_files:
log.info("sync_all: no users with strava_token.json found in %s", root_data_dir)
return results
log.info("sync_all: %d user(s) with Strava token", len(token_files))
for tf in token_files:
user_dir = tf.parent
handle = user_dir.name
try:
results[handle] = sync_user(user_dir)
except Exception as exc:
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
results[handle] = (0, -1)
return results
def _post_rebuild(url: str, secret: str | None) -> None:
headers: dict[str, str] = {"Content-Type": "application/json"}
if secret:
headers["X-Sync-Secret"] = secret
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
log.info("rebuild triggered: HTTP %d", resp.status)
except urllib.error.HTTPError as exc:
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
except Exception as exc:
log.error("rebuild trigger failed: %s", exc)
@click.command("sync-strava")
@click.option("--data-dir", "data_dir_str", required=True,
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
@click.option("--user", "only_user", default=None,
help="Sync only this handle instead of all users.")
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
help="POST here after a successful sync to trigger a site rebuild.")
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
def sync_strava_cmd(
data_dir_str: str,
only_user: str | None,
rebuild_url: str | None,
rebuild_secret: str | None,
) -> None:
"""Headless Strava sync for all users (designed for systemd timer).
Discovers every user directory that has both strava_token.json and
strava_credentials.json, syncs new activities, and optionally triggers
a site rebuild via an HTTP POST.
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s%(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
root = Path(data_dir_str).expanduser().resolve()
if not root.is_dir():
raise click.ClickException(f"Data dir not found: {root}")
if only_user:
user_dir = root / only_user
if not user_dir.is_dir():
raise click.ClickException(f"User dir not found: {user_dir}")
new_count, err_count = sync_user(user_dir)
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
total_new = new_count
else:
results = sync_all(root)
total_new = sum(n for n, _ in results.values())
total_err = sum(e for _, e in results.values())
click.echo(
f"Sync complete: {len(results)} users, "
f"{total_new} new activities, {total_err} errors"
)
if total_new > 0 and rebuild_url:
_post_rebuild(rebuild_url, rebuild_secret)
+84
View File
@@ -0,0 +1,84 @@
# CLAUDE.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
---
## Project notes
### Activity page power curve — comparison lines (future)
The activity page shows a single-activity power curve from `detail.mmp` (pre-computed at
extract time, zero extra requests). Adding "last 365 d / last 90 d" comparison overlays
requires the pre-computed `power_curve.last_365d` / `power_curve.last_90d` arrays, which
currently live only in `athlete.json`. Loading `athlete.json` at activity-page time is
wasteful (it's a large file with all activity summaries).
**Clean solution when the time comes:** at `render` time (inside `_merge_edits` or a
dedicated step in `bincio/render/cli.py`), bake the comparison curves into each activity's
detail JSON — e.g. add a `power_curve_context` key with `all_time`, `last_365d`, `last_90d`.
The activity page then gets them for free with the detail JSON it already fetches.
Requires a one-time `bincio render` (no code changes to the extractor).
Component to update: `site/src/components/ActivityPowerCurve.svelte`.
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=BincioActivity Garmin sync
Documentation=https://github.com/bincio/bincio-activity
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/bincio
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
EnvironmentFile=/etc/bincio/sync.env
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-garmin \
--data-dir /var/bincio/data \
--rebuild-url http://localhost:4041/api/internal/rebuild
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bincio-sync-garmin
# Don't restart on failure — the timer will retry in 3 hours.
Restart=no
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=BincioActivity Garmin sync — every 3 hours
Documentation=https://github.com/bincio/bincio-activity
[Timer]
# Fire at 01:30, 04:30, 07:30, 10:30, 13:30, 16:30, 19:30, 22:30 UTC
# Offset by 1h30m from the Strava timer to avoid simultaneous rebuilds.
OnCalendar=*-*-* 01,04,07,10,13,16,19,22:30:00
# Catch up if the VPS was offline during a scheduled run
Persistent=true
# Spread load within a 2-minute window
RandomizedDelaySec=120
[Install]
WantedBy=timers.target
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=BincioActivity Strava sync
Documentation=https://github.com/bincio/bincio-activity
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/bincio
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
EnvironmentFile=/etc/bincio/sync.env
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-strava \
--data-dir /var/bincio/data \
--rebuild-url http://localhost:4041/api/internal/rebuild
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bincio-sync
# Don't restart on failure — the timer will retry in 3 hours.
Restart=no
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=BincioActivity Strava sync — every 3 hours
Documentation=https://github.com/bincio/bincio-activity
[Timer]
# Fire at 00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 UTC
OnCalendar=*-*-* 00,03,06,09,12,15,18,21:00:00
# Catch up if the VPS was offline during a scheduled run
Persistent=true
# Spread load within a 2-minute window to avoid exact midnight spikes
RandomizedDelaySec=120
[Install]
WantedBy=timers.target
+7
View File
@@ -0,0 +1,7 @@
# /etc/bincio/sync.env — secrets for bincio-sync.service
# Copy this file to /etc/bincio/sync.env and fill in the values.
# chmod 600 /etc/bincio/sync.env
# Must match the --sync-secret / BINCIO_SYNC_SECRET value passed to `bincio serve`.
# Generate with: openssl rand -hex 32
BINCIO_SYNC_SECRET=your-secret-here
+7
View File
@@ -26,6 +26,13 @@ Welcome to BincioActivity — a federated, self-hosted activity stats platform.
**[CLI Reference](reference/cli.md)** — All bincio commands and options.
## Mobile App
The mobile app (Expo/React Native) source code is now in the separate `bincio_autarchive` repository. See:
- **[Mobile App Design](mobile-app.md)** — Architecture, Pyodide extraction, Karoo integration, sync protocol (reference documentation)
- **`bincio_autarchive/DEVELOPMENT.md`** — Build & development instructions
## Quick Links
- [GitHub repo](https://github.com/brutsalvadi/bincio-activity)
+2
View File
@@ -1,5 +1,7 @@
# Bincio Mobile App — Design Document
> **Note:** The mobile app source code is now in the separate `bincio_autarchive` repository. This document remains here as the authoritative design reference. For build & development instructions, see the `bincio_autarchive/DEVELOPMENT.md` file.
## Vision
The long-term goal is full independence from Garmin Connect, Strava, Hammerhead,
+64
View File
@@ -0,0 +1,64 @@
# Gear Feature Plan
## Why the gap exists
Neither sync path populates gear today. The Strava API returns `gear_id` per activity
(brut's originals show `b3437566`, `g10422777` etc.) but `strava_to_parsed()` ignores it.
The ZIP path also ignores the gear column in activities.csv.
Diego_p's "Rose Backroad" was set manually via the EditDrawer free-text field.
---
## Data model — `{user_dir}/gear.json`
```json
{
"items": [
{
"id": "uuid-abc123",
"name": "Rose Backroad",
"type": "bike",
"retired": false,
"strava_id": "b3437566"
}
]
}
```
- `type` enum: `bike | shoes | skis | other`
- Per-activity gear stays as a plain string (the gear **name**) — backward compatible with existing sidecars
- `strava_id` is optional, used for deduplication during Strava sync
---
## Build order
### [x] Step 1 — `gear.json` CRUD API ✓
File: `bincio/serve/routers/gear.py`
- `GET /api/gear` → list items (auth required)
- `POST /api/gear` → add item (auto-generate UUID id)
- `PATCH /api/gear/{id}` → update (name, type, retired)
- `DELETE /api/gear/{id}` → delete
File lives at `{user_dir}/gear.json`, same pattern as `athlete.json`.
Add gear router to `server.py`.
### [x] Step 2 — Gear tab in AthleteView (ownerOnly) ✓
- Added `'gear'` to `Tab` type and `ALL_TABS` in `AthleteView.svelte`
- Inline gear management: list, add, edit, retire — no separate component
### [x] Step 3 — EditDrawer gear selector ✓
- At drawer open, fetches `/api/gear`
- Shows `<select>` from registry (if items exist), with "Other…" revealing text input
- Falls back to plain text input if no gear items registered
- Value still stored as gear name string — backward compatible
### [x] Step 4 — Strava sync gear extraction ✓
- `strava_api.py`: added `fetch_gear()` + `gear` field on `strava_to_parsed()` via `_gear_name` meta key
- `ingest.py`: during sync, resolves gear_id → name, adds new items to registry
- New endpoint `POST /api/strava/import-gear`: one-time backfill from stored originals
### [x] Step 5 — ZIP import gear column ✓
- `strava_zip.py`: reads `Gear` column from activities.csv and sets `parsed.gear`
### [x] Step 6 — One-time backfill endpoint ✓
`POST /api/strava/import-gear` implemented in `strava.py`.
+16 -9
View File
@@ -4,6 +4,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bincio</title>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(r) { r.unregister(); });
});
caches.keys().then(function(keys) {
keys.forEach(function(k) { caches.delete(k); });
});
}
</script>
<script>
(function () {
var palettes = {
@@ -52,7 +62,6 @@
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
body { visibility: hidden; }
.wrap {
max-width: 384px;
@@ -222,12 +231,13 @@
function showApps(user) {
loginDiv.style.display = 'none';
appsDiv.style.display = '';
appsDiv.style.display = 'block';
greeting.textContent = 'Ciao ' + (user.display_name || user.handle);
cardsDiv.innerHTML = '';
if (user.activity_access)
// activity_access/wiki_access not in CurrentUserResponse yet — default to true
if (user.activity_access !== false)
cardsDiv.appendChild(appCard('BincioActivity', 'Tracks, strade e numeri', ACTIVITY_URL));
if (user.wiki_access)
if (user.wiki_access !== false)
cardsDiv.appendChild(appCard('BincioWiki', 'La memoria collettiva del gruppo', WIKI_URL));
}
@@ -237,11 +247,8 @@
}
fetch('/api/me', { credentials: 'include' })
.then(async r => {
document.body.style.visibility = '';
if (r.ok) showApps(await r.json());
})
.catch(() => { document.body.style.visibility = ''; });
.then(async r => { if (r.ok) showApps(await r.json()); })
.catch(() => {});
form.addEventListener('submit', async e => {
e.preventDefault();
+11
View File
@@ -0,0 +1,11 @@
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => {
e.waitUntil((async () => {
await self.clients.claim();
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of all) c.navigate(c.url);
await self.registration.unregister();
})());
});
-18
View File
@@ -1,18 +0,0 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# Generated native projects (managed workflow — produced by EAS, not committed)
android/
ios/
# Local env overrides
.env.local
-51
View File
@@ -1,51 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useSQLiteContext } from 'expo-sqlite';
import { getSetting, setSetting } from '@/db/queries';
import { autoKey, PALETTES, type PaletteKey, type Theme } from '@/theme';
type ThemeCtx = {
theme: Theme;
paletteKey: PaletteKey;
setPaletteOverride: (key: PaletteKey) => void;
};
const ThemeContext = createContext<ThemeCtx>({
theme: PALETTES.default,
paletteKey: 'auto',
setPaletteOverride: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const db = useSQLiteContext();
const [paletteKey, setPaletteKey] = useState<PaletteKey>('auto');
useEffect(() => {
getSetting(db, 'palette_override').then(val => {
if (val) setPaletteKey(val as PaletteKey);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function setPaletteOverride(key: PaletteKey) {
setPaletteKey(key);
setSetting(db, 'palette_override', key);
}
const resolved = paletteKey === 'auto' ? autoKey() : paletteKey;
const theme = PALETTES[resolved as keyof typeof PALETTES] ?? PALETTES.default;
return (
<ThemeContext.Provider value={{ theme, paletteKey, setPaletteOverride }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): Theme {
return useContext(ThemeContext).theme;
}
export function usePaletteControl(): Pick<ThemeCtx, 'paletteKey' | 'setPaletteOverride'> {
const { paletteKey, setPaletteOverride } = useContext(ThemeContext);
return { paletteKey, setPaletteOverride };
}
-195
View File
@@ -1,195 +0,0 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
// Embed the JS bundle in debug builds so the APK runs without a Metro server
// (needed for deployment to Karoo and other standalone devices).
// debuggableVariants lists variants that skip bundling; empty = bundle all variants.
debuggableVariants = []
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'org.bincio.app'
defaultConfig {
applicationId 'org.bincio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "0.1.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
splits {
abi {
enable true
reset()
include "arm64-v8a", "armeabi-v7a"
universalApk true
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
-49
View File
@@ -1,49 +0,0 @@
{
"expo": {
"name": "Bincio",
"slug": "bincio",
"version": "0.1.0",
"orientation": "portrait",
"scheme": "bincio",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"platforms": ["ios", "android"],
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#09090b"
},
"android": {
"package": "org.bincio.app",
"usesCleartextTraffic": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#09090b"
},
"permissions": [
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.POST_NOTIFICATIONS"
]
},
"ios": {
"bundleIdentifier": "org.bincio.app",
"supportsTablet": true
},
"plugins": [
"expo-system-ui",
"expo-router",
"expo-sqlite",
[
"expo-document-picker",
{ "iCloudContainerEnvironment": "Production" }
],
"expo-background-fetch",
"expo-task-manager",
"@maplibre/maplibre-react-native"
]
}
}
-45
View File
@@ -1,45 +0,0 @@
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { useTheme } from '@/ThemeContext';
const isKaroo = Platform.OS === 'android' && (Platform.Version as number) < 29;
export default function TabLayout() {
const theme = useTheme();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: '#71717a',
}}
>
<Tabs.Screen
name="index"
options={{ title: 'Feed', tabBarIcon: ({ color }) => <TabIcon label="⬡" color={color} /> }}
/>
<Tabs.Screen
name="import"
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color }) => <TabIcon label="⌕" color={color} />,
href: isKaroo ? null : '/search',
}}
/>
<Tabs.Screen
name="settings"
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
/>
</Tabs>
);
}
function TabIcon({ label, color }: { label: string; color: string }) {
const { Text } = require('react-native');
return <Text style={{ color, fontSize: 18 }}>{label}</Text>;
}
-604
View File
@@ -1,604 +0,0 @@
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
import { PyodideWebView } from '@/extraction/PyodideWebView';
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
import { useTheme } from '@/ThemeContext';
async function sha256hex(text: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
type ImportState =
| { status: 'idle' }
| { status: 'loading'; msg: string; current: number; total: number }
| { status: 'done'; count: number; errors: Array<{ name: string; message: string }> }
| { status: 'error'; message: string };
export default function ImportScreen() {
const db = useSQLiteContext();
const theme = useTheme();
const [state, setState] = useState<ImportState>({ status: 'idle' });
const [watchPath, setWatchPath] = useState('');
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
const isImporting = useRef(false);
// Track engine availability so we can show the server-extraction notice.
useEffect(() => {
waitForEngine(30_000)
.then(() => setEngineAvailable(true))
.catch((e: unknown) => {
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
});
}, []);
// Reload watch path every time the Import tab comes into focus so changes
// saved in Settings are picked up without remounting the tab.
useFocusEffect(useCallback(() => {
if (Platform.OS !== 'android') return;
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
['auto_import_path'],
);
setWatchPath(row?.value ?? '');
}, [db]));
// Auto-scan watch folder on mount and when app comes to foreground.
useEffect(() => {
if (Platform.OS !== 'android') return;
runAutoScan();
const sub = AppState.addEventListener('change', (next) => {
if (next === 'active') runAutoScan();
});
return () => sub.remove();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function runAutoScan() {
if (isImporting.current) return;
const path = await getSetting(db, 'auto_import_path');
if (!path) return;
const instanceUrl = await getSetting(db, 'instance_url');
if (!instanceUrl) return;
// Wait for engine — skip auto-scan on init failure, but continue if device is
// too old for local extraction (importNativeFile will use the server instead).
try { await waitForEngine(120_000); } catch (e: unknown) {
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
}
// Server-mode requires a valid token — verify before touching any files.
if (isEngineAvailable() === false) {
const token = await getSetting(db, 'api_token');
if (!token) return;
try { await checkServerAuth(instanceUrl, token); } catch { return; }
}
const newFiles = await discoverNewFiles(db, path);
if (newFiles.length === 0) return;
isImporting.current = true;
try {
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
} finally {
isImporting.current = false;
}
}
async function manualScan() {
if (isImporting.current) return;
const path = await getSetting(db, 'auto_import_path');
if (!path) return;
const instanceUrl = await getSetting(db, 'instance_url');
if (!instanceUrl) {
setState({ status: 'error', message: 'No Bincio instance configured. Go to Settings and enter an instance URL first — it\'s needed to download the extraction engine.' });
return;
}
const serverMode = isEngineAvailable() === false;
if (!serverMode) {
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
const unsubScan = onEngineProgress((msg) =>
setState({ status: 'loading', msg, current: 0, total: 0 }),
);
try {
await waitForEngine();
} catch (e: unknown) {
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
}
// engine_unavailable — fall through to server mode
} finally {
unsubScan();
}
} else {
const token = await getSetting(db, 'api_token');
if (!token) {
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
return;
}
// Verify the token is valid before processing any files.
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
try {
await checkServerAuth(instanceUrl, token);
} catch (e: unknown) {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
}
}
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
const newFiles = await discoverNewFiles(db, path);
if (newFiles.length === 0) {
setState({ status: 'done', count: 0, errors: [] });
return;
}
isImporting.current = true;
try {
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
} finally {
isImporting.current = false;
}
}
async function pickFiles() {
if (isImporting.current) return;
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
try {
let result: DocumentPicker.DocumentPickerResult;
try {
result = await DocumentPicker.getDocumentAsync({
type: ['*/*'],
copyToCacheDirectory: true,
multiple: true,
});
} catch (pickerErr: unknown) {
// Some Android devices (e.g. Karoo) have no system file picker app.
const raw = pickerErr instanceof Error ? pickerErr.message : String(pickerErr);
const noApp = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
setState({
status: 'error',
message: noApp
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder.'
: raw,
});
return;
}
if (result.canceled || !result.assets?.length) {
setState({ status: 'idle' });
return;
}
isImporting.current = true;
const unsubPick = onEngineProgress((msg) =>
setState({ status: 'loading', msg, current: 0, total: 0 }),
);
try {
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
} finally {
unsubPick();
isImporting.current = false;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setState({ status: 'error', message: msg });
isImporting.current = false;
}
}
async function processBatch(files: Array<{ uri: string; name: string; sourcePath: string | null }>) {
const total = files.length;
const errors: Array<{ name: string; message: string }> = [];
let count = 0;
for (let i = 0; i < files.length; i++) {
const { uri, name, sourcePath } = files[i];
const lower = name.toLowerCase();
setState({ status: 'loading', msg: `Processing ${name}`, current: i + 1, total });
try {
if (lower.endsWith('.json')) {
await importBasJson(uri, name, sourcePath, (msg) =>
setState({ status: 'loading', msg, current: i + 1, total }),
);
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
await importNativeFile(uri, name, sourcePath, (msg) =>
setState({ status: 'loading', msg, current: i + 1, total }),
);
} else {
errors.push({ name, message: 'Unsupported file type' });
continue;
}
count++;
} catch (e: unknown) {
errors.push({ name, message: e instanceof Error ? e.message : String(e) });
}
}
setState({ status: 'done', count, errors });
}
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
async function importBasJson(
uri: string,
_name: string,
sourcePath: string | null,
onStatus: (msg: string) => void,
) {
onStatus('Importing…');
const text = await FileSystem.readAsStringAsync(uri);
const detail = JSON.parse(text);
if (!detail.id || !detail.started_at) {
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
}
const hash = detail.source_hash ?? await sha256hex(text);
const origDir = `${FileSystem.documentDirectory}originals/`;
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
const dest = `${origDir}${detail.id}.json`;
await FileSystem.copyAsync({ from: uri, to: dest });
await insertActivity(db, {
id: detail.id,
source_hash: hash,
detail_json: text,
timeseries_json: null,
geojson: null,
original_path: dest,
source_path: sourcePath,
origin: 'local',
});
}
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
async function importNativeFile(
uri: string,
name: string,
sourcePath: string | null,
onStatus: (msg: string) => void,
) {
onStatus('Reading file…');
// Read the original file as base64 so we can (a) pass it to the extractor
// and (b) copy it to permanent storage without a second read.
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
let result;
if (isEngineAvailable() === false) {
// Device WebView is too old for WebAssembly.Global (Chrome <69).
// Send the raw file to the Bincio instance for server-side extraction.
const instanceUrl = await getInstanceUrl(db);
const token = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?', ['api_token'],
)?.value ?? '';
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
} else {
// Fetch the bincio wheel here (React Native networking), not inside the
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
const instanceUrl = await getInstanceUrl(db);
onStatus('Fetching Bincio engine…');
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
}
onStatus('Saving…');
// Copy original file to permanent storage (keeps original bytes for future re-extraction)
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
const origDir = `${FileSystem.documentDirectory}originals/`;
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
const dest = `${origDir}${result.id}${ext}`;
await FileSystem.copyAsync({ from: uri, to: dest });
await insertActivity(db, {
id: result.id,
source_hash: result.sourceHash,
detail_json: JSON.stringify(result.detail),
timeseries_json: result.timeseries ? JSON.stringify(result.timeseries) : null,
geojson: result.geojson ? JSON.stringify(result.geojson) : null,
original_path: dest,
source_path: sourcePath,
origin: 'local',
});
}
return (
<View style={styles.screen}>
{/* Hidden WebView for Pyodide only mounted on devices that can run it.
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
<View style={styles.hiddenEngine}>
<PyodideWebView />
</View>
)}
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Import</Text>
<Text style={styles.body}>
Import FIT, GPX, or TCX files extracted on your device, nothing uploaded.
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
</Text>
{engineAvailable === false && (
<View style={styles.serverNotice}>
<Text style={styles.serverNoticeText}>
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
Activities are processed by your Bincio instance instead a connected account is required.
</Text>
</View>
)}
{watchPath ? (
<View style={styles.watchBox}>
<Text style={styles.watchLabel}>Watch folder</Text>
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
<Pressable
style={[styles.scanButton, state.status === 'loading' && styles.buttonDisabled]}
onPress={state.status !== 'loading' ? manualScan : undefined}
>
<Text style={styles.buttonText}>
{state.status === 'loading' ? 'Working…' : '↺ Scan for new rides'}
</Text>
</Pressable>
</View>
) : null}
<Pressable
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
onPress={state.status !== 'loading' ? pickFiles : undefined}
>
<Text style={styles.buttonText}>
{state.status === 'loading' ? 'Working…' : ' Pick files'}
</Text>
</Pressable>
{state.status === 'loading' && (
<View style={styles.statusBox}>
{state.total > 1 && (
<Text style={styles.statusCounter}>
File {state.current} of {state.total}
</Text>
)}
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
{engineAvailable !== false && (
<Text style={styles.statusHint}>
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
</Text>
)}
</View>
)}
{state.status === 'done' && (
<View style={[styles.success, state.count === 0 && state.errors.length === 0 && styles.successEmpty]}>
<Text style={styles.successText}>
{state.count === 0 && state.errors.length === 0
? 'No new rides found'
: `✓ Imported ${state.count} ${state.count === 1 ? 'activity' : 'activities'}`}
</Text>
{state.errors.map((e, i) => (
<Text key={i} style={styles.batchError}> {e.name}: {e.message}</Text>
))}
<Pressable onPress={() => setState({ status: 'idle' })}>
<Text style={styles.errorRetry}>Dismiss</Text>
</Pressable>
</View>
)}
{state.status === 'error' && (
<View style={styles.error}>
<Text style={styles.errorText}>{state.message}</Text>
<Pressable onPress={() => setState({ status: 'idle' })}>
<Text style={styles.errorRetry}>Try again</Text>
</Pressable>
</View>
)}
<View style={styles.divider} />
<Text style={styles.sectionTitle}>Supported formats</Text>
{([
['FIT', 'Garmin, Wahoo, Karoo native format'],
['GPX', 'Most GPS devices and apps'],
['TCX', 'Garmin Training Center'],
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
] as [string, string][]).map(([fmt, desc]) => (
<View key={fmt} style={styles.formatRow}>
<Text style={styles.formatName}>{fmt}</Text>
<Text style={styles.formatDesc}>{desc}</Text>
</View>
))}
<View style={styles.notice}>
<Text style={styles.noticeText}>
{engineAvailable === false
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
{'\n\n'}
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
</Text>
</View>
</ScrollView>
</View>
);
}
// ── Watch-folder helpers ──────────────────────────────────────────────────────
async function requestStoragePermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch {
return false;
}
}
async function discoverNewFiles(
db: ReturnType<typeof useSQLiteContext>,
watchPath: string,
): Promise<string[]> {
const ok = await requestStoragePermission();
if (!ok) return [];
// Normalize: strip trailing slash, then use file:// URI for expo-fs
const dir = watchPath.replace(/\/+$/, '');
const uri = dir.startsWith('file://') ? dir : `file://${dir}`;
let entries: string[];
try {
entries = await FileSystem.readDirectoryAsync(uri);
} catch {
return [];
}
const newFiles: string[] = [];
for (const entry of entries) {
const lower = entry.toLowerCase();
if (!lower.endsWith('.fit')) continue;
const fullPath = `${dir}/${entry}`;
if (!isSourcePathImported(db, fullPath)) {
newFiles.push(fullPath);
}
}
return newFiles;
}
// ── Module-level helpers ──────────────────────────────────────────────────────
async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<string> {
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
['instance_url'],
);
return (row?.value ?? '').replace(/\/$/, '');
}
// In-memory cache so repeated imports in one session don't re-download the wheel.
let _cachedWheel: { base64: string; filename: string } | null = null;
async function fetchWheelBase64(instanceUrl: string): Promise<{ base64: string; filename: string }> {
if (_cachedWheel) return _cachedWheel;
const base = instanceUrl || 'https://bincio.org';
// Ask the instance for the canonical wheel URL (handles both dev and prod layouts).
let wheelUrl = `${base}/api/wheel/download`;
let wheelFilename = 'bincio-0.1.0-py3-none-any.whl';
try {
const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
if (vr.ok) {
const d = await vr.json() as { api_url?: string; url?: string };
const path = d.api_url ?? d.url ?? '/api/wheel/download';
wheelUrl = path.startsWith('http') ? path : `${base}${path}`;
// Extract the filename from the URL path (last segment after final /)
const urlBasename = wheelUrl.split('/').pop() ?? '';
if (urlBasename.endsWith('.whl')) wheelFilename = urlBasename;
}
} catch {}
// Fetch via React Native networking (supports local HTTP; WKWebView would block it).
const resp = await fetch(wheelUrl);
if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`);
const buf = await resp.arrayBuffer();
_cachedWheel = { base64: arrayBufferToBase64(buf), filename: wheelFilename };
return _cachedWheel;
}
function arrayBufferToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = '';
// Process in chunks to avoid spread-operator stack overflow on large arrays.
const CHUNK = 8192;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...(bytes.subarray(i, i + CHUNK) as unknown as number[]));
}
return btoa(binary);
}
// ── Styles ───────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#09090b' },
hiddenEngine: { position: 'absolute', width: 1, height: 1, overflow: 'hidden' },
container: { flex: 1 },
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
code: { color: '#60a5fa', fontFamily: 'monospace' },
serverNotice: {
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
borderColor: '#854d0e', padding: 12, marginBottom: 16,
},
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
watchBox: {
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
},
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
scanButton: {
backgroundColor: '#16a34a', borderRadius: 10,
paddingVertical: 14, alignItems: 'center',
},
button: {
backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
},
buttonDisabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
statusBox: {
backgroundColor: '#18181b', borderRadius: 8, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 6,
},
statusCounter: { color: '#71717a', fontSize: 12, textAlign: 'center' },
statusMsg: { color: '#60a5fa', fontSize: 14, textAlign: 'center' },
statusHint: { color: '#52525b', fontSize: 12, textAlign: 'center', lineHeight: 16 },
success: {
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, gap: 6,
},
successEmpty: { backgroundColor: '#1c1c1e' },
successText: { color: '#86efac', fontSize: 14 },
batchError: { color: '#fca5a5', fontSize: 12 },
error: {
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
},
errorText: { color: '#fca5a5', fontSize: 14 },
errorRetry: { color: '#71717a', fontSize: 13, textDecorationLine: 'underline', marginTop: 4 },
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
notice: {
marginTop: 8, backgroundColor: '#18181b',
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
},
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
noticeCode: { fontFamily: 'monospace', color: '#a1a1aa' },
});
-302
View File
@@ -1,302 +0,0 @@
import * as FileSystem from 'expo-file-system';
import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { useCallback, useState } from 'react';
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE } from '@/db/queries';
import { downloadFeed, uploadFeed } from '@/db/sync';
import { useTheme } from '@/ThemeContext';
import { ActivityCard } from '@/components/ActivityCard';
export default function FeedScreen() {
const db = useSQLiteContext();
const theme = useTheme();
const [refreshKey, setRefreshKey] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [limit, setLimit] = useState(PAGE_SIZE);
const activities = useActivities(searchQuery, limit);
const totalCount = useActivityCount(searchQuery);
const hasMore = activities.length < totalCount;
const [downloading, setDownloading] = useState(false);
const [uploading, setUploading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const selecting = selected.size > 0;
// Auto-refresh the local list whenever the tab comes into focus.
// SQLite getAllSync is sub-millisecond — no network, no lag.
useFocusEffect(useCallback(() => {
setRefreshKey(k => k + 1);
}, []));
function showMsg(ok: boolean, text: string) {
setStatusMsg({ ok, text });
setTimeout(() => setStatusMsg(null), 3500);
}
const doDownload = useCallback(async () => {
setDownloading(true);
setStatusMsg(null);
const result = await downloadFeed(db);
setDownloading(false);
setRefreshKey(k => k + 1);
if (result.error) {
showMsg(false, result.error);
} else if (result.total === 0) {
showMsg(true, 'No activities on instance');
} else if (result.synced === 0 && !result.fetched) {
showMsg(true, `Up to date (${result.total} activities)`);
} else {
const parts = [];
if (result.synced > 0) parts.push(`${result.synced} new`);
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
}
}, [db]);
const doUpload = useCallback(async () => {
setUploading(true);
setStatusMsg(null);
const result = await uploadFeed(db, (n, total) => {
setStatusMsg({ ok: true, text: `Uploading ${n} / ${total}` });
});
setUploading(false);
if (result.error) {
showMsg(false, result.error);
} else if (!result.uploaded && !result.failed) {
showMsg(true, 'Nothing to upload');
} else {
const parts: string[] = [];
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
if (result.failed) parts.push(`${result.failed} failed`);
showMsg(result.failed ? false : true, parts.join(', '));
}
}, [db]);
function doRefresh() {
setRefreshKey(k => k + 1);
}
function handleSearch(q: string) {
setSearchQuery(q);
setLimit(PAGE_SIZE); // reset pagination when search changes
}
function loadMore() {
if (hasMore) setLimit(l => l + PAGE_SIZE);
}
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function cancelSelect() { setSelected(new Set()); }
function confirmDeleteSelected() {
const count = selected.size;
Alert.alert(
`Delete ${count} activit${count === 1 ? 'y' : 'ies'}`,
'These activities will be permanently removed from your device.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const ids = Array.from(selected);
const paths = await deleteActivities(db, ids);
setSelected(new Set());
for (const p of paths) {
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
}
},
},
],
);
}
const busy = downloading || uploading;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
{selecting ? (
<>
<Text style={styles.header}>{selected.size} selected</Text>
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
</>
) : (
<>
<Text style={styles.header}>Feed</Text>
<View style={styles.actionButtons}>
<ActionButton
icon="↑"
label="Upload"
loading={uploading}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doUpload}
/>
<ActionButton
icon="↓"
label="Download"
loading={downloading}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doDownload}
/>
<ActionButton
icon="↺"
label="Refresh"
loading={false}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doRefresh}
/>
</View>
</>
)}
</View>
{statusMsg && (
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
)}
{!selecting && (
<View style={styles.searchRow}>
<TextInput
style={styles.searchInput}
value={searchQuery}
onChangeText={handleSearch}
placeholder="Search activities…"
placeholderTextColor="#52525b"
returnKeyType="search"
clearButtonMode="while-editing"
/>
</View>
)}
{activities.length === 0 && !busy ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text>
<Text style={styles.emptyTitle}>No activities yet</Text>
<Text style={styles.emptyBody}>
Import a file or tap to pull from your instance.
</Text>
</View>
) : (
<FlatList
data={activities}
keyExtractor={(a) => a.id}
extraData={refreshKey}
renderItem={({ item }) => (
<ActivityCard
activity={item}
selecting={selecting}
checked={selected.has(item.id)}
onToggleSelect={() => toggleSelect(item.id)}
onLongPress={() => toggleSelect(item.id)}
/>
)}
contentContainerStyle={styles.list}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={doRefresh}
tintColor="#60a5fa"
/>
}
/>
)}
{selecting && (
<View style={styles.actionBar}>
<Pressable style={styles.deleteBarButton} onPress={confirmDeleteSelected}>
<Text style={styles.deleteBarText}>Delete {selected.size}</Text>
</Pressable>
</View>
)}
</View>
);
}
function ActionButton({
icon, label, loading, disabled, accent, dim, onPress,
}: {
icon: string;
label: string;
loading: boolean;
disabled: boolean;
accent: string;
dim: string;
onPress: () => void;
}) {
return (
<Pressable
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
onPress={disabled ? undefined : onPress}
accessibilityLabel={label}
>
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
{loading ? '…' : icon}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
},
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
actionButtons: { flexDirection: 'row', gap: 8 },
actionBtn: {
width: 36, height: 36, borderRadius: 8,
alignItems: 'center', justifyContent: 'center',
},
actionBtnDisabled: { opacity: 0.4 },
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
cancelButton: {
backgroundColor: '#27272a', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
},
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
searchRow: { paddingHorizontal: 16, paddingBottom: 10 },
searchInput: {
backgroundColor: '#18181b', borderWidth: 1, borderColor: '#27272a',
borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8,
color: '#f4f4f5', fontSize: 14,
},
list: { padding: 16, gap: 12, paddingBottom: 80 },
empty: {
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
},
emptyIcon: { fontSize: 48, marginBottom: 16 },
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
actionBar: {
position: 'absolute', bottom: 0, left: 0, right: 0,
backgroundColor: '#18181b', borderTopWidth: 1, borderTopColor: '#27272a',
paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 28,
},
deleteBarButton: {
backgroundColor: '#7f1d1d', borderRadius: 10,
paddingVertical: 14, alignItems: 'center',
},
deleteBarText: { color: '#fca5a5', fontSize: 15, fontWeight: '700' },
});
-151
View File
@@ -1,151 +0,0 @@
import { useState } from 'react';
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { PAGE_SIZE, useActivityYears, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries';
import { ActivityCard } from '@/components/ActivityCard';
import { useTheme } from '@/ThemeContext';
type SortKey = 'date' | 'distance' | 'elevation';
const SPORTS = [
{ value: '', label: 'All' },
{ value: 'cycling', label: '🚴 Cycling' },
{ value: 'running', label: '🏃 Running' },
{ value: 'hiking', label: '🥾 Hiking' },
{ value: 'swimming', label: '🏊 Swimming' },
{ value: 'walking', label: '🚶 Walking' },
];
const DATE_PRESETS = [
{ value: 'all', label: 'All time' },
{ value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' },
{ value: '6mo', label: '6 months' },
];
const SORTS: { value: SortKey; label: string }[] = [
{ value: 'date', label: 'Newest' },
{ value: 'distance', label: 'Distance' },
{ value: 'elevation', label: 'Elevation' },
];
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
if (preset === 'all') return { dateFrom: '', dateTo: '' };
if (/^\d{4}$/.test(preset)) {
const y = parseInt(preset, 10);
return { dateFrom: `${y}-01-01T000000Z`, dateTo: `${y + 1}-01-01T000000Z` };
}
const pad = (n: number) => String(n).padStart(2, '0');
const now = new Date();
let d: Date;
if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000);
else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000);
else { d = new Date(now); d.setMonth(d.getMonth() - 6); }
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`, dateTo: '' };
}
export default function SearchScreen() {
const theme = useTheme();
const [sport, setSport] = useState('');
const [datePre, setDatePre] = useState('all');
const [sort, setSort] = useState<SortKey>('date');
const [limit, setLimit] = useState(PAGE_SIZE);
const years = useActivityYears();
const dateOptions = [...DATE_PRESETS, ...years.map(y => ({ value: y, label: y }))];
const { dateFrom, dateTo } = computeDateRange(datePre);
const filter: ActivityFilter = { sport, dateFrom, dateTo, sort };
const activities = useFilteredActivities(filter, limit);
const total = useFilteredCount(filter);
const hasMore = activities.length < total;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.header}>Filter</Text>
{total > 0 && <Text style={styles.count}>{total} activities</Text>}
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
{SPORTS.map(s => (
<Pill key={s.value} label={s.label} active={sport === s.value} accent={theme.accent}
onPress={() => { setSport(s.value); setLimit(PAGE_SIZE); }} />
))}
</ScrollView>
<ScrollView horizontal showsHorizontalScrollIndicator={false}
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
{dateOptions.map(d => (
<Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent}
onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} />
))}
</ScrollView>
<View style={styles.sortRow}>
{SORTS.map(s => (
<Pressable key={s.value}
style={[styles.sortBtn, sort === s.value && { borderBottomColor: theme.accent, borderBottomWidth: 2 }]}
onPress={() => { setSort(s.value); setLimit(PAGE_SIZE); }}>
<Text style={[styles.sortText, sort === s.value && { color: theme.accent }]}>{s.label}</Text>
</Pressable>
))}
</View>
{activities.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyText}>No activities match</Text>
</View>
) : (
<FlatList
style={{ flex: 1 }}
data={activities}
keyExtractor={a => a.id}
renderItem={({ item }) => (
<ActivityCard activity={item} selecting={false} checked={false}
onToggleSelect={() => {}} onLongPress={() => {}} />
)}
contentContainerStyle={styles.list}
onEndReached={() => { if (hasMore) setLimit(l => l + PAGE_SIZE); }}
onEndReachedThreshold={0.3}
/>
)}
</View>
);
}
function Pill({ label, active, accent, onPress }: {
label: string; active: boolean; accent: string; onPress: () => void;
}) {
return (
<Pressable
style={[styles.pill, active && { backgroundColor: accent + '33', borderColor: accent }]}
onPress={onPress}
>
<Text style={[styles.pillText, active && { color: accent }]}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
},
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
count: { color: '#71717a', fontSize: 13 },
pillScroll: { flexGrow: 0, flexShrink: 0 },
pillRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 },
pill: {
borderRadius: 20, borderWidth: 1, borderColor: '#3f3f46',
paddingHorizontal: 14, paddingVertical: 7,
},
pillText: { color: '#a1a1aa', fontSize: 13, fontWeight: '500' },
sortRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 4 },
sortBtn: { marginRight: 24, paddingBottom: 8, borderBottomWidth: 2, borderBottomColor: 'transparent' },
sortText: { color: '#71717a', fontSize: 13, fontWeight: '600' },
list: { padding: 16, gap: 12, paddingBottom: 80 },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { color: '#52525b', fontSize: 15 },
});
-388
View File
@@ -1,388 +0,0 @@
import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react';
import {
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
Text, TextInput, View,
} from 'react-native';
import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries';
import { PALETTES, type PaletteKey } from '@/theme';
import { useTheme, usePaletteControl } from '@/ThemeContext';
export default function SettingsScreen() {
const db = useSQLiteContext();
const storedUrl = useSetting('instance_url') ?? '';
const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token');
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
const storedSyncUpload = useSetting('sync_upload') === 'true';
const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas';
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle);
const [autoPath, setAutoPath] = useState(storedPath);
const [syncMode, setSyncMode] = useState(storedSyncMode);
const [syncUpload, setSyncUpload] = useState(storedSyncUpload);
const [uploadFormat, setUploadFormat] = useState(storedUploadFormat);
const [saved, setSaved] = useState(false);
const theme = useTheme();
const { paletteKey: palette, setPaletteOverride } = usePaletteControl();
const [password, setPassword] = useState('');
const [connecting, setConnecting] = useState(false);
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [resetArmed, setResetArmed] = useState(false);
const [resetMsg, setResetMsg] = useState<string | null>(null);
async function save() {
await setSetting(db, 'instance_url', instanceUrl.trim());
await setSetting(db, 'handle', handle.trim());
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
async function connect() {
const url = instanceUrl.trim().replace(/\/$/, '');
const h = handle.trim();
if (!url || !h || !password) {
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
return;
}
setConnecting(true);
setConnectMsg(null);
try {
const resp = await fetch(`${url}/api/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle: h, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
return;
}
const data = await resp.json();
await setSetting(db, 'instance_url', url);
await setSetting(db, 'handle', h);
await setSetting(db, 'api_token', data.token);
setPassword('');
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
} catch {
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
} finally {
setConnecting(false);
}
}
async function disconnect() {
await setSetting(db, 'api_token', '');
setConnectMsg(null);
}
async function resetSyncedData() {
if (!resetArmed) {
setResetArmed(true);
return;
}
const n = await deleteRemoteActivities(db);
setResetArmed(false);
setResetMsg(`Removed ${n} synced ${n === 1 ? 'activity' : 'activities'}`);
setTimeout(() => setResetMsg(null), 3000);
}
const isConnected = !!storedToken;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Settings</Text>
<Section title="Instance">
<Field
label="Instance URL"
placeholder="https://bincio.org"
value={instanceUrl}
onChangeText={setInstanceUrl}
autoCapitalize="none"
keyboardType="url"
/>
<Field
label="Handle"
placeholder="yourhandle"
value={handle}
onChangeText={setHandle}
autoCapitalize="none"
/>
<Text style={styles.hint}>
Connect to a Bincio instance to sync your activities. Leave blank to use
the app offline only.
</Text>
</Section>
<Pressable style={styles.saveButton} onPress={save}>
<Text style={styles.saveButtonText}>
{saved ? '✓ Saved' : 'Save'}
</Text>
</Pressable>
<Section title="Connection">
{isConnected ? (
<>
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
<Pressable style={styles.disconnectButton} onPress={disconnect}>
<Text style={styles.disconnectText}>Disconnect</Text>
</Pressable>
</>
) : (
<>
<Field
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
autoCapitalize="none"
secureTextEntry
/>
<Pressable
style={[styles.connectButton, connecting && styles.buttonDisabled]}
onPress={connecting ? undefined : connect}
>
{connecting
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.connectText}>Connect</Text>}
</Pressable>
</>
)}
{connectMsg && (
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
{connectMsg.text}
</Text>
)}
<Text style={styles.hint}>
Your password is used once to obtain a session token, then forgotten.
The token is stored locally and sent with each sync request.
</Text>
</Section>
{Platform.OS === 'android' && (
<Section title="Auto-import (Android)">
{!storedUrl ? (
<Text style={[styles.hint, styles.hintWarn]}>
Configure and save a Bincio instance URL above first it's needed to download the extraction engine.
</Text>
) : (
<>
<Field
label="Watch directory"
placeholder="/sdcard/FitFiles"
value={autoPath}
onChangeText={setAutoPath}
onBlur={() => setSetting(db, 'auto_import_path', autoPath.trim())}
autoCapitalize="none"
/>
<Text style={styles.hint}>
New FIT files in this folder are imported automatically when you
open the app. Leave blank to disable. Requires storage permission.
</Text>
</>
)}
</Section>
)}
<Section title="Sync">
<Text style={styles.subLabel}>Download</Text>
<View style={styles.modeRow}>
<ModeButton label="Summaries only" active={syncMode === 'summaries'} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} />
<ModeButton label="Full data" active={syncMode === 'full'} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} />
</View>
<Text style={styles.hint}>
{syncMode === 'full'
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
</Text>
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
<View style={styles.modeRow}>
<ModeButton label="Off" active={!syncUpload} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} />
<ModeButton label="Upload local activities" active={syncUpload} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} />
</View>
<Text style={styles.hint}>
{syncUpload
? 'Local activities are uploaded to the instance during sync.'
: 'Local activities stay on device only.'}
</Text>
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload format</Text>
<View style={styles.modeRow}>
<ModeButton label="Original file" active={uploadFormat === 'raw'} accent={theme.accent} dim={theme.dim}
onPress={() => { setUploadFormat('raw'); setSetting(db, 'upload_format', 'raw'); }} />
<ModeButton label="Extracted JSON" active={uploadFormat === 'bas'} accent={theme.accent} dim={theme.dim}
onPress={() => { setUploadFormat('bas'); setSetting(db, 'upload_format', 'bas'); }} />
</View>
<Text style={styles.hint}>
{uploadFormat === 'raw'
? 'Uploads the original FIT/GPX/TCX file. The server re-extracts it with DEM elevation correction and updates your local copy.'
: 'Uploads the pre-extracted JSON. Faster, but no DEM elevation correction.'}
</Text>
</Section>
<Section title="Palette">
<Text style={[styles.hint, { paddingBottom: 0 }]}>
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing.
</Text>
<View style={styles.modeRow}>
{(['auto', 'default', 'giro', 'tour', 'vuelta'] as PaletteKey[]).map(key => {
const label = key === 'auto' ? 'Auto' : PALETTES[key as keyof typeof PALETTES].label;
const keyAccent = key === 'auto' ? theme.accent : PALETTES[key as keyof typeof PALETTES].accent;
const keyDim = key === 'auto' ? theme.dim : PALETTES[key as keyof typeof PALETTES].dim;
return (
<ModeButton
key={key}
label={label}
active={palette === key}
accent={keyAccent}
dim={keyDim}
onPress={() => setPaletteOverride(key)}
/>
);
})}
</View>
</Section>
<Section title="Data">
<Pressable
style={[styles.resetButton, resetArmed && styles.resetButtonArmed]}
onPress={resetSyncedData}
onBlur={() => setResetArmed(false)}
>
<Text style={[styles.resetText, resetArmed && styles.resetTextArmed]}>
{resetArmed ? 'Tap again to confirm' : 'Reset synced data'}
</Text>
</Pressable>
{resetMsg && <Text style={styles.msgOk}>{resetMsg}</Text>}
<Text style={styles.hint}>
Removes all activities synced from the instance. Locally imported files are kept.
</Text>
</Section>
<Section title="About">
<Row label="Version" value="0.1.0 (Phase 0.5)" />
<Row label="Schema" value="BAS 1.0" />
</Section>
</ScrollView>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.sectionBody}>{children}</View>
</View>
);
}
function Field({
label, placeholder, value, onChangeText, ...rest
}: {
label: string;
placeholder: string;
value: string;
onChangeText: (v: string) => void;
[key: string]: unknown;
}) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor="#52525b"
value={value}
onChangeText={onChangeText}
{...rest}
/>
</View>
);
}
function ModeButton({ label, active, accent, dim, onPress }: {
label: string; active: boolean; accent: string; dim: string; onPress: () => void;
}) {
return (
<Pressable
style={[styles.modeButton, active && { backgroundColor: dim, borderColor: accent }]}
onPress={onPress}
>
<Text style={[styles.modeButtonText, active && { color: accent }]}>{label}</Text>
</Pressable>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
section: { marginBottom: 28 },
sectionTitle: {
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
letterSpacing: 0.8, marginBottom: 8,
},
sectionBody: {
backgroundColor: '#18181b', borderRadius: 10,
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
},
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
input: { color: '#f4f4f5', fontSize: 15 },
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
hintWarn: { color: '#a16207' },
row: {
flexDirection: 'row', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: 1, borderBottomColor: '#27272a',
},
rowLabel: { color: '#a1a1aa', fontSize: 14 },
rowValue: { color: '#71717a', fontSize: 14 },
saveButton: {
backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
},
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
connectButton: {
backgroundColor: '#059669', borderRadius: 8, margin: 12,
paddingVertical: 12, alignItems: 'center',
},
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
buttonDisabled: { opacity: 0.5 },
disconnectButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
},
disconnectText: { color: '#71717a', fontSize: 14 },
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' },
resetButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
},
resetButtonArmed: { borderColor: '#ef4444', backgroundColor: '#1c0a0a' },
resetText: { color: '#71717a', fontSize: 14 },
resetTextArmed: { color: '#ef4444', fontWeight: '600' },
});
-16
View File
@@ -1,16 +0,0 @@
import { Stack } from 'expo-router';
import { SQLiteProvider } from 'expo-sqlite';
import { StatusBar } from 'expo-status-bar';
import { migrateDb } from '@/db';
import { ThemeProvider } from '@/ThemeContext';
export default function RootLayout() {
return (
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
<ThemeProvider>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</ThemeProvider>
</SQLiteProvider>
);
}
-531
View File
@@ -1,531 +0,0 @@
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
import * as FileSystem from 'expo-file-system';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
import { useSQLiteContext } from 'expo-sqlite';
import { deleteActivity, setActivityTitle, useActivity, useSetting } from '@/db/queries';
import { useTheme } from '@/ThemeContext';
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// ── Types ────────────────────────────────────────────────────────────────────
type Timeseries = {
t: number[];
elevation_m: (number | null)[];
speed_kmh?: (number | null)[] | null;
hr_bpm?: (number | null)[] | null;
cadence_rpm?: (number | null)[] | null;
power_w?: (number | null)[] | null;
lat?: (number | null)[] | null;
lon?: (number | null)[] | null;
};
// ── Screen ───────────────────────────────────────────────────────────────────
export default function ActivityScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const db = useSQLiteContext();
const theme = useTheme();
const row = useActivity(id);
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
const token = useSetting('api_token') ?? '';
const [geojson, setGeojson] = useState<object | null>(null);
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
const [loadingMap, setLoadingMap] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
async function confirmDelete() {
Alert.alert(
'Delete activity',
'This will permanently remove this activity from your device.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const originalPath = await deleteActivity(db, id);
if (originalPath) {
try { await FileSystem.deleteAsync(originalPath, { idempotent: true }); } catch {}
}
router.back();
},
},
],
);
}
// instanceUrl and token are in the dep array to avoid a stale-closure bug in
// release builds: Hermes executes effects sooner and captures empty strings if
// the deps are omitted. Guards on geojson/timeseries prevent double-fetching.
useEffect(() => {
if (!row) return;
if (row.geojson) {
setGeojson(JSON.parse(row.geojson));
} else if (row.origin === 'remote' && instanceUrl && token) {
setLoadingMap(true);
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setGeojson(data); })
.catch(() => {})
.finally(() => setLoadingMap(false));
}
if (row.timeseries_json) {
setTimeseries(JSON.parse(row.timeseries_json));
} else if (row.origin === 'remote' && instanceUrl && token) {
setLoadingChart(true);
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setTimeseries(data); })
.catch(() => {})
.finally(() => setLoadingChart(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [row?.id, instanceUrl, token]);
if (!row) {
return (
<View style={styles.center}>
<Text style={styles.notFound}>Activity not found</Text>
</View>
);
}
const detail = JSON.parse(row.detail_json);
const edits = row.edits_json ? JSON.parse(row.edits_json) : {};
const displayTitle = edits.title ?? detail.title;
const canEdit = row.origin === 'local';
const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.topBar}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Text style={[styles.backText, { color: theme.accent }]}> Back</Text>
</Pressable>
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
<Text style={styles.deleteText}>Delete</Text>
</Pressable>
</View>
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
{editingTitle ? (
<TextInput
style={styles.titleInput}
value={titleDraft}
onChangeText={setTitleDraft}
autoFocus
returnKeyType="done"
onEndEditing={(e) => {
const trimmed = e.nativeEvent.text.trim();
if (trimmed && trimmed !== displayTitle) {
setActivityTitle(db, id, trimmed);
}
setEditingTitle(false);
}}
/>
) : (
<Pressable
onPress={canEdit ? () => { setTitleDraft(displayTitle); setEditingTitle(true); } : undefined}
style={styles.titleRow}
>
<Text style={styles.title}>{displayTitle}</Text>
{canEdit && <Text style={styles.editHint}></Text>}
</Pressable>
)}
<Text style={styles.date}>{date}</Text>
{/* Map */}
<RouteMap geojson={geojson} loading={loadingMap} accent={theme.accent} />
{/* Stats grid */}
<View style={styles.grid}>
{km && <StatCell label="Distance" value={km} unit="km" />}
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
</View>
{/* Metric charts */}
<MetricCharts timeseries={timeseries} loading={loadingChart} accent={theme.accent} />
{/* Meta */}
<View style={styles.meta}>
<MetaRow label="Source" value={detail.source ?? '—'} />
<MetaRow label="Device" value={detail.device ?? '—'} />
<MetaRow label="Origin" value={row.origin} />
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
</View>
</ScrollView>
);
}
// ── Map ───────────────────────────────────────────────────────────────────────
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
const [fullscreen, setFullscreen] = useState(false);
const [currentZoom, setCurrentZoom] = useState(12);
const cameraRef = useRef<any>(null);
if (loading) {
return (
<View style={styles.mapPlaceholder}>
<Text style={{ color: accent, fontSize: 13 }}>Loading map</Text>
</View>
);
}
if (!geojson) return null;
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
// driver (Android <29) even without any interaction. Render a pure SVG route
// trace instead — no native GL surface, no crash.
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
return <SvgRouteView geojson={geojson} accent={accent} />;
}
const bounds = geoJsonBounds(geojson);
const routeSource = (
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
<Layer
type="line"
id="route-line"
paint={{ 'line-color': accent, 'line-width': 3 }}
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
/>
</GeoJSONSource>
);
const cameraBounds = bounds
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
: undefined;
return (
<>
{/* Thumbnail — tap to expand */}
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
{cameraBounds && <Camera initialViewState={cameraBounds} />}
{routeSource}
</Map>
<View style={styles.mapExpandHint}>
<Text style={styles.mapExpandText}> tap to explore</Text>
</View>
</Pressable>
{/* Full-screen map with +/- zoom buttons */}
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
<View style={styles.fullscreenMap}>
<Map
style={styles.map}
mapStyle={MAP_STYLE}
onRegionDidChange={(e: any) => {
const z = e?.properties?.zoomLevel;
if (typeof z === 'number') setCurrentZoom(z);
}}
>
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
{routeSource}
</Map>
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
<Text style={styles.closeText}></Text>
</Pressable>
<View style={styles.zoomButtons}>
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
<Text style={styles.zoomBtnText}>+</Text>
</Pressable>
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
<Text style={styles.zoomBtnText}></Text>
</Pressable>
</View>
</View>
</Modal>
</>
);
}
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
// Renders the GPS track as a colored path on a dark background with no tiles.
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
const W = 320;
const H = 180;
const PAD = 16;
const all: [number, number][] = [];
function collect(obj: unknown) {
if (!obj || typeof obj !== 'object') return;
const o = obj as Record<string, unknown>;
if (o.type === 'Feature') { collect(o.geometry); return; }
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
}
collect(geojson);
if (!all.length) return null;
const step = Math.max(1, Math.floor(all.length / 500));
const pts = all.filter((_, i) => i % step === 0);
const lons = pts.map(c => c[0]);
const lats = pts.map(c => c[1]);
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const spanLon = maxLon - minLon || 0.001;
const spanLat = maxLat - minLat || 0.001;
// Correct longitude for latitude (equirectangular)
const midLat = (minLat + maxLat) / 2;
const lonFactor = Math.cos((midLat * Math.PI) / 180);
const adjLon = spanLon * lonFactor;
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
const offX = (W - adjLon * scale) / 2;
const offY = (H - spanLat * scale) / 2;
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
return (
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
</Svg>
</View>
);
}
// ── Metric charts ─────────────────────────────────────────────────────────────
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
const TAB_META: Record<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
elevation: { label: 'Elevation', unit: 'm', color: '#00c8ff', decimals: 0 },
speed: { label: 'Speed', unit: 'km/h', color: '#ff6b35', decimals: 1 },
hr: { label: 'HR', unit: 'bpm', color: '#f87171', decimals: 0 },
cadence: { label: 'Cadence', unit: 'rpm', color: '#a78bfa', decimals: 0 },
power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 },
};
function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) {
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
if (loading) {
return (
<View style={styles.chartPlaceholder}>
<Text style={{ color: accent, fontSize: 13 }}>Loading chart</Text>
</View>
);
}
if (!timeseries) return null;
const seriesMap: Record<TabKey, (number | null)[] | null | undefined> = {
elevation: timeseries.elevation_m,
speed: timeseries.speed_kmh,
hr: timeseries.hr_bpm,
cadence: timeseries.cadence_rpm,
power: timeseries.power_w,
};
const available = (Object.keys(TAB_META) as TabKey[]).filter(
k => seriesMap[k]?.some(v => v != null)
);
if (!available.length) return null;
const tab = available.includes(activeTab) ? activeTab : available[0];
const { color, unit, decimals } = TAB_META[tab];
const raw = seriesMap[tab]!;
return (
<View style={styles.chartContainer}>
{/* Tab row */}
<View style={styles.chartTabs}>
{available.map(k => (
<Pressable
key={k}
style={[styles.chartTab, tab === k && { borderBottomColor: TAB_META[k].color, borderBottomWidth: 2 }]}
onPress={() => setActiveTab(k)}
>
<Text style={[styles.chartTabText, tab === k && { color: TAB_META[k].color }]}>
{TAB_META[k].label}
</Text>
</Pressable>
))}
</View>
{/* Chart */}
<MetricChart key={tab} times={timeseries.t} values={raw} color={color} unit={unit} decimals={decimals} />
</View>
);
}
function MetricChart({
times, values, color, unit, decimals,
}: {
times: number[];
values: (number | null)[];
color: string;
unit: string;
decimals: number;
}) {
const W = 340;
const H = 100;
const PAD = 4;
// Downsample to ≤300 points
const step = Math.max(1, Math.floor(values.length / 300));
const ts = times.filter((_, i) => i % step === 0);
const vs = values.filter((_, i) => i % step === 0).map(v => v ?? 0);
const minV = Math.min(...vs);
const maxV = Math.max(...vs);
const range = maxV - minV || 1;
const maxT = ts[ts.length - 1] || 1;
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
const y = (v: number) => PAD + (1 - (v - minV) / range) * (H - PAD * 2);
const pts = ts.map((t, i) => `${x(t).toFixed(1)},${y(vs[i]).toFixed(1)}`);
const linePath = `M ${pts.join(' L ')}`;
const areaPath = `M ${x(ts[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`;
const gradId = `grad-${color.replace('#', '')}`;
const fmt = (v: number) => decimals === 0 ? String(Math.round(v)) : v.toFixed(decimals);
return (
<>
<Text style={[styles.chartLabel, { color }]}>{fmt(maxV)} {unit}</Text>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Defs>
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
</LinearGradient>
</Defs>
<Path d={areaPath} fill={`url(#${gradId})`} />
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
</Svg>
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
</>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// Returns [west, south, east, north] per LngLatBounds spec
function geoJsonBounds(gj: object): [number, number, number, number] | null {
const coords: [number, number][] = [];
function collect(obj: unknown) {
if (!obj || typeof obj !== 'object') return;
const o = obj as Record<string, unknown>;
if (o.type === 'Feature') { collect(o.geometry); return; }
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
}
collect(gj);
if (!coords.length) return null;
const lons = coords.map(c => c[0]);
const lats = coords.map(c => c[1]);
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<View style={styles.statCell}>
<View style={styles.statValueRow}>
<Text style={styles.statValue}>{value}</Text>
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
</View>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{label}</Text>
<Text style={styles.metaValue}>{value}</Text>
</View>
);
}
// ── Styles ────────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
content: { paddingBottom: 40 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
notFound: { color: '#71717a', fontSize: 16 },
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 },
backButton: { paddingHorizontal: 16 },
backText: { fontSize: 15 },
deleteButton: { paddingHorizontal: 16 },
deleteText: { color: '#f87171', fontSize: 15 },
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 4 },
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', flexShrink: 1 },
titleInput: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: '#3b82f6' },
editHint: { color: '#52525b', fontSize: 16, marginLeft: 8 },
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
map: { flex: 1 },
mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
mapExpandHint: { position: 'absolute', bottom: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4 },
mapExpandText: { color: '#a1a1aa', fontSize: 11 },
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
closeText: { color: '#fff', fontSize: 16 },
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
chartTab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
chartTabText: { color: '#52525b', fontSize: 11, fontWeight: '600' },
chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2, marginHorizontal: 12, marginTop: 10 },
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
statUnit: { color: '#71717a', fontSize: 13 },
statLabel: { color: '#71717a', fontSize: 12 },
meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
metaLabel: { color: '#71717a', fontSize: 13 },
metaValue: { color: '#a1a1aa', fontSize: 13 },
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-6
View File
@@ -1,6 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
-108
View File
@@ -1,108 +0,0 @@
import { useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { ActivitySummary } from '@/db/queries';
import { useTheme } from '@/ThemeContext';
export function ActivityCard({
activity,
selecting,
checked,
onToggleSelect,
onLongPress,
}: {
activity: ActivitySummary;
selecting: boolean;
checked: boolean;
onToggleSelect: () => void;
onLongPress: () => void;
}) {
const router = useRouter();
const theme = useTheme();
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
});
function handlePress() {
if (selecting) onToggleSelect();
else router.push(`/activity/${activity.id}`);
}
return (
<Pressable
style={[styles.card, checked && { borderColor: theme.accent }]}
onPress={handlePress}
onLongPress={onLongPress}
>
<View style={styles.cardTop}>
<View style={styles.cardLeft}>
{selecting && (
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
{checked && <Text style={styles.checkmark}></Text>}
</View>
)}
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
</View>
<View style={styles.cardMeta}>
<Text style={styles.cardDate}>{date}</Text>
{activity.origin === 'remote'
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>cloud</Text>
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
}
</View>
</View>
<Text style={styles.cardTitle} numberOfLines={1}>{activity.user_title ?? activity.title}</Text>
<View style={styles.cardStats}>
{km && <Stat label="km" value={km} />}
{elev != null && <Stat label="m↑" value={String(elev)} />}
</View>
</Pressable>
);
}
export function Stat({ label, value }: { label: string; value: string }) {
return (
<View style={styles.stat}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
export function sportIcon(sport: string): string {
const icons: Record<string, string> = {
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
};
return icons[sport] ?? '🏅';
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#18181b', borderRadius: 12,
padding: 16, borderWidth: 1, borderColor: '#27272a',
},
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
sportIcon: { fontSize: 20 },
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
cardDate: { color: '#71717a', fontSize: 12 },
remoteBadge: {
fontSize: 10, borderWidth: 1,
borderRadius: 4, paddingHorizontal: 4,
},
localBadge: {
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
},
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
cardStats: { flexDirection: 'row', gap: 16 },
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
statLabel: { color: '#71717a', fontSize: 12 },
checkbox: {
width: 20, height: 20, borderRadius: 4, borderWidth: 1.5,
borderColor: '#52525b', alignItems: 'center', justifyContent: 'center',
},
checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' },
});
-47
View File
@@ -1,47 +0,0 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
await db.execAsync('PRAGMA journal_mode = WAL;');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
source_hash TEXT NOT NULL,
detail_json TEXT NOT NULL,
timeseries_json TEXT,
geojson TEXT,
original_path TEXT,
synced_at INTEGER,
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_activities_created_at
ON activities(created_at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Migration v2: source_path stores the original filesystem path a file was
// imported from (e.g. /sdcard/Karoo/Rides/ride.fit), used for watch-folder
// deduplication without re-hashing files.
try {
await db.execAsync('ALTER TABLE activities ADD COLUMN source_path TEXT');
await db.execAsync(
'CREATE INDEX IF NOT EXISTS idx_activities_source_path ON activities(source_path)',
);
} catch {
// Column already exists — migration already ran, ignore.
}
// Migration v3: edits_json stores user overrides (e.g. {"title": "My title"})
// kept separate from detail_json so server re-extraction (Option A) never
// clobbers user edits.
try {
await db.execAsync('ALTER TABLE activities ADD COLUMN edits_json TEXT');
} catch {
// Column already exists — migration already ran, ignore.
}
}
-264
View File
@@ -1,264 +0,0 @@
import { useSQLiteContext } from 'expo-sqlite';
// ── Types ──────────────────────────────────────────────────────────────────
export type ActivityRow = {
id: string;
source_hash: string;
detail_json: string;
timeseries_json: string | null;
geojson: string | null;
original_path: string | null;
source_path: string | null;
synced_at: number | null;
origin: 'local' | 'remote';
created_at: number;
edits_json: string | null;
};
export type ActivitySummary = {
id: string;
title: string;
user_title: string | null; // from edits_json; takes display priority over title
sport: string;
started_at: string;
distance_m: number | null;
duration_s: number | null;
elevation_gain_m: number | null;
origin: 'local' | 'remote';
synced_at: number | null;
};
// ── Activities ─────────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
const db = useSQLiteContext();
const like = `%${searchQuery}%`;
const rows = db.getAllSync<ActivitySummary>(`
SELECT
id, origin, synced_at,
json_extract(detail_json, '$.title') AS title,
json_extract(edits_json, '$.title') AS user_title,
json_extract(detail_json, '$.sport') AS sport,
json_extract(detail_json, '$.started_at') AS started_at,
json_extract(detail_json, '$.distance_m') AS distance_m,
json_extract(detail_json, '$.duration_s') AS duration_s,
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)
ORDER BY json_extract(detail_json, '$.started_at') DESC
LIMIT ?
`, [searchQuery, like, limit]);
return rows;
}
export function useActivityCount(searchQuery = ''): number {
const db = useSQLiteContext();
const like = `%${searchQuery}%`;
const row = db.getFirstSync<{ n: number }>(
`SELECT COUNT(*) as n FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)`,
[searchQuery, like],
);
return row?.n ?? 0;
}
export { PAGE_SIZE };
export type ActivityFilter = {
sport: string; // '' = all sports
dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison
dateTo: string; // '' = no upper bound
sort: 'date' | 'distance' | 'elevation';
};
const SORT_SQL: Record<string, string> = {
date: "json_extract(detail_json, '$.started_at') DESC",
distance: "json_extract(detail_json, '$.distance_m') DESC",
elevation: "json_extract(detail_json, '$.elevation_gain_m') DESC",
};
export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE): ActivitySummary[] {
const db = useSQLiteContext();
const order = SORT_SQL[filter.sort] ?? SORT_SQL.date;
return db.getAllSync<ActivitySummary>(`
SELECT
id, origin, synced_at,
json_extract(detail_json, '$.title') AS title,
json_extract(edits_json, '$.title') AS user_title,
json_extract(detail_json, '$.sport') AS sport,
json_extract(detail_json, '$.started_at') AS started_at,
json_extract(detail_json, '$.distance_m') AS distance_m,
json_extract(detail_json, '$.duration_s') AS duration_s,
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
ORDER BY ${order}
LIMIT ?
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo, limit]);
}
export function useFilteredCount(filter: ActivityFilter): number {
const db = useSQLiteContext();
const row = db.getFirstSync<{ n: number }>(`
SELECT COUNT(*) as n FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo]);
return row?.n ?? 0;
}
export function useActivityYears(): string[] {
const db = useSQLiteContext();
const rows = db.getAllSync<{ year: string }>(
`SELECT DISTINCT substr(json_extract(detail_json, '$.started_at'), 1, 4) AS year
FROM activities
WHERE json_extract(detail_json, '$.started_at') IS NOT NULL
ORDER BY year DESC`,
);
return rows.map(r => r.year).filter(Boolean);
}
export function useActivity(id: string): ActivityRow | null {
const db = useSQLiteContext();
return db.getFirstSync<ActivityRow>(
'SELECT * FROM activities WHERE id = ?',
[id],
) ?? null;
}
export async function insertActivity(
db: ReturnType<typeof useSQLiteContext>,
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>
& { source_path?: string | null },
): Promise<void> {
await db.runAsync(
`INSERT OR IGNORE INTO activities
(id, source_hash, detail_json, timeseries_json, geojson, original_path, source_path, origin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.source_hash,
row.detail_json,
row.timeseries_json ?? null,
row.geojson ?? null,
row.original_path ?? null,
row.source_path ?? null,
row.origin,
],
);
}
export function isSourcePathImported(
db: ReturnType<typeof useSQLiteContext>,
sourcePath: string,
): boolean {
const row = db.getFirstSync<{ id: string }>(
'SELECT id FROM activities WHERE source_path = ?',
[sourcePath],
);
return row != null;
}
export async function upsertRemoteActivity(
db: ReturnType<typeof useSQLiteContext>,
id: string,
detailJson: string,
): Promise<boolean> {
const now = Math.floor(Date.now() / 1000);
const result = await db.runAsync(
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
VALUES (?, ?, ?, 'remote', ?)
ON CONFLICT(id) DO UPDATE SET
detail_json = excluded.detail_json,
synced_at = excluded.synced_at
WHERE origin = 'remote'`,
[id, id, detailJson, now],
);
return result.changes > 0;
}
export async function deleteRemoteActivities(
db: ReturnType<typeof useSQLiteContext>,
): Promise<number> {
const result = await db.runAsync(`DELETE FROM activities WHERE origin = 'remote'`);
return result.changes;
}
export async function deleteActivity(
db: ReturnType<typeof useSQLiteContext>,
id: string,
): Promise<string | null> {
const row = db.getFirstSync<{ original_path: string | null }>(
'SELECT original_path FROM activities WHERE id = ?',
[id],
);
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
return row?.original_path ?? null;
}
export async function setActivityTitle(
db: ReturnType<typeof useSQLiteContext>,
id: string,
title: string,
): Promise<void> {
await db.runAsync(
`UPDATE activities
SET edits_json = json_set(COALESCE(edits_json, '{}'), '$.title', ?)
WHERE id = ?`,
[title, id],
);
}
export async function deleteActivities(
db: ReturnType<typeof useSQLiteContext>,
ids: string[],
): Promise<Array<string | null>> {
if (ids.length === 0) return [];
const rows = db.getAllSync<{ original_path: string | null }>(
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
);
const placeholders = ids.map(() => '?').join(',');
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
return rows.map(r => r.original_path ?? null);
}
// ── Settings ───────────────────────────────────────────────────────────────
export async function getSetting(
db: ReturnType<typeof useSQLiteContext>,
key: string,
): Promise<string | null> {
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
[key],
);
return row?.value ?? null;
}
export async function setSetting(
db: ReturnType<typeof useSQLiteContext>,
key: string,
value: string,
): Promise<void> {
await db.runAsync(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[key, value],
);
}
export function useSetting(key: string): string | null {
const db = useSQLiteContext();
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
[key],
);
return row?.value ?? null;
}
-279
View File
@@ -1,279 +0,0 @@
import * as FileSystem from 'expo-file-system/legacy';
import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
export type SyncResult = {
synced: number;
total: number;
fetched?: number;
uploaded?: number;
failed?: number;
error?: string;
};
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
const token = await getSetting(db, 'api_token');
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
return { instanceUrl, token };
}
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
const creds = await resolveCredentials(db);
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
const { instanceUrl, token } = creds;
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
}
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
const data: { activities?: RemoteSummary[] } = await resp.json();
const activities = data.activities ?? [];
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
let synced = 0;
for (const a of activities) {
const detailJson = JSON.stringify({
id: a.id,
title: a.title ?? a.id,
sport: a.sport ?? null,
started_at: a.started_at ?? null,
distance_m: a.distance_m ?? null,
moving_time_s: a.moving_time_s ?? null,
elevation_gain_m: a.elevation_gain_m ?? null,
avg_speed_kmh: a.avg_speed_kmh ?? null,
avg_hr_bpm: a.avg_hr_bpm ?? null,
avg_power_w: a.avg_power_w ?? null,
});
const changed = await upsertRemoteActivity(db, a.id, detailJson);
if (changed) synced++;
}
if (syncMode !== 'full') return { synced, total: activities.length };
// Full mode: fetch geojson + timeseries for activities missing them
const headers = { Authorization: `Bearer ${token}` };
let fetched = 0;
for (const a of activities) {
const row = db.getFirstSync<{ g: number; t: number }>(
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
[a.id],
);
if (row?.g && row?.t) continue;
let gj: string | null = null;
let ts: string | null = null;
try {
if (!row?.g) {
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
if (r.ok) gj = await r.text();
}
if (!row?.t) {
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
if (r.ok) ts = await r.text();
}
} catch {}
if (gj !== null || ts !== null) {
await db.runAsync(
`UPDATE activities SET
geojson = COALESCE(geojson, ?),
timeseries_json = COALESCE(timeseries_json, ?)
WHERE id = ? AND origin = 'remote'`,
[gj, ts, a.id],
);
fetched++;
}
}
return { synced, total: activities.length, fetched };
}
export async function uploadFeed(
db: SQLiteDatabase,
onProgress?: (n: number, total: number) => void,
): Promise<SyncResult> {
const creds = await resolveCredentials(db);
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
const { instanceUrl, token } = creds;
// Reconcile local synced_at against what the server actually has.
// If the server was wiped/reset, activities we thought were uploaded need
// re-uploading — clear their synced_at so they re-enter the upload queue.
try {
const feedResp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
if (feedResp.ok) {
const feedData: { activities?: { id: string }[] } = await feedResp.json();
const serverIds = new Set((feedData.activities ?? []).map(a => a.id));
const syncedRows = db.getAllSync<{ id: string }>(
`SELECT id FROM activities WHERE origin = 'local' AND synced_at IS NOT NULL`,
);
for (const row of syncedRows) {
if (!serverIds.has(row.id)) {
await db.runAsync(`UPDATE activities SET synced_at = NULL WHERE id = ?`, [row.id]);
}
}
}
} catch {
// Best-effort — proceed with upload even if reconciliation fails
}
const { uploaded, failed } = await uploadLocalActivities(db, instanceUrl, token, onProgress);
return { synced: 0, total: 0, uploaded, failed: failed || undefined };
}
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
const dl = await downloadFeed(db);
if (dl.error) return dl;
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
let uploaded = 0;
if (uploadEnabled) {
const ul = await uploadFeed(db);
uploaded = ul.uploaded ?? 0;
}
return { ...dl, uploaded: uploaded || undefined };
}
export async function countPendingUploads(db: SQLiteDatabase): Promise<number> {
const row = db.getFirstSync<{ n: number }>(
`SELECT COUNT(*) as n FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
);
return row?.n ?? 0;
}
async function uploadLocalActivities(
db: SQLiteDatabase,
instanceUrl: string,
token: string,
onProgress?: (n: number, total: number) => void,
): Promise<{ uploaded: number; failed: number }> {
const rows = db.getAllSync<{
id: string;
detail_json: string;
timeseries_json: string | null;
geojson: string | null;
original_path: string | null;
edits_json: string | null;
}>(
`SELECT id, detail_json, timeseries_json, geojson, original_path, edits_json
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
);
const preferRaw = (await getSetting(db, 'upload_format') ?? 'raw') === 'raw';
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
let uploaded = 0;
let failed = 0;
const now = Math.floor(Date.now() / 1000);
const total = rows.length;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
onProgress?.(i + 1, total);
try {
let resp: Response;
// When preferRaw is set and the original file is still on disk, send the raw
// bytes to /api/upload/raw so the server re-extracts with DEM elevation correction.
const useRaw = preferRaw &&
row.original_path !== null &&
(await FileSystem.getInfoAsync(row.original_path)).exists;
const userTitle: string | null = row.edits_json
? (JSON.parse(row.edits_json).title ?? null)
: null;
if (useRaw) {
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
encoding: FileSystem.EncodingType.Base64,
});
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
method: 'POST',
headers,
body: JSON.stringify({ filename, base64, ...(userTitle ? { user_title: userTitle } : {}) }),
});
} else {
const detail = JSON.parse(row.detail_json);
if (userTitle) detail.title = userTitle;
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
if (row.geojson) body.geojson = JSON.parse(row.geojson);
resp = await fetch(`${instanceUrl}/api/upload/bas`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
}
if (resp.ok) {
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
// Option A: after a raw upload, update local detail/timeseries/geojson with the
// server's DEM-corrected extraction so the app shows better elevation data.
if (useRaw) {
try {
const data = await resp.json() as {
id: string;
detail: object;
timeseries: object | null;
geojson: object | null;
source_hash: string;
};
if (data.id === row.id) {
await db.runAsync(
`UPDATE activities
SET detail_json = ?,
timeseries_json = COALESCE(?, timeseries_json),
geojson = COALESCE(?, geojson),
source_hash = ?
WHERE id = ?`,
[
JSON.stringify(data.detail),
data.timeseries ? JSON.stringify(data.timeseries) : null,
data.geojson ? JSON.stringify(data.geojson) : null,
data.source_hash,
row.id,
],
);
}
} catch {
// Non-fatal: synced_at is already set, local data stays as-is
}
}
uploaded++;
} else {
console.warn(`upload ${row.id}: HTTP ${resp.status}`);
failed++;
}
} catch (err) {
console.warn(`upload ${row.id}:`, err);
failed++;
}
}
return { uploaded, failed };
}
type RemoteSummary = {
id: string;
title?: string;
sport?: string;
started_at?: string;
distance_m?: number | null;
moving_time_s?: number | null;
elevation_gain_m?: number | null;
avg_speed_kmh?: number | null;
avg_hr_bpm?: number | null;
avg_power_w?: number | null;
};
-248
View File
@@ -1,248 +0,0 @@
import { StyleSheet } from 'react-native';
import WebView from 'react-native-webview';
import { handleWebViewMessage, pyodideRef } from './extractActivity';
const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
// v0.18.1: last version whose JS wrapper avoids ??, ?., and other syntax
// unavailable on Chrome <80 (e.g. Karoo WebView 61). Used in the compat path.
const CDN_COMPAT = 'https://cdn.jsdelivr.net/pyodide/v0.18.1/full/';
// Python snippets embedded as JSON strings to avoid any JS/TS escaping issues.
const PY_INSTALL_PACKAGES = [
'import micropip',
'await micropip.install(["fitdecode", "gpxpy"])',
].join('\n');
// emfs:// is Pyodide's Emscripten-FS URL scheme — the only reliable way to
// install a wheel from bytes without an http/https URL (blob: URLs are not
// recognised by micropip and cause an InvalidRequirement parse error).
// _wheel_path is set as a Pyodide global before this runs.
const PY_INSTALL_WHEEL = [
'import micropip',
'await micropip.install("emfs://" + _wheel_path, deps=False)',
].join('\n');
const PY_EXTRACT = [
'import json, shutil',
'from pathlib import Path',
'from bincio.extract.parsers.factory import parse_file',
'from bincio.extract.metrics import compute',
'from bincio.extract.writer import make_activity_id, write_activity',
'',
'outdir = Path("/tmp/bincio_out")',
'if outdir.exists(): shutil.rmtree(outdir)',
'outdir.mkdir()',
'',
'activity = parse_file(Path("/tmp/" + _filename))',
'metrics = compute(activity)',
'write_activity(activity, metrics, outdir, privacy="public", rdp_epsilon=0.0001)',
'act_id = make_activity_id(activity)',
'',
'detail_path = outdir / "activities" / (act_id + ".json")',
'ts_path = outdir / "activities" / (act_id + ".timeseries.json")',
'geojson_path = outdir / "activities" / (act_id + ".geojson")',
'',
'# write_activity in the installed wheel silently skips timeseries — write it directly.',
'if not ts_path.exists():',
' from bincio.extract.timeseries import build_timeseries as _bts',
' _ts = _bts(activity.points, activity.started_at, "public")',
' if _ts.get("t"):',
' ts_path.write_text(json.dumps(_ts))',
'',
'json.dumps({',
' "id": act_id,',
' "detail": json.loads(detail_path.read_text()),',
' "timeseries": json.loads(ts_path.read_text()) if ts_path.exists() else None,',
' "geojson": json.loads(geojson_path.read_text()) if geojson_path.exists() else None,',
'})',
].join('\n');
// JSON.stringify gives us safely-quoted JS string literals for embedding in HTML.
const PYODIDE_HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body>
<script>
var _PY_INSTALL_PACKAGES = ${JSON.stringify(PY_INSTALL_PACKAGES)};
var _PY_INSTALL_WHEEL = ${JSON.stringify(PY_INSTALL_WHEEL)};
var _PY_EXTRACT = ${JSON.stringify(PY_EXTRACT)};
var _CDN = ${JSON.stringify(CDN)};
var _CDN_COMPAT = ${JSON.stringify(CDN_COMPAT)};
function _post(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
var pyodide = null;
var packagesReady = false;
var wheelReady = false;
var initError = null;
(async function init() {
try {
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
// initialise on any version. Bail out immediately so the mobile app can
// fall back to server-side extraction without attempting a 35 MB download.
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
return;
}
_post({ type: 'progress', msg: 'Loading Python runtime…' });
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
// Chrome <71: no globalThis → factory throws ReferenceError immediately
// Chrome <63: no dynamic import() / for-await-of → parse/runtime failure
// Detection: read Chrome version from UA; absent means non-Chrome (assume modern).
var _chromeVer = (navigator.userAgent.match(/Chrome\\/([0-9]+)/) || [])[1];
var _needsPatch = _chromeVer && parseInt(_chromeVer) < 80;
if (_needsPatch) {
// Use v0.18.1 — its JS wrapper avoids ??, ?., and other Chrome-80+ syntax.
// Then apply three text patches before injecting via Blob URL (Blob scripts
// bypass the browser's module pre-scanner, so patched keywords are invisible).
//
// Patches (split/join avoids regex escapes, which template literals corrupt):
// 1. globalThis polyfill prepended — Chrome <71 lacks globalThis entirely
// 2. import( → __loadScript( — Chrome <63 cannot parse dynamic import
// 3. for await( → for( — Chrome <63 lacks async iteration;
// the only affected fn (getFsHandles/NativeFS) is never called by us
window.__loadScript = function(url) {
return new Promise(function(res, rej) {
var s = document.createElement('script');
s.src = url;
s.onload = res;
s.onerror = function() { rej(new Error('Failed to load ' + url)); };
document.head.appendChild(s);
});
};
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
var _pyCode = await _pyResp.text();
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
_pyCode = _pyCode.split('import(').join('__loadScript(');
_pyCode = _pyCode.split('for await(').join('for(');
await new Promise(function(res, rej) {
var blob = new Blob([_pyCode], { type: 'application/javascript' });
var blobUrl = URL.createObjectURL(blob);
var s = document.createElement('script');
s.src = blobUrl;
s.onload = function() { URL.revokeObjectURL(blobUrl); res(); };
s.onerror = function() { URL.revokeObjectURL(blobUrl); rej(new Error('Failed to inject patched pyodide.js')); };
document.head.appendChild(s);
});
pyodide = await loadPyodide({ indexURL: _CDN_COMPAT });
} else {
await new Promise(function(res, rej) {
var s = document.createElement('script');
s.src = _CDN + 'pyodide.js';
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
pyodide = await loadPyodide({ indexURL: _CDN });
}
_post({ type: 'progress', msg: 'Loading packages…' });
await pyodide.loadPackage(['lxml', 'pyyaml', 'micropip']);
_post({ type: 'progress', msg: 'Installing fitdecode, gpxpy…' });
await pyodide.runPythonAsync(_PY_INSTALL_PACKAGES);
packagesReady = true;
_post({ type: 'pyodide_ready' });
} catch(e) {
initError = String(e);
_post({ type: 'init_error', message: initError });
}
})();
window._bincioExtract = async function(params) {
var reqId = params.reqId;
var filename = params.filename;
var base64 = params.base64;
var wheelBase64 = params.wheelBase64; // pre-fetched by React Native (avoids ATS/HTTP issues)
var wheelFilename = params.wheelFilename; // e.g. "bincio-0.1.0-py3-none-any.whl"
function post(m) { _post(Object.assign({}, m, { reqId: reqId })); }
try {
// Wait for base packages if still loading
if (!packagesReady && !initError) {
await new Promise(function(res, rej) {
var n = 0;
var id = setInterval(function() {
if (packagesReady) { clearInterval(id); res(undefined); }
else if (initError) { clearInterval(id); rej(new Error(initError)); }
else if (++n > 300) { clearInterval(id); rej(new Error('Pyodide init timed out')); }
}, 200);
});
}
if (initError) throw new Error(initError);
// Install bincio wheel on first extraction.
// Wheel bytes arrive pre-fetched from React Native (avoids ATS/HTTP issues).
// Write to Pyodide's Emscripten FS so micropip can install via emfs:// URL
// (blob: URLs are not recognised by micropip — they cause an InvalidRequirement error).
if (!wheelReady) {
post({ type: 'progress', msg: 'Loading Bincio…' });
var wheelBytes = Uint8Array.from(atob(wheelBase64), function(c) { return c.charCodeAt(0); });
var wheelPath = '/tmp/' + wheelFilename;
pyodide.FS.writeFile(wheelPath, wheelBytes);
pyodide.globals.set('_wheel_path', wheelPath);
await pyodide.runPythonAsync(_PY_INSTALL_WHEEL);
wheelReady = true;
}
post({ type: 'progress', msg: 'Extracting…' });
// Decode base64 file bytes and write to Pyodide's virtual filesystem
var bytes = Uint8Array.from(atob(base64), function(c) { return c.charCodeAt(0); });
pyodide.FS.writeFile('/tmp/' + filename, bytes);
// SHA-256 of original file bytes (replaces the stub source_hash)
var hashBuf = await crypto.subtle.digest('SHA-256', bytes.buffer);
var sourceHash = Array.from(new Uint8Array(hashBuf))
.map(function(b) { return b.toString(16).padStart(2, '0'); })
.join('');
// Run the bincio extraction pipeline
pyodide.globals.set('_filename', filename);
var resultJson = await pyodide.runPythonAsync(_PY_EXTRACT);
var result = JSON.parse(resultJson);
_post({
type: 'result',
reqId: reqId,
id: result.id,
detail: result.detail,
timeseries: result.timeseries,
geojson: result.geojson,
sourceHash: sourceHash,
});
} catch(e) {
_post({ type: 'error', reqId: reqId, message: e.message || String(e) });
}
};
</script>
</body></html>`;
export function PyodideWebView() {
return (
<WebView
ref={pyodideRef}
source={{ html: PYODIDE_HTML, baseUrl: 'https://localhost' }}
style={styles.hidden}
onMessage={handleWebViewMessage}
javaScriptEnabled
originWhitelist={['*']}
/>
);
}
const styles = StyleSheet.create({
// Off-screen but still rendered — display:none / opacity:0 can suppress JS on some platforms.
hidden: {
position: 'absolute',
top: -2000,
left: 0,
width: 1,
height: 1,
},
});
-138
View File
@@ -1,138 +0,0 @@
import { createRef } from 'react';
import { Platform } from 'react-native';
import type WebView from 'react-native-webview';
import type { WebViewMessageEvent } from 'react-native-webview';
export type ExtractionResult = {
id: string;
detail: object;
timeseries: object | null;
geojson: object | null;
sourceHash: string;
};
type Pending = {
resolve: (r: ExtractionResult) => void;
reject: (e: Error) => void;
onStatus: (msg: string) => void;
};
export const pyodideRef = createRef<WebView>();
const pending = new Map<string, Pending>();
let reqCounter = 0;
let isExtracting = false;
// Engine readiness — tracked so callers can wait before batching files.
let _engineReady = false;
let _engineError: string | null = null;
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
// a WebView on those devices also causes GPU driver crashes (SurfaceView
// conflicts). Skip the engine entirely and route to server extraction instead.
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
const _engineResolvers: Array<() => void> = [];
const _engineRejecters: Array<(e: Error) => void> = [];
// Init-phase progress listeners (messages sent before any extraction starts).
const _progressListeners = new Set<(msg: string) => void>();
export function onEngineProgress(cb: (msg: string) => void): () => void {
_progressListeners.add(cb);
return () => _progressListeners.delete(cb);
}
export function isEngineAvailable(): boolean | null {
// null = not yet determined; true = ready; false = unavailable
if (_engineReady) return true;
if (_engineUnavailable || _engineError) return false;
return null;
}
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
if (_engineReady) return Promise.resolve();
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
if (_engineError) return Promise.reject(new Error(_engineError));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Extraction engine timed out — check network and Bincio instance URL'));
}, timeoutMs);
_engineResolvers.push(() => { clearTimeout(timer); resolve(); });
_engineRejecters.push((e) => { clearTimeout(timer); reject(e); });
});
}
export function handleWebViewMessage(e: WebViewMessageEvent): void {
let msg: Record<string, unknown>;
try { msg = JSON.parse(e.nativeEvent.data); } catch { return; }
const reqId = msg.reqId as string | undefined;
const p = reqId ? pending.get(reqId) : undefined;
switch (msg.type) {
case 'pyodide_ready':
_engineReady = true;
_engineResolvers.splice(0).forEach(fn => fn());
break;
case 'engine_unavailable':
_engineUnavailable = true;
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
break;
case 'init_error':
_engineError = msg.message as string;
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
break;
case 'result':
if (p) {
pending.delete(reqId!);
p.resolve({
id: msg.id as string,
detail: msg.detail as object,
timeseries: (msg.timeseries as object | null) ?? null,
geojson: (msg.geojson as object | null) ?? null,
sourceHash: msg.sourceHash as string,
});
}
break;
case 'error':
if (p) {
pending.delete(reqId!);
p.reject(new Error(msg.message as string));
}
break;
case 'progress':
if (p) {
p.onStatus(msg.msg as string);
} else {
_progressListeners.forEach(fn => fn(msg.msg as string));
}
break;
}
}
// wheelBase64 is the bincio .whl file pre-fetched by the React Native side
// (native networking supports HTTP on local network; WKWebView does not).
export function extractFile(
filename: string,
base64: string,
wheelBase64: string,
wheelFilename: string,
onStatus: (msg: string) => void = () => {},
): Promise<ExtractionResult> {
if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress'));
const webview = pyodideRef.current;
if (!webview) return Promise.reject(new Error('Extraction engine not ready — restart the app'));
isExtracting = true;
const reqId = String(++reqCounter);
const args = JSON.stringify({ reqId, filename, base64, wheelBase64, wheelFilename });
return new Promise<ExtractionResult>((resolve, reject) => {
pending.set(reqId, {
resolve: (r) => { isExtracting = false; resolve(r); },
reject: (e) => { isExtracting = false; reject(e); },
onStatus,
});
webview.injectJavaScript(`window._bincioExtract(${args}); true;`);
});
}
-63
View File
@@ -1,63 +0,0 @@
import type { ExtractionResult } from './extractActivity';
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
throw new Error('Could not reach Bincio instance — check your connection.');
}
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
}
export async function extractFileViaServer(
filename: string,
base64: string,
instanceUrl: string,
token: string,
onStatus: (msg: string) => void = () => {},
): Promise<ExtractionResult> {
onStatus('Uploading to Bincio instance…');
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename, base64 }),
});
} catch {
throw new Error('Could not reach Bincio instance — check your connection.');
}
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
if (resp.status === 422) {
const body = await resp.json().catch(() => ({})) as { detail?: string };
throw new Error(body.detail ?? 'Server could not process this file.');
}
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
onStatus('Processing on server…');
const data = await resp.json() as {
ok: boolean;
id: string;
detail: object;
timeseries: object | null;
geojson: object | null;
source_hash: string;
};
return {
id: data.id,
detail: data.detail,
timeseries: data.timeseries,
geojson: data.geojson,
sourceHash: data.source_hash,
};
}
-2
View File
@@ -1,2 +0,0 @@
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);
-9819
View File
File diff suppressed because it is too large Load Diff
-40
View File
@@ -1,40 +0,0 @@
{
"name": "bincio",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "expo lint"
},
"dependencies": {
"@maplibre/maplibre-react-native": "~11.0.0",
"expo": "~54.0.33",
"expo-background-fetch": "~14.0.9",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "~15.15.0",
"react-native-webview": "13.15.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
}
}
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Bincio mobile app — one-time setup
# Run from the mobile/ directory: ./setup.sh
# Or from the repo root: bash mobile/setup.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ── Colours ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m'
ok() { echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}${RESET} $*"; }
die() { echo -e "${RED}${RESET} $*" >&2; exit 1; }
step() { echo -e "\n${YELLOW}${RESET} $*"; }
echo ""
echo " Bincio mobile setup"
echo " ═══════════════════"
echo ""
# ── 1. Node.js ────────────────────────────────────────────────────────────────
step "Checking Node.js..."
if ! command -v node &>/dev/null; then
die "Node.js not found. Install from https://nodejs.org (v20+ recommended)."
fi
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -lt 18 ]; then
die "Node.js 18+ required (found $(node -v)). Update at https://nodejs.org"
fi
ok "Node.js $(node -v)"
# ── 2. npm ────────────────────────────────────────────────────────────────────
if ! command -v npm &>/dev/null; then
die "npm not found. It ships with Node.js — check your installation."
fi
ok "npm $(npm -v)"
# ── 3. Expo CLI (global, optional — we use npx) ───────────────────────────────
step "Checking Expo CLI..."
if command -v expo &>/dev/null; then
ok "Expo CLI $(expo --version) (global)"
else
warn "Expo CLI not installed globally. Using npx instead (slightly slower)."
warn "Install globally with: npm install -g expo-cli"
fi
# ── 4. Platform tools ─────────────────────────────────────────────────────────
step "Checking platform tools..."
PLATFORM="$(uname -s)"
if [ "$PLATFORM" = "Darwin" ]; then
if command -v xcodebuild &>/dev/null; then
ok "Xcode $(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')"
else
warn "Xcode not found — iOS builds will not work."
warn "Install Xcode from the App Store, then: xcode-select --install"
fi
if command -v xcrun &>/dev/null && xcrun --sdk iphoneos --show-sdk-version &>/dev/null; then
ok "iOS SDK available"
fi
fi
if command -v adb &>/dev/null; then
ok "Android SDK / adb found"
else
warn "adb not found — Android builds require Android Studio."
warn "Install from https://developer.android.com/studio"
fi
# ── 5. Install dependencies ───────────────────────────────────────────────────
step "Installing npm dependencies..."
if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then
ok "node_modules already present — running npm install to sync..."
fi
npm install
ok "Dependencies installed"
# ── 6. expo-env.d.ts (required by expo-router) ────────────────────────────────
step "Generating Expo type declarations..."
npx expo customize expo-env.d.ts --no-install 2>/dev/null || true
if [ ! -f expo-env.d.ts ]; then
echo '/// <reference types="expo-router/types" />' > expo-env.d.ts
fi
ok "expo-env.d.ts ready"
# ── 7. Summary ────────────────────────────────────────────────────────────────
echo ""
echo " ══════════════════════════════════════════"
echo " Setup complete! Next steps:"
echo ""
echo " Start with Expo Go (scan QR on your phone):"
echo " npx expo start"
echo ""
echo " Run on Android emulator:"
echo " npx expo run:android"
echo ""
echo " Run on iOS simulator (macOS only):"
echo " npx expo run:ios"
echo ""
echo " Build APK for Karoo sideload:"
echo " npx eas build -p android --profile preview"
echo " ══════════════════════════════════════════"
echo ""
-29
View File
@@ -1,29 +0,0 @@
export type PaletteKey = 'auto' | 'default' | 'giro' | 'tour' | 'vuelta';
export const PALETTES = {
default: { accent: '#60a5fa', dim: 'rgba(96,165,250,0.15)', label: 'Default' },
giro: { accent: '#f472b6', dim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
tour: { accent: '#facc15', dim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
vuelta: { accent: '#ef4444', dim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
} as const satisfies Record<string, { accent: string; dim: string; label: string }>;
export type Theme = (typeof PALETTES)[keyof typeof PALETTES];
// Race windows [month 0-indexed, day inclusive] — update each year
const RACES: Array<{ key: Exclude<PaletteKey, 'auto' | 'default'>; start: [number, number]; end: [number, number] }> = [
{ key: 'giro', start: [4, 8], end: [5, 1] }, // May 8 Jun 1
{ key: 'tour', start: [5, 27], end: [6, 19] }, // Jun 27 Jul 19
{ key: 'vuelta', start: [7, 15], end: [8, 6] }, // Aug 15 Sep 6
];
export function autoKey(): Exclude<PaletteKey, 'auto'> {
const now = new Date();
const y = now.getFullYear();
for (const r of RACES) {
const start = new Date(y, r.start[0], r.start[1]);
const end = new Date(y, r.end[0], r.end[1] + 1);
if (now >= start && now < end) return r.key;
}
return 'default';
}
-16
View File
@@ -1,16 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts"
]
}
+4
View File
@@ -24,6 +24,8 @@ dependencies = [
"rich>=13.0", # pretty console output
# Schema validation
"jsonschema>=4.23",
# Image generation (OG track images)
"Pillow>=10.0",
]
[project.optional-dependencies]
@@ -40,6 +42,7 @@ serve = [
"uvicorn[standard]>=0.29",
"python-multipart>=0.0.9",
"bcrypt>=4.1",
"PyJWT>=2.8",
]
strava = [
"requests>=2.32",
@@ -77,6 +80,7 @@ dev = [
"uvicorn[standard]>=0.29",
"python-multipart>=0.0.9",
"bcrypt>=4.1",
"PyJWT>=2.8",
"httpx>=0.28.1",
]
+479
View File
@@ -0,0 +1,479 @@
# Refactoring Plan
Branch: `refactoring`
Approach: test-first — each step starts with tests that prove correctness of the current behaviour, then the refactor makes those tests pass against the new structure.
---
## Step 1 — Extract shared image utilities
### Problem
`_ALLOWED_IMAGE_TYPES`, `_MAX_IMAGE_BYTES`, and `_unique_image_name()` are defined identically in two files:
| File | Lines |
|---|---|
| `bincio/edit/server.py` | 4658 |
| `bincio/serve/server.py` | 337357 |
Any change (e.g. adding `image/avif`) must be made in both places.
### Target
New module: `bincio/shared/images.py`
```python
# bincio/shared/images.py
from pathlib import Path
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset({
"image/jpeg", "image/png", "image/webp", "image/gif"
})
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 # 10 MB
def unique_image_name(directory: Path, filename: str) -> str:
"""Return a filename that does not collide with existing files in directory."""
stem, suffix = Path(filename).stem, Path(filename).suffix
candidate = filename
counter = 1
while (directory / candidate).exists():
candidate = f"{stem}_{counter}{suffix}"
counter += 1
return candidate
```
### Test plan
**New file**: `tests/test_shared_images.py`
Write these tests first (they will fail until the module exists):
```python
# tests/test_shared_images.py
from pathlib import Path
import pytest
from bincio.shared.images import ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES, unique_image_name
def test_constants():
assert "image/jpeg" in ALLOWED_IMAGE_TYPES
assert "image/png" in ALLOWED_IMAGE_TYPES
assert "image/webp" in ALLOWED_IMAGE_TYPES
assert "image/gif" in ALLOWED_IMAGE_TYPES
assert "image/avif" not in ALLOWED_IMAGE_TYPES # guard against accidental expansion
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
def test_unique_name_no_collision(tmp_path):
assert unique_image_name(tmp_path, "photo.jpg") == "photo.jpg"
def test_unique_name_single_collision(tmp_path):
(tmp_path / "photo.jpg").touch()
assert unique_image_name(tmp_path, "photo.jpg") == "photo_1.jpg"
def test_unique_name_multiple_collisions(tmp_path):
(tmp_path / "photo.jpg").touch()
(tmp_path / "photo_1.jpg").touch()
assert unique_image_name(tmp_path, "photo.jpg") == "photo_2.jpg"
def test_unique_name_no_suffix(tmp_path):
(tmp_path / "photo").touch()
assert unique_image_name(tmp_path, "photo") == "photo_1"
def test_unique_name_preserves_case(tmp_path):
assert unique_image_name(tmp_path, "MyPhoto.PNG") == "MyPhoto.PNG"
```
### Implementation steps
1. Create `bincio/shared/__init__.py` (empty).
2. Create `bincio/shared/images.py` with the public constants and function (no leading underscore).
3. In `bincio/edit/server.py`: replace lines 4658 with:
```python
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
from bincio.shared.images import unique_image_name as _unique_image_name
```
4. In `bincio/serve/server.py`: same replacement at lines 337357.
5. Run `pytest tests/test_shared_images.py` — all pass.
6. Run full test suite to confirm no regressions.
---
## Step 2 — Extract embedded HTML template from `edit/server.py`
### Problem
`bincio/edit/server.py` contains 285 lines of static HTML/CSS/JS as a Python string literal (`_HTML`, lines 63347). This:
- Makes the file 30% larger than it needs to be.
- Prevents syntax highlighting and linting of the HTML/JS.
- Cannot be tested in isolation (template substitution is done inline by the route handler).
### Target
New file: `bincio/edit/templates/edit.html`
The template already uses three placeholder tokens:
- `__SITE_URL__` — replaced with `site_url` at request time
- `__SPORT_OPTIONS__` — replaced with generated `<option>` tags
- `__STAT_CHECKBOXES__` — replaced with generated `<label>` tags
Extract a helper that loads and renders the template:
```python
# bincio/edit/server.py — replaces the _HTML string and inline render
from pathlib import Path as _Path
_TEMPLATE_PATH = _Path(__file__).parent / "templates" / "edit.html"
def _render_edit_html(activity_id: str) -> str:
template = _TEMPLATE_PATH.read_text(encoding="utf-8")
sport_options = "\n".join(
f'<option value="{s}">{s.title()}</option>' for s in SPORTS
)
stat_checkboxes = "\n".join(
f'<label class="check-item"><input type="checkbox" data-stat="{k}"> {v}</label>'
for k, v in STAT_PANELS.items()
)
return (
template
.replace("__SITE_URL__", site_url)
.replace("__SPORT_OPTIONS__", sport_options)
.replace("__STAT_CHECKBOXES__", stat_checkboxes)
)
```
### Test plan
**Extend** `tests/test_edit_server.py` with a new section:
```python
# tests/test_edit_server.py — new tests for template loading
import bincio.edit.server as edit_server
def test_edit_ui_returns_html(tmp_path):
"""GET /edit/<id> returns 200 with an HTML body containing the form."""
edit_server.data_dir = tmp_path
activities = tmp_path / "activities"
activities.mkdir()
(activities / "run-001.json").write_text('{"id":"run-001"}')
resp = CLIENT.get("/edit/run-001")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert '<form id="form"' in resp.text
def test_edit_ui_injects_site_url(tmp_path):
"""Template placeholder __SITE_URL__ is replaced with the configured site_url."""
edit_server.data_dir = tmp_path
edit_server.site_url = "http://localhost:1234"
(tmp_path / "activities").mkdir(exist_ok=True)
(tmp_path / "activities" / "run-001.json").write_text('{"id":"run-001"}')
resp = CLIENT.get("/edit/run-001")
assert "http://localhost:1234" in resp.text
assert "__SITE_URL__" not in resp.text
def test_edit_ui_no_unresolved_placeholders(tmp_path):
"""No placeholder tokens remain after rendering."""
edit_server.data_dir = tmp_path
(tmp_path / "activities").mkdir(exist_ok=True)
(tmp_path / "activities" / "run-001.json").write_text('{"id":"run-001"}')
resp = CLIENT.get("/edit/run-001")
for token in ("__SITE_URL__", "__SPORT_OPTIONS__", "__STAT_CHECKBOXES__"):
assert token not in resp.text, f"Unresolved placeholder: {token}"
def test_edit_template_file_exists():
"""The template file is present on disk (guards against accidental deletion)."""
from pathlib import Path
template = Path(edit_server.__file__).parent / "templates" / "edit.html"
assert template.exists(), f"Template not found at {template}"
```
### Implementation steps
1. Create `bincio/edit/templates/` directory.
2. Move the HTML content of `_HTML` into `bincio/edit/templates/edit.html` verbatim (keep the `__PLACEHOLDER__` tokens as-is).
3. Delete the `_HTML = """..."""` string literal from `server.py` (lines 63347).
4. Add `_render_edit_html()` helper as shown above.
5. Update the `/edit/{activity_id}` route handler to call `_render_edit_html(activity_id)` instead of the inline `_HTML.replace(...)` chain.
6. Run `pytest tests/test_edit_server.py` — all pass (new + existing).
---
## Step 3 — Split `serve/server.py` into APIRouter modules
### Problem
`bincio/serve/server.py` is 3,230 lines containing ~60 routes across 10 logical domains, all shared dependencies, all Pydantic models, and all background task machinery. It cannot be meaningfully reviewed, tested in isolation, or understood at a glance.
### Target structure
```
bincio/serve/
├── server.py # ~150 lines: app factory, middleware, router registration, startup
├── deps.py # module-level globals + shared FastAPI dependency functions
├── models.py # all Pydantic request/response models
├── tasks.py # background workers: site-rebuild, rebuild-for-handle, jobs registry
├── db.py # unchanged
└── routers/
├── __init__.py
├── auth.py # /api/auth/*, /api/register, /api/invites
├── me.py # /api/me/*
├── admin.py # /api/admin/*
├── activities.py # /api/activity/*, /api/activities/*
├── uploads.py # /api/upload/*
├── segments.py # /api/segments/*
├── strava.py # /api/strava/*
├── garmin.py # /api/garmin/*
├── ideas.py # /api/ideas/*, /api/feedback
└── feed.py # /api/feed, /api/stats, /api/me (read-only), /api/wheel/*
```
### `deps.py` — shared state and dependency functions
All module-level globals that multiple routers need move here. The CLI sets them on this module before uvicorn starts.
```python
# bincio/serve/deps.py
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Optional
import sqlite3
from fastapi import Cookie, HTTPException
from bincio.serve.db import User, get_session, get_user
# ── Module-level state (set by CLI) ──────────────────────────────────────────
data_dir: Path | None = None
site_dir: Path | None = None
webroot: Path | None = None
strava_client_id: str = ""
strava_client_secret: str = ""
public_url: str = ""
dem_url: str = "https://api.open-elevation.com"
sync_secret: str = ""
garmin_key: bytes | None = None
_db: sqlite3.Connection | None = None
# ── Constants ─────────────────────────────────────────────────────────────────
VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
SESSION_COOKIE = "bincio_session"
COOKIE_MAX_AGE = 30 * 86400
SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
# ── Dependency functions ───────────────────────────────────────────────────────
def get_data_dir() -> Path:
if data_dir is None:
raise HTTPException(500, "Server not configured")
return data_dir
def get_db() -> sqlite3.Connection:
global _db
if _db is None:
from bincio.serve.db import open_db
_db = open_db(get_data_dir())
return _db
def get_current_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
if not bincio_session:
raise HTTPException(401, "Not authenticated")
sess = get_session(get_db(), bincio_session)
if sess is None:
raise HTTPException(401, "Invalid or expired session")
user = get_user(get_db(), sess.handle)
if user is None or user.suspended:
raise HTTPException(401, "Account not found or suspended")
return user
def require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
user = get_current_user(bincio_session)
if not user.is_admin:
raise HTTPException(403, "Admin required")
return user
```
### `models.py` — Pydantic models
All `class *Request(BaseModel)` and `class *Response(BaseModel)` definitions move to `bincio/serve/models.py`. Routers import from there.
### `tasks.py` — background workers
Move `_site_rebuild_worker`, `_site_rebuild_event`, `_rebuild_for_handle`, `_active_jobs`, `_jobs_lock`, `_job_start`, `_job_update`, `_job_finish` to `bincio/serve/tasks.py`. These import from `deps.py` for `webroot` and `site_dir`.
### Router example — `routers/auth.py`
```python
# bincio/serve/routers/auth.py
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from bincio.serve import deps
from bincio.serve.models import LoginRequest, LoginResponse, ...
router = APIRouter()
@router.post("/api/auth/login", response_model=LoginResponse)
async def login(body: LoginRequest, request: Request, response: Response):
db = deps.get_db()
...
```
Main `server.py` becomes:
```python
# bincio/serve/server.py (~150 lines)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from bincio.serve.routers import auth, me, admin, activities, uploads, segments, strava, garmin, ideas, feed
app = FastAPI(title="BincioActivity Serve")
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(CORSMiddleware, ...)
for router in [auth.router, me.router, admin.router, activities.router,
uploads.router, segments.router, strava.router, garmin.router,
ideas.router, feed.router]:
app.include_router(router)
@app.on_event("startup")
async def _on_startup() -> None: ...
```
### Test plan
**Philosophy**: write tests that call routes through the full app (via `TestClient`), so they remain valid before and after the split. Each router gets its own test file.
#### Before starting the split
Write `tests/serve/test_auth_router.py` (and equivalents for each router). These tests pass against the current monolith. After the split they must still pass — this is the regression guard.
```python
# tests/serve/test_auth_router.py
import pytest
from fastapi.testclient import TestClient
from bincio.serve.server import app
import bincio.serve.deps as deps
@pytest.fixture()
def client(tmp_path):
deps.data_dir = tmp_path
deps._db = None
return TestClient(app, raise_server_exceptions=False)
def test_login_missing_body(client):
r = client.post("/api/auth/login", json={})
assert r.status_code == 422
def test_login_wrong_password(client):
r = client.post("/api/auth/login", json={"handle": "nobody", "password": "x"})
assert r.status_code in (401, 404)
def test_logout_unauthenticated(client):
r = client.post("/api/auth/logout")
assert r.status_code == 401
def test_register_invite_required(client):
r = client.post("/api/register", json={"handle": "alice", "password": "pass1234", "invite": ""})
assert r.status_code in (400, 403)
```
Write similar test files for `me`, `admin`, `activities`, `uploads` before touching any production code.
#### After the split
Run the full test suite. No test should fail — the tests are route-level and do not import from the monolith's internals.
#### Additional tests enabled by the split
Once routers are isolated, add unit tests for dependency functions in `deps.py`:
```python
# tests/serve/test_deps.py
import pytest
from fastapi import HTTPException
import bincio.serve.deps as deps
def test_get_data_dir_raises_when_unset():
deps.data_dir = None
with pytest.raises(HTTPException) as exc:
deps.get_data_dir()
assert exc.value.status_code == 500
def test_get_current_user_raises_without_cookie(tmp_path):
deps.data_dir = tmp_path
deps._db = None
with pytest.raises(HTTPException) as exc:
deps.get_current_user(bincio_session=None)
assert exc.value.status_code == 401
```
### Implementation steps (do not start until all pre-split tests are green)
1. Create `bincio/serve/deps.py` — move globals, constants, `get_db`, `get_current_user`, `require_admin`, `get_data_dir`. Update `serve/cli.py` to set `deps.*` instead of `server.*`.
2. Create `bincio/serve/models.py` — move all Pydantic models. Update imports in `server.py`.
3. Create `bincio/serve/tasks.py` — move `_site_rebuild_worker`, `_site_rebuild_event`, `_rebuild_for_handle`, jobs registry. Import from `deps`.
4. Create `bincio/serve/routers/__init__.py` (empty).
5. Extract one router at a time in this order (least-coupled first):
- `feed.py` (read-only, minimal deps)
- `auth.py` (depends only on db, no user dir operations)
- `me.py` (depends on current user + user dir)
- `ideas.py`
- `segments.py`
- `strava.py`
- `garmin.py`
- `activities.py`
- `uploads.py`
- `admin.py` (most complex, depends on tasks)
6. After each router extraction: run the full test suite before proceeding.
7. Once all routers are extracted, reduce `server.py` to the app factory and middleware only.
---
## Step 4 — Narrow broad `except Exception:` catches
### Problem
After the Step 3 split, the router files inherited ~35 bare `except Exception:` clauses from the original monolith. Most were in route handlers where a specific narrow type is knowable and preferable — broad catches hide bugs and let surprising failures silently produce wrong results.
### Classification rule
| Situation | Decision |
|---|---|
| Background thread top-level guard (calls `log.exception`) | **Keep** — last-resort, full traceback essential |
| SSE stream generator top-level | **Keep** — must convert any error to a client event |
| Per-item batch loop (must not abort on one failure) | **Keep**`log.warning/error` already present |
| Explicitly non-fatal post-upload merge step | **Keep**`log.warning` present; upload already succeeded |
| Route handler: reading/writing JSON files | `(OSError, json.JSONDecodeError)` |
| Route handler: datetime parsing | `ValueError` |
| Route handler: base64 decoding | `ValueError` |
| Route handler: YAML parsing | `(OSError, yaml.YAMLError)` |
| Route handler: GeoJSON coord extraction | `(TypeError, IndexError, AttributeError)` |
| Startup cleanup (`Path.unlink`) | `OSError` |
| JSON line parsing inside SSE batch | `json.JSONDecodeError` |
### What was changed (28 catches narrowed across 8 files)
- `server.py` — startup `tmp*.zip` cleanup → `OSError`
- `segments.py` — file-scan loops (6 catches) → `(OSError, json.JSONDecodeError, ValueError)` / `ValueError`
- `me.py` — credential file reads, manifest write (4 catches) → `(OSError, json.JSONDecodeError)`
- `activities.py` — index/cache reads (2 catches) → `(OSError, json.JSONDecodeError)`; YAML enrichment (1) → `(OSError, yaml.YAMLError)` with `import yaml` moved above the `try`
- `admin.py` — diag index reads, strava-status loop reads (5 catches) → `(OSError, json.JSONDecodeError)` / `json.JSONDecodeError`
- `ideas.py` — idea file reads (3 catches) → `(OSError, json.JSONDecodeError)`
- `strava.py` — index parse in reset endpoint → `(OSError, json.JSONDecodeError, ValueError)`
- `uploads.py` — GeoJSON coords, base64 decode, cache update (3 catches)
### What was kept (11 catches, all intentional)
`tasks.py:97`, `tasks.py:133` — background thread tops with `log.exception`
`admin.py:579` — admin strava-sync background thread top with `log.exception`
`admin.py:630` — per-activity batch loop in recompute-elevation with `log.warning`
`garmin.py:112`, `strava.py:164`, `uploads.py:491` — SSE stream tops
`uploads.py:143`, `uploads.py:259` — non-fatal post-upload merge with `log.warning`
`uploads.py:245` — extraction failure → 422 (any parser failure must surface as 422)
`uploads.py:404` — per-file batch loop in upload event stream
---
## Progress tracker
| # | Step | Status |
|---|---|---|
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Done |
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Done |
| 4 | Narrow broad `except Exception:` catches | Done |
> **Note on dependency pinning**: not included. `uv.lock` already pins every dependency (including transitives) to exact versions, which is strictly stronger than switching `>=` to `~=` in `pyproject.toml`. The lockfile is the right mechanism for this concern.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Backfill Garmin gear for all users who have stored Garmin credentials.
Usage (on VPS):
cd /opt/bincio
uv run python3 scripts/backfill_garmin_gear.py --data-dir /var/bincio/data
# Limit to specific users:
uv run python3 scripts/backfill_garmin_gear.py --data-dir /var/bincio/data --users plagzo12
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Backfill Garmin gear for all users")
parser.add_argument("--data-dir", required=True, type=Path, help="Root data directory")
parser.add_argument("--users", nargs="*", help="Limit to these user handles (default: all)")
args = parser.parse_args()
data_dir: Path = args.data_dir.resolve()
if not data_dir.is_dir():
sys.exit(f"data-dir not found: {data_dir}")
from bincio.extract.garmin_api import GarminError, has_credentials
from bincio.extract.garmin_sync import import_garmin_gear
candidates = (
[data_dir / h for h in args.users]
if args.users
else sorted(p for p in data_dir.iterdir() if p.is_dir())
)
garmin_users = [p for p in candidates if has_credentials(p)]
if not garmin_users:
print("No users with Garmin credentials found.")
return
print(f"Found {len(garmin_users)} Garmin user(s): {[p.name for p in garmin_users]}\n")
for user_dir in garmin_users:
handle = user_dir.name
print(f"[{handle}] importing gear...", flush=True)
try:
result = import_garmin_gear(data_dir, user_dir)
print(
f"[{handle}] done — "
f"gear_added={result['gear_added']}, "
f"activities_updated={result['activities_updated']}"
)
except GarminError as exc:
print(f"[{handle}] Garmin error: {exc}")
except Exception as exc:
print(f"[{handle}] unexpected error: {type(exc).__name__}: {exc}")
print("\nAll done. Run merge_all or trigger a rebuild to refresh the index shards.")
if __name__ == "__main__":
main()
+5
View File
@@ -142,6 +142,11 @@ def prepare_serve() -> None:
_write_root_manifest(DATA_DIR)
ok("root manifest updated")
from bincio.explore import bake_tracks
for handle in ("dave", "brut"):
n = bake_tracks(handle, DATA_DIR)
ok(f"{handle}: {n} track(s) baked for explore")
# ── 4. Hand off to bincio dev ─────────────────────────────────────────────────
+93
View File
@@ -0,0 +1,93 @@
"""Pre-generate OG track images for all activities.
Writes 400×400 PNGs to {www_root}/og-image/{user}/{activity_id}.png.
Skips activities that already have an up-to-date image (mtime check).
Safe to run repeatedly only processes new/changed activities.
Usage:
uv run scripts/generate_og_images.py [--data-dir /var/bincio/data] [--www-root /var/www/activity]
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
def generate_all(data_dir: Path, www_root: Path) -> None:
out_root = www_root / "og-image"
out_root.mkdir(parents=True, exist_ok=True)
from bincio.render.ogimage import generate
total = generated = skipped = errors = 0
users = sorted(
d.name for d in data_dir.iterdir()
if d.is_dir() and not d.name.startswith("_") and d.name != "segments"
)
for handle in users:
user_dir = data_dir / handle
acts_dir = user_dir / "activities"
img_dir = out_root / handle
if not acts_dir.exists():
continue
img_dir.mkdir(exist_ok=True)
u_gen = u_skip = u_err = 0
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
activity_id = ts_path.name.replace(".timeseries.json", "")
out_path = img_dir / f"{activity_id}.png"
total += 1
# Skip if image is newer than timeseries
if out_path.exists() and out_path.stat().st_mtime >= ts_path.stat().st_mtime:
skipped += 1
u_skip += 1
continue
try:
ts = json.loads(ts_path.read_text(encoding="utf-8"))
lat_arr = ts.get("lat") or []
lon_arr = ts.get("lon") or []
ele_arr = ts.get("elevation_m") or []
png = generate(lat_arr, lon_arr, ele_arr)
out_path.write_bytes(png)
generated += 1
u_gen += 1
time.sleep(0.05)
except Exception as exc:
errors += 1
u_err += 1
print(f" ERROR {handle}/{activity_id}: {exc}", file=sys.stderr)
if u_gen or u_err:
print(f"{handle:<25} generated={u_gen:4d} skipped={u_skip:4d} errors={u_err}")
else:
print(f"{handle:<25} skipped={u_skip:4d} (all up to date)")
print(f"\nDone — {generated} generated, {skipped} skipped, {errors} errors (total {total})")
def main() -> None:
ap = argparse.ArgumentParser(description="Pre-generate OG track images")
ap.add_argument("--data-dir", default="/var/bincio/data", type=Path)
ap.add_argument("--www-root", default="/var/www/activity", type=Path)
args = ap.parse_args()
if not args.data_dir.exists():
print(f"ERROR: data dir not found: {args.data_dir}", file=sys.stderr)
sys.exit(1)
print(f"data-dir : {args.data_dir}")
print(f"www-root : {args.www_root}")
print(f"output : {args.www_root}/og-image/\n")
generate_all(args.data_dir, args.www_root)
if __name__ == "__main__":
main()
+62 -9
View File
@@ -6,17 +6,70 @@ set -e
VPS=${1:?Usage: $0 user@host}
REMOTE=/var/bincio/data/_feedback
LOCAL=$(dirname "$0")/../feedback
SEEN="$LOCAL/.seen"
mkdir -p "$LOCAL"
echo "Syncing feedback from $VPS:$REMOTE$LOCAL"
rsync -avz --progress "${VPS}:${REMOTE}/" "$LOCAL/"
rsync -az "${VPS}:${REMOTE}/" "$LOCAL/"
echo ""
echo "=== Feedback summary ==="
for f in "$LOCAL"/*.json; do
[[ -f "$f" ]] || continue
handle=$(basename "$f" .json)
count=$(python3 -c "import json,sys; d=json.load(open('$f')); print(len(d) if isinstance(d, list) else 1)" 2>/dev/null || echo "?")
echo " @$handle: $count submission(s)"
done
python3 - "$LOCAL" "$SEEN" <<'EOF'
import json, sys, os
from datetime import datetime, timezone
local_dir, seen_file = sys.argv[1], sys.argv[2]
seen = set()
if os.path.exists(seen_file):
seen = set(json.loads(open(seen_file).read()))
new_entries = []
all_ids = []
for fname in sorted(os.listdir(local_dir)):
if not fname.endswith('.json') or fname.startswith('.'):
continue
handle = fname[:-5]
try:
entries = json.load(open(os.path.join(local_dir, fname)))
if not isinstance(entries, list):
entries = [entries]
except Exception:
continue
for e in entries:
eid = e.get('id', '')
all_ids.append(eid)
if eid not in seen:
new_entries.append((handle, e))
if new_entries:
print(f'\n\033[1;32m=== {len(new_entries)} new submission(s) ===\033[0m')
for handle, e in new_entries:
ts = e.get('submitted_at', '')[:10]
text = e.get('text', '').replace('\n', ' ')
images = e.get('images', [])
img_note = f' [{len(images)} image(s)]' if images else ''
print(f'\n \033[1m@{handle}\033[0m {ts}')
if text:
print(f' {text[:200]}{"…" if len(text) > 200 else ""}')
if img_note:
print(f' {img_note}')
else:
print('\n=== No new feedback ===')
# Print totals
print('\n--- totals ---')
for fname in sorted(os.listdir(local_dir)):
if not fname.endswith('.json') or fname.startswith('.'):
continue
handle = fname[:-5]
try:
entries = json.load(open(os.path.join(local_dir, fname)))
count = len(entries) if isinstance(entries, list) else 1
print(f' @{handle}: {count}')
except Exception:
pass
# Save updated seen set
open(seen_file, 'w').write(json.dumps(list(set(all_ids))))
EOF
+198
View File
@@ -0,0 +1,198 @@
"""Audit elevation accuracy vs Strava.
Friends add a note with the Strava elevation to their activity descriptions.
Supported formats (case-insensitive):
- "strava 1323md+" most common
- "strava 1323 m d+"
- "Strava 1625 m d+"
- "Strava Elevation 1173m"
- "1038 m d+ Strava" number before the word strava
- "Strava 207 metri di dislivello"
Descriptions live in _merged/activities/ (sidecar merge).
Computed elevation_gain_m is read from activities/ (main file).
Usage:
uv run scripts/strava_elevation_audit.py [--data-dir /var/bincio/data] [--out elevation_audit.csv]
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from pathlib import Path
from bincio.extract.metrics import elevation_params
# Patterns tried in order; first match wins.
# Each pattern must have exactly one capturing group for the numeric value.
_PATTERNS: list[re.Pattern] = [
# "strava NNN m ..." or "strava NNNmd+"
re.compile(r'\bstrava\b\s*([0-9][0-9.,]*)\s*m', re.IGNORECASE),
# "Strava Elevation NNNm" or "Strava ... NNNm" (one word between)
re.compile(r'\bstrava\b\s+\w+\s+([0-9][0-9.,]*)\s*m', re.IGNORECASE),
# "NNN m ... strava" (number comes first, up to 20 chars before strava)
re.compile(r'([0-9][0-9.,]*)\s*m\b.{0,20}?\bstrava\b', re.IGNORECASE),
# "Strava NNN metri di dislivello" (Italian)
re.compile(r'\bstrava\b.*?([0-9][0-9.,]*)\s+metr', re.IGNORECASE),
]
def _find_strava_elevation(description: str) -> float | None:
for pat in _PATTERNS:
m = pat.search(description)
if m:
raw = m.group(1).replace(',', '.')
try:
return float(raw)
except ValueError:
continue
return None
def audit(data_dir: Path, out_path: Path) -> list[dict]:
rows: list[dict] = []
unmatched: list[tuple[str, str]] = [] # (path, desc) couldn't parse elevation
for merged_path in sorted(data_dir.glob("*/_merged/activities/*.json")):
if merged_path.suffix != ".json":
continue
if ".timeseries." in merged_path.name or ".geojson" in merged_path.name:
continue
try:
merged = json.loads(merged_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
description = merged.get("description") or ""
if not description or "strava" not in description.lower():
continue
# Skip strava:// athlete-mention links (not elevation notes)
if re.search(r'strava://', description, re.IGNORECASE):
continue
strava_elev = _find_strava_elevation(description)
if strava_elev is None:
unmatched.append((str(merged_path), description))
continue
# Read computed elevation from main activity file
main_path = (
merged_path.parents[3] # data_dir
/ merged_path.parents[2].name # user
/ "activities"
/ merged_path.name
)
try:
main = json.loads(main_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
main = merged # fall back to merged values
our_elev = main.get("elevation_gain_m")
title = main.get("title") or merged.get("title") or merged_path.stem
user = merged_path.parents[2].name
altitude_source = main.get("altitude_source") or "unknown"
source = main.get("source") or ""
device = main.get("device") or "unknown"
ma_window, threshold = elevation_params(altitude_source, source)
delta = round(our_elev - strava_elev, 1) if our_elev is not None else None
pct = (
round((our_elev - strava_elev) / strava_elev * 100, 1)
if our_elev is not None and strava_elev != 0
else None
)
rows.append({
"file": merged_path.name,
"user": user,
"title": title,
"device": device,
"altitude_source": altitude_source,
"source": source,
"ma_window_s": ma_window,
"threshold_m": threshold,
"our_elevation_m": our_elev,
"strava_elevation_m": strava_elev,
"delta_m": delta,
"delta_pct": pct,
"description": description[:120].replace("\n", " ").replace("\r", ""),
})
rows.sort(key=lambda r: abs(r["delta_m"] or 0), reverse=True)
if rows:
with out_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
if unmatched:
print(f"\nCould not parse elevation from {len(unmatched)} description(s):")
for path, desc in unmatched:
print(f" {Path(path).name} {desc[:80]!r}")
return rows
def main() -> None:
ap = argparse.ArgumentParser(description="Audit elevation accuracy vs Strava notes")
ap.add_argument("--data-dir", default="/var/bincio/data", type=Path)
ap.add_argument("--out", default="elevation_audit.csv", type=Path)
args = ap.parse_args()
if not args.data_dir.exists():
print(f"ERROR: data dir not found: {args.data_dir}", file=sys.stderr)
sys.exit(1)
print(f"Scanning {args.data_dir}")
rows = audit(args.data_dir, args.out)
if not rows:
print("No activities found with a parseable Strava elevation note.")
return
print(f"\nFound {len(rows)} activit{'y' if len(rows)==1 else 'ies'}:\n")
header = (
f"{'File':<50} {'User':<15} {'Source':<16} {'AltSrc':<12}"
f" {'MA':>4} {'Thr':>5} {'Ours':>8} {'Strava':>8} {'Delta':>8} {'Delta%':>7}"
)
print(header)
print("-" * len(header))
for r in rows:
delta_str = f"{r['delta_m']:+.0f}" if r['delta_m'] is not None else "n/a"
pct_str = f"{r['delta_pct']:+.1f}%" if r['delta_pct'] is not None else "n/a"
our_str = f"{r['our_elevation_m']:.0f}" if r['our_elevation_m'] is not None else "n/a"
print(
f"{r['file']:<50} {r['user']:<15} {r['source']:<16} {r['altitude_source']:<12}"
f" {r['ma_window_s']:>4} {r['threshold_m']:>5.1f}"
f" {our_str:>8} {r['strava_elevation_m']:>8.0f}"
f" {delta_str:>8} {pct_str:>7}"
)
n = len(rows)
pcts = [r["delta_pct"] for r in rows if r["delta_pct"] is not None]
deltas = [r["delta_m"] for r in rows if r["delta_m"] is not None]
if pcts:
avg_pct = sum(pcts) / len(pcts)
sorted_pcts = sorted(pcts)
median_pct = sorted_pcts[len(sorted_pcts) // 2]
within_10 = sum(1 for p in pcts if abs(p) <= 10)
within_15 = sum(1 for p in pcts if abs(p) <= 15)
avg_d = sum(deltas) / len(deltas) if deltas else 0
print(
f"\n n={n} avg={avg_pct:+.1f}% median={median_pct:+.1f}%"
f" avg delta={avg_d:+.0f} m"
f" within ±10%: {within_10}/{n} within ±15%: {within_15}/{n}"
)
print(f"\nCSV saved to: {args.out}")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More