Merge branch 'main' into mobile_app

This commit is contained in:
Davide Scaini
2026-04-24 09:52:27 +02:00
101 changed files with 12509 additions and 1073 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
- run: uv sync
- run: uv sync --extra serve --extra edit --extra strava
- run: uv run pytest
frontend:
+17
View File
@@ -11,6 +11,7 @@ build/
htmlcov/
.coverage
.idea*
feedback*
# uv
uv.lock
@@ -20,6 +21,9 @@ site/node_modules/
site/dist/
site/.astro/
# MkDocs
mkdocs-site/
# BAS data stores (user data, not committed to the tool repo)
bincio_data/
*.bincio_cache.json
@@ -28,6 +32,19 @@ bincio_data/
.env
extract_config.yaml
# Local working / scratch files
advice.md
issues.md
todo.md
site/public/data
site/public/*.whl
.claude/settings.local.json
dns.md
ngix_bincio.md
publish.sh
docs/squash-for-github.md
CLAUDE.md
# Capacitor native projects
# Commit these if you want to track native code changes;
# omit these lines if you regenerate them from `npx cap add`
+338 -2
View File
@@ -1,6 +1,342 @@
# Changelog
## [Unreleased] — 2026-04-06
## [0.1.0] — 2026-04-22
### Improvement — DEM & hysteresis algorithm refinements
**Hysteresis-only recalculation** (`recalculate_elevation_hysteresis`) reworked:
- Pre-smooths the elevation series with a **30 s centred moving average** (O(n)
cumsum implementation) before accumulation. Pre-smoothing suppresses barometric
quantization steps and GPS jitter without discarding real terrain.
- Hysteresis thresholds reduced to **1 m (barometric)** / **3 m (GPS/unknown)**
— safe after pre-smoothing, and accurate enough to capture genuine small climbs
that the previous 5 m / 10 m thresholds were swallowing.
- Response key renamed `source``altitude_source` for consistency with the
detail JSON field.
**DEM recalculation** median-filter window widened from 45 s → **60 s** to more
reliably absorb the occasional larger SRTM tile-boundary step.
`altitude_source` is now written into the activity detail JSON at extract time
(`writer.py`), making the hysteresis endpoint source-aware for all newly uploaded
activities.
### Tests
- **`tests/test_dem.py`** (new) — 21 tests covering `_moving_average`,
`_median_filter`, `_hysteresis_gain_loss`, and `recalculate_elevation_hysteresis`
at the file level (no network, no extract pipeline)
- **`tests/test_edit_server.py`** (new) — 11 `TestClient` API tests for both
`/recalculate-elevation/hysteresis` and `/recalculate-elevation/dem` endpoints,
covering happy path, error codes (404/422/503), path-traversal rejection, and
on-disk JSON patching
- `httpx` added as a dev dependency (required by FastAPI `TestClient`)
---
## [0.1.0-dev] — 2026-04-20
### Improvement — Elevation gain accuracy (hysteresis accumulation)
The previous algorithm accumulated every positive elevation delta between
consecutive track points, counting GPS jitter and barometric quantization
noise as real climbing. This consistently overestimated gain — in extreme
cases by 100% on flat coastal routes.
The new algorithm uses **hysteresis dead-band accumulation**: elevation is
only committed when it changes by more than a source-specific threshold from
the last committed value. GPS noise is suppressed without losing real climbs.
- **`bincio/extract/models.py`** — `ParsedActivity` gains an `altitude_source`
field (`"barometric"` / `"gps"` / `"unknown"`)
- **`bincio/extract/parsers/fit.py`** — detects whether any record frame used
`enhanced_altitude` (barometric altimeter) vs `altitude` (GPS-derived) and
sets `altitude_source` accordingly
- **`bincio/extract/parsers/gpx.py`**, **`tcx.py`** — both set
`altitude_source = "gps"`
- **`bincio/extract/metrics.py`** — `_elevation()` replaced with hysteresis
accumulator; thresholds: **5 m** for barometric, **10 m** for GPS/unknown
- **`tests/test_metrics.py`** — 5 new parametric tests: flat GPS noise
suppression, barometric vs GPS threshold difference, real climb approximation,
unknown-treated-as-gps invariant
### New feature — On-demand elevation recalculation from the edit drawer
Two new buttons in the activity edit drawer fix inaccurate elevation stats
without re-uploading the file:
**📐 Recalculate (hysteresis)** — re-applies source-aware hysteresis
accumulation to the original recorded elevation. Fast, offline, no network
required. Best for barometric altimeters (Karoo 2, Garmin with
`enhanced_altitude`, Wahoo) that were extracted before the noise-filtering
improvement.
**⛰ Recalculate (DEM)** — replaces GPS altitude with SRTM terrain data, then
re-applies hysteresis. Best for GPS-only devices where the recorded altitude
is noisy.
DEM pipeline (revised after discovering that a naive 5 m threshold produced
results worse than no correction on some activities):
1. Subsample GPS track to one point per 10 s
2. Query Open-Elevation API in batches of 512
3. Linearly interpolate back to the full 1 Hz series
4. Apply a **45 s sliding median filter** to suppress SRTM tile-boundary
steps (occur every ~7 s at cycling speed; were accumulating through 5 m
threshold and inflating gain by 50 %+)
5. Apply **10 m hysteresis** to the smoothed series
6. Back up original `elevation_m` as `elevation_m_original` in the timeseries
on the first DEM run (never overwrites an existing backup)
- **`bincio/extract/dem.py`** (new) — `lookup_elevations()`,
`recalculate_elevation()` (DEM + median + 10 m hysteresis),
`recalculate_elevation_hysteresis()` (offline, reads `elevation_m_original`
if available, uses 5 m/10 m source-aware threshold)
- **`POST /api/activity/{id}/recalculate-elevation/dem`** and
**`POST /api/activity/{id}/recalculate-elevation/hysteresis`** — on both
`bincio serve` (auth-gated, triggers `merge_one` + rebuild) and
`bincio edit` (no auth)
- **`bincio serve --dem-url URL`** / **`bincio edit --dem-url URL`** — override
the default DEM endpoint (also read from `DEM_URL` env var)
- Default DEM endpoint: **`https://api.open-elevation.com`** — works out of
the box with no configuration
- **`GET /api/me`** response gains `dem_configured: bool`
- **`EditDrawer.svelte`** — two side-by-side buttons with individual spinners,
shows `↑ Xm ↓ Ym` on success or inline error
---
## [0.1.0-dev] — 2026-04-16
### New feature — Self-service user settings page
- **`site/src/pages/settings/index.astro`** — new `/settings/` page with three sections:
- **Account** — display name editor, storage quota view (uploaded activities + originals size)
- **Integrations** — per-user Strava client ID/secret (replaces instance-level credentials for
multi-user deployments); saved to `settings` table via `PATCH /api/me`
- **Danger zone** — two separate destructive actions:
- **Delete originals** — removes `{user_dir}/originals/` without touching activities
- **Delete all activities** — wipes all activities, edits, GeoJSON, and `_merged/`; triggers rebuild
- Nav visibility toggles — user can hide any combination of Feed / Stats / Athlete tabs from
their navigation; preference saved to `settings` table and applied in `Base.astro`
### New feature — Upload overwrite option
- **`POST /api/upload`** — new `overwrite: bool` form field; when true, an existing activity
with the same ID is replaced rather than returning 409. UI checkbox added to the upload modal.
### New feature — Admin tools
- **Ghost user detection** — `/admin/` now marks users whose handle has a data directory but
no entry in the `users` table (e.g. manually created dirs, or users deleted from DB); shown
with a "ghost" badge
- **Delete directory button** — admin can delete a user's entire data directory without
touching the DB entry; useful for cleaning up ghost dirs or corrupted accounts
- **Delete all activities** (`DELETE /api/admin/users/{handle}/activities`) — wipes
`activities/`, `edits/`, `_merged/`, and `index.json` for a handle, then triggers a rebuild;
admin page shows a confirmation `<dialog>` before firing
- **"Admin" nav link** — visible in the top-right for admins only
### New feature — Password reset (admin-generated one-time code)
No email infrastructure required. Flow:
1. Admin visits `/admin/` → clicks "Reset pwd" → a 24-hour code appears inline (click to copy)
2. Admin sends it out-of-band (Signal, Telegram, etc.)
3. User goes to `/reset-password/`, enters handle + code + new password
- `POST /api/admin/users/{handle}/reset-password-code` (admin) → `{code, expires_in_hours: 24}`
- `POST /api/auth/reset-password` (public) → body `{handle, code, password}`
- `reset_codes` table in `instance.db`; generating a new code invalidates prior unused codes;
used codes kept for audit
### New feature — Re-extract from Strava originals
- **`POST /api/admin/reextract`** — re-runs the extract pipeline over all
`{user_dir}/originals/strava/*.json` files without hitting the Strava API again;
streams progress via SSE; useful after pipeline improvements
- Runs as a subprocess to avoid OOM (`malloc_trim` + `gc.collect` every 50 activities);
processes in batches of 100 to bound peak RSS
### New feature — Community page
- **`/community/` tab** — sortable table of all registered users: display name, handle,
member since, invited by; replaces the earlier inline community section on the about page
### New feature — Streaming upload progress
- **`POST /api/upload`** now returns `text/event-stream` instead of JSON
- Per-file progress events: `↓ 3/47 (6%) — morning_ride.fit`
- Final `done` event: `"12 added, 35 duplicates"`
- Vite proxy configured to not buffer the stream
### Bug fixes
- **`elevation_gain_m` null for modern Garmin FIT files** — session message `total_ascent`
field now read as fallback when per-point elevation gain is zero
- **Map flash on activity detail** — map container height set before `fitBounds` to prevent
a zero-height frame during load
- **Absolute `track_url` / `detail_url` paths** — `ActivityDetail` and `loadActivity` now
handle both relative and absolute paths in BAS JSON
- **Corrupted time streams causing OOM** — `metrics.py` guards against non-monotonic or
pathologically large time arrays before allocating the 1 Hz dense array
- **Merge race condition** — `merge_all` wipe + rewrite is now guarded; concurrent upload
triggers can no longer interleave a `shutil.rmtree` with a write from another request
- **Temp ZIP leak** — upload temp files now written to `/tmp/` and always deleted in a
`finally` block; a startup hook auto-cleans any leftovers
- **`bincio init` always overwrites `private`** — fixed to preserve existing value when
`index.json` already exists
- **Auth wall flash** — `Base.astro` now sets the auth state synchronously from a cookie
hint before the `fetch('/api/me')` resolves, eliminating the visible flash
- **Single-user redirect loop** — `index.astro` no longer redirects to `/u/{handle}/` on
private (multi-user) instances
- **Theme-aware Plot tooltips** — forced black text on white background; was rendering
grey-on-white (unreadable in light mode) and white-on-dark (unreadable in dark mode)
- **Theme-aware chart axis colors** — axis labels and tick marks now use the correct
foreground color in both light and dark themes
- **TS type annotation in `define:vars` script** — removed; Astro injects `define:vars`
blocks as plain JS, not TypeScript
- **Image refs with spaces/parens in filenames** — local image references in markdown
descriptions are now stripped before rendering to avoid broken inline `<img>` tags
---
## [0.1.0-dev] — 2026-04-10
### New feature — Per-instance user limit
Operators can now cap the maximum number of registered users on an instance.
- **`bincio/serve/db.py`**
- New `settings` table (key/value, upsert-safe via `ON CONFLICT DO UPDATE`).
- `count_users(db)` — returns total number of rows in `users`.
- `get_setting(db, key)` / `set_setting(db, key, value)` — generic persistent settings store.
- **`bincio/serve/server.py`** — `POST /api/register` now reads the `max_users` setting; if
set to N > 0 and the current user count is already ≥ N, registration is rejected with
HTTP 403 and a clear message. Imports `count_users` and `get_setting`.
- **`bincio/serve/init_cmd.py`** — new `--max-users N` flag (default 0 = unlimited). Saves
the value to the `settings` table via `set_setting`. Printed in the init summary.
- **`bincio/serve/cli.py`** — new `--max-users N` flag on `bincio serve`. Writes to the DB
on startup (lets operators change the limit without re-running `bincio init`). Startup
banner now shows `Users: max N` or `Users: unlimited`.
---
### New feature — Original file storage option (upload & Strava sync)
Users can now choose whether to keep their source files on the server after processing.
Keeping originals allows reprocessing if the pipeline improves; discarding them is the
privacy-conscious choice. Previously, uploaded files were always deleted after processing.
- **`bincio/serve/db.py`** — `store_originals` is stored as a settings key. `bincio init`
writes `store_originals=true` on first run.
- **`bincio/serve/server.py`** — `POST /api/upload` accepts a new `store_original: bool`
form field. On success, if true, the staged file is moved to `{user_dir}/originals/`
instead of being deleted. `GET /api/me` now includes `store_originals_default: bool`
(read from the instance setting) so the frontend can pre-populate the checkbox.
`POST /api/strava/sync` checks the `store_originals` instance setting; if true, creates
`{user_dir}/originals/strava/` and passes it as `originals_dir` to `run_strava_sync`.
- **`bincio/edit/server.py`** — `POST /api/upload` gains the same `store_original` form
field with identical behaviour (originals stored in `{data_dir}/originals/`).
- **`bincio/edit/ops.py`** — `run_strava_sync` gains an `originals_dir: Optional[Path]`
parameter, passed through to `ingest.strava_sync`.
- **`bincio/extract/ingest.py`** — `strava_sync` gains `originals_dir: Optional[Path]`.
When set, saves `{"meta": …, "streams": …}` as JSON to
`originals_dir/{activity_id}.json` before processing each activity. This preserves the
raw Strava API response for future reprocessing without needing another API call.
- **`bincio/serve/init_cmd.py`** — sets `store_originals=true` in the settings table on
first init (skipped if the key already exists, so re-running init doesn't override
an operator's choice).
- **`site/src/layouts/Base.astro`** — upload modal file view gains a "Keep original file on
server" checkbox. Defaults to unchecked; pre-checked after login if the instance setting
is `true` (read from `store_originals_default` in the `/api/me` response). The checkbox
value is sent as the `store_original` form field.
- **`bincio/serve/server.py`** and **`bincio/edit/server.py`** — `Form` added to the
FastAPI imports (was missing, causing a startup `NameError`).
---
### New feature — About page (multilingual)
New static `/about/` page explaining the project, with a Ko-fi donation button, data
storage disclaimer, and early-software caveats. Available in four languages.
- **`site/src/pages/about/index.astro`** — English
- **`site/src/pages/about/it/index.astro`** — Italian
- **`site/src/pages/about/es/index.astro`** — Spanish
- **`site/src/pages/about/ca/index.astro`** — Catalan
All four pages share the same structure:
- Language switcher (EN / IT / ES / CA) in the top-right corner.
- Ko-fi donation button (`https://ko-fi.com/brutsalvadi`) at the top.
- **Community stats section** — fetches `GET /api/stats` on load; shown only in
multi-user mode (silently hidden in single-user mode where the endpoint doesn't exist).
Displays total member count and an indented invitation tree: each row shows display name,
`@handle`, membership duration (days / months), and either "founder" or "invited by @X".
UI labels are fully translated per language.
- Sections: What is this · Your data on this server · Early-stage software · Disclaimer ·
Open source.
- All pages use `public={true}` so they bypass the instance auth wall.
"About" link added to the main nav bar (visible when not on a public page).
The upload modal's "Keep original file" checkbox links to `/about/` for context.
---
### New feature — Community stats API
- **`bincio/serve/db.py`** — `get_member_tree(db)` joins `users` with `invites` (on
`used_by`) to reconstruct the invitation graph. Returns a list ordered oldest-first with
`handle`, `display_name`, `created_at`, and `invited_by` (inviter handle or `None` for
the founder/admin).
- **`bincio/serve/server.py`** — new public `GET /api/stats` endpoint (no auth required).
Returns `user_count` and a `members` array where each entry includes `handle`,
`display_name`, `member_since` (Unix timestamp), `member_for_days`, and `invited_by`.
---
### Fix — `bincio dev` now watches data directory for live re-merge
Previously, editing a sidecar or running `bincio extract` while `bincio dev` was running
required a manual restart to pick up changes. Now a background watcher thread re-merges
automatically.
- **`bincio/dev.py`** — new `_watch_data(data)` function, started as a daemon thread
alongside `bincio serve`. Uses `watchfiles` (already bundled with `uvicorn[standard]`,
no new dependency) for OS-level file event watching — no polling.
- Watches every `{user_dir}/edits/` and `{user_dir}/activities/` directory.
- On any change, identifies which users were affected and calls `merge_all(user_dir)`
for each.
- Skips churn files written by merge itself (`.timeseries.json`, `.geojson`,
`index.json`) to avoid re-triggering.
- Prints `↺ {handle}: merged` on each successful re-merge; warns on failure.
- Astro dev picks up the result automatically since `public/data` is a symlink into
the live data directory.
---
### Tests
- **`tests/test_server_imports.py`** (new) — smoke tests that import `bincio.serve.server`
and `bincio.edit.server` at module level, catching `NameError`, missing imports, and
syntax errors before they reach the runtime. Also asserts that key routes (`/api/me`,
`/api/upload`, `/api/strava/sync`, `/api/register`, `/api/activity/{activity_id}`) are
registered on each app.
---
## [0.1.0-dev] — 2026-04-06
### New feature — Strava sync from UI
@@ -37,7 +373,7 @@ bincio edit --strava-client-id YOUR_ID --strava-client-secret YOUR_SECRET
`site/.env`, regardless of whether the edit server is running. This is intentional — the env
var is the "edit mode enabled" flag. Remove it from `.env` to hide the button.
## [Unreleased] — 2026-04-01
## [0.1.0-dev] — 2026-04-01
### Security fixes (second-pass audit)
-265
View File
@@ -1,265 +0,0 @@
# BincioActivity — Context for Claude
## What this project is
BincioActivity is a federated, open-source, self-hosted activity stats platform
(think personal Strava). Two-stage pipeline:
1. **`bincio extract`** (Python): GPX/FIT/TCX → BAS JSON data store
2. **`bincio render`** (Astro/Node): BAS data store → static website
The BAS (BincioActivity Schema) JSON files are the federation protocol.
Anyone can publish their data as BAS JSON and others can include it.
## Key design decisions
- **Unified data layout** — single-user and multi-user share the same structure: activities always live in `{data-root}/{handle}/`. The only difference is the presence of `instance.db` (auth). No mode switching, no migration.
- **No database, no server** — everything is static files; multi-user VPS mode adds SQLite auth only
- **Python with uv** for the extract stage
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
- **Haversine** (not geopy) for distance calculations (10x faster)
- **Worker initializer pattern** for ProcessPoolExecutor — large shared data
(strava_lookup dict, known_hashes frozenset) is sent once per worker via
`initializer=`, not once per task
- **BAS activity IDs** always use UTC with Z suffix for URL safety
- **TCX files** from Garmin use both `http://` and `https://` namespace URIs —
parser handles both
- **Shard manifest for multi-user** — no activity data duplication; root `index.json`
lists user shard URLs; browser resolves all shards concurrently; same mechanism
handles yearly pagination and remote federation
- **Iterative RDP** implemented inline in `simplify.py` — no `rdp` PyPI package
(not available as a pure-Python wheel for Pyodide)
## Your data
- Source: `~/your-activity-data/`
- `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants)
- Any subdirectories with FIT files from Garmin/Karoo devices
- `activities.csv` — Strava metadata (names, descriptions, gear)
- Extracted output: `~/bincio_data/` (or `/tmp/bincio_test/` for testing)
Configure input paths in `extract_config.yaml`.
## Project structure
```
bincio/ Python package
extract/
models.py DataPoint, ParsedActivity, LapData
parsers/ GPX, FIT, TCX parsers + factory
sport.py sport name normalisation
metrics.py haversine-based stats computation (single pass)
timeseries.py downsample to 1Hz, build BAS timeseries object
simplify.py RDP track simplification → GeoJSON (iterative, no rdp dep)
dedup.py exact (hash) + near-duplicate detection
strava_csv.py Strava activities.csv importer
writer.py BAS JSON + GeoJSON writer
config.py extract_config.yaml loader
cli.py `bincio extract` CLI
render/
cli.py `bincio render` CLI (symlinks data, runs astro build/dev)
merge.py sidecar edit overlay (produces _merged/)
edit/
cli.py `bincio edit` CLI (single-user local only)
server.py FastAPI write API for the edit drawer
serve/
cli.py `bincio serve` CLI (multi-user VPS)
server.py FastAPI: auth, user mgmt, write API (auth-gated)
db.py SQLite data layer (users, sessions, invites)
init_cmd.py `bincio init` CLI: bootstrap instance.db + admin user
schema/
bas-v1.schema.json JSON Schema for BAS
SCHEMA.md Human-readable BAS spec
site/ Astro project
src/
layouts/Base.astro Reads instancePrivate from index.json; injects auth wall
pages/
index.astro Activity feed (loads index.json client-side)
activity/[id].astro Single activity (SSG, loads detail JSON client-side)
activity/local/ IDB-only activities (converted locally via Pyodide)
stats/index.astro Heatmap + year totals
u/[handle].astro Per-user profile pages (multi-user)
login/index.astro Login form (public page)
register/index.astro Registration with invite code (public page)
invites/index.astro Invite management
convert/index.astro Local file conversion via Pyodide (browser-only)
components/
ActivityFeed.svelte Card grid, sport filter, pagination
ActivityDetail.svelte Map + stats + charts + photo gallery
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
StatsView.svelte Yearly heatmap + totals
EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set)
LocalActivityDetail.svelte Detail view for IDB-only (locally converted) activities
lib/
types.ts BAS TypeScript types
format.ts formatDistance, formatDuration, sportIcon, etc.
localstore.ts IndexedDB store for locally converted activities
dataloader.ts Fetches index.json, resolves shards recursively
```
## How to run
```bash
# Single-user (no login)
cd ~/src/bincio_activity
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/{handle}/
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321/u/{handle}/
# Multi-user (with login)
uv run bincio init --data-dir /tmp/bincio_test --handle dave
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/dave/
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321 (login required)
# bincio dev does everything: merges sidecars, writes manifest,
# symlinks public/data, starts bincio serve (if instance.db exists),
# starts astro dev. Ctrl+C stops all.
# Tests
uv run pytest
```
## MapLibre GL + Vite/Astro — known gotchas
Learnt the hard way during debugging (March 2026):
- **`maplibregl.workerUrl = ...` is the v3 API and silently no-ops in v4+.**
The v5 API is `maplibregl.setWorkerUrl(url)`, but you don't need it at all in a
normal Vite environment — MapLibre handles the blob worker automatically.
- **`optimizeDeps: { exclude: ['maplibre-gl'] }` breaks tile loading.**
It prevents Vite from converting MapLibre's UMD bundle to ESM. The UMD bundle
uses AMD `define()` internally; served raw, the tile worker blob fails silently →
black map, no tiles. The correct setting is `include: ['maplibre-gl']`.
- **`build.target: 'es2022'` (and `optimizeDeps.esbuildOptions.target`) is required.**
MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it,
helpers like `__publicField` aren't available inside the serialised worker blob
scope → tile loading fails. This is a known upstream issue (maplibre-gl-js #6680).
- **Use static imports, not dynamic `await import('maplibre-gl')`, when possible.**
With `client:only="svelte"` in Astro, SSR never runs for the component so there is
no `window is not defined` risk. Static import lets Vite pre-bundle correctly.
- **Use `client:only="svelte"` (not `client:load`) for the activity detail page.**
`client:load` does SSR + hydration; complex interactive components with MapLibre
can hit hydration mismatch issues. `client:only` mounts fresh on the client only.
- **MapLibre v5 requires explicit `center` and `zoom` in the Map constructor.**
v4 silently defaulted to `center: [0,0], zoom: 0`. v5 leaves internal projection
state undefined → `Cannot read properties of undefined (reading 'lng')` crashes
on any operation that touches coordinates (markers, resize, render). Always pass
`center` and `zoom` even if you plan to `fitBounds` later.
- **MapLibre v5 requires `setLngLat()` on markers before `.addTo(map)`.**
v4 tolerated markers without coordinates. v5 calls `Marker._update()` inside
`addTo()`, which needs valid lngLat → same `'lng'` crash. Set a dummy `[0, 0]`
if the real position arrives later (e.g. hover markers).
## Observable Plot — known gotchas
- **Curve names are hyphenated, not camelCase.**
Use `"monotone-x"`, not `"monotoneX"`. Plot uses its own curve name registry
(not raw d3 identifiers). Wrong names throw `unknown curve` at runtime.
The working `astro.config.mjs` Vite section:
```js
vite: {
optimizeDeps: {
include: ['maplibre-gl'],
esbuildOptions: { target: 'es2022' },
},
build: { target: 'es2022' },
},
```
## Activity sidecar edits — design spec
Users edit activities via **sidecar markdown files** in the data dir.
No database, no server — consistent with the project's static-files-only philosophy.
### File naming
```
~/bincio_data/
activities/{id}.json ← immutable extract output
edits/{id}.md ← user edits (sidecar)
edits/images/{id}/ ← uploaded photos
_merged/ ← render-time merge output (gitignored-style)
```
### Sidecar format
```markdown
---
title: "Epic climb up Monte Grappa"
sport: cycling
hide_stats: [cadence]
highlight: true
private: false
gear: "Trek Domane"
---
Rode with friends. Legs felt great after the rest week...
```
### Editing UX: drawer in Astro + `bincio edit` write API
- `bincio edit --data-dir ~/bincio_data` starts a FastAPI server on port 4041
- Set `PUBLIC_EDIT_URL=http://localhost:4041` in `site/.env` to enable the edit button
- Clicking Edit on any activity detail page opens a slide-in drawer
- Saving writes the sidecar and triggers `merge_all()` automatically
- `bincio render` always runs `merge_all()` before build/serve and symlinks `public/data``_merged/`
### `PUBLIC_EDIT_URL` as feature flag
- **Unset** → no Edit button, normal static site
- **Set** → edit drawer enabled; lives in `site/.env` (gitignored)
## Multi-user VPS architecture
`bincio serve` is a FastAPI app that owns auth and write ops. nginx proxies `/api/*` to it; static files are served by nginx directly. The Vite dev server replicates this proxy for local testing.
Key facts:
- Session cookie: `bincio_session`, httpOnly, SameSite=Lax, 30-day max-age
- Rate limiting: 10 login attempts / 15 min / IP (in-memory, resets on restart)
- Invite limits: admins unlimited, regular users 3 each (`_MAX_USER_INVITES` in `db.py`)
- Instance privacy: `instance.private=true` in root `index.json``Base.astro` injects a
`fetch('/api/me')` auth wall; `/login/` and `/register/` have `public={true}` to skip it
- Incremental rebuild: `POST /api/activity/{id}` triggers `bincio render --handle {user}`
as a fire-and-forget subprocess (only if `--site-dir` was passed to `bincio serve`)
- Write API in `bincio serve` delegates to `bincio.edit.server._apply_sidecar_edit`; the
Strava sync delegates to `bincio.edit.server.strava_sync` with a temporary data_dir swap
## Known issues / next steps
- `bincio render --watch` mode not yet implemented
- Activity IDs in older test data may use `+0000` format (pre-fix); re-run extract to get `Z` format
- Some activities appear with both untitled and titled IDs (near-dedup timing race)
- Remote federation (remote shard URLs in root manifest) is parsed but not yet displayed with attribution in the UI
- The `site/.env` file is gitignored — copy from `site/.env.example`
## What "good" looks like (not yet done)
- [ ] Friends/federation pages in site (remote shard attribution)
- [ ] Personal records page
- [ ] Activity search / full-text filter in feed
- [ ] GitHub Actions template for auto-publish
- [ ] Karoo/Garmin Connect importers beyond Strava
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
- [ ] Highlight badge in activity feed cards
- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort
- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site
- [x] `PUBLIC_EDIT_URL` feature flag
- [x] Markdown rendering in activity description with image path rewriting
- [x] Photo gallery with lightbox on activity detail page
- [x] `bincio serve` — multi-user VPS server (auth, invites, write API)
- [x] `bincio init` — instance bootstrap (SQLite, admin user, root manifest)
- [x] Login, register, invites pages
- [x] Per-user profile pages (`/u/{handle}/`)
- [x] Instance privacy (auth wall, private-by-default)
- [x] Shard-based combined feed (no duplication, concurrent resolution)
- [x] Local file conversion via Pyodide (`/convert/` page, IDB storage)
+1 -2
View File
@@ -203,7 +203,7 @@ Privacy is enforced at extract time. A `private` activity never enters `index.js
`index.json` is everything the feed page needs — no extra fetches until you open an activity. `{id}.json` contains the full timeseries (elevation, speed, HR, cadence, power at 1 Hz) for charts and the detail map. Both are human-readable and editable with any text editor.
See [SCHEMA.md](SCHEMA.md) for the full specification.
See [SCHEMA.md](docs/schema.md) for the full specification.
---
@@ -293,7 +293,6 @@ bincio/ Python package
server.py FastAPI write API (activity edits, image + file upload)
schema/
bas-v1.schema.json JSON Schema for BAS format
SCHEMA.md Human-readable BAS specification
site/ Astro project
src/
pages/
+2
View File
@@ -18,6 +18,7 @@ from bincio.import_.cli import import_group # noqa: E402
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
main.add_command(extract)
main.add_command(render)
@@ -26,3 +27,4 @@ main.add_command(import_group)
main.add_command(init)
main.add_command(serve)
main.add_command(dev)
main.add_command(reextract_originals)
+60
View File
@@ -86,6 +86,62 @@ def _start_serve(data: Path, api_port: int, site: Path) -> None:
server.run()
def _watch_data(data: Path) -> None:
"""Watch the data directory for sidecar/activity changes and re-merge.
Monitors every user's edits/ and activities/ subdirectories. When any file
changes (new activity extracted, sidecar saved), re-runs merge_all for that
user so the _merged/ symlink tree stays current. Astro dev picks up the
result automatically because public/data is a symlink into the live data dir.
Uses watchfiles (bundled with uvicorn[standard]) for efficient OS-level
file watching — no polling.
"""
from watchfiles import watch, Change
watch_paths = []
for user_dir in _user_dirs(data):
for sub in ("edits", "activities"):
p = user_dir / sub
p.mkdir(exist_ok=True)
watch_paths.append(p)
if not watch_paths:
return
console.print(f" [dim]Watching {len(watch_paths)} director{'y' if len(watch_paths) == 1 else 'ies'} for changes…[/dim]")
# Build a map from path prefix → user dir for targeted merge
prefix_to_user: dict[str, Path] = {}
for user_dir in _user_dirs(data):
for sub in ("edits", "activities"):
prefix_to_user[str(user_dir / sub)] = user_dir
for changes in watch(*watch_paths, yield_on_timeout=False):
# Find which users were affected
affected: set[Path] = set()
for change_type, path in changes:
# Skip timeseries / geojson / index churn written by merge itself
if any(path.endswith(s) for s in (".timeseries.json", ".geojson", "index.json")):
continue
for prefix, user_dir in prefix_to_user.items():
if path.startswith(prefix):
affected.add(user_dir)
break
if not affected:
continue
for user_dir in affected:
handle = user_dir.name
try:
from bincio.render.merge import merge_all
merge_all(user_dir)
console.print(f" [dim]↺ {handle}: merged[/dim]")
except Exception as exc:
console.print(f" [yellow]⚠ {handle}: merge failed — {exc}[/yellow]")
@click.command("dev")
@click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)")
@click.option("--site-dir", default=None, help="Astro project directory (default: ./site)")
@@ -144,6 +200,10 @@ def dev(
t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True)
t.start()
# Watch data dir for sidecar/activity changes → auto-merge
watcher = threading.Thread(target=_watch_data, args=(data,), daemon=True)
watcher.start()
# Build env for astro dev
env = {
**os.environ,
+5
View File
@@ -24,6 +24,8 @@ console = Console()
help="Strava API client ID (enables Strava sync in the UI). Also reads STRAVA_CLIENT_ID env var.")
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET",
help="Strava API client secret. Also reads STRAVA_CLIENT_SECRET env var.")
@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 edit(
data_dir: Optional[str],
port: int,
@@ -31,6 +33,7 @@ def edit(
config_path: Optional[str],
strava_client_id: Optional[str],
strava_client_secret: Optional[str],
dem_url: Optional[str],
) -> None:
"""Start a local web UI for editing activity sidecar files.
@@ -69,11 +72,13 @@ def edit(
srv.site_url = site_url
srv.strava_client_id = strava_client_id or ""
srv.strava_client_secret = strava_client_secret or ""
srv.dem_url = dem_url or ""
if strava_client_id:
console.print(f"Strava sync: [green]enabled[/green] (client {strava_client_id})")
else:
console.print("Strava sync: [yellow]disabled[/yellow] (pass --strava-client-id to enable)")
console.print(f"DEM: [cyan]{srv.dem_url}[/cyan]")
uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
+10 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from typing import Any, Optional
# ── Shared constants (imported by edit/server.py and serve/server.py) ─────────
@@ -58,13 +58,20 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
merge_one(data_dir, activity_id)
def run_strava_sync(data_dir: Path, client_id: str, client_secret: str) -> dict[str, Any]:
def run_strava_sync(
data_dir: Path,
client_id: str,
client_secret: str,
originals_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Fetch new Strava activities and write them into data_dir.
Args:
data_dir: Per-user data directory.
client_id: Strava OAuth client ID.
client_secret: Strava OAuth client secret.
originals_dir: If set, raw Strava API data (meta + streams) is saved here
as JSON files for potential future reprocessing.
Returns:
Dict with keys: ok, imported, skipped, error_count, errors.
@@ -75,7 +82,7 @@ def run_strava_sync(data_dir: Path, client_id: str, client_secret: str) -> dict[
from bincio.extract.ingest import strava_sync as _strava_sync
from bincio.render.merge import merge_all
result = _strava_sync(data_dir, client_id, client_secret)
result = _strava_sync(data_dir, client_id, client_secret, originals_dir=originals_dir)
if result["imported"]:
merge_all(data_dir)
+224 -59
View File
@@ -3,14 +3,15 @@
from __future__ import annotations
import json
import secrets
import shutil
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID
@@ -19,6 +20,10 @@ data_dir: Path | None = None
site_url: str = "http://localhost:4321"
strava_client_id: str = ""
strava_client_secret: str = ""
dem_url: str = "https://api.open-elevation.com" # Open-Elevation-compatible API base URL
# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use
_oauth_states: set[str] = set()
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
@@ -38,6 +43,21 @@ 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
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 = """\
@@ -164,7 +184,7 @@ textarea { resize: vertical; min-height: 140px; }
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
</label>
<label class="toggle" id="toggle-private">
<input type="checkbox" id="private" name="private"> Private (hide from feed)
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
</label>
</div>
</div>
@@ -388,7 +408,7 @@ async def get_activity(activity_id: str) -> JSONResponse:
"gear": fm.get("gear", detail.get("gear") or ""),
"description": body or fm.get("description") or detail.get("description") or "",
"highlight": fm.get("highlight", detail.get("custom", {}).get("highlight", False)),
"private": fm.get("private", detail.get("privacy") == "private"),
"private": fm.get("private", detail.get("privacy") in ("private", "unlisted")),
"hide_stats": fm.get("hide_stats", detail.get("custom", {}).get("hide_stats", [])),
"images": images,
})
@@ -408,6 +428,45 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
@app.post("/api/activity/{activity_id}/recalculate-elevation/dem")
async def recalculate_elevation_dem_endpoint(activity_id: str) -> JSONResponse:
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
Requires --dem-url to be set when starting bincio edit.
"""
if not dem_url:
raise HTTPException(503, "DEM URL not configured.")
dd = _get_data_dir()
_check_id(activity_id)
try:
from bincio.extract.dem import recalculate_elevation
from bincio.render.merge import merge_one
result = recalculate_elevation(dd, activity_id, dem_url)
merge_one(dd, activity_id)
return JSONResponse(result)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(422, str(e))
@app.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis")
async def recalculate_elevation_hysteresis_endpoint(activity_id: str) -> JSONResponse:
"""Recompute gain/loss from original recorded elevation using source-aware hysteresis."""
dd = _get_data_dir()
_check_id(activity_id)
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)
return JSONResponse(result)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(422, str(e))
@app.post("/api/activity/{activity_id}/images")
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
dd = _get_data_dir()
@@ -419,14 +478,15 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe
images_dir = dd / "edits" / "images" / activity_id
images_dir.mkdir(parents=True, exist_ok=True)
safe_name = Path(file.filename).name
# Only allow image content types
ct = file.content_type or ""
if not ct.startswith("image/"):
raise HTTPException(400, f"Only image files are accepted (got {ct})")
dest = images_dir / safe_name
dest.write_bytes(await file.read())
return JSONResponse({"ok": True, "filename": dest.name})
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)")
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
(images_dir / safe_name).write_bytes(contents)
return JSONResponse({"ok": True, "filename": safe_name})
@app.get("/api/athlete")
@@ -517,63 +577,115 @@ def _file_suffix(name: str) -> str:
@app.post("/api/upload")
async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
"""Accept a FIT/GPX/TCX file, extract it, update index.json, and re-merge."""
async def upload_activity(
files: list[UploadFile] = File(...),
store_original: bool = Form(False),
) -> 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)
"""
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
dd = _get_data_dir()
name = Path(file.filename or "upload.fit").name # strip any path components
suffix = _file_suffix(name)
if suffix not in _SUPPORTED_SUFFIXES:
raise HTTPException(400, f"Unsupported file type '{Path(name).suffix}'. Expected FIT, GPX, or TCX.")
_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
contents = await file.read()
if len(contents) > _MAX_UPLOAD_BYTES:
raise HTTPException(413, f"File too large ({len(contents)} bytes). Maximum is 50 MB.")
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]] = []
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 found (activities.csv from Strava export)
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)
def event_stream():
added = 0
duplicates = 0
errors = 0
any_added = False
for n, (name, contents) in enumerate(activity_items, 1):
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) > _MAX_UPLOAD_BYTES:
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:
from bincio.extract.metrics import compute
from bincio.extract.parsers.factory import parse_file
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
activity = parse_file(staged)
metrics = compute(activity)
if metadata is not None:
metadata.enrich(name, activity)
activity_id = make_activity_id(activity)
existing_json = dd / "activities" / f"{activity_id}.json"
if existing_json.exists():
raise HTTPException(409, f"Activity already exists: {activity_id}")
write_activity(activity, metrics, dd, privacy="public", rdp_epsilon=0.0001)
summary = build_summary(activity, metrics, activity_id, "public")
# Read current index to preserve owner + existing summaries
index_path = dd / "index.json"
if index_path.exists():
index_data = json.loads(index_path.read_text(encoding="utf-8"))
else:
index_data = {"owner": {"handle": "unknown"}, "activities": []}
owner = index_data.get("owner", {})
existing = {s["id"]: s for s in index_data.get("activities", [])}
existing[activity_id] = summary
write_index(list(existing.values()), dd, owner)
from bincio.render.merge import merge_all
merge_all(dd)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(422, f"Failed to process activity file: {type(exc).__name__}")
if (dd / "activities" / f"{activity_id}.json").exists():
duplicates += 1
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
continue
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
added += 1
any_added = True
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'imported'})}\n\n"
except Exception:
errors += 1
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error'})}\n\n"
finally:
if not kept:
staged.unlink(missing_ok=True)
return JSONResponse({"ok": True, "id": activity_id})
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)
yield f"data: {json.dumps({'type': 'done', 'added': added, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/import-bas")
@@ -667,16 +779,21 @@ async def strava_auth_url(request: Request) -> JSONResponse:
"""Return the Strava OAuth URL the browser should open."""
if not strava_client_id:
raise HTTPException(400, "Strava client ID not configured. Pass --strava-client-id to bincio edit.")
state = secrets.token_urlsafe(16)
_oauth_states.add(state)
redirect_uri = str(request.url_for("strava_callback"))
from bincio.extract.strava_api import auth_url
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri)})
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)})
@app.get("/api/strava/callback", name="strava_callback")
async def strava_callback(code: str = "", error: str = "") -> RedirectResponse:
async def strava_callback(code: str = "", error: str = "", state: str = "") -> RedirectResponse:
"""Strava OAuth callback — exchange code for token then redirect to the site."""
if error or not code:
return RedirectResponse(f"{site_url}?strava=error")
if state not in _oauth_states:
return RedirectResponse(f"{site_url}?strava=error")
_oauth_states.discard(state)
if not strava_client_id or not strava_client_secret:
return RedirectResponse(f"{site_url}?strava=error")
dd = _get_data_dir()
@@ -747,3 +864,51 @@ async def strava_reset(request: Request) -> JSONResponse:
token["last_sync_at"] = last_ts
save_token(dd, token)
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
@app.post("/api/upload/strava-zip")
async def upload_strava_zip(
file: UploadFile = File(...),
private: str = Form(default="false"),
) -> 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.
"""
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(400, "Please upload a .zip file")
privacy = "private" if private.lower() in ("true", "1", "yes") else "public"
dd = _get_data_dir()
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd)
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
def event_stream():
any_imported = False
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" and event.get("status") == "imported":
any_imported = True
if event.get("type") == "done" and any_imported:
merge_all(dd)
except Exception as exc:
zip_path.unlink(missing_ok=True)
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"},
)
+1 -1
View File
@@ -171,7 +171,7 @@ def extract(
dedup = DedupIndex(output_dir=cfg.output_dir)
known_hashes: frozenset = frozenset(dedup._by_hash.keys())
n_workers = workers or os.cpu_count() or 4
n_workers = workers or cfg.workers or os.cpu_count() or 4
console.print(f"Using [bold]{n_workers}[/bold] worker processes.")
owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name}
+2
View File
@@ -51,6 +51,7 @@ class ExtractConfig:
track: TrackConfig = field(default_factory=TrackConfig)
classifier: ClassifierConfig = field(default_factory=ClassifierConfig)
incremental: bool = True
workers: Optional[int] = None # None → use CPU count
owner_handle: str = "me"
owner_display_name: str = "Me"
athlete: AthleteConfig | None = None
@@ -109,6 +110,7 @@ def load_config(path: Path) -> ExtractConfig:
track=track,
classifier=classifier,
incremental=raw.get("incremental", True),
workers=raw.get("workers"),
owner_handle=owner.get("handle", "me"),
owner_display_name=owner.get("display_name", "Me"),
athlete=athlete,
+382
View File
@@ -0,0 +1,382 @@
"""DEM (Digital Elevation Model) lookup and elevation recalculation.
Queries any Open-Elevation-compatible HTTP API to replace noisy GPS altitude
with terrain altitude, then re-applies hysteresis-based accumulation.
Compatible APIs:
- https://api.open-elevation.com (free, SRTM, accepts large batches)
- https://api.opentopodata.org/v1/srtm30m (more reliable, max 100 pts/req)
Pass the base URL (without path) to bincio serve/edit via --dem-url.
"""
from __future__ import annotations
import json
import statistics
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
# 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.
_DEFAULT_SAMPLE_INTERVAL_S = 10
# Maximum locations per API request. Open-Elevation supports ~512 per POST.
_DEFAULT_BATCH_SIZE = 512
# Hysteresis threshold after DEM correction.
# SRTM30 at 1 Hz produces tile-boundary steps of ~13 m every few seconds.
# A 5 m threshold barely filters them; 10 m suppresses them reliably.
_DEM_HYSTERESIS_M = 10.0
# Median filter window (seconds / samples at 1 Hz) applied to DEM-interpolated
# series before hysteresis. 45 s smooths SRTM tile steps while keeping real
# climbs (typical cycling ramp > 100 m over > 2 min).
_MEDIAN_WINDOW_S = 60
# Moving-average window (seconds) applied to the 1 Hz elevation series before
# hysteresis in the on-demand recalculation. Pre-smoothing lets us use a
# much lower dead-band (capturing real small climbs) while still suppressing
# GPS jitter and barometric quantization noise.
_MA_WINDOW_S = 30
def _moving_average(values: list[float], window: int) -> list[float]:
"""Apply a centred sliding-window moving average to *values*.
Edge handling: window shrinks symmetrically at both ends (same effective
behaviour as scipy's 'nearest' / numpy's 'reflect' mode).
"""
half = window // 2
n = len(values)
out: list[float] = []
cumsum = [0.0] * (n + 1)
for i, v in enumerate(values):
cumsum[i + 1] = cumsum[i] + v
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
out.append((cumsum[hi] - cumsum[lo]) / (hi - lo))
return out
def _median_filter(values: list[float], window: int) -> list[float]:
"""Apply a sliding-window median filter to *values*.
The window is centred on each sample; edges are handled by shrinking the
window symmetrically (same as scipy's 'reflect' / 'nearest' default).
"""
half = window // 2
n = len(values)
out: list[float] = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
out.append(statistics.median(values[lo:hi]))
return out
def lookup_elevations(
latlons: list[tuple[float, float]],
api_url: str,
batch_size: int = _DEFAULT_BATCH_SIZE,
timeout_s: int = 30,
) -> list[Optional[float]]:
"""Query a DEM API for terrain elevation at the given (lat, lon) pairs.
Uses the Open-Elevation API format::
POST {api_url}/api/v1/lookup
{"locations": [{"latitude": lat, "longitude": lon}, ...]}
Returns a list the same length as *latlons*. Elements are ``None``
wherever the API returned no data (network error, ocean, etc.).
"""
if not latlons:
return []
results: list[Optional[float]] = [None] * len(latlons)
url = f"{api_url.rstrip('/')}/api/v1/lookup"
for start in range(0, len(latlons), batch_size):
batch = latlons[start : start + batch_size]
payload = json.dumps(
{"locations": [{"latitude": lat, "longitude": lon} for lat, lon in batch]}
).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
data = json.loads(resp.read().decode("utf-8"))
for i, item in enumerate(data.get("results", [])):
elev = item.get("elevation")
if elev is not None:
results[start + i] = float(elev)
except (urllib.error.URLError, json.JSONDecodeError, KeyError, ValueError):
pass # leave this batch as None; caller checks overall coverage
return results
def _hysteresis_gain_loss(
elevations: list[float], threshold_m: float
) -> tuple[float, float]:
"""Compute elevation gain and loss using a hysteresis dead-band.
Only commits a new elevation level when it differs from the last committed
value by at least *threshold_m*. Returns (gain, loss) in metres, both
as positive numbers.
"""
gain = loss = 0.0
committed = elevations[0]
for e in elevations[1:]:
diff = e - committed
if abs(diff) >= threshold_m:
if diff > 0:
gain += diff
else:
loss += abs(diff)
committed = e
return gain, loss
def recalculate_elevation(
user_dir: Path,
activity_id: str,
dem_url: str,
sample_interval_s: int = _DEFAULT_SAMPLE_INTERVAL_S,
) -> dict:
"""Replace GPS elevation with DEM terrain elevation and recompute gain/loss.
Algorithm
---------
1. Read the activity's 1 Hz timeseries for lat / lon / t arrays.
2. Subsample GPS points every *sample_interval_s* seconds.
3. Query the DEM API for those points (batched).
4. Linearly interpolate DEM elevation back to every GPS-valid second.
5. Apply a 45 s median filter to smooth SRTM tile-boundary noise.
6. Apply :data:`_DEM_HYSTERESIS_M` (10 m) hysteresis to compute gain/loss.
7. Preserve the original elevation as ``elevation_m_original`` in the
timeseries (only on the first DEM run — never overwrites a prior backup).
8. Write the smoothed DEM elevation array as ``elevation_m``.
9. Patch ``elevation_gain_m`` / ``elevation_loss_m`` in the detail JSON.
10. Patch ``elevation_gain_m`` in ``index.json`` (summary entry for feed).
Returns
-------
dict with keys ``elevation_gain_m``, ``elevation_loss_m``,
``points_queried`` (DEM responses received).
Raises
------
FileNotFoundError
Activity detail or timeseries file not found.
ValueError
Activity has no GPS coordinates, or the DEM API returned too few results.
"""
acts_dir = user_dir / "activities"
json_path = acts_dir / f"{activity_id}.json"
ts_path = acts_dir / f"{activity_id}.timeseries.json"
if not json_path.exists():
raise FileNotFoundError(f"Activity not found: {activity_id}")
if not ts_path.exists():
raise ValueError("Activity has no timeseries data")
ts = json.loads(ts_path.read_text(encoding="utf-8"))
lat_arr: list[Optional[float]] = ts.get("lat") or []
lon_arr: list[Optional[float]] = ts.get("lon") or []
t_arr: list[int] = ts.get("t") or []
if not lat_arr or not lon_arr:
raise ValueError(
"Activity has no GPS coordinates "
"(privacy=no_gps or data recorded without GPS)"
)
n = len(t_arr)
# ── 1. Subsample GPS-valid indices ────────────────────────────────────────
gps_idx = [
i for i in range(n)
if lat_arr[i] is not None and lon_arr[i] is not None
]
if len(gps_idx) < 2:
raise ValueError("Too few GPS points for DEM lookup")
sampled_idx = gps_idx[::sample_interval_s]
if gps_idx[-1] not in sampled_idx:
sampled_idx.append(gps_idx[-1]) # always include the last point
# ── 2. Query DEM ──────────────────────────────────────────────────────────
latlons = [(float(lat_arr[i]), float(lon_arr[i])) for i in sampled_idx] # type: ignore[arg-type]
dem_elev = lookup_elevations(latlons, dem_url)
# Build (t, elevation) pairs for valid DEM responses only
valid_pairs: list[tuple[int, float]] = [
(t_arr[sampled_idx[k]], dem_elev[k])
for k in range(len(sampled_idx))
if dem_elev[k] is not None
]
n_queried = len(valid_pairs)
if n_queried < 2:
raise ValueError(
f"DEM API returned too few results "
f"({n_queried} of {len(sampled_idx)} points). "
f"Check the DEM URL: {dem_url}"
)
# ── 3. Linear interpolation → full 1 Hz series ───────────────────────────
new_ele: list[Optional[float]] = [None] * n
j = 0
for i in gps_idx:
t = t_arr[i]
while j + 1 < len(valid_pairs) - 1 and valid_pairs[j + 1][0] <= t:
j += 1
t0, e0 = valid_pairs[j]
if j + 1 < len(valid_pairs):
t1, e1 = valid_pairs[j + 1]
frac = max(0.0, min(1.0, (t - t0) / (t1 - t0))) if t1 > t0 else 0.0
new_ele[i] = round(e0 + frac * (e1 - e0), 1)
else:
new_ele[i] = round(e0, 1)
# ── 4. Median filter to suppress SRTM tile-boundary steps ────────────────
valid_indices = [i for i, e in enumerate(new_ele) if e is not None]
valid_eles_raw = [new_ele[i] for i in valid_indices] # type: ignore[misc]
smoothed = _median_filter(valid_eles_raw, _MEDIAN_WINDOW_S) # type: ignore[arg-type]
# Write smoothed values back into new_ele
for idx, e in zip(valid_indices, smoothed):
new_ele[idx] = round(e, 1)
# ── 5. Hysteresis accumulation on smoothed series ─────────────────────────
smoothed_valid = [e for e in new_ele if e is not None]
gain, loss = _hysteresis_gain_loss(smoothed_valid, _DEM_HYSTERESIS_M) # type: ignore[arg-type]
gain_r = round(gain, 1)
loss_r = round(loss, 1)
# ── 6. Preserve original elevation (only if not already backed up) ────────
if "elevation_m_original" not in ts:
ts["elevation_m_original"] = ts.get("elevation_m")
# ── 7. Write timeseries ───────────────────────────────────────────────────
ts["elevation_m"] = new_ele
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
# ── 8. Patch activity detail JSON ─────────────────────────────────────────
detail = json.loads(json_path.read_text(encoding="utf-8"))
detail["elevation_gain_m"] = gain_r
detail["elevation_loss_m"] = loss_r
json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8")
# ── 9. 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"
)
return {
"elevation_gain_m": gain_r,
"elevation_loss_m": loss_r,
"points_queried": n_queried,
}
def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
"""Recompute elevation gain/loss from the original recorded elevation data.
Algorithm
---------
1. Read ``elevation_m_original`` (backup from a prior DEM run) if present,
otherwise read ``elevation_m`` from the timeseries.
2. Apply a :data:`_MA_WINDOW_S` (30 s) moving average to smooth out
barometric quantization steps and GPS jitter.
3. Apply a low dead-band threshold to the smoothed series:
- **1 m** for barometric altimeters (FIT files with ``enhanced_altitude``)
- **3 m** for GPS-derived altitude (GPX, TCX, FIT without enhanced_altitude)
The 30 s pre-smoothing makes the low thresholds safe: after averaging,
0.2 m barometric quantization noise and short-period GPS jitter are
suppressed below the threshold, while real terrain changes (which persist
across the window) are preserved.
The elevation array in the timeseries is **not** modified — only the
summary stats in the detail JSON and ``index.json`` are patched.
``altitude_source`` is read from the detail JSON (written by the extractor
for activities recorded after this field was added). For older activities
it falls back to ``"unknown"`` → 3 m GPS threshold.
Returns
-------
dict with keys ``elevation_gain_m``, ``elevation_loss_m``,
``threshold_m``, ``altitude_source``.
"""
acts_dir = user_dir / "activities"
json_path = acts_dir / f"{activity_id}.json"
ts_path = acts_dir / f"{activity_id}.timeseries.json"
if not json_path.exists():
raise FileNotFoundError(f"Activity not found: {activity_id}")
if not ts_path.exists():
raise ValueError("Activity has no timeseries data")
ts = json.loads(ts_path.read_text(encoding="utf-8"))
# Prefer the pre-DEM backup; fall back to the current elevation array
ele_arr: list[Optional[float]] = (
ts.get("elevation_m_original") or ts.get("elevation_m") or []
)
elevations = [e for e in ele_arr if e is not None]
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
# Pre-smooth to suppress noise, then accumulate with low dead-band
smoothed = _moving_average(elevations, _MA_WINDOW_S)
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
gain_r = round(gain, 1)
loss_r = round(loss, 1)
# Patch detail JSON
detail["elevation_gain_m"] = gain_r
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"
)
return {
"elevation_gain_m": gain_r,
"elevation_loss_m": loss_r,
"threshold_m": threshold,
"altitude_source": altitude_source,
}
+225
View File
@@ -0,0 +1,225 @@
"""Garmin Connect credential storage and client factory.
Credential storage layout
─────────────────────────
{data_dir.parent}/.garmin_key ← Fernet key (outside nginx webroot, chmod 600)
{user_dir}/garmin_creds.json ← encrypted email + password
{user_dir}/garmin_session/ ← garth OAuth token directory (plain JSON, short-lived)
Security model
──────────────
- The Fernet key lives one directory above the data root, which nginx does NOT serve.
For a standard VPS install: data_dir = /var/bincio/data/ → key at /var/bincio/.garmin_key.
- Credentials are encrypted with that key before being written to disk.
- The garth session directory holds OAuth tokens (not the user's password).
These expire independently and are refreshed automatically by the library.
- If the session expires and re-login is needed, the stored credentials are decrypted
and used automatically — the user does not need to re-enter them.
DISCLAIMER
──────────
This module uses the unofficial `garminconnect` library.
See docs/garmin_connect_disclaimer.md before shipping this feature to users.
"""
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
from typing import Optional
# ── Constants ─────────────────────────────────────────────────────────────────
_CREDS_FILE = "garmin_creds.json"
_SESSION_DIR = "garmin_session"
_KEY_FILENAME = ".garmin_key"
class GarminError(Exception):
pass
# ── Encryption key management ─────────────────────────────────────────────────
def _key_path(data_dir: Path) -> Path:
"""Return the path to the Fernet key file (one level above data_dir)."""
return data_dir.parent / _KEY_FILENAME
def _get_or_create_key(data_dir: Path) -> bytes:
"""Load the Fernet key, creating and locking it down on first use."""
from cryptography.fernet import Fernet
kp = _key_path(data_dir)
if kp.exists():
return kp.read_bytes().strip()
key = Fernet.generate_key()
kp.parent.mkdir(parents=True, exist_ok=True)
kp.write_bytes(key)
kp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 — owner read/write only
return key
def _fernet(data_dir: Path):
from cryptography.fernet import Fernet
return Fernet(_get_or_create_key(data_dir))
# ── Credential encryption helpers ─────────────────────────────────────────────
def _encrypt(data_dir: Path, value: str) -> str:
return _fernet(data_dir).encrypt(value.encode()).decode()
def _decrypt(data_dir: Path, token: str) -> str:
try:
return _fernet(data_dir).decrypt(token.encode()).decode()
except Exception as exc:
raise GarminError("Failed to decrypt Garmin credentials — key may have changed") from exc
# ── Credential CRUD ───────────────────────────────────────────────────────────
def has_credentials(user_dir: Path) -> bool:
return (user_dir / _CREDS_FILE).exists()
def save_credentials(data_dir: Path, user_dir: Path, email: str, password: str) -> None:
"""Encrypt and persist the user's Garmin email + password."""
payload = {
"email": _encrypt(data_dir, email),
"password": _encrypt(data_dir, password),
}
creds_path = user_dir / _CREDS_FILE
creds_path.write_text(json.dumps(payload, indent=2))
creds_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
def load_credentials(data_dir: Path, user_dir: Path) -> tuple[str, str]:
"""Return (email, password) decrypted from disk."""
creds_path = user_dir / _CREDS_FILE
if not creds_path.exists():
raise GarminError("No Garmin credentials stored for this user")
try:
raw = json.loads(creds_path.read_text())
except Exception as exc:
raise GarminError("Garmin credentials file is corrupt") from exc
return _decrypt(data_dir, raw["email"]), _decrypt(data_dir, raw["password"])
def delete_credentials(user_dir: Path) -> None:
"""Remove stored credentials and session (disconnect)."""
creds_path = user_dir / _CREDS_FILE
if creds_path.exists():
creds_path.unlink()
session_dir = user_dir / _SESSION_DIR
if session_dir.exists():
import shutil
shutil.rmtree(session_dir)
# ── Session management (garth OAuth tokens) ───────────────────────────────────
def _session_dir(user_dir: Path) -> Path:
d = user_dir / _SESSION_DIR
d.mkdir(exist_ok=True)
return d
def _save_session(user_dir: Path, client) -> None:
"""Persist garth OAuth tokens so the next sync skips re-login."""
try:
client.garth.dump(str(_session_dir(user_dir)))
except Exception:
pass # session save is best-effort
def _load_session(user_dir: Path, client) -> bool:
"""Try to restore a saved garth session. Returns True on success."""
sd = user_dir / _SESSION_DIR
if not sd.exists():
return False
try:
client.garth.load(str(sd))
return True
except Exception:
return False
# ── Client factory ────────────────────────────────────────────────────────────
def get_client(data_dir: Path, user_dir: Path):
"""Return a logged-in Garmin client.
Strategy:
1. Try to resume a saved garth session (fast, no network round-trip).
2. If that fails or the session has expired, re-login using the stored
(encrypted) credentials.
3. Persist the refreshed session for next time.
Raises GarminError if credentials are missing or login fails.
"""
try:
import garminconnect
except ImportError as exc:
raise GarminError(
"garminconnect is not installed. "
"Run: uv sync --extra garmin"
) from exc
client = garminconnect.Garmin()
# Try cached session first
if _load_session(user_dir, client):
try:
client.garth.refresh_oauth2() # renew access token if needed
_save_session(user_dir, client) # persist refreshed token
return client
except Exception:
pass # session is dead — fall through to full re-login
# Full login with stored credentials
email, password = load_credentials(data_dir, user_dir)
try:
client = garminconnect.Garmin(email=email, password=password)
client.login()
except Exception as exc:
raise GarminError(f"Garmin login failed: {exc}") from exc
_save_session(user_dir, client)
return client
def test_login(data_dir: Path, user_dir: Path, email: str, password: str) -> dict:
"""Attempt a login with the supplied credentials (does not save them).
Returns a dict with display_name and full_name on success.
Raises GarminError on failure.
"""
try:
import garminconnect
except ImportError as exc:
raise GarminError("garminconnect is not installed") from exc
try:
client = garminconnect.Garmin(email=email, password=password)
client.login()
except Exception as exc:
raise GarminError(f"Login failed: {exc}") from exc
try:
profile = client.get_profile_user_summary()
display = profile.get("displayName", email)
full = f"{profile.get('firstName', '')} {profile.get('lastName', '')}".strip()
except Exception:
display, full = email, ""
# Credentials are valid — save them and the session
save_credentials(data_dir, user_dir, email, password)
_save_session(user_dir, client)
return {"display_name": display, "full_name": full}
+196
View File
@@ -0,0 +1,196 @@
"""Garmin Connect incremental sync — generator-based, mirrors strava_sync_iter.
Sync state is stored in {user_dir}/garmin_sync.json:
{
"last_sync_at": "2026-04-12" ← date of last successful sync (YYYY-MM-DD)
}
We query Garmin for all activities from (last_sync_at - 1 day) to today,
then skip any that already exist (FileExistsError from ingest_parsed).
The -1 day buffer catches activities that were saved to Garmin slightly
after their recorded end time crosses midnight.
Each yielded dict has a ``type`` key:
- ``"fetching"`` — about to contact Garmin
- ``"progress"`` — one activity processed; keys: n, total, name, status, garmin_id
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
- ``"error"`` — fatal error; key: message
"""
from __future__ import annotations
import io
import json
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Generator
_SYNC_FILE = "garmin_sync.json"
# ── Sync state helpers ────────────────────────────────────────────────────────
def _load_sync_state(user_dir: Path) -> dict:
p = user_dir / _SYNC_FILE
if not p.exists():
return {}
try:
return json.loads(p.read_text())
except Exception:
return {}
def _save_sync_state(user_dir: Path, state: dict) -> None:
(user_dir / _SYNC_FILE).write_text(json.dumps(state, indent=2))
# ── FIT extraction from ZIP ───────────────────────────────────────────────────
def _extract_fit(zip_bytes: bytes) -> tuple[bytes, str]:
"""Return (fit_bytes, filename) from a Garmin activity ZIP.
Garmin always packages the original FIT as the first .fit entry.
Raises ValueError if no FIT file is found.
"""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
if not fit_names:
raise ValueError(f"No FIT file in archive. Contents: {zf.namelist()}")
name = fit_names[0]
return zf.read(name), name
# ── Main generator ────────────────────────────────────────────────────────────
def garmin_sync_iter(
data_dir: Path,
user_dir: Path,
) -> Generator[dict, None, None]:
"""Fetch new activities from Garmin Connect and ingest them.
Args:
data_dir: Root data directory (used for encryption key lookup).
user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.).
"""
from bincio.extract.garmin_api import GarminError, get_client
from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.fit import FitParser
# ── Login ──────────────────────────────────────────────────────────────────
try:
client = get_client(data_dir, user_dir)
except GarminError as exc:
yield {"type": "error", "message": str(exc)}
return
yield {"type": "fetching"}
# ── Determine date range ───────────────────────────────────────────────────
state = _load_sync_state(user_dir)
last = state.get("last_sync_at")
if last:
# Start one day before last sync to catch edge cases around midnight
start_dt = datetime.fromisoformat(last) - timedelta(days=1)
else:
# First sync: import everything Garmin has
start_dt = datetime(2000, 1, 1)
start_date = start_dt.strftime("%Y-%m-%d")
end_date = datetime.now().strftime("%Y-%m-%d")
# ── Fetch activity list ────────────────────────────────────────────────────
try:
activities = client.get_activities_by_date(
startdate=start_date,
enddate=end_date,
)
except Exception as exc:
yield {"type": "error", "message": f"Failed to fetch activity list: {exc}"}
return
total = len(activities)
imported = 0
skipped = 0
errors: list[str] = []
parser = FitParser()
# ── Process each activity ──────────────────────────────────────────────────
for n, meta in enumerate(activities, 1):
garmin_id = meta.get("activityId")
name = meta.get("activityName") or "Untitled"
try:
# Download original FIT (wrapped in a ZIP by Garmin)
try:
zip_bytes = client.download_activity(
garmin_id,
dl_fmt=client.ActivityDownloadFormat.ORIGINAL,
)
except Exception as exc:
raise RuntimeError(f"Download failed: {exc}") from exc
try:
fit_bytes, fit_name = _extract_fit(zip_bytes)
except Exception as exc:
raise RuntimeError(f"ZIP extraction failed: {exc}") from exc
# Parse FIT — pass a dummy Path so the parser has a filename for
# any format-detection logic; raw bytes are the actual data.
fake_path = Path(fit_name)
try:
parsed = parser.parse(fake_path, fit_bytes)
except Exception as exc:
raise RuntimeError(f"FIT parse error: {exc}") from exc
# Ingest — raises FileExistsError if already present (dedup)
ingest_parsed(parsed, user_dir)
imported += 1
yield {
"type": "progress",
"n": n, "total": total, "name": name,
"status": "imported",
"garmin_id": garmin_id,
}
except FileExistsError:
skipped += 1
yield {
"type": "progress",
"n": n, "total": total, "name": name,
"status": "skipped",
"garmin_id": garmin_id,
}
except Exception as exc:
errors.append(f"{garmin_id} ({name}): {type(exc).__name__}: {exc}")
yield {
"type": "progress",
"n": n, "total": total, "name": name,
"status": "error",
"garmin_id": garmin_id,
}
# ── Persist sync state ─────────────────────────────────────────────────────
state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
_save_sync_state(user_dir, state)
yield {
"type": "done",
"imported": imported,
"skipped": skipped,
"error_count": len(errors),
"errors": errors[:5],
}
def run_garmin_sync(data_dir: Path, user_dir: Path) -> dict:
"""Blocking wrapper around garmin_sync_iter for non-SSE callers."""
result: dict = {}
for event in garmin_sync_iter(data_dir, user_dir):
if event["type"] == "done":
result = event
elif event["type"] == "error":
raise RuntimeError(event["message"])
return result
+71 -22
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
from bincio.extract.models import ParsedActivity
@@ -47,8 +46,9 @@ def ingest_parsed(
raise FileExistsError(f"Activity already exists: {activity_id}")
metrics = compute(parsed)
write_activity(parsed, metrics, data_dir, privacy=privacy, rdp_epsilon=rdp_epsilon)
summary = build_summary(parsed, metrics, activity_id, privacy)
effective_privacy = parsed.privacy if parsed.privacy is not None else privacy
write_activity(parsed, metrics, data_dir, privacy=effective_privacy, rdp_epsilon=rdp_epsilon)
summary = build_summary(parsed, metrics, activity_id, effective_privacy)
index_path = data_dir / "index.json"
if index_path.exists():
@@ -60,26 +60,36 @@ def ingest_parsed(
summaries[activity_id] = summary
write_index(list(summaries.values()), data_dir, owner)
# Rebuild athlete.json with updated MMP curves and records.
# Preserve any manually-set fields (max_hr, ftp_w, zones, etc.) from the existing file.
from bincio.extract.writer import write_athlete_json
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
athlete_config: dict[str, Any] = {}
athlete_path = data_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(list(summaries.values()), data_dir, athlete_config)
return activity_id
def strava_sync(
def strava_sync_iter(
data_dir: Path,
client_id: str,
client_secret: str,
) -> dict[str, Any]:
"""Fetch new Strava activities and ingest them into data_dir.
originals_dir: Optional[Path] = None,
):
"""Generator version of strava_sync — yields progress dicts, then a final summary.
Args:
data_dir: Per-user data directory.
client_id: Strava OAuth client ID.
client_secret: Strava OAuth client secret.
Returns:
Dict with keys: ok, imported, skipped, error_count, errors.
Raises:
RuntimeError: If Strava credentials are missing or API calls fail.
Each yielded dict has a ``type`` key:
- ``"fetching"`` — about to fetch the activity list from Strava
- ``"progress"`` — one activity processed; keys: n, total, name, status ("imported"|"skipped"|"error")
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
- ``"error"`` — fatal error before processing started; key: message
"""
import time
@@ -95,43 +105,82 @@ def strava_sync(
from bincio.extract.writer import make_activity_id
if not client_id or not client_secret:
raise RuntimeError("Strava not configured (missing client_id or client_secret)")
yield {"type": "error", "message": "Strava not configured"}
return
try:
token = ensure_fresh(data_dir, client_id, client_secret)
except StravaError as e:
raise RuntimeError(str(e)) from e
yield {"type": "error", "message": str(e)}
return
yield {"type": "fetching"}
after: Optional[int] = token.get("last_sync_at")
try:
activities = fetch_activities(token["access_token"], after=after)
except StravaError as e:
raise RuntimeError(str(e)) from e
yield {"type": "error", "message": str(e)}
return
total = len(activities)
imported = 0
skipped = 0
errors: list[str] = []
for meta in activities:
for n, meta in enumerate(activities, 1):
name = meta.get("name", "Untitled")
try:
activity_id = make_activity_id(strava_meta_to_partial(meta))
if (data_dir / "activities" / f"{activity_id}.json").exists():
skipped += 1
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
continue
streams = fetch_streams(token["access_token"], meta["id"])
if originals_dir is not None:
orig_path = originals_dir / f"{activity_id}.json"
orig_path.write_text(
json.dumps({"meta": meta, "streams": streams}, indent=2),
encoding="utf-8",
)
parsed = strava_to_parsed(meta, streams)
ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001)
imported += 1
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "imported"}
except Exception as exc:
errors.append(f"{meta.get('id')}: {type(exc).__name__}")
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "error"}
token["last_sync_at"] = int(time.time())
save_token(data_dir, token)
return {
"ok": True,
yield {
"type": "done",
"imported": imported,
"skipped": skipped,
"error_count": len(errors),
"errors": errors[:5],
}
def strava_sync(
data_dir: Path,
client_id: str,
client_secret: str,
originals_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Fetch new Strava activities and ingest them into data_dir.
Returns:
Dict with keys: ok, imported, skipped, error_count, errors.
Raises:
RuntimeError: If Strava credentials are missing or API calls fail.
"""
result: dict[str, Any] = {}
for event in strava_sync_iter(data_dir, client_id, client_secret, originals_dir):
if event["type"] == "error":
raise RuntimeError(event["message"])
if event["type"] == "done":
result = event
return {"ok": True, **{k: v for k, v in result.items() if k != "type"}}
+35 -4
View File
@@ -70,7 +70,7 @@ 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)
gain, loss = _elevation(pts, activity.altitude_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])
@@ -131,6 +131,10 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis
t_min = min(sparse)
t_max = max(sparse)
# Guard against corrupted time data (e.g. absolute Unix timestamps stored as
# elapsed offsets, which can make t_max astronomically large and OOM the process).
if t_max - t_min > 7 * 24 * 3600: # > 1 week → corrupted stream
return None
power_1hz: list[int] = [sparse.get(t, 0) for t in range(t_min, t_max + 1)]
n = len(power_1hz)
@@ -190,6 +194,10 @@ def compute_best_efforts(
t_min = min(sparse_speed)
t_max = max(sparse_speed)
# Guard against corrupted time data (e.g. absolute Unix timestamps stored as
# elapsed offsets, which can make t_max astronomically large and OOM the process).
if t_max - t_min > 7 * 24 * 3600: # > 1 week → corrupted stream
return None, None
speed_1hz: list[float] = [sparse_speed.get(t, 0.0) for t in range(t_min, t_max + 1)]
ele_1hz: list[Optional[float]] = [sparse_ele.get(t) for t in range(t_min, t_max + 1)]
@@ -339,17 +347,40 @@ def _duration(pts: list[DataPoint]) -> Optional[int]:
return int((pts[-1].timestamp - pts[0].timestamp).total_seconds())
def _elevation(pts: list[DataPoint]) -> tuple[Optional[float], Optional[float]]:
# 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(
pts: list[DataPoint],
altitude_source: str = "unknown",
) -> 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.
"""
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)
gain = loss = 0.0
for a, b in zip(elevations, elevations[1:]):
diff = b - a
committed = elevations[0]
for e in elevations[1:]:
diff = e - committed
if abs(diff) >= threshold:
if diff > 0:
gain += diff
else:
loss += diff
committed = e
return gain, loss
+5
View File
@@ -55,4 +55,9 @@ class ParsedActivity:
description: Optional[str] = None
gear: Optional[str] = None
strava_id: Optional[str] = None
privacy: Optional[str] = None # "public", "private", or None (caller decides)
laps: list[LapData] = field(default_factory=list)
# "barometric" = device has a barometric altimeter (FIT enhanced_altitude present)
# "gps" = altitude derived from GPS triangulation (GPX, TCX, FIT altitude-only)
# "unknown" = source not detected (treated as gps for threshold purposes)
altitude_source: str = "unknown"
+7 -1
View File
@@ -27,8 +27,14 @@ class BaseParser(ABC):
raw_bytes is the original file content (used for hashing).
decompressed_bytes is what parsers should actually parse.
Gzip is handled both by extension (.gz) and by magic bytes (0x1f 0x8b),
so files that are gzip-compressed but named without .gz still parse correctly.
"""
raw = path.read_bytes()
if path.suffix == ".gz":
if path.suffix == ".gz" or raw[:2] == b'\x1f\x8b':
try:
return raw, gzip.decompress(raw)
except Exception:
pass # not actually gzip despite the magic bytes — fall through
return raw, raw
+13 -1
View File
@@ -20,6 +20,8 @@ class FitParser:
sub_sport: str | None = None
device: str | None = None
has_baro_alt = False # True if any record used enhanced_altitude
with fitdecode.FitReader(io.BytesIO(raw_bytes)) as fit:
for frame in fit:
if not isinstance(frame, fitdecode.FitDataMessage):
@@ -57,12 +59,19 @@ class FitParser:
lat = _semicircles_to_deg(_get(frame, "position_lat"))
lon = _semicircles_to_deg(_get(frame, "position_long"))
speed_raw = _get(frame, "speed") # m/s
# enhanced_altitude is written by barometric altimeters (most
# modern Garmins). Fall back to GPS-derived altitude if absent.
_alt = _get(frame, "enhanced_altitude")
if _alt is not None:
has_baro_alt = True
else:
_alt = _get(frame, "altitude")
dp = DataPoint(
timestamp=ts,
lat=lat,
lon=lon,
elevation_m=_get(frame, "altitude"),
elevation_m=_alt,
hr_bpm=_get(frame, "heart_rate"),
cadence_rpm=_get(frame, "cadence"),
speed_kmh=speed_raw * 3.6 if speed_raw is not None else None,
@@ -95,6 +104,8 @@ class FitParser:
if not points:
raise ValueError(f"No record messages found in {path.name}")
altitude_source = "barometric" if has_baro_alt else "gps"
return ParsedActivity(
points=points,
sport=sport,
@@ -104,6 +115,7 @@ class FitParser:
laps=laps,
source_file=path.name,
source_hash="",
altitude_source=altitude_source,
)
+1
View File
@@ -53,6 +53,7 @@ class GpxParser(BaseParser):
started_at=started_at,
source_file=path.name,
source_hash="", # set by factory
altitude_source="gps",
)
+1
View File
@@ -83,6 +83,7 @@ class TcxParser:
started_at=points[0].timestamp,
source_file=path.name,
source_hash="",
altitude_source="gps",
)
+11 -4
View File
@@ -37,16 +37,18 @@ class StravaError(Exception):
# ── OAuth helpers ──────────────────────────────────────────────────────────────
def auth_url(client_id: str, redirect_uri: str) -> str:
def auth_url(client_id: str, redirect_uri: str, state: str = "") -> str:
"""Return the Strava OAuth authorization URL."""
params = urllib.parse.urlencode({
params: dict[str, str] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "activity:read_all",
"approval_prompt": "auto",
})
return f"{_AUTH_URL}?{params}"
}
if state:
params["state"] = state
return f"{_AUTH_URL}?{urllib.parse.urlencode(params)}"
def exchange_code(client_id: str, client_secret: str, code: str) -> dict:
@@ -199,6 +201,10 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
source = f"strava:{meta['id']}"
source_hash = "sha256:" + hashlib.sha256(source.encode()).hexdigest()
# Map Strava visibility to BAS privacy: only_me → unlisted, everything else → public
visibility = meta.get("visibility") or ""
is_private = meta.get("private", False) or visibility == "only_me"
return ParsedActivity(
points=points,
sport=normalise_sport(meta.get("sport_type") or meta.get("type") or ""),
@@ -208,4 +214,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
title=meta.get("name") or None,
description=meta.get("description") or None,
strava_id=str(meta["id"]),
privacy="unlisted" if is_private else "public",
)
+107 -7
View File
@@ -1,14 +1,14 @@
"""Import metadata from Strava's activities.csv bulk export.
Strava export columns we care about:
Activity ID, Activity Date, Activity Name, Activity Type,
Activity Description, Filename
Activity ID, Activity Date, Activity Name, Activity Description, Filename
"""
import csv
import json
import re
from pathlib import Path
from typing import Optional
from typing import Iterator, Optional
_STRAVA_DATE_FMTS = (
@@ -18,10 +18,11 @@ _STRAVA_DATE_FMTS = (
class StravaMetadata:
"""Maps original filename → Strava metadata."""
"""Maps original filename → Strava metadata, with secondary strava_id index."""
def __init__(self, csv_path: Path) -> None:
self._by_filename: dict[str, dict] = {}
self._by_strava_id: dict[str, dict] = {}
self._load(csv_path)
def _load(self, path: Path) -> None:
@@ -29,16 +30,21 @@ class StravaMetadata:
reader = csv.DictReader(f)
for row in reader:
filename = row.get("Filename", "").strip()
if not filename:
continue
# Strava stores paths like "activities/12345.fit.gz"
if filename:
basename = Path(filename).name
self._by_filename[basename] = row
strava_id = row.get("Activity ID", "").strip()
if strava_id:
self._by_strava_id[strava_id] = row
def lookup(self, source_file: str) -> Optional[dict]:
"""Return the Strava CSV row for a given source filename, or None."""
return self._by_filename.get(source_file)
def lookup_by_strava_id(self, strava_id: str) -> Optional[dict]:
"""Return the Strava CSV row for a given Strava activity ID, or None."""
return self._by_strava_id.get(str(strava_id))
def enrich(self, source_file: str, activity: object) -> None:
"""Mutate a ParsedActivity with Strava metadata if found."""
row = self.lookup(source_file)
@@ -53,3 +59,97 @@ class StravaMetadata:
if not activity.strava_id and row.get("Activity ID"): # type: ignore[attr-defined]
activity.strava_id = row["Activity ID"].strip() # type: ignore[attr-defined]
# ── Retroactive sidecar update ────────────────────────────────────────────────
def _parse_sidecar(path: Path) -> tuple[dict, str]:
"""Return (frontmatter_dict, body) from a sidecar .md file."""
import re as _re
import yaml
text = path.read_text(encoding="utf-8")
if text.startswith("---"):
parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE)
if len(parts) >= 3:
fm = yaml.safe_load(parts[1]) or {}
return fm, parts[2].strip()
return {}, text.strip()
def _write_sidecar(path: Path, fm: dict, body: str) -> None:
import yaml
path.parent.mkdir(parents=True, exist_ok=True)
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"
path.write_text(content, encoding="utf-8")
def _update_sidecar_from_row(sidecar_path: Path, row: dict) -> bool:
"""Create or update a sidecar with CSV title/description.
Only fills fields that are not already set in the sidecar.
Returns True if anything changed.
"""
title = row.get("Activity Name", "").strip()
description = row.get("Activity Description", "").strip()
if not title and not description:
return False
fm, body = _parse_sidecar(sidecar_path) if sidecar_path.exists() else ({}, "")
changed = False
if title and "title" not in fm:
fm["title"] = title
changed = True
if description and not body:
body = description
changed = True
if not changed:
return False
_write_sidecar(sidecar_path, fm, body)
return True
def apply_csv_to_data_dir(data_dir: Path, metadata: StravaMetadata) -> int:
"""Retroactively apply CSV metadata to existing activities via sidecars.
Scans all activity JSONs in data_dir/activities/. For each activity that
has a strava_id, looks up the corresponding CSV row and creates/updates
the sidecar in data_dir/edits/ with any missing title or description.
Only writes fields not already present in the sidecar — manual edits are
never overwritten.
Returns the count of activities whose sidecars were created or updated.
"""
activities_dir = data_dir / "activities"
edits_dir = data_dir / "edits"
if not activities_dir.exists():
return 0
updated = 0
for json_path in sorted(activities_dir.glob("*.json")):
try:
detail = json.loads(json_path.read_text(encoding="utf-8"))
except Exception:
continue
strava_id = detail.get("strava_id")
if not strava_id:
continue
row = metadata.lookup_by_strava_id(str(strava_id))
if row is None:
continue
activity_id = json_path.stem
sidecar_path = edits_dir / f"{activity_id}.md"
if _update_sidecar_from_row(sidecar_path, row):
updated += 1
return updated
+148
View File
@@ -0,0 +1,148 @@
"""Process a Strava bulk export ZIP file into a BAS data store.
The ZIP (downloaded from strava.com/athlete/delete_your_account or the data export
page) contains:
activities/ ← GPX, FIT, TCX files (plain or .gz variants)
activities.csv ← metadata (title, description, gear, strava ID)
bikes.csv / shoes.csv / … (ignored here)
Processing strategy: stream one activity at a time to keep disk usage low.
The ZIP is never fully extracted; each activity file is extracted to a temp path,
parsed, ingested, then immediately deleted. The ZIP itself is deleted once done.
"""
from __future__ import annotations
import io
import json
import tempfile
import zipfile
from pathlib import Path
from typing import Generator, Optional
# File extensions recognised as activity files inside the ZIP.
_ACTIVITY_SUFFIXES = {".gpx", ".fit", ".tcx", ".gpx.gz", ".fit.gz", ".tcx.gz"}
def _is_activity_file(name: str) -> bool:
n = name.lower()
return any(n.endswith(s) for s in _ACTIVITY_SUFFIXES)
def strava_zip_iter(
zip_path: Path,
data_dir: Path,
originals_dir: Optional[Path] = None,
privacy: str = "public",
) -> Generator[dict, None, None]:
"""Process a Strava export ZIP, yielding SSE-style progress dicts.
Event types:
{"type": "validating"}
{"type": "error", "message": str}
{"type": "extracting_csv"}
{"type": "progress", "n": int, "total": int, "name": str, "status": "imported"|"skipped"|"error"}
{"type": "done", "imported": int, "skipped": int, "error_count": int, "errors": list[str]}
The zip_path file is deleted after processing regardless of success/failure.
"""
from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.factory import parse_file
from bincio.extract.strava_csv import StravaMetadata
yield {"type": "validating"}
try:
zf = zipfile.ZipFile(zip_path, "r")
except zipfile.BadZipFile as e:
zip_path.unlink(missing_ok=True)
yield {"type": "error", "message": f"Not a valid ZIP file: {e}"}
return
try:
names = zf.namelist()
# Validate structure
has_csv = "activities.csv" in names
activity_files = [n for n in names if n.startswith("activities/") and _is_activity_file(n)]
if not has_csv:
yield {"type": "error", "message": "This doesn't look like a Strava export: activities.csv not found"}
return
if not activity_files:
yield {"type": "error", "message": "No activity files found in activities/ folder"}
return
# Load activities.csv into memory (it's small — ~700 KB)
yield {"type": "extracting_csv"}
csv_bytes = zf.read("activities.csv")
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp_csv:
tmp_csv.write(csv_bytes)
tmp_csv_path = Path(tmp_csv.name)
try:
metadata = StravaMetadata(tmp_csv_path)
finally:
tmp_csv_path.unlink(missing_ok=True)
total = len(activity_files)
imported = 0
skipped = 0
errors: list[str] = []
for n, zip_entry in enumerate(activity_files, 1):
entry_name = Path(zip_entry).name # e.g. "12345678.fit.gz"
# Title from metadata if available; fall back to filename stem
meta_row = metadata.lookup(entry_name)
display_name = (meta_row or {}).get("Activity Name", "").strip() or entry_name
# Determine activity ID from entry to check for duplicates before extracting
# (can't do this without parsing, so we extract to a small temp file)
suffix = "".join(Path(entry_name).suffixes) # ".fit.gz" or ".gpx" etc.
tmp_path: Optional[Path] = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=data_dir) as tmp:
tmp.write(zf.read(zip_entry))
tmp_path = Path(tmp.name)
parsed = parse_file(tmp_path)
# Enrich with CSV metadata
if meta_row:
if not parsed.title and meta_row.get("Activity Name"):
parsed.title = meta_row["Activity Name"].strip()
if not parsed.description and meta_row.get("Activity Description"):
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 originals_dir is not None:
import shutil
orig_dest = originals_dir / entry_name
shutil.copy2(tmp_path, orig_dest)
ingest_parsed(parsed, data_dir, privacy=privacy)
imported += 1
yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "imported"}
except FileExistsError:
skipped += 1
yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "skipped"}
except Exception as exc:
errors.append(f"{entry_name}: {type(exc).__name__}")
yield {"type": "progress", "n": n, "total": total, "name": display_name, "status": "error"}
finally:
if tmp_path is not None:
tmp_path.unlink(missing_ok=True)
finally:
zf.close()
zip_path.unlink(missing_ok=True)
yield {
"type": "done",
"imported": imported,
"skipped": skipped,
"error_count": len(errors),
"errors": errors[:5],
}
+3 -2
View File
@@ -14,13 +14,14 @@ def build_timeseries(
) -> dict:
"""Return the BAS `timeseries` object.
privacy='no_gps' or 'private' → lat/lon set to null.
privacy='no_gps' → lat/lon set to null. All other privacy levels
(including 'unlisted') retain GPS in the timeseries.
Downsamples so at most one point per second is emitted.
"""
if not points:
return {"t": []}
include_gps = privacy not in ("no_gps", "private")
include_gps = privacy not in ("no_gps", "private") # "private" = legacy alias for "unlisted"
# Downsample: keep at most one point per second
sampled: list[DataPoint] = []
+6 -2
View File
@@ -48,7 +48,10 @@ def write_activity(
acts_dir.mkdir(parents=True, exist_ok=True)
source = _infer_source(activity)
has_gps = metrics.bbox is not None and privacy not in ("no_gps", "private")
# "unlisted" activities keep their GPS track (not in the public feed, but the
# URL is not secret — same model as the detail JSON). Only "no_gps" suppresses
# the track. "private" is the legacy alias for "unlisted".
has_gps = metrics.bbox is not None and privacy not in ("no_gps",)
# Build timeseries once — written to a separate file to keep detail JSON small.
# Treat an empty timeseries (no points) as None so no file is created.
@@ -90,6 +93,7 @@ def write_activity(
"source": source,
"source_file": activity.source_file,
"source_hash": activity.source_hash,
"altitude_source": activity.altitude_source,
"strava_id": activity.strava_id,
"duplicate_of": duplicate_of,
"privacy": privacy,
@@ -220,7 +224,7 @@ def build_summary(
privacy: str = "public",
) -> dict:
"""Build the Activity Summary object for index.json."""
has_gps = metrics.bbox is not None and privacy not in ("no_gps", "private")
has_gps = metrics.bbox is not None and privacy not in ("no_gps",)
return {
"id": activity_id,
"title": activity.title or _auto_title(activity),
+147
View File
@@ -0,0 +1,147 @@
"""bincio reextract-originals — re-extract activities from stored Strava originals."""
from __future__ import annotations
import ctypes
import gc
import json
import sys
from pathlib import Path
import click
def _emit(obj: dict) -> None:
"""Write a JSON progress line to stdout (flushed immediately)."""
print(json.dumps(obj), flush=True)
# On Linux, malloc_trim(0) returns freed arenas to the OS, keeping RSS low.
# CPython's allocator otherwise holds onto freed memory indefinitely.
try:
_libc = ctypes.CDLL("libc.so.6")
def _trim_heap() -> None:
_libc.malloc_trim(0)
except Exception:
def _trim_heap() -> None: # type: ignore[misc]
pass
_GC_EVERY = 50 # call gc.collect() + malloc_trim every N activities
@click.command("reextract-originals")
@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory")
@click.option("--handle", required=True, help="User handle to re-extract for")
@click.option("--force", is_flag=True, default=False, help="Re-extract even if activity JSON already exists")
@click.option("--offset", default=0, type=int, help="Skip first N originals (for batch processing)")
@click.option("--limit", default=0, type=int, help="Process at most N originals then stop (0 = all)")
def reextract_originals(data_dir: str, handle: str, force: bool, offset: int, limit: int) -> None:
"""Re-extract activities from stored Strava originals (originals/strava/*.json).
Prints one JSON object per line to stdout for streaming progress:
{"type": "status", "message": "..."}
{"type": "progress", "n": 1, "total": 2015, "name": "...", "status": "imported"|"skipped"|"error", ["detail": "..."]}
{"type": "done", "imported": N, "skipped": N, "errors": N}
{"type": "error", "message": "..."}
"""
from bincio.extract.strava_api import strava_to_parsed
from bincio.extract.metrics import compute as compute_metrics
from bincio.extract.writer import (
build_summary, make_activity_id, write_activity, write_index,
)
from bincio.render.merge import merge_all
dd = Path(data_dir).expanduser().resolve()
user_dir = dd / handle
originals_dir = user_dir / "originals" / "strava"
if not originals_dir.exists():
_emit({"type": "error", "message": f"No Strava originals directory at {originals_dir}"})
sys.exit(1)
all_files = sorted(originals_dir.glob("*.json"))
if not all_files:
_emit({"type": "error", "message": "No Strava originals found"})
sys.exit(1)
# Apply offset/limit for batch processing
batch = all_files[offset:] if not limit else all_files[offset: offset + limit]
total_all = len(all_files)
total = len(batch)
original_files = batch
_emit({"type": "status", "message": (
f"Batch {offset + 1}{offset + total} of {total_all}, starting extraction…"
if offset or limit else
f"Found {total_all} originals, starting extraction…"
)})
# Load existing index to get owner info and existing summaries
index_path = user_dir / "index.json"
try:
existing_index = json.loads(index_path.read_text(encoding="utf-8")) if index_path.exists() else {}
except Exception:
existing_index = {}
owner = existing_index.get("owner", {"handle": handle})
summaries: dict[str, dict] = {s["id"]: s for s in existing_index.get("activities", [])}
imported = skipped = errors = 0
for n, orig_path in enumerate(original_files, 1):
try:
raw = json.loads(orig_path.read_text(encoding="utf-8"))
meta = raw.get("meta", {})
streams = raw.get("streams", {})
name = meta.get("name", orig_path.stem)
parsed = strava_to_parsed(meta, streams)
activity_id = make_activity_id(parsed)
if not force and (user_dir / "activities" / f"{activity_id}.json").exists():
skipped += 1
_emit({"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"})
else:
metrics = compute_metrics(parsed)
ep = parsed.privacy if parsed.privacy is not None else "public"
write_activity(parsed, metrics, user_dir, privacy=ep, rdp_epsilon=0.0001)
summaries[activity_id] = build_summary(parsed, metrics, activity_id, ep)
imported += 1
_emit({"type": "progress", "n": n, "total": total, "name": name, "status": "imported"})
# Explicitly free large objects; also free the raw JSON dict and streams
raw = meta = streams = None # type: ignore[assignment]
try:
del parsed, metrics
except NameError:
pass
except Exception as exc:
errors += 1
_emit({"type": "progress", "n": n, "total": total, "name": orig_path.stem,
"status": "error", "detail": str(exc)})
# Periodically reclaim freed memory from CPython's allocator arena
if n % _GC_EVERY == 0:
gc.collect()
_trim_heap()
# Final cleanup before the index write (which loads all summaries at once)
gc.collect()
_trim_heap()
if imported > 0:
_emit({"type": "status", "message": "Writing index…"})
try:
write_index(list(summaries.values()), user_dir, owner)
except Exception as exc:
_emit({"type": "error", "message": f"write_index failed: {exc}"})
sys.exit(1)
_emit({"type": "status", "message": "Running merge…"})
try:
merge_all(user_dir)
except Exception as exc:
_emit({"type": "error", "message": f"merge_all failed: {exc}"})
sys.exit(1)
_emit({"type": "done", "imported": imported, "skipped": skipped, "errors": errors})
+14 -1
View File
@@ -133,6 +133,11 @@ def _write_root_manifest(data: Path) -> None:
root.write_text(json.dumps(manifest, indent=2))
console.print(f"Root manifest updated: [cyan]{len(users)}[/cyan] user shard(s)")
if len(users) > 1:
from bincio.render.merge import write_combined_feed
n = write_combined_feed(data)
console.print(f"Combined feed: [cyan]{n}[/cyan] activities across all users")
def _link_data(site: Path, data: Path) -> None:
"""Symlink site/public/data → data root (each user has their own _merged/)."""
@@ -168,6 +173,8 @@ def _link_data(site: Path, data: Path) -> None:
help="Deploy after build. Currently supports: github.")
@click.option("--handle", default=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).")
def render(
config_path: Optional[str],
data_dir: Optional[str],
@@ -176,6 +183,7 @@ def render(
serve: bool,
deploy: Optional[str],
handle: Optional[str],
no_build: bool,
) -> None:
"""Build (or serve) the BincioActivity static site from a BAS data store."""
@@ -185,9 +193,14 @@ def render(
console.print(f"Site: [cyan]{site}[/cyan]")
console.print(f"Data: [cyan]{data}[/cyan]")
_ensure_npm(site)
_merge_edits(data, handle=handle)
_write_root_manifest(data)
if no_build:
console.print("[green]Data updated.[/green] Skipping Astro build (--no-build).")
return
_ensure_npm(site)
_link_data(site, data)
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
+136 -14
View File
@@ -49,7 +49,7 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
if "highlight" in fm:
d["custom"]["highlight"] = bool(fm["highlight"])
if "private" in fm:
d["privacy"] = "private" if fm["private"] else detail.get("privacy", "public")
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 [])]
@@ -69,7 +69,7 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
if "highlight" in fm:
s["custom"]["highlight"] = bool(fm["highlight"])
if "private" in fm:
s["privacy"] = "private" if fm["private"] else summary.get("privacy", "public")
s["privacy"] = "unlisted" if fm["private"] else summary.get("privacy", "public")
return s
@@ -152,12 +152,10 @@ def merge_one(data_dir: Path, activity_id: str) -> None:
s = _apply_sidecar_summary(s, fm)
activities.append(s)
activities = [a for a in activities if a.get("privacy") != "private"]
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1)
index["activities"] = activities
(merged_dir / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False))
_write_year_shards(merged_dir, activities, index)
def merge_all(data_dir: Path) -> int:
@@ -261,18 +259,142 @@ def merge_all(data_dir: Path) -> int:
s = _apply_sidecar_summary(s, fm)
activities.append(s)
# Drop private activities from the published feed
activities = [a for a in activities if a.get("privacy") != "private"]
# Sort: newest first, then bring highlighted activities to the top
# "unlisted" (and legacy "private") activities are kept in the index so
# the owner can reach them by direct URL; the feed UI filters them out
# for non-owners client-side.
# Sort: newest first, then bring highlighted activities to the top.
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1)
index["activities"] = activities
(merged_dir / "index.json").write_text(
json.dumps(index, indent=2, ensure_ascii=False)
)
elif (merged_dir / "index.json").exists():
_write_year_shards(merged_dir, activities, index)
else:
# Remove any stale year shard files if the source index disappeared
for f in merged_dir.glob("index-*.json"):
f.unlink()
if (merged_dir / "index.json").exists():
(merged_dir / "index.json").unlink()
return len(sidecars)
# Fields only needed for athlete.json aggregation at extract time — they add
# bulk to every summary entry but are never read by the feed UI.
_FEED_STRIP = {"best_efforts", "best_climb_m", "source"}
def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dict) -> None:
"""Split activities by year and write index-{year}.json shards.
Replaces merged_dir/index.json with a shard manifest so the feed can
load only the most-recent year on first paint and fetch older years lazily.
"""
from collections import defaultdict
# Remove stale year shard files from previous runs
for f in merged_dir.glob("index-*.json"):
f.unlink()
by_year: dict[str, list[dict]] = defaultdict(list)
for a in activities:
year = (a.get("started_at") or "")[:4] or "unknown"
# Strip aggregation-only fields to keep shard files small
slim = {k: v for k, v in a.items() if k not in _FEED_STRIP}
by_year[year].append(slim)
years = sorted(by_year.keys(), reverse=True) # newest first
shards = []
for year in years:
shard_doc = {
**{k: v for k, v in index_meta.items() if k not in ("activities", "shards")},
"shards": [],
"activities": by_year[year],
}
fname = f"index-{year}.json"
(merged_dir / fname).write_text(json.dumps(shard_doc, indent=2, ensure_ascii=False))
shards.append({"url": fname, "year": int(year) if year.isdigit() else 0,
"count": len(by_year[year])})
root_doc = {
**{k: v for k, v in index_meta.items() if k not in ("activities", "shards")},
"shards": shards,
"activities": [],
}
(merged_dir / "index.json").write_text(json.dumps(root_doc, indent=2, ensure_ascii=False))
FEED_PAGE_SIZE = 50
# Extra fields stripped from the combined feed — preview_coords is the biggest
# contributor (~24% of shard size) but the feed cards need it for thumbnails,
# so we keep it. mmp is never displayed in feed cards.
_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.
The global feed page loads this single file instead of resolving 20+ user
shards recursively. Returns the number of activities written.
"""
user_dirs = sorted(
p for p in data_dir.iterdir()
if p.is_dir() and (p / "activities").exists()
)
all_activities: list[dict] = []
for user_dir in user_dirs:
handle = user_dir.name
merged = user_dir / "_merged"
index_path = merged / "index.json" if merged.exists() else user_dir / "index.json"
if not index_path.exists():
continue
index = json.loads(index_path.read_text(encoding="utf-8"))
shards = index.get("shards", [])
activities = index.get("activities", [])
if shards:
year_shards = [s for s in shards if re.match(r"index-\d{4}\.json$", s.get("url", ""))]
base = index_path.parent
for shard in year_shards:
shard_path = base / shard["url"]
if shard_path.exists():
shard_data = json.loads(shard_path.read_text(encoding="utf-8"))
for a in shard_data.get("activities", []):
a_tagged = {**a, "handle": handle}
detail_url = a_tagged.get("detail_url", "")
if detail_url and not detail_url.startswith("http") and not detail_url.startswith("/"):
merged_rel = f"{handle}/_merged/" if merged.exists() else f"{handle}/"
a_tagged["detail_url"] = merged_rel + detail_url
track_url = a_tagged.get("track_url", "")
if track_url and not track_url.startswith("http") and not track_url.startswith("/"):
merged_rel = f"{handle}/_merged/" if merged.exists() else f"{handle}/"
a_tagged["track_url"] = merged_rel + track_url
all_activities.append(a_tagged)
else:
for a in activities:
all_activities.append({**a, "handle": handle})
all_activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
# Remove stale feed pages
for f in data_dir.glob("feed*.json"):
f.unlink()
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))
return len(all_activities)
+22 -2
View File
@@ -19,9 +19,13 @@ console = Console()
@click.option("--strava-client-id", default=None, envvar="STRAVA_CLIENT_ID", help="Strava OAuth client ID (enables per-user Strava sync)")
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava OAuth client secret")
@click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)")
@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]) -> None:
max_users: Optional[int], public_url: Optional[str],
webroot: Optional[str], dem_url: Optional[str]) -> None:
"""Start the bincio multi-user application server.
Handles auth, user management, and write operations.
@@ -51,6 +55,12 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
srv.strava_client_id = strava_client_id
if strava_client_secret:
srv.strava_client_secret = strava_client_secret
if public_url:
srv.public_url = public_url
if webroot and site_dir:
srv.webroot = Path(webroot).expanduser().resolve()
if dem_url:
srv.dem_url = dem_url
db = open_db(dd)
current_limit = get_setting(db, "max_users")
@@ -60,11 +70,21 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
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)")
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()
uvicorn.run(srv.app, host=host, port=port, log_level="info")
log_config = uvicorn.config.LOGGING_CONFIG.copy()
# Make bincio.serve logger emit at INFO through uvicorn's handler
log_config["loggers"]["bincio.serve"] = {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
}
uvicorn.run(srv.app, host=host, port=port, log_level="info", log_config=log_config)
+134
View File
@@ -45,17 +45,36 @@ CREATE TABLE IF NOT EXISTS invites (
used_at INTEGER
);
CREATE TABLE IF NOT EXISTS reset_codes (
code TEXT PRIMARY KEY,
handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_prefs (
handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (handle, key)
);
CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle);
CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by);
CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle);
CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle);
"""
_SESSION_DAYS = 30
_INVITE_LENGTH = 8
_RESET_CODE_TTL_S = 24 * 3600 # 24 hours
# ── Data classes ──────────────────────────────────────────────────────────────
@@ -143,6 +162,13 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional
)
def change_password(db: sqlite3.Connection, handle: str, new_password: str) -> None:
"""Replace the password hash for a user."""
new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
db.execute("UPDATE users SET password_hash = ? WHERE handle = ?", (new_hash, handle))
db.commit()
def list_users(db: sqlite3.Connection) -> list[User]:
rows = db.execute("SELECT * FROM users ORDER BY created_at").fetchall()
return [User(handle=r["handle"], display_name=r["display_name"],
@@ -154,6 +180,33 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None:
db.commit()
def get_member_tree(db: sqlite3.Connection) -> list[dict]:
"""Return users with their inviter handle and join timestamp.
Each entry: {handle, display_name, created_at, invited_by (handle or None)}.
Ordered oldest-first so callers can build the tree top-down.
"""
users = {r["handle"]: r for r in db.execute(
"SELECT handle, display_name, created_at FROM users ORDER BY created_at"
).fetchall()}
# Map invitee → inviter from the used invites
invited_by: dict[str, str] = {}
for row in db.execute(
"SELECT created_by, used_by FROM invites WHERE used_by IS NOT NULL"
).fetchall():
invited_by[row["used_by"]] = row["created_by"]
return [
{
"handle": r["handle"],
"display_name": r["display_name"],
"created_at": r["created_at"],
"invited_by": invited_by.get(r["handle"]),
}
for r in users.values()
]
def count_users(db: sqlite3.Connection) -> int:
"""Return the total number of registered users."""
row = db.execute("SELECT COUNT(*) FROM users").fetchone()
@@ -290,3 +343,84 @@ def get_invite(db: sqlite3.Connection, code: str) -> Optional[Invite]:
created_at=row["created_at"],
used_at=row["used_at"],
)
# ── Password reset codes ──────────────────────────────────────────────────────
def create_reset_code(db: sqlite3.Connection, handle: str, created_by: str) -> str:
"""Generate a password reset code for a user (admin only, out-of-band delivery).
Any previous unused codes for this handle are invalidated first.
Returns the new code.
"""
now = int(time.time())
# Invalidate existing unused codes for this handle
db.execute(
"DELETE FROM reset_codes WHERE handle = ? AND used_at IS NULL",
(handle,),
)
code = secrets.token_urlsafe(_INVITE_LENGTH)[:_INVITE_LENGTH].upper()
db.execute(
"INSERT INTO reset_codes (code, handle, created_by, created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?)",
(code, handle, created_by, now, now + _RESET_CODE_TTL_S),
)
db.commit()
return code
# ── User preferences ─────────────────────────────────────────────────────────
def get_user_prefs(db: sqlite3.Connection, handle: str) -> dict[str, str]:
"""Return all preferences for a user as a plain dict."""
rows = db.execute(
"SELECT key, value FROM user_prefs WHERE handle = ?", (handle,)
).fetchall()
return {r["key"]: r["value"] for r in rows}
def set_user_pref(db: sqlite3.Connection, handle: str, key: str, value: str) -> None:
db.execute(
"INSERT INTO user_prefs (handle, key, value) VALUES (?, ?, ?) "
"ON CONFLICT(handle, key) DO UPDATE SET value = excluded.value",
(handle, key, value),
)
db.commit()
def set_user_prefs(db: sqlite3.Connection, handle: str, prefs: dict[str, str]) -> None:
"""Bulk-upsert multiple preferences for a user."""
for key, value in prefs.items():
db.execute(
"INSERT INTO user_prefs (handle, key, value) VALUES (?, ?, ?) "
"ON CONFLICT(handle, key) DO UPDATE SET value = excluded.value",
(handle, key, value),
)
db.commit()
def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool:
"""Validate a reset code for the given handle and mark it used.
Returns False if the code is invalid, already used, expired, or
belongs to a different handle.
"""
now = int(time.time())
row = db.execute(
"SELECT handle, expires_at, used_at FROM reset_codes WHERE code = ?",
(code,),
).fetchone()
if not row:
return False
if row["handle"] != handle:
return False
if row["used_at"] is not None:
return False
if row["expires_at"] < now:
return False
db.execute(
"UPDATE reset_codes SET used_at = ? WHERE code = ?",
(now, code),
)
db.commit()
return True
+19 -4
View File
@@ -24,7 +24,7 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str
Creates the SQLite database, the admin user, the per-user data directory,
and prints a first invite code. Safe to re-run — skips steps already done.
"""
from bincio.serve.db import create_invite, create_user, get_user, open_db, set_setting
from bincio.serve.db import create_invite, create_user, get_user, open_db, set_setting, get_setting
dd = Path(data_dir).expanduser().resolve()
dd.mkdir(parents=True, exist_ok=True)
@@ -55,7 +55,19 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str
from datetime import datetime, timezone
root_index = dd / "index.json"
if not root_index.exists():
if root_index.exists():
# Preserve existing manifest but always enforce private: True for a multi-user instance.
manifest = json.loads(root_index.read_text())
instance = manifest.setdefault("instance", {})
if not instance.get("private"):
instance["private"] = True
if name:
instance["name"] = name
root_index.write_text(json.dumps(manifest, indent=2))
console.print(" [green]✓[/green] root index.json updated (private: true)")
else:
console.print(" [yellow]·[/yellow] root index.json already private — skipping")
else:
manifest = {
"bas_version": "1.0",
"instance": {"name": name or "BincioActivity", "private": True},
@@ -65,8 +77,6 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str
}
root_index.write_text(json.dumps(manifest, indent=2))
console.print(" [green]✓[/green] root index.json manifest written")
else:
console.print(" [yellow]·[/yellow] root index.json already exists — skipping")
# ── User limit ────────────────────────────────────────────────────────────
if max_users > 0:
@@ -75,6 +85,11 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str
else:
console.print(" [dim]·[/dim] no user limit (unlimited)")
# ── Original file storage default ─────────────────────────────────────────
if get_setting(db, "store_originals") is None:
set_setting(db, "store_originals", "true")
console.print(" [green]✓[/green] store_originals = true (users can override per upload)")
# ── First invite code ─────────────────────────────────────────────────────
code = create_invite(db, handle)
+1671 -82
View File
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
# Administrator Guide
This guide covers everything needed to deploy and maintain a multi-user BincioActivity instance.
## Before You Start
**[Multi-user Deployment](deployment/multi-user.md)** has the complete step-by-step instructions. This guide focuses on day-to-day admin tasks once the instance is running.
## Initializing an Instance
```bash
uv sync --extra serve
uv run bincio init \
--data-dir /var/bincio \
--handle your_admin_handle \
--display-name "Your Name" \
--name "Instance Name"
```
You'll be prompted for a password. This creates:
- `/var/bincio/instance.db` — SQLite database (users, sessions, invites, reset codes)
- `/var/bincio/index.json` — root shard manifest (`"private": true` by default)
- Your admin user account
- A first invite code
`bincio init` is idempotent — safe to re-run.
Optional flags:
- `--max-users N` — limit total registered users (0 or omitted = unlimited)
- `--store-originals false` — don't keep uploaded source files (defaults to true)
## Inviting Users
### Generate an invite code (as admin)
From the web UI at `/invites/` (requires login as admin), or via CLI:
```bash
uv run python -c "
from pathlib import Path
from bincio.serve.db import open_db, create_invite
db = open_db(Path('/var/bincio'))
code = create_invite(db, 'your_handle')
print(f'https://yourdomain.com/register/?code={code}')
"
```
### Invite limits
- **Admins:** unlimited invites
- **Regular users:** 3 invites each (configurable in `bincio/serve/db.py` as `_MAX_USER_INVITES`)
### Share the invite link
Send the registration link to the user:
```
https://yourdomain.com/register/?code=ABCD1234
```
They create their own handle and password. After registration, they can:
- Upload activity files (GPX, FIT, TCX)
- Sync from Strava
- Edit activity titles, descriptions, photos
- Control privacy per activity
## Password Reset
BincioActivity has no email system. Password resets work via **admin-generated one-time codes**.
### Reset a user's password (as admin)
1. Open `/admin/` in the web UI (must be logged in as admin)
2. Find the user and click **Reset password**
3. A code appears (monospace, click to copy)
4. Send the code out-of-band (Signal, Telegram, WhatsApp, etc.)
The code is valid for **24 hours**. Users reset their password at `/reset-password/` by entering:
- Their **handle**
- The **code**
- Their **new password**
### Reset code API (CLI)
To generate a reset code programmatically:
```bash
uv run python -c "
from pathlib import Path
from bincio.serve.db import open_db, create_reset_code
db = open_db(Path('/var/bincio'))
code, expires_in_hours = create_reset_code(db, 'user_handle', 'your_handle')
print(f'Code: {code} (expires in {expires_in_hours} hours)')
"
```
## Monitoring Active Jobs
The `/api/admin/jobs` endpoint (admin-only) shows which uploads/syncs are in progress:
```bash
curl -b "bincio_session=$(cat /tmp/session.txt)" http://localhost:4041/api/admin/jobs
```
Returns:
```json
[
{
"id": "a1b2c3d4",
"user": "alice",
"started_at": 1712345678,
"total": 50,
"done": 23,
"current": "activity_2026-03-15_120000Z.fit"
}
]
```
## Triggering Rebuilds
`bincio serve` can trigger incremental rebuilds when you pass `--site-dir`:
```bash
uv run bincio serve \
--data-dir /var/bincio \
--site-dir /var/www/bincio/src/site
```
After any write operation (edit, upload, Strava sync), the affected user's shard is rebuilt automatically and the static site is updated.
To manually rebuild a single user's shard:
```bash
uv run bincio render \
--data-dir /var/bincio \
--handle alice
```
To rebuild everything (slow):
```bash
uv run bincio render --data-dir /var/bincio
```
## Instance Settings
Settings are stored in `instance.db` and control instance-wide behavior:
| Setting | Default | Controls |
|---------|---------|----------|
| `max_users` | unlimited | Maximum registered users allowed |
| `store_originals` | `true` | Keep uploaded source files and Strava sync data |
Read/set settings via CLI:
```bash
uv run python -c "
from pathlib import Path
from bincio.serve.db import open_db, get_setting, set_setting
db = open_db(Path('/var/bincio'))
print(get_setting(db, 'max_users'))
set_setting(db, 'max_users', 100)
db.commit()
"
```
Or check the database directly:
```bash
sqlite3 /var/bincio/instance.db
> SELECT key, value FROM settings;
```
## Instance Privacy
By default, new instances are **private** — only authenticated users can view anything. Edit the root `index.json` to toggle:
```json
{
"private": false,
"shards": [...]
}
```
- **`"private": true`** — all pages (except login/register) require authentication
- **`"private": false`** — public access to all activities; individual activities can still be marked private via the `private` flag in sidecars
After any change, run `bincio render` to apply it:
```bash
uv run bincio render --data-dir /var/bincio
```
## Data Directory Layout
```
/var/bincio/
instance.db ← SQLite: users, sessions, invites, reset codes
index.json ← root shard manifest
{handle}/
index.json ← user's BAS feed (activities list)
_merged/ ← sidecar-merged output (served to browser)
activities/ ← extracted activity JSON files
{id}.json
...
edits/ ← user-made sidecar edits
{id}.md
images/{id}/
athlete.json ← profile (from Strava or manual)
strava_token.json ← OAuth token (if synced from Strava)
originals/ ← source files (if store_originals=true)
_feedback/ ← user feedback submissions
{handle}.json
{handle}/
{timestamp}_{id}_{filename}
```
## Database Schema
`instance.db` contains:
- **`users`** — handle, password hash, display_name, is_admin, created_at
- **`sessions`** — session_id, handle, created_at, expires_at
- **`invites`** — code, created_by, created_at, used_by, used_at
- **`reset_codes`** — code, handle, created_by, created_at, expires_at, used_at
- **`settings`** — key, value (instance config)
- **`user_preferences`** — handle, key, value (per-user settings)
Query the database directly:
```bash
sqlite3 /var/bincio/instance.db ".tables"
sqlite3 /var/bincio/instance.db "SELECT handle, is_admin FROM users;"
```
## API Endpoints for Admins
The `/api/admin/*` endpoints require authentication and admin privileges:
- `GET /api/admin/users` — List all users
- `POST /api/admin/users/{handle}/reset-password-code` — Generate a reset code
- `GET /api/admin/jobs` — Show active uploads/syncs
- `GET /api/stats` — Community stats (public)
See [API Reference](reference/api.md) for full details.
### Explore the API with Swagger UI
When `bincio serve` is running, visit `/api/docs` to see an interactive Swagger UI. You can:
- Browse all endpoints with their parameters and response types
- Try out requests directly (if you're logged in as admin)
- See live examples of request/response bodies
ReDoc (another API documentation format) is also available at `/api/redoc` with a different UI.
## Running as a systemd service
See [Multi-user Deployment](deployment/multi-user.md#step-5--start-bincio-serve) for the systemd unit file. Key points:
- Set `User=bincio` (unprivileged user)
- Set `WorkingDirectory` to the repo root
- Use `--site-dir` to enable incremental rebuilds
- Restart policy: `Restart=on-failure`
Monitor with:
```bash
systemctl status bincio
journalctl -u bincio -f
```
## Troubleshooting
### Activities not appearing after upload
1. Check if the job is still running: `GET /api/admin/jobs`
2. Check logs: `journalctl -u bincio -f`
3. If `store_originals=true`, verify the source file is readable in `{handle}/originals/`
4. Re-trigger the merge: `uv run bincio render --data-dir /var/bincio --handle alice`
### Database locked
If you see "database is locked":
1. Verify no other `bincio` processes are running: `ps aux | grep bincio`
2. Kill any stuck processes: `pkill -f 'uv run bincio'`
3. Restart the service: `systemctl restart bincio`
### High memory usage
The first rebuild on a large instance can be memory-intensive. Consider:
- Running `bincio render` during off-hours
- Rebuilding one user at a time: `uv run bincio render --data-dir /var/bincio --handle alice`
- Increasing swap or upgrading the machine
## See also
- [Multi-user Deployment](deployment/multi-user.md) — complete step-by-step setup
- [Single-user Deployment](deployment/single-user.md) — if you're hosting a read-only site
- [API Reference](reference/api.md) — all HTTP endpoints
+1 -1
View File
@@ -21,7 +21,7 @@ GPX / FIT / TCX files
Any static host (GitHub Pages, Netlify, VPS, USB stick, …)
```
The BAS data store is the contract between the two stages. Any tool in any language can produce BAS-compliant JSON. See [SCHEMA.md](../SCHEMA.md) for the format.
The BAS data store is the contract between the two stages. Any tool in any language can produce BAS-compliant JSON. See [SCHEMA.md](schema.md) for the format.
---
+274
View File
@@ -0,0 +1,274 @@
graph LR
subgraph API
subgraph api_activity["activity"]
api__api_activity__activity_id_["GET /api/activity/{activity_id}"]
api__api_activity__activity_id_["POST /api/activity/{activity_id}"]
api__api_activity__activity_id_["DELETE /api/activity/{activity_id}"]
api__api_activity__activity_id__images["GET /api/activity/{activity_id}/images"]
api__api_activity__activity_id__images["POST /api/activity/{activity_id}/images"]
api__api_activity__activity_id__images__filename_["DELETE /api/activity/{activity_id}/images/{filename}"]
end
subgraph api_admin["admin"]
api__api_admin_users["GET /api/admin/users"]
api__api_admin_jobs["GET /api/admin/jobs"]
api__api_admin_disk["GET /api/admin/disk"]
api__api_admin_users__handle__reset_password_code["POST /api/admin/users/{handle}/reset-password-code"]
api__api_admin_users__handle__rebuild["POST /api/admin/users/{handle}/rebuild"]
api__api_admin_users__handle__activities["DELETE /api/admin/users/{handle}/activities"]
end
subgraph api_athlete["athlete"]
api__api_athlete["GET /api/athlete"]
api__api_athlete["POST /api/athlete"]
end
subgraph api_auth["auth"]
api__api_auth_login["POST /api/auth/login"]
api__api_auth_logout["POST /api/auth/logout"]
api__api_auth_reset_password["POST /api/auth/reset-password"]
end
subgraph api_feedback["feedback"]
api__api_feedback["POST /api/feedback"]
end
subgraph api_garmin["garmin"]
api__api_garmin_status["GET /api/garmin/status"]
api__api_garmin_connect["POST /api/garmin/connect"]
api__api_garmin_disconnect["POST /api/garmin/disconnect"]
api__api_garmin_sync_stream["GET /api/garmin/sync/stream"]
end
subgraph api_invites["invites"]
api__api_invites["GET /api/invites"]
api__api_invites["POST /api/invites"]
end
subgraph api_me["me"]
api__api_me["GET /api/me"]
end
subgraph api_register["register"]
api__api_register["POST /api/register"]
end
subgraph api_stats["stats"]
api__api_stats["GET /api/stats"]
end
subgraph api_strava["strava"]
api__api_strava_status["GET /api/strava/status"]
api__api_strava_reset["POST /api/strava/reset"]
api__api_strava_auth_url["GET /api/strava/auth-url"]
api__api_strava_callback["GET /api/strava/callback"]
api__api_strava_sync_stream["GET /api/strava/sync/stream"]
api__api_strava_sync["POST /api/strava/sync"]
end
subgraph api_upload["upload"]
api__api_upload["POST /api/upload"]
api__api_upload_strava_zip["POST /api/upload/strava-zip"]
end
end
subgraph Pages
site_src_pages_about_ca_index_astro["pages/about/ca/"]
site_src_pages_about_es_index_astro["pages/about/es/"]
site_src_pages_about_index_astro["pages/about/"]
site_src_pages_about_it_index_astro["pages/about/it/"]
site_src_pages_activity__id__astro["pages/activity/[id].astro"]
site_src_pages_activity_index_astro["pages/activity/"]
site_src_pages_activity_local_index_astro["pages/activity/local/"]
site_src_pages_admin_index_astro["pages/admin/"]
site_src_pages_athlete_index_astro["pages/athlete/"]
site_src_pages_community_index_astro["pages/community/"]
site_src_pages_convert_index_astro["pages/convert/"]
site_src_pages_feedback_index_astro["pages/feedback/"]
site_src_pages_index_astro["pages/"]
site_src_pages_invites_index_astro["pages/invites/"]
site_src_pages_login_index_astro["pages/login/"]
site_src_pages_record_index_astro["pages/record/"]
site_src_pages_register_index_astro["pages/register/"]
site_src_pages_reset_password_index_astro["pages/reset-password/"]
site_src_pages_stats_index_astro["pages/stats/"]
site_src_pages_u__handle__athlete_index_astro["pages/u/[handle]/athlete/"]
site_src_pages_u__handle__index_astro["pages/u/[handle]/"]
site_src_pages_u__handle__stats_index_astro["pages/u/[handle]/stats/"]
end
subgraph Components
site_src_components_ActivityCharts_svelte["components/ActivityCharts.svelte"]
site_src_components_ActivityDetail_svelte["components/ActivityDetail.svelte"]
site_src_components_ActivityDetailLoader_svelte["components/ActivityDetailLoader.svelte"]
site_src_components_ActivityFeed_svelte["components/ActivityFeed.svelte"]
site_src_components_ActivityMap_svelte["components/ActivityMap.svelte"]
site_src_components_AthleteDrawer_svelte["components/AthleteDrawer.svelte"]
site_src_components_AthleteView_svelte["components/AthleteView.svelte"]
site_src_components_CommunityView_svelte["components/CommunityView.svelte"]
site_src_components_EditDrawer_svelte["components/EditDrawer.svelte"]
site_src_components_LocalActivityDetail_svelte["components/LocalActivityDetail.svelte"]
site_src_components_MmpChart_svelte["components/MmpChart.svelte"]
site_src_components_RecordsView_svelte["components/RecordsView.svelte"]
site_src_components_StatsView_svelte["components/StatsView.svelte"]
end
subgraph Python
subgraph py_edit["edit"]
bincio_edit_cli_py["cli"]
bincio_edit_ops_py["ops"]
bincio_edit_server_py["server"]
end
subgraph py_extract["extract"]
bincio_extract_cli_py["cli"]
bincio_extract_config_py["config"]
bincio_extract_dedup_py["dedup"]
bincio_extract_garmin_api_py["garmin_api"]
bincio_extract_garmin_sync_py["garmin_sync"]
bincio_extract_ingest_py["ingest"]
bincio_extract_metrics_py["metrics"]
bincio_extract_models_py["models"]
bincio_extract_parsers_base_py["base"]
bincio_extract_parsers_factory_py["factory"]
bincio_extract_parsers_fit_py["fit"]
bincio_extract_parsers_gpx_py["gpx"]
bincio_extract_parsers_tcx_py["tcx"]
bincio_extract_simplify_py["simplify"]
bincio_extract_sport_py["sport"]
bincio_extract_strava_api_py["strava_api"]
bincio_extract_strava_csv_py["strava_csv"]
bincio_extract_strava_zip_py["strava_zip"]
bincio_extract_timeseries_py["timeseries"]
bincio_extract_writer_py["writer"]
end
subgraph py_import_["import_"]
bincio_import__cli_py["cli"]
bincio_import__strava_py["strava"]
end
subgraph py_render["render"]
bincio_render_cli_py["cli"]
bincio_render_merge_py["merge"]
end
subgraph py_root["root"]
bincio_cli_py["cli"]
bincio_dev_py["dev"]
end
subgraph py_serve["serve"]
bincio_serve_cli_py["cli"]
bincio_serve_db_py["db"]
bincio_serve_init_cmd_py["init_cmd"]
bincio_serve_server_py["server"]
end
end
site_src_components_EditDrawer_svelte -->|fetch| api__api_activity_
site_src_components_AthleteDrawer_svelte -->|fetch| api__api_athlete
site_src_components_AthleteView_svelte -->|fetch| api__api_athlete
site_src_components_AthleteView_svelte -->|fetch| api__api_athlete__
site_src_layouts_Base_astro -->|fetch| api__api_me
site_src_layouts_Base_astro -->|fetch| api__api_admin_jobs
site_src_layouts_Base_astro -->|fetch| api__api_auth_logout
site_src_layouts_Base_astro -->|fetch| api__api_admin_jobs__
site_src_layouts_Base_astro -->|fetch| api__api_auth_logout__
site_src_layouts_Base_astro -->|fetch| api__api_upload
site_src_layouts_Base_astro -->|fetch| api__api_strava_status
site_src_layouts_Base_astro -->|fetch| api__api_strava_auth_url
site_src_layouts_Base_astro -->|fetch| api__api_strava_sync_stream
site_src_layouts_Base_astro -->|fetch| api__api_strava_reset
site_src_layouts_Base_astro -->|fetch| api__api_upload_strava_zip
site_src_layouts_Base_astro -->|fetch| api__api_garmin_status
site_src_layouts_Base_astro -->|fetch| api__api_garmin_connect
site_src_layouts_Base_astro -->|fetch| api__api_garmin_sync_stream
site_src_layouts_Base_astro -->|fetch| api__api_garmin_disconnect
site_src_pages_admin_index_astro -->|fetch| api__api_admin_disk
site_src_pages_admin_index_astro -->|fetch| api__api_admin_users___h__rebuild
site_src_pages_admin_index_astro -->|fetch| api__api_admin_users___h__reset_password_code
site_src_pages_admin_index_astro -->|fetch| api__api_admin_users___pendingHandle__activities
site_src_pages_admin_index_astro -->|fetch| api__api_admin_disk__
site_src_pages_admin_index_astro -->|fetch| api__api_admin_users_
site_src_pages_about_index_astro -->|fetch| api__api_me
site_src_pages_about_index_astro -->|fetch| api__api_stats
site_src_pages_about_index_astro -->|fetch| api__api_stats___
site_src_pages_feedback_index_astro -->|fetch| api__api_feedback
site_src_pages_feedback_index_astro -->|fetch| api__api_me
site_src_pages_feedback_index_astro -->|fetch| api__api_feedback__
site_src_pages_feedback_index_astro -->|fetch| api__api_me__
site_src_pages_register_index_astro -->|fetch| api__api_register
site_src_pages_reset_password_index_astro -->|fetch| api__api_auth_reset_password
site_src_pages_invites_index_astro -->|fetch| api__api_invites
site_src_pages_invites_index_astro -->|fetch| api__api_invites__
site_src_pages_login_index_astro -->|fetch| api__api_auth_login
site_src_pages_convert_index_astro -->|fetch| api__api_import_bas
site_src_pages_about_it_index_astro -->|fetch| api__api_me
site_src_pages_about_it_index_astro -->|fetch| api__api_stats
site_src_pages_about_it_index_astro -->|fetch| api__api_stats___
site_src_pages_about_ca_index_astro -->|fetch| api__api_me
site_src_pages_about_ca_index_astro -->|fetch| api__api_stats
site_src_pages_about_ca_index_astro -->|fetch| api__api_stats___
site_src_pages_about_es_index_astro -->|fetch| api__api_me
site_src_pages_about_es_index_astro -->|fetch| api__api_stats
site_src_pages_about_es_index_astro -->|fetch| api__api_stats___
site_src_components_ActivityDetail_svelte --> site_src_components_ActivityMap_svelte
site_src_components_ActivityDetail_svelte --> site_src_components_ActivityCharts_svelte
site_src_components_ActivityDetail_svelte --> site_src_components_EditDrawer_svelte
site_src_components_ActivityDetailLoader_svelte --> site_src_components_ActivityDetail_svelte
site_src_components_AthleteView_svelte --> site_src_components_MmpChart_svelte
site_src_components_AthleteView_svelte --> site_src_components_RecordsView_svelte
site_src_components_AthleteView_svelte --> site_src_components_AthleteDrawer_svelte
site_src_components_LocalActivityDetail_svelte --> site_src_components_ActivityDetail_svelte
site_src_pages_index_astro --> site_src_components_ActivityFeed_svelte
site_src_pages_index_astro --> site_src_layouts_Base_astro
site_src_pages_record_index_astro --> site_src_layouts_Base_astro
site_src_pages_activity__id__astro --> site_src_components_ActivityDetail_svelte
site_src_pages_activity__id__astro --> site_src_layouts_Base_astro
site_src_pages_activity_index_astro --> site_src_components_ActivityDetailLoader_svelte
site_src_pages_activity_index_astro --> site_src_layouts_Base_astro
site_src_pages_admin_index_astro --> site_src_layouts_Base_astro
site_src_pages_about_index_astro --> site_src_layouts_Base_astro
site_src_pages_feedback_index_astro --> site_src_layouts_Base_astro
site_src_pages_register_index_astro --> site_src_layouts_Base_astro
site_src_pages_reset_password_index_astro --> site_src_layouts_Base_astro
site_src_pages_community_index_astro --> site_src_components_CommunityView_svelte
site_src_pages_community_index_astro --> site_src_layouts_Base_astro
site_src_pages_invites_index_astro --> site_src_layouts_Base_astro
site_src_pages_login_index_astro --> site_src_layouts_Base_astro
site_src_pages_convert_index_astro --> site_src_layouts_Base_astro
site_src_pages_activity_local_index_astro --> site_src_components_LocalActivityDetail_svelte
site_src_pages_activity_local_index_astro --> site_src_layouts_Base_astro
site_src_pages_u__handle__index_astro --> site_src_components_ActivityFeed_svelte
site_src_pages_u__handle__index_astro --> site_src_layouts_Base_astro
site_src_pages_u__handle__athlete_index_astro --> site_src_components_AthleteView_svelte
site_src_pages_u__handle__athlete_index_astro --> site_src_layouts_Base_astro
site_src_pages_u__handle__stats_index_astro --> site_src_components_StatsView_svelte
site_src_pages_u__handle__stats_index_astro --> site_src_layouts_Base_astro
site_src_pages_about_it_index_astro --> site_src_layouts_Base_astro
site_src_pages_about_ca_index_astro --> site_src_layouts_Base_astro
site_src_pages_about_es_index_astro --> site_src_layouts_Base_astro
bincio_cli_py --> bincio_edit_cli_py
bincio_cli_py --> bincio_extract_cli_py
bincio_cli_py --> bincio_render_cli_py
bincio_cli_py --> bincio_serve_cli_py
bincio_cli_py --> bincio_import__cli_py
bincio_cli_py --> bincio_serve_init_cmd_py
bincio_cli_py --> bincio_dev_py
bincio_import__strava_py --> bincio_extract_models_py
bincio_import__strava_py --> bincio_extract_sport_py
bincio_edit_server_py --> bincio_edit_ops_py
bincio_extract_simplify_py --> bincio_extract_models_py
bincio_extract_metrics_py --> bincio_extract_models_py
bincio_extract_ingest_py --> bincio_extract_models_py
bincio_extract_strava_api_py --> bincio_extract_models_py
bincio_extract_strava_api_py --> bincio_extract_sport_py
bincio_extract_cli_py --> bincio_extract_parsers_factory_py
bincio_extract_cli_py --> bincio_extract_config_py
bincio_extract_cli_py --> bincio_extract_dedup_py
bincio_extract_writer_py --> bincio_extract_metrics_py
bincio_extract_writer_py --> bincio_extract_simplify_py
bincio_extract_writer_py --> bincio_extract_timeseries_py
bincio_extract_writer_py --> bincio_extract_models_py
bincio_extract_timeseries_py --> bincio_extract_models_py
bincio_serve_server_py --> bincio_serve_db_py
bincio_serve_server_py --> bincio_edit_ops_py
bincio_extract_parsers_tcx_py --> bincio_extract_models_py
bincio_extract_parsers_tcx_py --> bincio_extract_sport_py
bincio_extract_parsers_fit_py --> bincio_extract_models_py
bincio_extract_parsers_fit_py --> bincio_extract_sport_py
bincio_extract_parsers_gpx_py --> bincio_extract_sport_py
bincio_extract_parsers_gpx_py --> bincio_extract_models_py
bincio_extract_parsers_gpx_py --> bincio_extract_parsers_base_py
bincio_extract_parsers_factory_py --> bincio_extract_parsers_gpx_py
bincio_extract_parsers_factory_py --> bincio_extract_models_py
bincio_extract_parsers_factory_py --> bincio_extract_parsers_tcx_py
bincio_extract_parsers_factory_py --> bincio_extract_parsers_base_py
bincio_extract_parsers_factory_py --> bincio_extract_parsers_fit_py
bincio_extract_parsers_base_py --> bincio_extract_models_py
+1 -1
View File
@@ -204,4 +204,4 @@ The browser fetches and merges remote shards concurrently. Remote activities app
- [CLI reference — bincio serve](../reference/cli.md#bincio-serve)
- [CLI reference — bincio dev](../reference/cli.md#bincio-dev)
- [API reference](../reference/api.md)
- [BAS schema — instance manifest](../../SCHEMA.md#instance-manifest)
- [BAS schema — instance manifest](../schema.md#instance-manifest)
+410
View File
@@ -0,0 +1,410 @@
# VPS deployment guide
Concrete setup for a Debian VPS running a private multi-user bincio instance.
Code is deployed directly from your laptop via `git push` — no GitHub required.
## Assumptions
- Bare Debian 12 VPS with root SSH access
- You own a domain pointed at the VPS
- You have Strava API credentials
- Up to ~30 users
---
## 1. Install system dependencies
```bash
apt update && apt upgrade -y
apt install -y git curl nginx certbot python3-certbot-nginx sqlite3 rsync
```
**Node.js 20 LTS** (the Debian package is too old):
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
```
**uv** (manages Python and all Python deps):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
# add to PATH:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
## 2. Set up the code directory
```bash
mkdir -p /opt/bincio
git init --bare /opt/bincio-repo.git
```
Create the post-receive hook at `/opt/bincio-repo.git/hooks/post-receive`:
```bash
#!/bin/bash
set -e
REPO=/opt/bincio-repo.git
DEPLOY=/opt/bincio
DATA=/var/bincio/data
while read oldrev newrev refname; do
echo "--- Checking out $refname ---"
git --work-tree=$DEPLOY --git-dir=$REPO checkout -f $newrev
echo "--- Syncing Python deps ---"
cd $DEPLOY
~/.local/bin/uv sync --extra serve --extra strava --extra garmin
echo "--- Syncing JS deps ---"
cd $DEPLOY/site
npm install --silent
echo "--- Building site ---"
cd $DEPLOY
~/.local/bin/uv run bincio render --data-dir $DATA --site-dir $DEPLOY/site
echo "--- Pruning dist/data (nginx serves /data/ directly from $DATA) ---"
rm -rf $DEPLOY/site/dist/data
echo "--- Copying dist to webroot ---"
rsync -a --delete --exclude=data/ $DEPLOY/site/dist/ /var/www/bincio/
echo "--- Restarting API ---"
systemctl restart bincio || echo "WARNING: bincio service restart failed — check journalctl -u bincio"
echo "--- Done ---"
done
```
```bash
chmod +x /opt/bincio-repo.git/hooks/post-receive
mkdir -p /var/www/bincio /var/bincio/data /var/bincio/sources
```
---
## 3. systemd service
The hook restarts the `bincio` service on every deploy, so it must exist before the first push.
Create `/etc/bincio/secrets.env`:
```bash
mkdir -p /etc/bincio
chmod 700 /etc/bincio
cat > /etc/bincio/secrets.env <<EOF
STRAVA_CLIENT_ID=your_client_id
STRAVA_CLIENT_SECRET=your_client_secret
EOF
chmod 600 /etc/bincio/secrets.env
```
Create `/etc/systemd/system/bincio.service`:
```ini
[Unit]
Description=BincioActivity API
After=network.target
[Service]
WorkingDirectory=/opt/bincio
ExecStart=/root/.local/bin/uv run bincio serve \
--data-dir /var/bincio/data \
--site-dir /opt/bincio/site \
--webroot /var/www/bincio \
--host 127.0.0.1 \
--port 4041 \
--public-url https://yourdomain.com
EnvironmentFile=/etc/bincio/secrets.env
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
systemctl daemon-reload
systemctl enable --now bincio
systemctl status bincio
```
---
## 4. First deploy from your laptop
Add the VPS as a git remote (run this locally, once):
```bash
git remote add vps root@<your-vps-ip>:/opt/bincio-repo.git
```
Push your code:
```bash
git push vps main
```
The hook checks out the code, installs deps, and builds the site.
Subsequent pushes (including unpublished branches) work the same way:
```bash
git push vps mobile_app # deploy any branch directly
```
---
## 5. Initialise the instance
```bash
cd /opt/bincio
uv run bincio init \
--data-dir /var/bincio/data \
--handle dave \
--display-name "Dave" \
--name "My Bincio"
# prompted for password; prints a first invite code
```
Enable the edit/upload UI (this env var is read at build time and is gitignored, so it must be set on the server):
```bash
echo "PUBLIC_EDIT_ENABLED=true" > /opt/bincio/site/.env
```
Set the user cap:
```bash
sqlite3 /var/bincio/data/instance.db \
"INSERT INTO settings VALUES ('max_users', '30');"
```
---
## 6. Prepare your own activities
Source files (raw GPX/FIT) live separately from the BAS output:
```
/var/bincio/sources/dave/ ← raw activity files, rsync'd from laptop
/var/bincio/data/dave/ ← BAS JSON output (bincio extract writes here)
```
Configure `/opt/bincio/extract_config.yaml` on the server to point to your
source dir:
```yaml
sources:
- path: /var/bincio/sources/dave/activities
type: strava_export
- path: /var/bincio/sources/dave/activities.csv
type: strava_csv
output:
dir: /var/bincio/data
workers: 2 # cap extract parallelism on the VPS (default: all CPUs)
```
Sync and extract (run from your laptop or SSH in):
```bash
# push raw files from laptop
rsync -avz ~/your-activity-data/ root@<vps>:/var/bincio/sources/dave/
# extract on server
ssh root@<vps> "cd /opt/bincio && uv run bincio extract"
# rebuild site
ssh root@<vps> "cd /opt/bincio && \
uv run bincio render --data-dir /var/bincio/data --site-dir site && \
rm -rf site/dist/data && \
rsync -a --delete --exclude=data/ site/dist/ /var/www/bincio/"
```
---
## 7. nginx
Create `/etc/nginx/sites-available/bincio`:
```nginx
server {
listen 80;
server_name yourdomain.com;
root /var/www/bincio;
index index.html;
client_max_body_size 2G; # Strava export ZIPs can exceed 1 GB
client_body_timeout 300s; # allow slow uploads without nginx dropping the connection
# API → bincio serve
location /api/ {
proxy_pass http://127.0.0.1:4041;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s; # Strava sync can be slow
}
# Data files served live from disk — bypasses the build/rsync cycle
# so uploads and merges are visible immediately without a site rebuild.
#
# IMPORTANT: because nginx owns /data/ here, the post-receive hook must
# delete dist/data/ before rsyncing to the webroot. Otherwise astro build
# copies all activity JSON (GBs) into dist/ and rsync duplicates it again.
# The hook already does this; manual rebuilds must do the same.
location /data/ {
alias /var/bincio/data/;
add_header Cache-Control "no-cache, must-revalidate";
}
# Activity detail pages: fall back to the dynamic shell for activities uploaded
# after the last site build (avoids 404 while waiting for a rebuild).
location /activity/ {
try_files $uri $uri/ /activity/index.html;
}
# Per-user profile pages: fall back to the home page while the background
# rebuild (triggered automatically on registration) completes.
location /u/ {
try_files $uri $uri/ /index.html;
}
# Static files
location / {
try_files $uri $uri/ $uri.html =404;
}
}
```
```bash
# disable the default nginx welcome page
rm /etc/nginx/sites-enabled/default
ln -s /etc/nginx/sites-available/bincio /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
```
### Enable gzip compression
The default `nginx.conf` has gzip on but `gzip_types` commented out, so only
HTML is compressed. Activity index shards are JSON and compress ~90% — enable
the full list:
```bash
# In /etc/nginx/nginx.conf, uncomment the gzip block:
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
```
```bash
nginx -t && systemctl reload nginx
```
You can verify the site is served correctly by hitting the IP directly:
`http://<your-vps-ip>/` — you should see the bincio activity feed, not the nginx welcome page.
---
## 8. SSL
SSL requires the domain to be pointing at the VPS first. In your DNS provider, add:
```
Type: A
Name: @
Value: <your-vps-ip>
TTL: 300
```
Verify propagation before running certbot:
```bash
dig yourdomain.com A +short # must return your VPS IP
```
Then:
```bash
certbot --nginx -d yourdomain.com
# certbot edits the nginx config and sets up automatic renewal
```
---
## 9. Invite users
After `bincio init` prints the first invite code, you can generate more from
the browser at `/u/{handle}/athlete/`**Invites** button (visible only to
the page owner), or directly via the CLI:
```bash
sqlite3 /var/bincio/data/instance.db \
"INSERT INTO invites (code, created_by, created_at) \
VALUES (upper(hex(randomblob(4))), 'dave', unixepoch());"
```
Share the link: `https://yourdomain.com/register/?code=XXXXXXXX`
Each new user uploads their activities via the **+** button in the top nav
(supports bulk GPX/FIT/TCX drop). They can later connect Strava for
incremental sync from the same modal.
---
## Reading user feedback
Users can submit feedback from the **Feedback** link in the nav (visible when logged in).
Submissions are stored as JSON on the server:
```
/var/bincio/data/_feedback/
{handle}.json ← one file per user, array of submissions
{handle}/ ← attached images
```
To read all feedback:
```bash
cat /var/bincio/data/_feedback/*.json | python3 -m json.tool
```
Per-user only:
```bash
cat /var/bincio/data/_feedback/pres.json | python3 -m json.tool
```
---
## Day-to-day operations
| Task | Command |
|------|---------|
| Deploy code update | `git push vps main` (from laptop) |
| Sync your raw files | `rsync -avz ~/your-activity-data/ root@<vps>:/var/bincio/sources/dave/` |
| Re-extract after sync | `ssh root@<vps> "cd /opt/bincio && uv run bincio extract"` then push again to rebuild |
| View API logs | `journalctl -u bincio -f` |
| Restart API | `systemctl restart bincio` |
| Check nginx logs | `tail -f /var/log/nginx/error.log` |
| Renew SSL (auto) | `certbot renew --dry-run` |
---
## See also
- [Multi-user architecture](multi-user.md)
- [CLI reference](../reference/cli.md)
- [API reference](../reference/api.md)
+307
View File
@@ -0,0 +1,307 @@
# Developer Guide
This guide is for developers contributing to BincioActivity.
## Prerequisites
- **Python 3.12+** with [uv](https://docs.astral.sh/uv/)
- **Node 20+** with npm
- **Git**
## Local Setup
```bash
git clone https://github.com/brutsalvadi/bincio-activity.git
cd bincio-activity
# Install Python dependencies
uv sync
# Install optional extras for multi-user development
uv sync --extra serve --extra edit
# Install Node dependencies (for the site)
cd site && npm install && cd ..
```
## Running Locally
### Single-user (fastest for testing extract logic)
```bash
# Configure where to find your test activities
cp extract_config.example.yaml extract_config.yaml
$EDITOR extract_config.yaml # set input.dirs and output.dir
# Extract activities
uv run bincio extract
# Start the dev server (no login, no API server)
uv run bincio dev --data-dir ~/bincio_data
# → http://localhost:4321/u/{handle}/
```
### Multi-user (for testing auth, write API, admin features)
```bash
# Create a test instance with an admin user
uv run bincio init --data-dir /tmp/bincio_test --handle testadmin
# Extract activities
uv run bincio extract --output /tmp/bincio_test
# Start everything (bincio serve + astro dev)
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321 (login with testadmin/{password})
```
Ctrl+C stops both servers.
## Running Tests
```bash
# All tests
uv run pytest
# Specific test file
uv run pytest tests/extract/test_parsers.py
# Specific test function
uv run pytest tests/extract/test_parsers.py::test_gpx_parser
# With verbose output
uv run pytest -vv
# With coverage report
uv run pytest --cov=bincio
```
Tests are in `tests/` and use pytest + fixtures for DRY test data.
## Project Structure
```
bincio/
extract/ Python package for GPX/FIT/TCX parsing
models.py DataPoint, ParsedActivity, LapData
parsers/ GPX, FIT, TCX parsers + factory
sport.py Sport name normalization
metrics.py Haversine-based stats (distance, elevation)
timeseries.py 1Hz downsampling → BAS timeseries object
simplify.py RDP track simplification (no external deps)
dedup.py Exact + fuzzy duplicate detection
strava_csv.py Strava activities.csv importer
writer.py BAS JSON + GeoJSON output
config.py extract_config.yaml loader
cli.py `bincio extract` command
render/
cli.py `bincio render` command
merge.py Sidecar edit overlay (produces _merged/)
edit/
cli.py `bincio edit` FastAPI server
server.py Edit API endpoints
serve/
cli.py `bincio serve` command
server.py Multi-user FastAPI server (auth, invites, admin)
db.py SQLite data layer
init_cmd.py `bincio init` bootstrap
shared/ (if needed)
site/ Astro + Svelte + Tailwind frontend
src/
layouts/ Base.astro (auth wall, nav)
pages/ Routes (activity feed, detail, login, etc.)
components/ Svelte components (maps, charts, edit drawer)
lib/ TypeScript utilities (types, format, dataloader)
tests/ pytest test suite
extract/
render/
serve/
fixtures/ Shared test data
```
## Key Concepts
### BAS (BincioActivity Schema)
Activity data flows as **BAS JSON** files in `{user}/activities/`. The format is specified in [SCHEMA.md](schema.md).
Key files:
- `{id}.json` — activity metadata + timeseries
- `_merged/` symlink — sidecar edits overlaid on activities
- `edits/{id}.md` — user-created sidecar (optional)
### Shard model
Multi-user instances use a **shard manifest** (root `index.json`) that lists per-user shards. The browser fetches all shards concurrently and merges them. This allows:
- Federation (remote shard URLs)
- Yearly pagination
- No data duplication
### Extract pipeline
```
GPX/FIT/TCX files
↓ (parse)
ParsedActivity
↓ (calculate metrics)
BAS Activity JSON
↓ (downsample to 1Hz)
Timeseries
↓ (simplify with RDP)
GeoJSON
↓ (write)
activities/{id}.json + activities/{id}.geojson
```
### Render pipeline
```
{user}/
activities/*.json (extracted)
edits/*.md (user sidecars)
↓ (merge_all)
_merged/
index.json (sidecar edits applied)
activities/{id}.json
{id}.geojson
↓ (astro build)
site/dist/
```
Editing does not require re-extraction.
## Making Changes
### Adding a new endpoint
1. Add a route in `bincio/serve/server.py` (or `bincio/edit/server.py` for single-user)
2. Add Pydantic models for request/response if needed
3. Add tests in `tests/serve/`
4. Update `docs/reference/api.md` with the new endpoint
5. If admin-only, protect it with `await _require_admin(bincio_session)`
### Adding a parser for a new format
1. Create `bincio/extract/parsers/myformat.py`
2. Implement a parser class with `parse(file_path: Path) -> ParsedActivity`
3. Register it in `bincio/extract/parsers/__init__.py`
4. Add tests in `tests/extract/test_parsers.py`
### Modifying BAS schema
1. Edit `schema/bas-v1.schema.json` (JSON Schema)
2. Update `SCHEMA.md` (human-readable spec)
3. Update TypeScript types in `site/src/lib/types.ts`
4. Add a migration if the change is breaking
### Frontend changes
**Svelte components** are in `site/src/components/`. Key ones:
- `ActivityFeed.svelte` — activity grid + filters
- `ActivityDetail.svelte` — activity page (maps, charts, photos)
- `EditDrawer.svelte` — sidecar editor
Use `uv run bincio dev` to test changes live. The site hot-reloads on file changes.
## Code Style
- **Python:** PEP 8, type hints where possible
- **JavaScript/TypeScript:** ESLint + Prettier (configured in `site/`)
- **Svelte:** No self-closing non-void tags; interactive divs need `role` + keyboard handler
## Git Workflow
1. Create a branch: `git checkout -b feature/my-feature`
2. Make changes and test locally
3. Commit: `git commit -m "Clear, specific commit message"`
4. Push: `git push origin feature/my-feature`
5. Open a pull request
**Commit message style:**
- Imperative mood ("add feature", not "added feature")
- Reference issues if relevant: "fix #123"
- First line ≤ 50 characters
- Blank line, then detailed explanation if needed
## Performance Considerations
### Extract speed
- **ProcessPoolExecutor with initializer** — large data (Strava lookups, hash sets) is sent once per worker, not per task
- **Haversine** — 10x faster than geopy for distance calculations
- **Lazy parsing** — FIT files decoded only once per task
### Render speed
- **RDP simplification** — custom implementation (no external wheels for Pyodide)
- **Gzip compression** — activity JSON and geojson are served gzipped
- **Concurrent shard fetch** — browser loads all shards in parallel
### Frontend
- **MapLibre GL v5** — requires explicit center/zoom and workarounds
- **Observable Plot** — use hyphenated curve names (e.g. `"monotone-x"`)
- **Client-only for complex components** — use `client:only="svelte"` for activity detail to avoid hydration mismatches
## Debugging
### Python
```bash
# Interactive debugger
uv run python -m pdb -m bincio.extract.cli
# Or use breakpoint() in code
breakpoint()
uv run bincio extract
```
### TypeScript
Check your editor's TypeScript integration. The site has strict `tsconfig.json`.
### Frontend
- Open DevTools (F12)
- Check the Network tab for API calls
- Check Console for client-side errors
### Database
```bash
# Inspect the SQLite database directly
sqlite3 /tmp/bincio_test/instance.db
> SELECT * FROM users;
```
## Documentation
- User-facing docs go in `docs/`
- API docs are auto-generated from FastAPI routes (and should be typed with Pydantic models)
- Code comments should explain *why*, not *what*
## Known Issues & Limitations
See the [GitHub repository](https://github.com/brutsalvadi/bincio-activity) for known issues and planned features.
## Contributing
Contributions are welcome! Please:
1. Check existing issues/PRs so you're not duplicating work
2. Open an issue first for large changes
3. Include tests for new features
4. Update docs (user guide, API ref, or developer guide)
5. Follow the code style guidelines
## See also
- [Architecture](architecture.md) — system design and data flow
- [BAS Schema](schema.md) — activity data format
- [API Reference](reference/api.md) — all HTTP endpoints
+344
View File
@@ -0,0 +1,344 @@
# Elevation gain calculation — problem analysis and roadmap
## The problem
Bincio's current algorithm naively accumulates every positive elevation delta between
consecutive track points. This **always overestimates** real climbing because it treats
sensor noise as genuine ascent. The overestimation ranges from insignificant on long
mountain rides to catastrophic on flat routes where 100% of the reported gain is noise.
---
## Current algorithm (`metrics.py:_elevation`)
```python
def _elevation(pts):
elevations = [p.elevation_m for p in pts if p.elevation_m is not None]
gain = loss = 0.0
for a, b in zip(elevations, elevations[1:]):
diff = b - a
if diff > 0:
gain += diff
else:
loss += diff
return gain, loss
```
Every positive step — including 0.1m GPS jitter, barometric quantization steps of
0.2m, and random-walk noise — is counted as climbing. There is no filtering,
smoothing, or minimum-step threshold of any kind.
---
## Root causes of overestimation
### 1. GPS-derived altitude noise
GPS units measure altitude from satellite triangulation. This is inherently less
accurate than horizontal positioning: typical GPS altitude error is ±515m, and the
error follows a correlated random walk. On a flat route, the track oscillates above
and below the true elevation, and the positive half of those oscillations accumulates
as phantom climbing.
**Characteristic signature:** elevation range far smaller than reported gain; nearly
100% of deltas are sub-1m; median step size is 0.0m.
### 2. Barometric altimeter quantization
Devices with a barometric sensor report higher-quality data, but they still apply
internal smoothing and quantise the output to fixed increments (commonly 0.2m or
0.4m). The device holds the reading steady for several seconds, then steps to the
next quantised value. Small-but-real oscillations at the quantisation boundary
(e.g. hovering between 128.2m and 128.4m while essentially flat) produce repeated
tiny up/down steps that accumulate.
**Characteristic signature:** many repeated identical elevations (device holding);
most transitions are 0.0m, 0.2m or 0.4m; significant fraction of gain from sub-1m
steps even on a real climb.
### 3. High sampling rate amplifying both effects
At 1 Hz, both GPS and barometric sensors produce more noise steps per meter of
real climbing than at lower rates. Downsampling to 1 Hz (as the timeseries writer
does) does not eliminate noise already present in the source data.
---
## Case studies
### Activity 1 — diego_p, 2026-04-11T051441Z (Wahoo ELEMNT, GPX)
- **URL:** https://bincio.org/activity/2026-04-11T051441Z/
- **Bincio reports:** 353.6m gain
- **Correct estimate:** ~150160m (Wahoo device's own reading, which applies internal
thresholding)
- **Excess:** ~200m (56% overestimate)
| Metric | Value |
|--------|-------|
| Points | 15,721 |
| Elevation range | 10.6m to +5.4m (16m total span) |
| Median \|delta\| | 0.000m |
| Zero-change steps | 12,358 (79%) |
| Sub-0.5m steps | 3,339 (21%) |
| Steps ≥ 1m | 0 (0%) |
| Gain from sub-1m steps | 353.6m (100% of total) |
**Diagnosis:** This is a flat coastal route. The GPS altitude range is only 16m.
Every single metre of reported gain is sub-1m GPS jitter — no real climbing is
recorded at all. Even a 1m threshold would produce exactly 0m gain, which is wrong
in the other direction (the route may have minor real undulation). The Wahoo device's
own algorithm uses internal hysteresis to report ~153m.
### Activity 2 — m4xw3ll__, 2026-04-14T161945Z (Bryton Rider, FIT)
- **URL:** http://your-instance/activity/2026-04-14T161945Z/
- **Bincio reports:** 1285.2m gain
- **Correct estimate:** ~885m (Strava / device reading)
- **Excess:** ~400m (45% overestimate)
| Metric | Value |
|--------|-------|
| Points | 6,583 |
| Elevation range | 0.0m to 454.0m (454m total span) |
| Median \|delta\| | 0.000m |
| Zero-change steps | 4,077 (62%) |
| Sub-0.5m steps | 769 (12%) |
| 0.51m steps | 647 (10%) |
| 12m steps | 921 (14%) |
| 2m+ steps | 168 (3%) |
| Gain from sub-1m steps | 484.0m (38% of total) |
**Diagnosis:** Real climbing exists (0454m) but 38% of the reported gain comes from
sub-1m barometric quantization noise. The Bryton records elevation at ≈0.2m
increments. At quantization boundaries the device oscillates producing repeated
tiny up/down steps. A simple 1m threshold gives 801m (10% below truth); 2m gives
only 221m (too aggressive). Pure threshold-based filtering doesn't work well here.
---
## Alternative algorithms
### A. Simple threshold
Only count a step if it exceeds `min_step_m`:
```python
gain += diff if diff >= min_step_m else 0
```
**Pros:** trivial to implement, zero overhead.
**Cons:** flat/hiking routes with gradual slopes produce many steps < threshold
that together represent real climbing. A 2m threshold already loses 30% of real
gain on the Bryton activity. Requires per-device tuning that is impractical.
---
### B. Hysteresis / dead-band accumulation
Track a "committed" elevation. Only commit a new elevation when it differs from
the last committed value by more than `threshold_m`. Accumulate from committed to
committed only.
```python
def _elevation_hysteresis(elevations, threshold_m=10.0):
gain = loss = 0.0
committed = elevations[0]
for e in elevations[1:]:
diff = e - committed
if abs(diff) >= threshold_m:
if diff > 0:
gain += diff
else:
loss += abs(diff)
committed = e
return gain, loss
```
**Pros:** naturally handles both GPS drift and barometric quantization; used by
Strava (proprietary variant), RideWithGPS (10m default), and GPSies (5m).
**Cons:** threshold choice is critical and device-dependent. On a genuine 8m climb
followed by descent, a 10m threshold records zero. Needs to be lower for cycling
than hiking (slopes are smoother, sensors better).
**Results on our case studies with threshold=10m:**
- Wahoo flat (correct ~153m): would likely produce 030m. Fixes the gross overcount
but may undercount real minor undulation.
- Bryton climb (correct ~885m): would need evaluation on the raw data.
---
### C. Moving-average pre-smoothing
Apply a sliding-window mean or Gaussian blur to the elevation series, then
accumulate naively.
```python
import statistics
def smooth(elevations, window=30):
half = window // 2
out = []
for i, e in enumerate(elevations):
lo, hi = max(0, i - half), min(len(elevations), i + half + 1)
out.append(statistics.mean(elevations[lo:hi]))
return out
gain, loss = _elevation(smooth(elevations, window=30))
```
**Pros:** easy to implement; smoothing removes high-frequency noise while preserving
long-wavelength terrain.
**Cons:** loses real short climbs (e.g. a 20m ramp over 20 seconds is averaged to
near-flat). Window size needs tuning per sample rate. Edge effects at start/end.
---
### D. Savitzky-Golay filter
A polynomial least-squares smoothing filter that better preserves peaks and
troughs than a simple moving average. Available in `scipy.signal.savgol_filter`
(scipy is already an indirect dependency via numpy, which is used nowhere critical —
but adding scipy is a dependency choice).
**Pros:** better terrain shape preservation than moving average; standard in
scientific signal processing.
**Cons:** requires scipy; harder to implement without it; window/order tuning still
required.
---
### E. Kalman filter (device-class-aware)
A Kalman filter can be tuned with separate process noise and measurement noise
parameters for GPS vs barometric data. This is what high-end cycling computers do
internally.
**Pros:** theoretically optimal; can be parameterised per device class.
**Cons:** significantly more complex; requires knowing the device class (GPS-only vs
barometric); still requires parameter tuning.
---
### F. Source-aware strategy
Use different algorithms depending on the file type and whether the device reported
enhanced (barometric) altitude:
- **FIT file with `enhanced_altitude` field**: barometric data, use hysteresis 5m
- **FIT file with GPS altitude only**: treat as GPS, use hysteresis 1015m or
discard altitude entirely and use a DEM lookup
- **GPX with `<ele>` tag**: assume GPS unless `<extensions>` contains barometric
fields; use hysteresis 1015m
- **Strava-enriched data**: Strava's API provides corrected `altitude` arrays; use
as-is with hysteresis 2m to catch quantization
---
## What Strava/Garmin/others do
| Platform | Method |
|---|---|
| Strava | Proprietary; replaces raw altitude with DEM-corrected data for GPS-only devices; applies internal smoothing before accumulation |
| Garmin Connect | Uses enhanced\_altitude (barometric), applies Kalman filter on-device; Connect re-applies server-side smoothing |
| Wahoo | On-device hysteresis (≈3m threshold); the GPX file contains already-smoothed altitude |
| RideWithGPS | 10m hysteresis by default, configurable |
| Komoot | DEM correction + smoothing |
| TrainingPeaks | Configurable threshold (5m default) |
Strava's approach of DEM (Digital Elevation Model) correction is the gold standard
for GPS-only tracks: replace the noisy GPS altitude entirely with the ground truth
from a 30m-resolution DEM such as SRTM. This requires an additional data source
(e.g. the Open-Elevation API or a locally hosted SRTM tile set) but completely
eliminates GPS altitude noise.
---
## Recommended fix
Given the two failure modes observed:
### Short term — ✅ Implemented (2026-04-20)
**Hysteresis accumulation** with source-aware thresholds, applied at extract time:
| Source | Threshold |
|---|---|
| FIT with `enhanced_altitude` (barometric) | 5 m |
| FIT with GPS altitude | 10 m |
| GPX | 10 m |
| TCX | 10 m |
`ParsedActivity.altitude_source` is set by each parser (`"barometric"` / `"gps"` /
`"unknown"`). `_elevation()` in `metrics.py` selects the threshold from this value.
New activities extracted after this change benefit automatically. Existing activities
require re-extraction from source files.
### Medium term — ✅ Implemented (2026-04-20)
**Two on-demand recalculation options** in the activity edit drawer:
#### Option 1 — Hysteresis (fast, offline)
Re-applies the same source-aware hysteresis accumulation as the extract
pipeline directly to the recorded elevation, with no network calls.
- Uses `elevation_m_original` from the timeseries (the backup saved on the
first DEM run) if present; otherwise uses the current `elevation_m`.
- Threshold: **5 m** for barometric sources, **10 m** for GPS.
- Does not modify the elevation array in the timeseries — only patches
`elevation_gain_m` / `elevation_loss_m`.
- Best for: devices with a barometric altimeter (e.g. Karoo 2, Garmin with
`enhanced_altitude`) where the recorded elevation is already accurate but
was extracted before the hysteresis fix was deployed.
#### Option 2 — DEM terrain correction (SRTM30, requires network)
Replaces the recorded GPS altitude with terrain data from an
Open-Elevation-compatible API (SRTM30, ~30 m resolution):
1. GPS track subsampled to one point per 10 s to minimise API calls.
2. Terrain elevation fetched via `POST https://api.open-elevation.com/api/v1/lookup`
in batches of 512.
3. DEM elevation linearly interpolated back to the full 1 Hz series.
4. **45 s sliding median filter** applied to suppress SRTM tile-boundary
steps (these occur every ~7 s at cycling speed and accumulate as phantom
gain through a naive threshold).
5. **10 m hysteresis** applied to the smoothed series.
6. Original elevation backed up as `elevation_m_original` in the timeseries
(only on the first DEM run — never overwrites an existing backup).
7. Timeseries and activity JSON patched in place; chart and stats update.
Best for: GPS-only devices (no barometric sensor) where the recorded
altitude is noisy and the DEM terrain is a better ground truth.
> **Why median + 10 m, not 5 m?** SRTM30 at 1 Hz produces step changes at
> tile boundaries of 13 m every few seconds. A 5 m threshold lets most of
> these through; they accumulate and can inflate the result by 50 %+. The
> 45 s median smooths the steps before the dead-band sees them; 10 m catches
> any residual outliers.
Implementation: `bincio/extract/dem.py``lookup_elevations()`,
`recalculate_elevation()`, `recalculate_elevation_hysteresis()`.
API endpoints: `POST /api/activity/{id}/recalculate-elevation/dem` and
`POST /api/activity/{id}/recalculate-elevation/hysteresis` on both servers.
Default DEM endpoint: `https://api.open-elevation.com`; override with
`--dem-url` or `DEM_URL` env var.
---
## Implementation status
| File | Status |
|---|---|
| `bincio/extract/models.py` | ✅ `altitude_source` field added |
| `bincio/extract/parsers/fit.py` | ✅ detects `enhanced_altitude` vs GPS |
| `bincio/extract/parsers/gpx.py` | ✅ sets `altitude_source = "gps"` |
| `bincio/extract/parsers/tcx.py` | ✅ sets `altitude_source = "gps"` |
| `bincio/extract/metrics.py` | ✅ hysteresis `_elevation()` with source-aware threshold |
| `bincio/extract/dem.py` | ✅ `lookup_elevations()` + `recalculate_elevation()` (median+10m) + `recalculate_elevation_hysteresis()` |
| `bincio/serve/server.py` | ✅ `POST /api/activity/{id}/recalculate-elevation/{dem\|hysteresis}` |
| `bincio/edit/server.py` | ✅ same endpoints (single-user) |
| `site/src/components/EditDrawer.svelte` | ✅ two buttons: "Recalculate (hysteresis)" + "Recalculate (DEM)" |
| `tests/test_metrics.py` | ✅ 5 parametric tests |
+109
View File
@@ -0,0 +1,109 @@
# Garmin Connect Sync — Disclaimer
**This feature uses an unofficial, community-maintained library to access Garmin Connect.
It is not affiliated with, endorsed by, or supported by Garmin Ltd. or its subsidiaries.**
---
## What this feature does
When you enable Garmin Connect sync, BincioActivity will:
1. Ask for your Garmin Connect **email address and password**
2. Store those credentials on the server, encrypted at rest
3. Use them to log in to Garmin Connect on your behalf and download your activity files (FIT format)
4. Import those activities into your BincioActivity account
---
## What you need to know before enabling this
### Your credentials are stored on the server
Unlike Strava (which uses OAuth — you authorize without sharing your password),
Garmin Connect has no official third-party API. This feature works by logging in
as you, using your actual email and password.
This means:
- The server operator has technical access to your stored credentials
- You are trusting both the software and the person running the server
- Only enable this on a server you control or run by someone you fully trust
### This uses an unofficial API
Garmin does not provide a public developer API for activity data.
This feature relies on a reverse-engineered interface that:
- May break without notice when Garmin changes their systems
- Is not covered by any Garmin service agreement or SLA
- May violate Garmin Connect's Terms of Service
BincioActivity takes no responsibility for account restrictions or bans
that may result from using this feature.
### Cloudflare bot protection and rate limiting
Garmin's login page (`sso.garmin.com`) is protected by Cloudflare, which
periodically blocks automated login attempts. When this happens, the sync
feature will fail at the login step with a "Login failed" error — even if
your credentials are correct.
The underlying `garth` library tries three login strategies in sequence.
A blocked session typically looks like this in the server logs:
```
mobile+cffi returned 429: Mobile login returned 429 — IP rate limited by Garmin
mobile+requests failed: Mobile login failed (non-JSON): HTTP 403
widget+cffi failed: Widget login: unexpected title 'GARMIN Authentication Application'
```
What each error means:
- **429** — Garmin is rate-limiting the server's IP address
- **403** — Cloudflare is blocking the request outright
- **unexpected title 'GARMIN Authentication Application'** — the login flow hit a
CAPTCHA or MFA challenge page that the library cannot handle automatically
This is an upstream issue outside BincioActivity's control. The underlying
`garminconnect`/`garth` library usually releases a fix within days to weeks.
The workaround is to update those packages on the server:
```bash
uv sync --extra garmin
```
If login consistently fails despite updating, check the
[garminconnect issue tracker](https://github.com/cyberjunky/python-garminconnect/issues)
for the current status.
### Two-factor authentication (2FA)
If your Garmin account has 2FA enabled, this feature may not work or may
require additional steps. Garmin has changed their authentication flow
several times; compatibility depends on the current state of the underlying library.
### Rate limits
Garmin does not publish API rate limits. Syncing too frequently or importing
large volumes of activities may result in temporary or permanent IP blocks.
BincioActivity applies conservative limits, but cannot guarantee uninterrupted access.
---
## How to revoke access
BincioActivity does not hold an OAuth token that can be revoked from Garmin's settings.
To stop BincioActivity from accessing your Garmin account:
1. Delete your stored credentials from BincioActivity (Settings → Garmin Connect → Disconnect)
2. **Change your Garmin Connect password** — this is the only way to guarantee that
no previously stored credentials can be used
---
## Recommendation
If you have concerns about credential storage, consider the alternative:
export your activities from Garmin Connect or Garmin Express as FIT files
and upload them directly to BincioActivity. This requires no credentials
and is always available.
+1 -1
View File
@@ -117,4 +117,4 @@ In multi-user mode the edit UI is always available via `bincio serve` — no ext
- [Single-user deployment](deployment/single-user.md) — GitHub Pages, Netlify, VPS
- [Multi-user deployment](deployment/multi-user.md) — VPS with nginx, inviting users
- [CLI reference](reference/cli.md) — all commands and options
- [BAS schema](../SCHEMA.md) — the data format and federation protocol
- [BAS schema](schema.md) — the data format and federation protocol
+1370
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# BincioActivity Documentation
Welcome to BincioActivity — a federated, self-hosted activity stats platform. This documentation is organized by audience:
## For Users
**[Getting Started](getting-started.md)** — Extract your activities from Strava/Garmin, set up a local site, and deploy it.
**[User Guide](user-guide.md)** — Upload activities, sync from Strava, edit titles/descriptions, manage photos, control privacy, configure your profile.
## For Administrators
**[Admin Guide](admin-guide.md)** — Deploy a multi-user instance, manage users, reset passwords, monitor rebuild status.
**[Multi-user Deployment](deployment/multi-user.md)** — Step-by-step setup with nginx, systemd, and multi-user architecture.
**[Single-user Deployment](deployment/single-user.md)** — Deploy as a read-only static site or with a local edit server.
## For Developers
**[Developer Guide](developer-guide.md)** — Local setup, how to run tests, architecture overview, how to contribute.
**[Architecture](architecture.md)** — BAS data format, shard model, federation protocol, federation design.
**[API Reference](reference/api.md)** — HTTP endpoints, request/response formats, authentication, rate limits.
**[CLI Reference](reference/cli.md)** — All bincio commands and options.
## Quick Links
- [GitHub repo](https://github.com/brutsalvadi/bincio-activity)
- [BAS Schema](schema.md) — The data format specification
- [Architecture diagram](architecture.mmd) (Mermaid diagram)
- Live Swagger UI at `/api/docs` (when server is running)
---
**Status:** This is early-stage, self-hosted software. See the [GitHub repo](https://github.com/brutsalvadi/bincio-activity) for known issues and planned features.
+2
View File
@@ -116,6 +116,7 @@ uv run bincio edit [OPTIONS]
| `--port PORT` | `4041` | Bind port |
| `--strava-client-id ID` | from config | Strava OAuth client ID |
| `--strava-client-secret SECRET` | from config | Strava OAuth client secret |
| `--dem-url URL` | `https://api.open-elevation.com` | Open-Elevation-compatible API for the "Recalculate elevation" button (also `DEM_URL` env var) |
Set `PUBLIC_EDIT_URL=http://localhost:4041` in `site/.env` to enable the Edit button and Upload ↑ button in the site.
@@ -164,6 +165,7 @@ uv run bincio serve [OPTIONS]
| `--site-dir DIR` | — | Astro site dir — enables post-write incremental rebuilds |
| `--host HOST` | `127.0.0.1` | Bind address (keep on localhost; nginx proxies from outside) |
| `--port PORT` | `4041` | Bind port |
| `--dem-url URL` | `https://api.open-elevation.com` | Open-Elevation-compatible API for the "Recalculate elevation" button (also `DEM_URL` env var) |
Requires `bincio init` to have been run first. Handles auth, user management, and write operations. nginx is responsible for serving static files and proxying `/api/*` to this server.
+15 -6
View File
@@ -124,7 +124,7 @@ needed to render an activity card in a feed — no timeseries, no full track.
| `avg_cadence_rpm` | integer\|null | no | Average cadence (rpm for cycling, spm for running). |
| `avg_power_w` | integer\|null | no | Average power in watts. |
| `source` | string\|null | no | Origin of data. See **Source values**. |
| `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `private`. |
| `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `unlisted`. (`private` is a deprecated alias for `unlisted`.) |
| `mmp` | array\|null | no | Mean Maximal Power curve — `[[duration_s, avg_watts], ...]`. |
| `best_efforts` | array\|null | no | Best efforts by distance — `[[distance_km, time_s], ...]`. |
| `best_climb_m` | number\|null | no | Best single climb in metres (Kadane's algorithm). |
@@ -165,12 +165,21 @@ timestamp alone is sufficient: `2024-06-01T073012Z`.
### Privacy levels
| Level | GPS track published | Timeseries lat/lon | Stats in index |
| Level | GPS track published | Timeseries lat/lon | Shown in feed |
|---|---|---|---|
| `public` | Full track | Included | Yes |
| `blur_start` | First/last 200 m removed | Trimmed | Yes |
| `no_gps` | Not published | Not included | Yes |
| `private` | Not published | Not included | No (not in index at all) |
| `public` | Full track | Included | Yes — everyone |
| `blur_start` | First/last 200 m removed | Trimmed | Yes — everyone |
| `no_gps` | Not published | Not included | Yes — everyone |
| `unlisted` | Full track | Included | No — owner only (via direct URL) |
| `private` | *(deprecated alias for `unlisted`)* | Included | No — owner only |
**`unlisted`** activities are not shown in the public feed but are fully accessible
by direct URL — the GPS track, timeseries, and detail JSON are all served as normal
static files. This is "security by obscurity": knowing the URL is sufficient to
access the activity. If you need true data exclusion, use `no_gps` for GPS removal
while keeping stats public, or delete the activity entirely.
The legacy `private` value is accepted everywhere `unlisted` is valid.
---
+198
View File
@@ -0,0 +1,198 @@
# User Guide
This guide covers everything you can do as a BincioActivity user.
## Getting Your Account
Your instance administrator sends you a registration link:
```
https://yourdomain.com/register/?code=ABCD1234
```
Click it and create:
- **Handle** — your username in URLs (lowercase letters, numbers, `_`, `-`; 130 chars)
- **Password** — at least 8 characters
- **Display name** — your full name (shown on your profile page)
You're now logged in and ready to upload activities!
## Uploading Activities
Click **Upload** to add activities from files.
### Supported formats
- **GPX** — GPS Exchange format (most common)
- **FIT** — Garmin's native format
- **TCX** — Training Center XML
- **Compressed files**`.gz` variants of any format above
### Using Strava export
If you exported activities from Strava, you likely have a folder like:
```
activities/
├── 2026-03-15_morning_run.gpx
├── 2026-03-14_evening_ride.fit
└── ...
```
Just drag the whole `activities/` folder into the upload box, or select multiple files at once.
### Upload options
- **Store original files** — keep the source GPX/FIT/TCX file on the server (checked by default; you can uncheck per upload)
- **Skip duplicates** — the system detects exact duplicates automatically
After upload, the server extracts GPS tracks, calculates distance/elevation/time, and generates your activity feed. You can keep uploading — the system deduplicates by file hash.
## Syncing from Strava
If your instance supports Strava sync, click **Sync from Strava** in the upload modal.
1. Authorize BincioActivity to read your Strava data
2. Select which activities to import
3. The server fetches GPS and metrics from Strava and stores them
Your OAuth token is stored securely on the server. You can revoke access at any time in [Strava Settings](https://www.strava.com/settings/apps).
## Editing Activities
Click **Edit** on any activity to:
- **Change the title** — rename the activity
- **Add a description** — write notes or a story (supports markdown and embedded images)
- **Upload photos** — add photos taken during the activity
- **Choose sport type** — cycling, running, hiking, etc.
- **Assign gear** — tag the bike/shoes/watch used
- **Set privacy** — hide the activity from the public feed
- **Highlight** — mark your favorite activities
Changes save instantly. The site rebuilds in the background.
### Recalculating elevation
If an activity shows an unrealistic elevation gain, the edit drawer has two buttons:
**📐 Recalculate (hysteresis)** — recomputes gain and loss from the original recorded
elevation using a noise-filtering dead-band algorithm. Fast and offline — no network
call. Best for devices with a barometric altimeter (Garmin, Karoo, Wahoo) whose
elevation data is accurate but was extracted before the noise-filtering was improved.
**⛰ Recalculate (DEM)** — replaces the recorded GPS altitude with SRTM terrain data
from the [Open-Elevation API](https://open-elevation.com) and recomputes gain and
loss. The elevation chart and summary stats both update. Best for GPS-only devices
(no barometric sensor) where the recorded altitude is noisy.
> **Note:** Both corrections require a GPS track (activities marked *No GPS* cannot be
> corrected). The DEM option uses ~30 m resolution terrain data; very short or indoor
> activities see little improvement from DEM correction.
### Photo gallery
Upload photos for an activity. They appear in a lightbox on the activity detail page. The server stores them in your data directory.
### Markdown in descriptions
Descriptions support basic markdown:
```markdown
# Title
**bold** _italic_ `code`
- bullet list
- another item
[link](https://example.com)
![image name](image.jpg)
```
Images are stored in `edits/images/{id}/` and paths are rewritten automatically.
## Privacy Control
Each activity has a privacy setting:
- **Public** (`public: true`) — visible to all logged-in users in the feed
- **Unlisted** (`private: true`) — not shown in the feed, but accessible by direct URL (for sharing)
- **No GPS** (remove GPS track) — hides the map but keeps distance/time stats
Your instance admin can also make the whole instance public or private.
### Deleting an activity
You can't delete an activity directly, but you can:
- Mark it **private** to hide it from the feed
- Edit the sidecar manually in `{data-root}/edits/{id}.md` and delete the file
## Your Profile
Click your name in the top-right to view your profile at `/u/{handle}/`. It shows:
- Your display name
- All your public activities (organized by year)
- Summary stats (total distance, time, elevation)
## Account Settings
Click your name → **Settings** to:
- **Change password** — update your account password
- **View your handle** — the username used in URLs
- **See your data** — information about what's stored on the server
If you forget your password, ask your instance administrator to generate a reset code.
## Feedback
Found a bug or want to suggest a feature? Click **Feedback** at the bottom of any page to submit a message and optional screenshots. The admin team can see all feedback submissions.
## Local Activity Conversion
If your instance has the `/convert/` page enabled, you can:
1. Upload a GPX/FIT/TCX file **locally in your browser** (no server upload)
2. The file is processed in JavaScript (powered by Pyodide, Python in the browser)
3. You see the activity preview immediately
4. You can then save it to your local browser storage (IndexedDB) or upload it to the server
This is useful for testing or converting files without uploading them first.
## Offline Activity Storage (experimental)
Activities converted locally are stored in your browser's **IndexedDB** (local storage). They:
- Don't upload to the server
- Persist across browser sessions
- Can be deleted from settings
This is useful for activities you don't want to publish yet, or for testing before uploading.
## Frequently Asked Questions
**Can I download my data?**
Your instance's complete activity feed is at `/u/{handle}/index.json` (the BAS format). You can also ask the admin to copy your data directory directly.
**Can I transfer activities between instances?**
Yes! Copy the `{handle}/activities/` and `{handle}/edits/` directories to another instance. The system uses content hashing, so you can merge multiple instances.
**What formats does my activity support?**
BincioActivity extracts GPS tracks, distance, elevation, moving time, average speed, heart rate, power, cadence, and temperature (if available in the source file).
**Can I share my activities with someone outside my instance?**
Mark activities as **unlisted** (`private: true`). Anyone with the direct URL can view them, even if they're not logged in.
**How do I delete my account?**
Ask your instance administrator. They can delete your user record from `instance.db`, which removes you from the login system. Your activity data remains for audit, but can be deleted from disk if you request it.
## See also
- [Getting Started](getting-started.md) — initial setup
- [API Reference](reference/api.md) — technical details about how data flows
- [BAS Schema](schema.md) — the activity JSON format
- [Admin Guide](admin-guide.md) — for instance admins
+66
View File
@@ -0,0 +1,66 @@
site_name: BincioActivity
site_description: Federated, open-source, self-hosted activity stats platform
site_author: Davide Brugali
repo_url: https://github.com/brutsalvadi/bincio-activity
repo_name: brutsalvadi/bincio-activity
edit_uri: edit/main/docs/
docs_dir: docs
site_dir: mkdocs-site
theme:
name: material
palette:
- scheme: light
primary: blue
accent: blue
toggle:
icon: material/lightbulb-outline
name: Switch to dark mode
- scheme: slate
primary: blue
accent: blue
toggle:
icon: material/lightbulb
name: Switch to light mode
features:
- navigation.tabs
- navigation.tabs.sticky
- navigation.top
- search.suggest
- search.highlight
- content.code.copy
- content.code.annotate
plugins:
- search
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.highlight:
use_pygments: true
- pymdownx.inlinehilite
- pymdownx.tabbed:
alternate_style: true
- toc:
permalink: true
nav:
- Home: index.md
- Getting Started: getting-started.md
- User Guide: user-guide.md
- Admin Guide: admin-guide.md
- Deployment:
- Single-user: deployment/single-user.md
- Multi-user: deployment/multi-user.md
- VPS: deployment/vps.md
- Developer:
- Developer Guide: developer-guide.md
- Architecture: architecture.md
- Reference:
- API: reference/api.md
- CLI: reference/cli.md
- Schema: schema.md
- Garmin Disclaimer: garmin_connect_disclaimer.md
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE="github-public"
BRANCH="main"
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
PUBLISH_DIR="${LOCAL_DIR}/publish"
MANIFEST="${PUBLISH_DIR}/manifest"
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
*) echo "Unknown argument: $arg"; exit 1 ;;
esac
done
if ! git -C "$LOCAL_DIR" remote get-url "$REMOTE" &>/dev/null; then
echo "ERROR: remote '${REMOTE}' not found."
echo " git remote add ${REMOTE} https://github.com/brutsalvadi/bincio-activity.git"
exit 1
fi
if [[ -n "$(git -C "$LOCAL_DIR" status --porcelain --untracked-files=no)" ]]; then
echo "ERROR: uncommitted changes. Commit or stash first."
exit 1
fi
if [[ ! -f "$MANIFEST" ]]; then
echo "ERROR: manifest not found at ${MANIFEST}"
exit 1
fi
# Collect file list AND stage into temp dir before touching git state.
# Both must happen here — `git rm -rf .` removes the manifest itself,
# so it can't be re-read during the orphan branch step.
STAGING="$(mktemp -d)"
trap 'rm -rf "$STAGING"' EXIT
FILES=()
while IFS= read -r relpath || [[ -n "$relpath" ]]; do
[[ -z "$relpath" || "$relpath" == \#* ]] && continue
override="${PUBLISH_DIR}/${relpath}"
original="${LOCAL_DIR}/${relpath}"
dest="${STAGING}/${relpath}"
mkdir -p "$(dirname "$dest")"
if [[ -f "$override" ]]; then
cp "$override" "$dest"
elif [[ -f "$original" ]]; then
cp "$original" "$dest"
else
echo "ERROR: '${relpath}' in manifest but not found (no override, no original)"
exit 1
fi
FILES+=("$relpath")
done < "$MANIFEST"
echo "Files to be published:"
printf ' %s\n' "${FILES[@]}"
if $DRY_RUN; then
echo ""
echo "Dry run complete. No changes made."
exit 0
fi
# Create orphan branch, wipe working tree, restore only manifest files
git -C "$LOCAL_DIR" checkout --orphan _public_tmp
git -C "$LOCAL_DIR" rm -rf . --quiet
cp -r "${STAGING}/." "${LOCAL_DIR}/"
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Add only manifest files — never picks up untracked files outside the manifest
for relpath in "${FILES[@]}"; do
git -C "$LOCAL_DIR" add -- "$relpath"
done
git -C "$LOCAL_DIR" commit -m "Published ${TIMESTAMP}"
git -C "$LOCAL_DIR" push --force "$REMOTE" "HEAD:${BRANCH}"
git -C "$LOCAL_DIR" checkout main
git -C "$LOCAL_DIR" branch -D _public_tmp
echo ""
echo "Done: $(git -C "$LOCAL_DIR" remote get-url "$REMOTE")"
-223
View File
@@ -1,223 +0,0 @@
# BincioActivity — Context for Claude
## What this project is
BincioActivity is a federated, open-source, self-hosted activity stats platform
(think personal Strava). Two-stage pipeline:
1. **`bincio extract`** (Python): GPX/FIT/TCX → BAS JSON data store
2. **`bincio render`** (Astro/Node): BAS data store → static website
The BAS (BincioActivity Schema) JSON files are the federation protocol.
Anyone can publish their data as BAS JSON and others can include it.
## Key design decisions
- **No database, no server** — everything is static files
- **Python with uv** for the extract stage
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
- **Haversine** (not geopy) for distance calculations (10x faster)
- **Worker initializer pattern** for ProcessPoolExecutor — large shared data
(strava_lookup dict, known_hashes frozenset) is sent once per worker via
`initializer=`, not once per task
- **BAS activity IDs** always use UTC with Z suffix for URL safety
- **TCX files** from Garmin use both `http://` and `https://` namespace URIs —
parser handles both
## Your data
- Source: `~/your-activity-data/`
- `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants)
- Any subdirectories with FIT files from Garmin/Karoo devices
- `activities.csv` — Strava metadata (names, descriptions, gear)
- Extracted output: `~/bincio_data/` (or `/tmp/bincio_test/` for testing)
Configure input paths in `extract_config.yaml`.
## Project structure
```
bincio/ Python package
extract/
models.py DataPoint, ParsedActivity, LapData
parsers/ GPX, FIT, TCX parsers + factory
sport.py sport name normalisation
metrics.py haversine-based stats computation (single pass)
timeseries.py downsample to 1Hz, build BAS timeseries object
simplify.py RDP track simplification → GeoJSON
dedup.py exact (hash) + near-duplicate detection
strava_csv.py Strava activities.csv importer
writer.py BAS JSON + GeoJSON writer
config.py extract_config.yaml loader
cli.py `bincio extract` CLI
render/
cli.py `bincio render` CLI (symlinks data, runs astro build/dev)
merge.py sidecar edit overlay (produces _merged/)
edit/
cli.py `bincio edit` CLI
server.py FastAPI write API for the edit drawer
schema/
bas-v1.schema.json JSON Schema for BAS
SCHEMA.md Human-readable BAS spec
site/ Astro project
src/
layouts/Base.astro
pages/
index.astro Activity feed (loads index.json client-side)
activity/[id].astro Single activity (SSG, loads detail JSON client-side)
stats/index.astro Heatmap + year totals
components/
ActivityFeed.svelte Card grid, sport filter, pagination
ActivityDetail.svelte Map + stats + charts + photo gallery
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
StatsView.svelte Yearly heatmap + totals
EditDrawer.svelte Slide-in edit panel (visible when PUBLIC_EDIT_URL set)
lib/
types.ts BAS TypeScript types
format.ts formatDistance, formatDuration, sportIcon, etc.
```
## How to run
```bash
# Extract
cd ~/src/bincio_activity
uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test
# Site dev server
cd site
ln -sf /tmp/bincio_test/_merged public/data # point at merged output
cp .env.example .env && $EDITOR .env # set BINCIO_DATA_DIR
npm run dev
# Edit server (optional — enables edit drawer in the site)
uv run bincio edit --data-dir /tmp/bincio_test
# set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env
# Tests
uv run pytest
```
## MapLibre GL + Vite/Astro — known gotchas
Learnt the hard way during debugging (March 2026):
- **`maplibregl.workerUrl = ...` is the v3 API and silently no-ops in v4+.**
The v5 API is `maplibregl.setWorkerUrl(url)`, but you don't need it at all in a
normal Vite environment — MapLibre handles the blob worker automatically.
- **`optimizeDeps: { exclude: ['maplibre-gl'] }` breaks tile loading.**
It prevents Vite from converting MapLibre's UMD bundle to ESM. The UMD bundle
uses AMD `define()` internally; served raw, the tile worker blob fails silently →
black map, no tiles. The correct setting is `include: ['maplibre-gl']`.
- **`build.target: 'es2022'` (and `optimizeDeps.esbuildOptions.target`) is required.**
MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it,
helpers like `__publicField` aren't available inside the serialised worker blob
scope → tile loading fails. This is a known upstream issue (maplibre-gl-js #6680).
- **Use static imports, not dynamic `await import('maplibre-gl')`, when possible.**
With `client:only="svelte"` in Astro, SSR never runs for the component so there is
no `window is not defined` risk. Static import lets Vite pre-bundle correctly.
- **Use `client:only="svelte"` (not `client:load`) for the activity detail page.**
`client:load` does SSR + hydration; complex interactive components with MapLibre
can hit hydration mismatch issues. `client:only` mounts fresh on the client only.
- **MapLibre v5 requires explicit `center` and `zoom` in the Map constructor.**
v4 silently defaulted to `center: [0,0], zoom: 0`. v5 leaves internal projection
state undefined → `Cannot read properties of undefined (reading 'lng')` crashes
on any operation that touches coordinates (markers, resize, render). Always pass
`center` and `zoom` even if you plan to `fitBounds` later.
- **MapLibre v5 requires `setLngLat()` on markers before `.addTo(map)`.**
v4 tolerated markers without coordinates. v5 calls `Marker._update()` inside
`addTo()`, which needs valid lngLat → same `'lng'` crash. Set a dummy `[0, 0]`
if the real position arrives later (e.g. hover markers).
## Observable Plot — known gotchas
- **Curve names are hyphenated, not camelCase.**
Use `"monotone-x"`, not `"monotoneX"`. Plot uses its own curve name registry
(not raw d3 identifiers). Wrong names throw `unknown curve` at runtime.
The working `astro.config.mjs` Vite section:
```js
vite: {
optimizeDeps: {
include: ['maplibre-gl'],
esbuildOptions: { target: 'es2022' },
},
build: { target: 'es2022' },
},
```
## Activity sidecar edits — design spec
Users edit activities via **sidecar markdown files** in the data dir.
No database, no server — consistent with the project's static-files-only philosophy.
### File naming
```
~/bincio_data/
activities/{id}.json ← immutable extract output
edits/{id}.md ← user edits (sidecar)
edits/images/{id}/ ← uploaded photos
_merged/ ← render-time merge output (gitignored-style)
```
### Sidecar format
```markdown
---
title: "Epic climb up Monte Grappa"
sport: cycling
hide_stats: [cadence]
highlight: true
private: false
gear: "Trek Domane"
---
Rode with friends. Legs felt great after the rest week...
```
### Editing UX: drawer in Astro + `bincio edit` write API
- `bincio edit --data-dir ~/bincio_data` starts a FastAPI server on port 4041
- Set `PUBLIC_EDIT_URL=http://localhost:4041` in `site/.env` to enable the edit button
- Clicking Edit on any activity detail page opens a slide-in drawer
- Saving writes the sidecar and triggers `merge_all()` automatically
- `bincio render` always runs `merge_all()` before build/serve and symlinks `public/data``_merged/`
### `PUBLIC_EDIT_URL` as feature flag
- **Unset** → no Edit button, normal static site
- **Set** → edit drawer enabled; lives in `site/.env` (gitignored)
## Known issues / next steps
- `bincio render` Python CLI is functional but `--watch` mode not yet implemented
- Activity IDs in older test data may use `+0000` format (pre-fix); re-run extract to get `Z` format
- Some activities appear with both untitled and titled IDs (near-dedup timing race)
- Federation (remote data sources) not yet implemented in site
- Friends pages (`/friends/{handle}/`) not yet implemented
- The `site/.env` file is gitignored — copy from `site/.env.example`
## What "good" looks like (not yet done)
- [ ] `bincio render` Python CLI wraps `astro build` properly
- [ ] Friends/federation pages in site
- [ ] Personal records page
- [ ] Activity search / full-text filter in feed
- [ ] GitHub Actions template for auto-publish
- [ ] Karoo/Garmin Connect importers beyond Strava
- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort
- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site
- [x] `PUBLIC_EDIT_URL` feature flag
- [x] Markdown rendering in activity description with image path rewriting
- [x] Photo gallery with lightbox on activity detail page
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
- [ ] Highlight badge in activity feed cards
-31
View File
@@ -1,31 +0,0 @@
owner:
handle: yourname
display_name: Your Name
input:
dirs:
- ~/Activities/gpx
- ~/Activities/fit
# Strava bulk export metadata — provides names, descriptions, gear
# metadata_csv: ~/strava_export/activities.csv
output:
dir: ~/bincio_data
default_privacy: public
sensors:
heart_rate: true
cadence: true
temperature: true
power: true
track:
simplify: rdp
rdp_epsilon: 0.0001 # ~11m at equator
timeseries_hz: 1 # 1 sample/second max
classifier:
enabled: false # ML activity type classifier (requires scikit-learn extra)
incremental: true # skip files whose hash hasn't changed since last run
-72
View File
@@ -1,72 +0,0 @@
# BincioActivity — public release manifest
# One relative path per line.
# If publish/<path> exists, that sanitized version is used instead of the original.
.gitignore
.python-version
CHANGELOG.md
CHEATSHEET.md
CLAUDE.md
README.md
SCHEMA.md
pyproject.toml
extract_config.example.yaml
schema/bas-v1.schema.json
bincio/__init__.py
bincio/cli.py
bincio/edit/__init__.py
bincio/edit/cli.py
bincio/edit/server.py
bincio/extract/__init__.py
bincio/extract/cli.py
bincio/extract/config.py
bincio/extract/dedup.py
bincio/extract/metrics.py
bincio/extract/models.py
bincio/extract/parsers/__init__.py
bincio/extract/parsers/base.py
bincio/extract/parsers/factory.py
bincio/extract/parsers/fit.py
bincio/extract/parsers/gpx.py
bincio/extract/parsers/tcx.py
bincio/extract/simplify.py
bincio/extract/sport.py
bincio/extract/strava_csv.py
bincio/extract/timeseries.py
bincio/extract/writer.py
bincio/import_/__init__.py
bincio/import_/cli.py
bincio/import_/strava.py
bincio/render/__init__.py
bincio/render/cli.py
bincio/render/merge.py
publish.sh
publish/CLAUDE.md
publish/extract_config.example.yaml
publish/manifest
site/.env.example
site/astro.config.mjs
site/package.json
site/tailwind.config.mjs
site/tsconfig.json
site/src/components/ActivityCharts.svelte
site/src/components/ActivityDetail.svelte
site/src/components/ActivityFeed.svelte
site/src/components/ActivityMap.svelte
site/src/components/AthleteDrawer.svelte
site/src/components/AthleteView.svelte
site/src/components/EditDrawer.svelte
site/src/components/MmpChart.svelte
site/src/components/RecordsView.svelte
site/src/components/StatsView.svelte
site/src/layouts/Base.astro
site/src/lib/format.ts
site/src/lib/types.ts
site/src/pages/activity/[id].astro
site/src/pages/athlete/index.astro
site/src/pages/index.astro
site/src/pages/stats/index.astro
tests/__init__.py
tests/test_merge.py
tests/test_sport.py
tests/test_writer.py
+14
View File
@@ -44,6 +44,10 @@ serve = [
strava = [
"requests>=2.32",
]
garmin = [
"garminconnect>=0.2",
"cryptography>=42.0",
]
dev = [
"pytest>=9.0",
"pytest-cov>=5.0",
@@ -52,6 +56,9 @@ dev = [
"types-pyyaml",
"types-jsonschema",
]
docs = [
"mkdocs-material>=9.5",
]
[project.scripts]
bincio = "bincio.cli:main"
@@ -64,6 +71,13 @@ dev = [
"mypy>=1.11",
"types-pyyaml",
"types-jsonschema",
"mkdocs-material>=9.5",
# serve/edit extras pulled in so test_db.py and test_server_imports.py pass in CI
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"python-multipart>=0.0.9",
"bcrypt>=4.1",
"httpx>=0.28.1",
]
[tool.ruff]
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
Bulk-set activities matching a title pattern to private by writing sidecar files.
Usage:
uv run python scripts/bulk_private.py --data-dir /var/bincio/data/brut --match "morning walk" "afternoon walk"
--dry-run Print what would be changed without writing anything.
--handle Subdirectory name (if data-dir is the root, not the user dir).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
import yaml
def parse_sidecar(path: Path) -> tuple[dict, str]:
text = path.read_text(encoding="utf-8")
if text.startswith("---"):
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
if len(parts) >= 3:
fm = yaml.safe_load(parts[1]) or {}
return fm, parts[2].strip()
return {}, text.strip()
def write_sidecar(path: Path, fm: dict, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
content = "---\n" + yaml.dump(fm, allow_unicode=True, default_flow_style=False) + "---\n"
if body:
content += "\n" + body + "\n"
path.write_text(content, encoding="utf-8")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--data-dir", required=True, help="User data directory (e.g. /var/bincio/data/brut)")
ap.add_argument("--handle", default=None, help="Handle subdir if data-dir is the instance root")
ap.add_argument("--match", nargs="+", required=True, help="Title patterns to match (case-insensitive substring)")
ap.add_argument("--dry-run", action="store_true", help="Print changes without writing")
args = ap.parse_args()
data_dir = Path(args.data_dir)
if args.handle:
data_dir = data_dir / args.handle
index_path = data_dir / "index.json"
if not index_path.exists():
sys.exit(f"ERROR: index.json not found at {index_path}")
index = json.loads(index_path.read_text(encoding="utf-8"))
activities = index.get("activities", [])
patterns = [p.lower() for p in args.match]
matched = [
a for a in activities
if any(pat in (a.get("title") or "").lower() for pat in patterns)
]
if not matched:
print("No activities matched.")
return
print(f"Found {len(matched)} matching activities:")
edits_dir = data_dir / "edits"
changed = 0
for act in matched:
aid = act["id"]
title = act.get("title", "(no title)")
date = act.get("started_at", "")[:10]
sidecar_path = edits_dir / f"{aid}.md"
# Load existing sidecar if present
if sidecar_path.exists():
fm, body = parse_sidecar(sidecar_path)
else:
fm, body = {}, ""
if fm.get("private") is True:
print(f" [already private] {date} {title}")
continue
print(f" {'[DRY RUN] ' if args.dry_run else ''}→ private {date} {title}")
if not args.dry_run:
fm["private"] = True
write_sidecar(sidecar_path, fm, body)
changed += 1
if args.dry_run:
print("\nDry run — nothing written. Re-run without --dry-run to apply.")
else:
print(f"\n{changed} sidecar(s) written.")
if changed:
print("Running merge_all …")
from bincio.render.merge import merge_all
n = merge_all(data_dir)
print(f"merge_all done ({n} sidecar(s) applied).")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Bincio VPS disk usage report
# Run on the VPS: bash scripts/disk_report.sh
# Or remotely: ssh root@<vps> 'bash -s' < scripts/disk_report.sh
DATA=/var/bincio/data
SITE=/var/bincio/site # adjust if your site build lives elsewhere
hr() { echo; echo "── $* ──────────────────────────────────────"; }
hr "DISK OVERVIEW"
df -h / | tail -1 | awk '{printf "Used: %s / %s (%s full)\n", $3, $2, $5}'
hr "BINCIO ROOT"
du -sh /var/bincio/ 2>/dev/null
hr "DATA ROOT: $DATA"
du -sh "$DATA" 2>/dev/null
hr "PER-USER BREAKDOWN"
for user_dir in "$DATA"/*/; do
handle=$(basename "$user_dir")
[[ "$handle" == _* ]] && continue # skip _feedback etc.
total=$(du -sh "$user_dir" 2>/dev/null | cut -f1)
act=$(du -sh "$user_dir/activities" 2>/dev/null | cut -f1 || echo "—")
merged=$(du -sh "$user_dir/_merged" 2>/dev/null | cut -f1 || echo "—")
edits=$(du -sh "$user_dir/edits" 2>/dev/null | cut -f1 || echo "—")
images=$(du -sh "$user_dir/edits/images" 2>/dev/null | cut -f1 || echo "—")
orig=$(du -sh "$user_dir/originals" 2>/dev/null | cut -f1 || echo "—")
orig_strava=$(du -sh "$user_dir/originals/strava" 2>/dev/null | cut -f1 || echo "—")
orig_fit=$(du -sh "$user_dir/originals" 2>/dev/null) # will count below by extension
n_act=$(find "$user_dir/activities" -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
n_orig=$(find "$user_dir/originals" -type f 2>/dev/null | wc -l | tr -d ' ')
n_strava=$(find "$user_dir/originals/strava" -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
echo ""
echo " @$handle (total: $total)"
echo " activities/ $act ($n_act JSON files)"
echo " _merged/ $merged"
echo " edits/ $edits (images: $images)"
echo " originals/ $orig ($n_orig files)"
echo " strava/ $orig_strava ($n_strava JSON)"
done
hr "FEEDBACK"
du -sh "$DATA/_feedback" 2>/dev/null || echo " (none)"
hr "SITE BUILD"
du -sh "$SITE" 2>/dev/null || echo " (not found at $SITE)"
hr "LOGS"
journalctl --disk-usage 2>/dev/null || echo " (journalctl unavailable)"
hr "LARGEST FILES IN DATA (top 20)"
find "$DATA" -type f -printf '%s\t%p\n' 2>/dev/null \
| sort -rn | head -20 \
| awk '{
size=$1; path=$2;
if (size >= 1048576) printf "%6.1f MB %s\n", size/1048576, path;
else if (size >= 1024) printf "%6.1f KB %s\n", size/1024, path;
else printf "%6d B %s\n", size, path;
}'
hr "EXTENSION BREAKDOWN IN originals/"
find "$DATA" -path "*/originals/*" -type f 2>/dev/null \
| sed 's/.*\.//' | sort | uniq -c | sort -rn \
| awk '{printf " %6d .%s\n", $1, $2}'
echo
+544
View File
@@ -0,0 +1,544 @@
#!/usr/bin/env python3
"""Generate architecture graphs for the bincio codebase.
Outputs:
docs/architecture.mmd Mermaid source (embeddable in markdown / GitHub)
docs/graph.html interactive vis.js graph (open in a browser)
Usage:
uv run python scripts/gen_graph.py
# or just:
python scripts/gen_graph.py
"""
import json
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
SITE_SRC = ROOT / "site" / "src"
DOCS = ROOT / "docs"
DOCS.mkdir(exist_ok=True)
# ── helpers ───────────────────────────────────────────────────────────────────
def read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except Exception:
return ""
def short(path: Path, base: Path) -> str:
"""Return a short display label for a file path."""
try:
rel = path.relative_to(base)
except ValueError:
rel = path
parts = rel.parts
# Drop leading site/src/ or bincio/
if parts[:2] == ("site", "src"):
parts = parts[2:]
elif parts[:1] == ("bincio",):
parts = parts[1:]
name = "/".join(parts)
# Strip index.astro → parent dir
if name.endswith("/index.astro"):
name = name[: -len("/index.astro")] + "/"
return name
# ── 1. API routes from server.py ──────────────────────────────────────────────
def extract_routes(server_path: Path) -> list[dict]:
"""Parse @app.{method}("/api/...") decorators."""
text = read(server_path)
routes = []
for m in re.finditer(
r'@app\.(get|post|put|patch|delete)\("(/api/[^"]+)"',
text,
re.MULTILINE,
):
method, path = m.group(1).upper(), m.group(2)
# Find the function name on the next non-blank line
tail = text[m.end():]
fn_m = re.search(r"async def (\w+)", tail[:200])
fn = fn_m.group(1) if fn_m else "?"
routes.append({"method": method, "path": path, "fn": fn})
return routes
# ── 2. Frontend → API edges ───────────────────────────────────────────────────
_FETCH_RE = re.compile(r"""fetch\(\s*[`'"](/api/[^`'"]+)[`'"]""")
_INTERP_RE = re.compile(r"""`[^`]*/api/([^`$\s{]+)""") # template literals
def extract_api_calls(file_path: Path) -> list[str]:
"""Return all /api/... paths referenced by a frontend file."""
text = read(file_path)
found = []
for m in _FETCH_RE.finditer(text):
found.append(m.group(1).split("?")[0]) # strip query string
# Template literals: `/api/admin/users/${h}/rebuild` → /api/admin/users/{h}/rebuild
for m in _INTERP_RE.finditer(text):
raw = "/api/" + m.group(1)
normalised = re.sub(r"\$\{[^}]+\}", "{x}", raw)
found.append(normalised)
return found
def normalise_route(path: str, routes: list[dict]) -> str | None:
"""Match a raw path like /api/admin/users/brut/rebuild to a known route pattern."""
for r in routes:
pattern = re.sub(r"\{[^}]+\}", r"[^/]+", re.escape(r["path"])) + "$"
if re.match(pattern, path):
return r["path"]
return path # keep as-is if not matched
# ── 3. Component imports (Svelte / Astro) ─────────────────────────────────────
_IMPORT_SVELTE_RE = re.compile(
r"""import\s+\w+\s+from\s+['"]([^'"]+\.svelte)['"]"""
)
_IMPORT_ASTRO_RE = re.compile(
r"""import\s+\w+\s+from\s+['"]([^'"]+\.astro)['"]"""
)
def extract_component_imports(file_path: Path) -> list[Path]:
text = read(file_path)
results = []
for pattern in (_IMPORT_SVELTE_RE, _IMPORT_ASTRO_RE):
for m in pattern.finditer(text):
ref = m.group(1)
target = (file_path.parent / ref).resolve()
if target.exists():
results.append(target)
return results
# ── 4. Python module imports ──────────────────────────────────────────────────
_PY_FROM_RE = re.compile(r"^from (bincio\.\S+) import", re.MULTILINE)
_PY_IMP_RE = re.compile(r"^import (bincio\.\S+)", re.MULTILINE)
def extract_py_imports(file_path: Path, py_files: list[Path]) -> list[Path]:
text = read(file_path)
modules = set()
for m in _PY_FROM_RE.finditer(text):
modules.add(m.group(1))
for m in _PY_IMP_RE.finditer(text):
modules.add(m.group(1))
results = []
for mod in modules:
# bincio.serve.db → bincio/serve/db.py
candidate = ROOT / Path(*mod.split(".")).with_suffix(".py")
if candidate.exists() and candidate != file_path:
results.append(candidate)
return results
# ── 5. Collect all data ───────────────────────────────────────────────────────
def collect() -> dict:
server_path = ROOT / "bincio" / "serve" / "server.py"
routes = extract_routes(server_path)
# Frontend files
fe_files = list(SITE_SRC.rglob("*.svelte")) + list(SITE_SRC.rglob("*.astro"))
# Python files (bincio package only)
py_files = [
p for p in (ROOT / "bincio").rglob("*.py")
if "__pycache__" not in str(p) and p.name != "__init__.py"
]
# --- edges: page/component → API endpoint
api_edges = [] # (source_file, route_path)
for f in fe_files:
calls = extract_api_calls(f)
for call in calls:
norm = normalise_route(call, routes)
api_edges.append((f, norm))
# --- edges: component imports
comp_edges = [] # (importer_file, imported_file)
for f in fe_files:
for dep in extract_component_imports(f):
comp_edges.append((f, dep))
# --- edges: python imports
py_edges = [] # (importer_file, imported_file)
for f in py_files:
for dep in extract_py_imports(f, py_files):
py_edges.append((f, dep))
return {
"routes": routes,
"fe_files": fe_files,
"py_files": py_files,
"api_edges": api_edges,
"comp_edges": comp_edges,
"py_edges": py_edges,
}
# ── 6. Mermaid output ─────────────────────────────────────────────────────────
def to_node_id(path: Path) -> str:
return re.sub(r"[^a-zA-Z0-9]", "_", str(path.relative_to(ROOT)))
def write_mermaid(data: dict) -> Path:
lines = ["graph LR", ""]
routes = data["routes"]
# Subgraph: API endpoints grouped by domain
domains: dict[str, list[dict]] = {}
for r in routes:
parts = r["path"].strip("/").split("/")
domain = parts[1] if len(parts) > 1 else "other"
domains.setdefault(domain, []).append(r)
lines.append(" subgraph API")
for domain, rs in sorted(domains.items()):
lines.append(f" subgraph api_{domain}[\"{domain}\"]")
for r in rs:
nid = "api_" + re.sub(r"[^a-zA-Z0-9]", "_", r["path"])
lines.append(f' {nid}["{r["method"]} {r["path"]}"]')
lines.append(" end")
lines.append(" end")
lines.append("")
# Subgraph: pages
pages = [f for f in data["fe_files"] if "/pages/" in str(f)]
lines.append(" subgraph Pages")
for f in sorted(pages):
nid = to_node_id(f)
label = short(f, ROOT)
lines.append(f' {nid}["{label}"]')
lines.append(" end")
lines.append("")
# Subgraph: components
comps = [f for f in data["fe_files"] if "/components/" in str(f)]
lines.append(" subgraph Components")
for f in sorted(comps):
nid = to_node_id(f)
label = short(f, ROOT)
lines.append(f' {nid}["{label}"]')
lines.append(" end")
lines.append("")
# Subgraph: Python modules
py_groups: dict[str, list[Path]] = {}
for f in data["py_files"]:
rel = f.relative_to(ROOT / "bincio")
group = rel.parts[0] if len(rel.parts) > 1 else "root"
py_groups.setdefault(group, []).append(f)
lines.append(" subgraph Python")
for group, files in sorted(py_groups.items()):
lines.append(f' subgraph py_{group}["{group}"]')
for f in sorted(files):
nid = to_node_id(f)
lines.append(f' {nid}["{f.stem}"]')
lines.append(" end")
lines.append(" end")
lines.append("")
# Edges: page/component → API
seen = set()
for src, route_path in data["api_edges"]:
src_nid = to_node_id(src)
dst_nid = "api_" + re.sub(r"[^a-zA-Z0-9]", "_", route_path)
edge = f" {src_nid} -->|fetch| {dst_nid}"
if edge not in seen:
lines.append(edge)
seen.add(edge)
# Edges: component imports
seen_comp = set()
for src, dst in data["comp_edges"]:
src_nid = to_node_id(src)
dst_nid = to_node_id(dst)
edge = f" {src_nid} --> {dst_nid}"
if edge not in seen_comp:
lines.append(edge)
seen_comp.add(edge)
# Edges: python imports
seen_py = set()
for src, dst in data["py_edges"]:
src_nid = to_node_id(src)
dst_nid = to_node_id(dst)
edge = f" {src_nid} --> {dst_nid}"
if edge not in seen_py:
lines.append(edge)
seen_py.add(edge)
out = DOCS / "architecture.mmd"
out.write_text("\n".join(lines), encoding="utf-8")
return out
# ── 7. vis.js HTML output ─────────────────────────────────────────────────────
def write_visjs(data: dict) -> Path:
nodes: list[dict] = []
edges: list[dict] = []
node_ids: dict[str, int] = {}
def add_node(key: str, label: str, group: str, title: str = "") -> int:
if key in node_ids:
return node_ids[key]
nid = len(nodes)
node_ids[key] = nid
nodes.append({"id": nid, "label": label, "group": group, "title": title or label})
return nid
def add_edge(src_key: str, dst_key: str, label: str = "") -> None:
if src_key not in node_ids or dst_key not in node_ids:
return
e: dict = {"from": node_ids[src_key], "to": node_ids[dst_key], "arrows": "to"}
if label:
e["label"] = label
edges.append(e)
# API endpoint nodes
for r in data["routes"]:
key = f"api:{r['path']}"
label = f"{r['method']}\n{r['path']}"
add_node(key, label, "api", f"{r['method']} {r['path']}{r['fn']}()")
# Frontend file nodes
for f in data["fe_files"]:
key = str(f)
label = f.name.replace("/index.astro", "/").replace("index.astro", f.parent.name + "/")
is_page = "/pages/" in str(f)
is_layout = "/layouts/" in str(f)
group = "page" if is_page else ("layout" if is_layout else "component")
title = short(f, ROOT)
add_node(key, label, group, title)
# Python module nodes
for f in data["py_files"]:
key = str(f)
rel = f.relative_to(ROOT / "bincio")
group = "py_" + rel.parts[0] if len(rel.parts) > 1 else "py_root"
add_node(key, f.stem, group, str(f.relative_to(ROOT)))
# Edges: page/component → API
seen = set()
for src, route_path in data["api_edges"]:
src_key = str(src)
dst_key = f"api:{route_path}"
k = (src_key, dst_key)
if k not in seen:
seen.add(k)
add_edge(src_key, dst_key, "fetch")
# Edges: component imports
seen_comp = set()
for src, dst in data["comp_edges"]:
k = (str(src), str(dst))
if k not in seen_comp:
seen_comp.add(k)
add_edge(str(src), str(dst))
# Edges: python imports
seen_py = set()
for src, dst in data["py_edges"]:
k = (str(src), str(dst))
if k not in seen_py:
seen_py.add(k)
add_edge(str(src), str(dst))
# Group colours for legend
groups = {
"api": {"color": {"background": "#f59e0b", "border": "#d97706"}, "font": {"color": "#000"}},
"page": {"color": {"background": "#3b82f6", "border": "#2563eb"}, "font": {"color": "#fff"}},
"component": {"color": {"background": "#8b5cf6", "border": "#7c3aed"}, "font": {"color": "#fff"}},
"layout": {"color": {"background": "#06b6d4", "border": "#0891b2"}, "font": {"color": "#000"}},
"py_extract": {"color": {"background": "#22c55e", "border": "#16a34a"}, "font": {"color": "#000"}},
"py_render": {"color": {"background": "#84cc16", "border": "#65a30d"}, "font": {"color": "#000"}},
"py_serve": {"color": {"background": "#ef4444", "border": "#dc2626"}, "font": {"color": "#fff"}},
"py_edit": {"color": {"background": "#f97316", "border": "#ea580c"}, "font": {"color": "#fff"}},
"py_root": {"color": {"background": "#6b7280", "border": "#4b5563"}, "font": {"color": "#fff"}},
}
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Bincio architecture graph</title>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: #0f172a; color: #e2e8f0; font-family: system-ui, sans-serif; overflow: hidden; }}
#toolbar {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #1e293b; border-bottom: 1px solid #334155; flex-wrap: wrap; }}
#toolbar h1 {{ font-size: 14px; font-weight: 600; color: #94a3b8; margin-right: 8px; }}
.filter-group {{ display: flex; gap: 6px; flex-wrap: wrap; }}
.filter-group label {{ display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; padding: 3px 8px; border-radius: 4px; border: 1px solid #334155; }}
.filter-group label:hover {{ background: #334155; }}
.dot {{ width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }}
#search {{ background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 4px 10px; border-radius: 6px; font-size: 12px; width: 180px; }}
#search::placeholder {{ color: #475569; }}
#info {{ margin-left: auto; font-size: 11px; color: #64748b; white-space: nowrap; }}
#graph {{ position: fixed; left: 0; right: 0; bottom: 0; }}
#tooltip {{ position: fixed; background: #1e293b; border: 1px solid #334155; border-radius: 6px; padding: 8px 12px; font-size: 12px; color: #e2e8f0; pointer-events: none; display: none; max-width: 320px; z-index: 100; }}
</style>
</head>
<body>
<div id="toolbar">
<h1>Bincio architecture</h1>
<div class="filter-group">
<label><input type="checkbox" data-group="api" checked> <span class="dot" style="background:#f59e0b"></span> API endpoints</label>
<label><input type="checkbox" data-group="page" checked> <span class="dot" style="background:#3b82f6"></span> Pages</label>
<label><input type="checkbox" data-group="component" checked> <span class="dot" style="background:#8b5cf6"></span> Components</label>
<label><input type="checkbox" data-group="layout" checked> <span class="dot" style="background:#06b6d4"></span> Layouts</label>
<label><input type="checkbox" data-group="py_extract" checked> <span class="dot" style="background:#22c55e"></span> extract</label>
<label><input type="checkbox" data-group="py_render" checked> <span class="dot" style="background:#84cc16"></span> render</label>
<label><input type="checkbox" data-group="py_serve" checked> <span class="dot" style="background:#ef4444"></span> serve</label>
<label><input type="checkbox" data-group="py_edit" checked> <span class="dot" style="background:#f97316"></span> edit</label>
</div>
<input id="search" type="text" placeholder="Search nodes…" />
<span id="info"></span>
</div>
<div id="graph"></div>
<div id="tooltip"></div>
<script>
const allNodes = {json.dumps(nodes, indent=2)};
const allEdges = {json.dumps(edges, indent=2)};
const groups = {json.dumps(groups, indent=2)};
// Size the graph container to fill below the toolbar
function sizeGraph() {{
const tb = document.getElementById('toolbar');
const g = document.getElementById('graph');
const h = tb.getBoundingClientRect().height;
g.style.top = h + 'px';
g.style.height = (window.innerHeight - h) + 'px';
}}
sizeGraph();
window.addEventListener('resize', () => {{ sizeGraph(); if (window._network) window._network.redraw(); }});
const nodesDS = new vis.DataSet(allNodes);
const edgesDS = new vis.DataSet(allEdges);
const container = document.getElementById('graph');
const options = {{
nodes: {{
shape: 'box',
borderWidth: 1,
font: {{ size: 11, face: 'monospace' }},
margin: 6,
}},
edges: {{
smooth: {{ type: 'continuous' }},
color: {{ color: '#334155', highlight: '#60a5fa' }},
font: {{ size: 10, color: '#64748b', align: 'middle' }},
width: 1,
selectionWidth: 2,
}},
groups,
physics: {{
solver: 'forceAtlas2Based',
forceAtlas2Based: {{ gravitationalConstant: -40, springLength: 120 }},
stabilization: {{ iterations: 200 }},
}},
interaction: {{
hover: true,
tooltipDelay: 100,
navigationButtons: true,
keyboard: true,
}},
}};
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, options);
window._network = network;
// Info count
document.getElementById('info').textContent =
`${{allNodes.length}} nodes · ${{allEdges.length}} edges`;
// Tooltip on hover
const tooltip = document.getElementById('tooltip');
network.on('hoverNode', params => {{
const node = nodesDS.get(params.node);
tooltip.textContent = node.title || node.label;
tooltip.style.display = 'block';
}});
network.on('blurNode', () => {{ tooltip.style.display = 'none'; }});
document.addEventListener('mousemove', e => {{
tooltip.style.left = (e.clientX + 14) + 'px';
tooltip.style.top = (e.clientY + 14) + 'px';
}});
// Highlight connected nodes on click
network.on('click', params => {{
if (!params.nodes.length) {{ network.unselectAll(); return; }}
const nid = params.nodes[0];
const connected = network.getConnectedNodes(nid);
network.selectNodes([nid, ...connected]);
}});
// Group visibility toggle
document.querySelectorAll('[data-group]').forEach(cb => {{
cb.addEventListener('change', () => {{
const group = cb.dataset.group;
const hidden = !cb.checked;
const toUpdate = allNodes
.filter(n => n.group === group)
.map(n => ({{ id: n.id, hidden }}));
nodesDS.update(toUpdate);
}});
}});
// Search / highlight
document.getElementById('search').addEventListener('input', e => {{
const q = e.target.value.trim().toLowerCase();
if (!q) {{ nodesDS.update(allNodes.map(n => ({{ id: n.id, opacity: 1 }}))); return; }}
const updates = allNodes.map(n => {{
const match = (n.label + n.title).toLowerCase().includes(q);
return {{ id: n.id, opacity: match ? 1 : 0.15 }};
}});
nodesDS.update(updates);
}});
</script>
</body>
</html>
"""
out = DOCS / "graph.html"
out.write_text(html, encoding="utf-8")
return out
# ── main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("Collecting codebase graph data…")
data = collect()
r = len(data["routes"])
f = len(data["fe_files"])
p = len(data["py_files"])
ae = len(data["api_edges"])
ce = len(data["comp_edges"])
pe = len(data["py_edges"])
print(f" {r} API routes | {f} frontend files | {p} Python modules")
print(f" {ae} API call edges | {ce} component import edges | {pe} Python import edges")
mmd = write_mermaid(data)
print(f"\nMermaid → {mmd.relative_to(ROOT)}")
html = write_visjs(data)
print(f"vis.js → {html.relative_to(ROOT)}")
print("\nOpen docs/graph.html in a browser to explore interactively.")
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Pull user feedback from the VPS into ./feedback/ locally.
# Usage: bash scripts/pull_feedback.sh <user@host>
set -e
VPS=${1:?Usage: $0 user@host}
REMOTE=/var/bincio/data/_feedback
LOCAL=$(dirname "$0")/../feedback
mkdir -p "$LOCAL"
echo "Syncing feedback from $VPS:$REMOTE$LOCAL"
rsync -avz --progress "${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
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
REPO=/opt/bincio-repo.git
DEPLOY=/opt/bincio
DATA=/var/bincio/data
echo "--- Syncing Python deps ---"
cd $DEPLOY
~/.local/bin/uv sync --extra serve --extra strava --extra garmin
echo "--- Syncing JS deps ---"
cd $DEPLOY/site
npm install --silent
echo "--- Building site ---"
cd $DEPLOY
~/.local/bin/uv run bincio render --data-dir $DATA --site-dir $DEPLOY/site
echo "--- Copying dist to webroot ---"
rsync -a --delete $DEPLOY/site/dist/ /var/www/bincio/
echo "--- Restarting API ---"
systemctl restart bincio || echo "WARNING: bincio service restart failed — check journalctl -u bincio"
echo "--- Done ---"
+22
View File
@@ -25,6 +25,28 @@ export default defineConfig({
// In production nginx handles this — same pattern, no code change needed.
server: {
proxy: {
// Both /api/upload and /api/upload/strava-zip return SSE streams in response
// to POST requests. Vite's default proxy buffers the full body before forwarding,
// which breaks streaming and causes EPIPE on long uploads.
// selfHandleResponse + manual pipe sends chunks as they arrive.
'/api/upload': {
target: serveTarget,
changeOrigin: true,
selfHandleResponse: true,
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes, req, res) => {
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
proxy.on('error', (err, _req, res) => {
if (err.code === 'EPIPE' || err.code === 'ECONNRESET') return;
if (!res.headersSent) {
res.writeHead(502);
res.end('proxy error');
}
});
},
},
'/api': {
target: serveTarget,
changeOrigin: true,
+1 -1
View File
@@ -22,7 +22,7 @@
"@capacitor/filesystem": "^8.1.2",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/ios": "^8.3.0",
"@observablehq/plot": "^0.6.0",
"@observablehq/plot": "0.6.17",
"@types/dompurify": "^3.0.5",
"astro": "^5.0.0",
"dompurify": "^3.3.3",
+60 -18
View File
@@ -21,15 +21,20 @@
let chartEl: HTMLDivElement;
let chart: SVGElement | null = null;
// Cumulative distance in km, integrated from speed_kmh
// Cumulative distance in km, integrated from speed_kmh.
// Speeds > 150 km/h are treated as 0 (GPS glitch guard) — otherwise a single
// 1-second spike at 220 km/h pushes all subsequent points ~60 m too far right
// on the distance axis and stretches the chart out of proportion.
$: dist_km = (() => {
if (!timeseries.speed_kmh.some(v => v != null)) return null;
const d: (number | null)[] = [0];
const d: number[] = [0];
for (let i = 1; i < timeseries.t.length; i++) {
const v = timeseries.speed_kmh[i];
const dt = timeseries.t[i] - timeseries.t[i - 1];
const prev = d[i - 1];
d.push(v != null && prev != null ? prev + v * dt / 3600 : prev);
// Clamp to 150 km/h; treat null or out-of-range as 0 movement
const vSafe = (v != null && v > 0 && v <= 150) ? v : 0;
d.push(prev + vSafe * dt / 3600);
}
return d;
})();
@@ -79,6 +84,15 @@
$: dataMin = metricValues.length ? Math.min(...metricValues) : 0;
$: dataMax = metricValues.length ? Math.max(...metricValues) : 100;
// Explicit y domain for the line chart.
// We compute this once from all data and pass it explicitly to Plot so that
// switching x-axis mode (time ↔ distance) never changes the y range — Observable
// Plot auto-infers different domains when the x-channel changes because it only
// considers plottable points, but we want the scale to stay anchored to the
// full dataset. areaY extends down to 0, so include 0 in the minimum.
$: lineDomainMin = Math.min(0, dataMin);
$: lineDomainMax = dataMax;
// Range handles — reset whenever the metric or chart type changes
let trimMin = 0;
let trimMax = 100;
@@ -121,9 +135,27 @@
// Reset when switching away from a zone-capable metric or leaving histogram
$: if (!canAlignZones) alignZones = false;
// ── Theme-aware colours ──────────────────────────────────────────────────
function getThemeColors() {
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
return {
axis: isDark ? '#71717a' : '#52525b', // zinc-500 / zinc-600
rule: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)',
tooltipFg: isDark ? '#ffffff' : '#18181b',
tooltipBg: isDark ? '#09090b' : '#ffffff', // text outline backing
ruleY: isDark ? '#3f3f46' : '#d4d4d8', // baseline rule
};
}
// ── Rendering ────────────────────────────────────────────────────────────
onMount(() => { renderChart(); });
onDestroy(() => { chart?.remove(); chart = null; });
let themeObserver: MutationObserver | null = null;
onMount(() => {
renderChart();
themeObserver = new MutationObserver(() => renderChart());
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
});
onDestroy(() => { chart?.remove(); chart = null; themeObserver?.disconnect(); });
$: if (chartEl) {
activeTab; xMode; chartType; histData; histThresholds; alignZones;
@@ -162,24 +194,33 @@
function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) {
const x = xMode === 'distance' ? 'dist_km' : 't';
const tc = getThemeColors();
const marks: any[] = [];
// monotone-x requires strictly increasing x. In time mode t is always
// strictly increasing. In distance mode, stopped segments produce many
// consecutive points with identical dist_km, which causes NaN Bézier
// control points and visual artifacts — use linear instead.
const curve = xMode === 'distance' ? 'linear' : 'monotone-x';
if (activeTab === 'cadence') {
marks.push(Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }));
marks.push(Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }));
} else {
marks.push(
Plot.areaY(data, { x, y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }),
Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }),
Plot.areaY(data, { x, y: yKey, fill: color, fillOpacity: 0.15, curve }),
Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }),
);
}
marks.push(
Plot.ruleX(data, Plot.pointerX({ x, stroke: 'rgba(255,255,255,0.3)', strokeWidth: 1, strokeDasharray: '4,4' })),
Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: 'white', strokeWidth: 1.5 })),
Plot.ruleX(data, Plot.pointerX({ x, stroke: tc.rule, strokeWidth: 1, strokeDasharray: '4,4' })),
Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: tc.tooltipBg, strokeWidth: 1.5 })),
Plot.text(data, Plot.pointerX({
x, y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
dy: -12,
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
fontSize: 11, fontWeight: '600',
})),
);
@@ -193,9 +234,9 @@
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' },
style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 },
y: { label: yLabel, grid: true, tickCount: 4 },
y: { label: yLabel, grid: true, tickCount: 4, domain: [lineDomainMin, lineDomainMax] },
marks,
});
}
@@ -204,6 +245,7 @@
const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`;
const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null;
const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS;
const tc = getThemeColors();
// ── Zone-aligned: one colored bar per zone ──────────────────────────────
if (alignZones && rawZones?.length) {
@@ -224,7 +266,7 @@
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' },
style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: yLabel, grid: false, domain: [clampedZones[0][0], clampedZones[clampedZones.length - 1][1]] },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks: [
@@ -240,7 +282,7 @@
fontSize: 10, fontWeight: '600',
dy: -8,
}),
Plot.ruleY([0], { stroke: '#52525b' }),
Plot.ruleY([0], { stroke: tc.ruleY }),
],
});
}
@@ -251,7 +293,7 @@
{ y: 'count' },
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
)),
Plot.ruleY([0], { stroke: '#52525b' }),
Plot.ruleY([0], { stroke: tc.ruleY }),
];
if (rawZones?.length) {
@@ -282,7 +324,7 @@
return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' },
style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks,
@@ -347,7 +389,7 @@
</div>
</div>
<div bind:this={chartEl} class="w-full overflow-hidden"></div>
<div bind:this={chartEl} class="w-full overflow-hidden" style="min-height:220px"></div>
<!-- Histogram controls (range + bins) -->
{#if chartType === 'histogram'}
+16 -6
View File
@@ -54,7 +54,7 @@
}
$: trackUrl = activity.track_url
? (activity.track_url.startsWith('http') ? activity.track_url : `${base}data/${activity.track_url}`)
? (activity.track_url.startsWith('http') || activity.track_url.startsWith('/') ? activity.track_url : `${base}data/${activity.track_url}`)
: null;
$: color = sportColor(activity.sport);
@@ -70,22 +70,31 @@
$: rawDescription = localDescription || detail?.description || '';
$: descriptionHtml = (() => {
if (!rawDescription) return '';
// Strip local image refs before marked sees them. marked only parses ![alt](url) as an
// image when the URL has no spaces — filenames like "WhatsApp Image 2026.jpg" are left
// as literal text instead. The lazy .*? anchored to the image extension handles filenames
// with spaces and nested parens (e.g. "file(2).jpg") correctly.
const stripped = rawDescription
.replace(/!\[[^\]]*\]\((?!https?:\/\/|\/|data:).*?\.(?:jpe?g|png|gif|webp|bmp|avif|heic)\)/gi, '')
.trim();
if (!stripped) return '';
const renderer = new marked.Renderer();
// Local relative images are always shown in the gallery — suppress inline rendering
// Any remaining remote images render inline; local ones (shouldn't exist after strip) are suppressed
renderer.image = ({ href, title, text }) => {
const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:');
if (isLocal) return '';
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
};
return DOMPurify.sanitize(marked(rawDescription, { renderer }) as string);
return DOMPurify.sanitize(marked(stripped, { renderer }) as string);
})();
// Derive image dir from detail_url so multi-user paths resolve correctly.
// "dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
// Relative: "dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
// Absolute: "/data/dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
$: imageBase = (() => {
const du = activity.detail_url ?? '';
const dir = du.startsWith('http')
const dir = du.startsWith('http') || du.startsWith('/')
? du.substring(0, du.lastIndexOf('/') + 1)
: du.includes('/')
? `${base}data/${du.substring(0, du.lastIndexOf('/') + 1)}`
@@ -112,7 +121,7 @@
<svelte:window on:keydown={onKeydown} />
{#if editOpen && editEnabled}
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} on:close={() => editOpen = false} />
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} on:close={() => editOpen = false} on:deleted={() => { window.location.href = base; }} />
{/if}
<!-- Lightbox -->
@@ -244,6 +253,7 @@
{trackUrl}
{timeseries}
bbox={detail?.bbox ?? null}
initialCoords={activity.preview_coords}
accentColor={color}
bind:hoveredIdx
/>
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import { loadIndexPaged } from '../lib/dataloader';
import ActivityDetail from './ActivityDetail.svelte';
import { isUnlisted } from '../lib/format';
import type { ActivitySummary, BASIndex } from '../lib/types';
export let base: string = '/';
let activity: ActivitySummary | null = null;
let notFound = false;
let loading = true;
/**
* Build an ActivitySummary stub from a detail JSON object.
* Used when we fetch the detail file directly without going through the index.
*/
function summaryFromDetail(d: any, detailUrl: string, handle?: string): ActivitySummary {
return {
id: d.id,
title: d.title ?? d.id,
sport: d.sport ?? 'other',
sub_sport: d.sub_sport ?? null,
started_at: d.started_at ?? '',
distance_m: d.distance_m ?? null,
duration_s: d.duration_s ?? null,
moving_time_s: d.moving_time_s ?? null,
elevation_gain_m: d.elevation_gain_m ?? null,
avg_speed_kmh: d.avg_speed_kmh ?? null,
max_speed_kmh: d.max_speed_kmh ?? null,
avg_hr_bpm: d.avg_hr_bpm ?? null,
max_hr_bpm: d.max_hr_bpm ?? null,
avg_cadence_rpm: d.avg_cadence_rpm ?? null,
avg_power_w: d.avg_power_w ?? null,
mmp: d.mmp ?? null,
source: d.source ?? null,
privacy: d.privacy ?? 'public',
detail_url: detailUrl,
track_url: d.bbox && d.privacy !== 'no_gps'
? detailUrl.replace(/\.json$/, '.geojson')
: null,
preview_coords: null,
...(handle ? { handle } : {}),
};
}
/**
* Fallback: fetch the activity detail file directly without loading the index.
* Tries single-user path first, then each multi-user handle shard.
*/
async function fetchActivityDirect(id: string): Promise<ActivitySummary | null> {
// Single-user: public/data → _merged/, so activities/ resolves directly
try {
const url = `${base}data/activities/${id}.json`;
const r = await fetch(url);
if (r.ok) {
const d = await r.json();
if (d.id === id) return summaryFromDetail(d, `activities/${id}.json`);
}
} catch { /* fall through */ }
// Multi-user: try each handle shard
try {
const r = await fetch(`${base}data/index.json`);
if (!r.ok) return null;
const root: BASIndex = await r.json();
for (const shard of (root.shards ?? [])) {
if (!shard.handle) continue;
const url = `${base}data/${shard.handle}/_merged/activities/${id}.json`;
try {
const dr = await fetch(url);
if (!dr.ok) continue;
const d = await dr.json();
if (d.id === id) {
return summaryFromDetail(
d,
`${shard.handle}/_merged/activities/${id}.json`,
shard.handle,
);
}
} catch { /* try next */ }
}
} catch { /* ignore */ }
return null;
}
onMount(async () => {
// Extract activity ID from the URL path: /activity/{id}/
const match = window.location.pathname.match(/\/activity\/([^/]+)/);
const id = match?.[1];
if (!id) { notFound = true; loading = false; return; }
try {
// Load only the most-recent year shard — avoids downloading all years just
// to look up one activity. Falls back to a direct file fetch if not found.
const { index } = await loadIndexPaged(base);
activity = index.activities.find(a => a.id === id) ?? null;
if (!activity) {
// Not in first year shard (old activity) or shard fetch failed —
// fetch the detail file directly to avoid loading all remaining shards.
activity = await fetchActivityDirect(id);
}
if (!activity) notFound = true;
} catch {
notFound = true;
}
loading = false;
});
</script>
{#if loading}
<p class="text-zinc-500 text-sm mt-8 text-center">Loading activity…</p>
{:else if notFound}
<div class="text-center mt-16">
<p class="text-zinc-400 text-sm mb-2">Activity not found.</p>
<p class="text-zinc-600 text-xs">It may still be processing — try refreshing in a moment.</p>
<a href={base} class="mt-4 inline-block text-blue-400 hover:text-blue-300 text-sm">← Back to feed</a>
</div>
{:else if activity}
<ActivityDetail {activity} {base} />
{/if}
+99 -15
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndex } from '../lib/dataloader';
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndexPaged, loadShardActivities, loadCombinedFeed, loadCombinedFeedPage } from '../lib/dataloader';
/** Render preview_coords as an SVG polyline path string. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
@@ -41,12 +41,63 @@
let sport: Sport | 'all' = 'all';
let shown = PAGE_SIZE;
let loading = true;
let loadingMore = false;
let error = '';
let mounted = false;
let pendingShards: string[] = [];
/** Remaining combined-feed pages (multi-user global feed). */
let feedNextPage = 0;
let feedTotalPages = 0;
/** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */
let totalActivities = 0;
/** Logged-in handle — resolved async via bincio:me event. */
let me: string = '';
$: filtered = sport === 'all' ? all : all.filter(a => a.sport === sport);
// Show private activities only to their owner.
// On a profile page (filterHandle set): show unlisted if me === filterHandle.
// On the global feed: show unlisted only for the logged-in user's own activities.
$: isOwner = filterHandle !== '' && me === filterHandle;
$: withPrivacy = all.filter(a => {
if (isUnlisted(a.privacy)) {
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
}
return true;
});
$: filtered = sport === 'all' ? withPrivacy : withPrivacy.filter(a => a.sport === sport);
$: visible = filtered.slice(0, shown);
$: hasMore = shown < filtered.length;
$: canShowMore = shown < filtered.length;
$: hasMore = canShowMore || pendingShards.length > 0 || feedNextPage > 0;
async function loadMore() {
if (canShowMore) {
shown += PAGE_SIZE;
return;
}
loadingMore = true;
try {
let fresh: ActivitySummary[] = [];
if (feedNextPage > 0) {
fresh = await loadCombinedFeedPage(base, feedNextPage);
feedNextPage = feedNextPage < feedTotalPages ? feedNextPage + 1 : 0;
} else if (pendingShards.length) {
const url = pendingShards[0];
pendingShards = pendingShards.slice(1);
fresh = await loadShardActivities(url);
} else {
return;
}
const existing = new Map(all.map(a => [a.id, a]));
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
all = [...existing.values()].sort((a, b) =>
(b.started_at ?? '').localeCompare(a.started_at ?? ''),
);
shown += PAGE_SIZE;
} catch {
// load failed — don't block the user
} finally {
loadingMore = false;
}
}
$: if (sport) shown = PAGE_SIZE; // reset pagination on filter change
@@ -60,18 +111,35 @@
onMount(async () => {
sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all';
mounted = true;
// Resolve the logged-in handle so we can show the owner their private activities.
if ((window as any).__bincioMe !== undefined) {
me = (window as any).__bincioMe;
} else {
window.addEventListener('bincio:me', (e: Event) => { me = (e as CustomEvent).detail; }, { once: true });
}
try {
const isGlobalFeed = !profileIndexUrl && !filterHandle;
if (isGlobalFeed) {
const combined = await loadCombinedFeed(base);
if (combined) {
all = combined.activities;
totalActivities = combined.totalActivities;
feedTotalPages = combined.remainingPages + 1;
feedNextPage = combined.remainingPages > 0 ? 2 : 0;
loading = false;
return;
}
}
const indexUrl = profileIndexUrl
? `${base}data/${profileIndexUrl}`
: `${base}data/index.json`;
const index = await loadIndex(base, indexUrl);
let activities = index.activities.filter(a => a.privacy !== 'private');
// filterHandle only applies when loading the root manifest (multi-user feed).
// When profileIndexUrl is set we already loaded the right user's shard directly —
// activities from a direct shard fetch have no handle tag, so the filter would
// remove everything.
const { index, pendingShards: pending } = await loadIndexPaged(base, indexUrl);
pendingShards = pending;
let activities = index.activities;
if (filterHandle && !profileIndexUrl) {
activities = activities.filter(a => a.handle === filterHandle);
activities = activities.filter(a => (a as any).handle === filterHandle);
}
all = activities;
} catch (e: any) {
@@ -110,7 +178,11 @@
{/each}
{#if all.length > 0}
<span class="ml-auto text-sm text-zinc-500 self-center">
{#if totalActivities > filtered.length}
{filtered.length} of {totalActivities} activities
{:else}
{filtered.length} {filtered.length === 1 ? 'activity' : 'activities'}
{/if}
</span>
{/if}
</div>
@@ -140,10 +212,13 @@
>@{a.handle}</a>{/if}
</p>
<!-- stretched link covers the whole card; sits below the handle link -->
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors flex items-center gap-1.5">
{#if isUnlisted(a.privacy)}
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
{/if}
<a
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
class="before:absolute before:inset-0 before:content-['']"
class="before:absolute before:inset-0 before:content-[''] truncate"
>{a.title}</a>
</h3>
</div>
@@ -207,10 +282,19 @@
{#if hasMore}
<div class="text-center mt-8">
<button
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors text-sm"
on:click={() => shown += PAGE_SIZE}
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white disabled:opacity-40 transition-colors text-sm"
disabled={loadingMore}
on:click={loadMore}
>
{#if loadingMore}
Loading…
{:else if canShowMore}
Load more ({filtered.length - shown} remaining)
{:else if feedNextPage > 0}
Load more activities
{:else}
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'year' : 'years'})
{/if}
</button>
</div>
{/if}
+28 -5
View File
@@ -7,6 +7,10 @@
export let trackUrl: string;
export let timeseries: Timeseries | null = null;
export let bbox: [number, number, number, number] | null = null;
/** ~20 [lat, lon] preview coords from the activity summary — used to position
* the map immediately on mount so there's no flash of world view before the
* detail JSON loads and bbox arrives. */
export let initialCoords: [number, number][] | null = null;
export let accentColor: string = '#00c8ff';
export let hoveredIdx: number | null = null;
@@ -19,11 +23,25 @@
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
onMount(() => {
// Derive initial center and zoom from preview_coords so the map starts at
// the right location without waiting for the async detail JSON / bbox load.
let initCenter: [number, number] = [0, 0];
let initZoom = 1;
if (initialCoords && initialCoords.length > 0) {
const lats = initialCoords.map(c => c[0]);
const lons = initialCoords.map(c => c[1]);
initCenter = [
(Math.min(...lons) + Math.max(...lons)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
];
initZoom = 10; // rough default; fitBounds will correct this when bbox arrives
}
map = new maplibregl.Map({
container: mapEl,
style: TILE_STYLE,
center: [0, 0],
zoom: 1,
center: initCenter,
zoom: initZoom,
attributionControl: false,
});
@@ -72,12 +90,17 @@
});
});
// Fit to bbox when detail JSON loads (bbox is null at map init)
// Fit to bbox when detail JSON loads (bbox is null at map init).
// Always resize first so MapLibre knows the real container dimensions,
// and defer with rAF so the browser has finished laying out the container.
$: if (map && bbox) {
const fit = () => map.fitBounds(
const fit = () => requestAnimationFrame(() => {
map.resize();
map.fitBounds(
[[bbox![0], bbox![1]], [bbox![2], bbox![3]]],
{ padding: 40, animate: true },
{ padding: 40, animate: false },
);
});
map.loaded() ? fit() : map.once('load', fit);
}
+28 -7
View File
@@ -4,6 +4,7 @@
import MmpChart from './MmpChart.svelte';
import RecordsView from './RecordsView.svelte';
import AthleteDrawer from './AthleteDrawer.svelte';
import { isUnlisted } from '../lib/format';
import { loadIndex, loadAthlete } from '../lib/dataloader';
export let base: string = '/';
@@ -42,9 +43,16 @@
loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined),
loadIndex(import.meta.env.BASE_URL, indexUrl || undefined),
]);
if (!athleteData) throw new Error('athlete.json not found — run bincio extract first');
athlete = athleteData;
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
// Static file may not exist yet if the background rebuild hasn't finished — fall back to API
let resolvedAthlete = athleteData as AthleteJson | null;
if (!resolvedAthlete && editEnabled) {
try {
const r = await fetch('/api/athlete', { credentials: 'include' });
if (r.ok) resolvedAthlete = await r.json() as AthleteJson;
} catch { /* ignore */ }
}
athlete = resolvedAthlete;
activities = index.activities.filter(a => a.mmp && !isUnlisted(a.privacy));
} catch (e: any) {
error = e.message;
} finally {
@@ -53,8 +61,11 @@
});
async function onSaved() {
const res = await fetch(`${import.meta.env.BASE_URL}data/athlete.json?t=${Date.now()}`);
if (res.ok) athlete = await res.json();
// Try static file first; fall back to API (works before the background rebuild finishes)
const staticUrl = athleteUrl || `${import.meta.env.BASE_URL}data/athlete.json`;
let res = await fetch(`${staticUrl}?t=${Date.now()}`);
if (!res.ok) res = await fetch('/api/athlete', { credentials: 'include' });
if (res.ok) athlete = await res.json() as AthleteJson;
drawerOpen = false;
}
@@ -78,7 +89,17 @@
<p class="text-zinc-400 text-sm">Loading…</p>
{:else if error}
<p class="text-red-400 text-sm">{error}</p>
{:else if athlete}
{:else if !athlete}
<div class="text-zinc-400 text-sm space-y-3">
<p>No athlete profile yet.</p>
{#if editEnabled}
<button
on:click={() => drawerOpen = true}
class="px-3 py-1.5 text-xs border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white rounded-md transition-colors"
>Create profile</button>
{/if}
</div>
{:else}
<!-- Header row: tabs + edit button -->
<div class="flex items-center justify-between mb-6 border-b border-zinc-800 pb-0">
@@ -110,7 +131,7 @@
<MmpChart {athlete} {activities} />
</div>
{:else}
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data and re-run <code class="text-zinc-300">bincio extract</code>.</p>
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p>
{/if}
<!-- Records tab -->
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { BASIndex, ActivitySummary } from '../lib/types';
import { formatDistance, formatDuration, isUnlisted, sportIcon } from '../lib/format';
export let base: string = '/';
type Period = 'week' | 'month' | 'year' | 'all';
type SortKey = 'display_name' | 'count' | 'distance_m' | 'elevation_m' | 'duration_s' | 'sports' | 'streak';
interface UserRaw {
handle: string;
display_name: string;
activities: ActivitySummary[];
}
interface UserStat {
handle: string;
display_name: string;
count: number;
distance_m: number;
elevation_m: number;
duration_s: number;
sports: string[];
streak: number;
}
interface Totals {
count: number;
distance_m: number;
elevation_m: number;
duration_s: number;
users: number;
}
let period: Period = 'month';
let sortKey: SortKey = 'distance_m';
let sortAsc = false;
let users: UserRaw[] = [];
let stats: UserStat[] = [];
let totals: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
let loading = true;
let error: string | null = null;
// ── Helpers ───────────────────────────────────────────────────────────────
function periodStart(p: Period): Date {
const now = new Date();
if (p === 'all') return new Date(0);
if (p === 'year') return new Date(now.getFullYear(), 0, 1);
if (p === 'month') return new Date(now.getFullYear(), now.getMonth(), 1);
const d = new Date(now);
const day = d.getDay();
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
d.setHours(0, 0, 0, 0);
return d;
}
function maxStreak(activities: ActivitySummary[]): number {
if (!activities.length) return 0;
const days = [...new Set(activities.map(a => a.started_at.slice(0, 10)))].sort();
let max = 1, cur = 1;
for (let i = 1; i < days.length; i++) {
const diff = (new Date(days[i]).getTime() - new Date(days[i - 1]).getTime()) / 86_400_000;
cur = diff === 1 ? cur + 1 : 1;
if (cur > max) max = cur;
}
return max;
}
function computeStats(rawUsers: UserRaw[], p: Period): { stats: UserStat[]; totals: Totals } {
const start = periodStart(p);
const result: UserStat[] = [];
const tot: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
for (const u of rawUsers) {
const pub = u.activities.filter(a => !isUnlisted(a.privacy));
const filtered = pub.filter(a => new Date(a.started_at) >= start);
const stat: UserStat = {
handle: u.handle,
display_name: u.display_name,
count: filtered.length,
distance_m: filtered.reduce((s, a) => s + (a.distance_m ?? 0), 0),
elevation_m: filtered.reduce((s, a) => s + (a.elevation_gain_m ?? 0), 0),
duration_s: filtered.reduce((s, a) => s + (a.duration_s ?? 0), 0),
sports: [...new Set(filtered.map(a => a.sport))],
streak: maxStreak(pub),
};
tot.count += stat.count;
tot.distance_m += stat.distance_m;
tot.elevation_m += stat.elevation_m;
tot.duration_s += stat.duration_s;
tot.users++;
result.push(stat);
}
return { stats: result, totals: tot };
}
// ── Data loading ──────────────────────────────────────────────────────────
async function fetchShard(url: string): Promise<ActivitySummary[]> {
const data: BASIndex = await fetch(url).then(r => { if (!r.ok) throw new Error(String(r.status)); return r.json(); });
const own = data.activities ?? [];
if (!data.shards?.length) return own;
const shardBase = url.substring(0, url.lastIndexOf('/') + 1);
const nested = await Promise.allSettled(
data.shards.map(s => fetchShard(s.url.startsWith('http') ? s.url : `${shardBase}${s.url}`))
);
return [...own, ...nested.flatMap(r => r.status === 'fulfilled' ? r.value : [])];
}
async function loadData() {
try {
const rootUrl = `${base}data/index.json`;
const root: BASIndex = await fetch(rootUrl).then(r => r.json());
const userShards = (root.shards ?? []).filter(s => s.handle);
if (userShards.length === 0) { error = 'No community members found.'; return; }
const results = await Promise.allSettled(
userShards.map(async shard => {
const url = shard.url.startsWith('http') ? shard.url : `${base}data/${shard.url}`;
const shardIndex: BASIndex = await fetch(url).then(r => r.json());
const activities = await fetchShard(url);
return { handle: shard.handle!, display_name: shardIndex.owner?.display_name ?? shard.handle!, activities } as UserRaw;
})
);
users = results.flatMap(r => r.status === 'fulfilled' ? [r.value] : []);
({ stats, totals } = computeStats(users, period));
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
$: if (users.length) ({ stats, totals } = computeStats(users, period));
$: sorted = [...stats].sort((a, b) => {
let av: number | string, bv: number | string;
if (sortKey === 'display_name') { av = a.display_name.toLowerCase(); bv = b.display_name.toLowerCase(); }
else if (sortKey === 'sports') { av = a.sports.length; bv = b.sports.length; }
else { av = a[sortKey] as number; bv = b[sortKey] as number; }
if (av < bv) return sortAsc ? -1 : 1;
if (av > bv) return sortAsc ? 1 : -1;
return 0;
});
function setSort(key: SortKey) {
if (sortKey === key) sortAsc = !sortAsc;
else { sortKey = key; sortAsc = false; }
}
function chevron(key: SortKey) {
if (sortKey !== key) return '';
return sortAsc ? ' ↑' : ' ↓';
}
onMount(loadData);
const PERIODS: { key: Period; label: string }[] = [
{ key: 'week', label: 'This week' },
{ key: 'month', label: 'This month' },
{ key: 'year', label: 'This year' },
{ key: 'all', label: 'All time' },
];
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-white mb-1">Community</h1>
<p class="text-zinc-400 text-sm">What everyone's been up to — together.</p>
</div>
{#if loading}
<p class="text-zinc-400 text-sm">Loading…</p>
{:else if error}
<p class="text-red-400 text-sm">{error}</p>
{:else}
<!-- Period selector -->
<div class="flex gap-2 flex-wrap">
{#each PERIODS as p}
<button
on:click={() => period = p.key}
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors"
class:bg-blue-500={period === p.key}
class:border-blue-500={period === p.key}
class:text-white={period === p.key}
class:border-zinc-700={period !== p.key}
class:text-zinc-400={period !== p.key}
class:hover:text-white={period !== p.key}
>{p.label}</button>
{/each}
</div>
<!-- Community totals -->
{#if totals.users > 0}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
{#each [
{ label: 'Activities', value: totals.count.toLocaleString() },
{ label: 'Distance', value: formatDistance(totals.distance_m) },
{ label: 'Elevation', value: `${Math.round(totals.elevation_m / 1000).toLocaleString()} km` },
{ label: 'Time', value: formatDuration(totals.duration_s) },
] as item}
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 text-center">
<div class="text-xl font-bold text-white">{item.value}</div>
<div class="text-xs text-zinc-500 mt-0.5">{item.label}</div>
</div>
{/each}
</div>
{/if}
<!-- Table -->
{#if totals.users === 0}
<p class="text-zinc-500 text-sm">No public activities in this period yet.</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-zinc-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3 font-medium w-6">#</th>
<th class="text-left px-4 py-3 font-medium">
<button on:click={() => setSort('display_name')} class="hover:text-white transition-colors">
Athlete{chevron('display_name')}
</button>
</th>
<th class="text-right px-4 py-3 font-medium">
<button on:click={() => setSort('count')} class="hover:text-white transition-colors">
Activities{chevron('count')}
</button>
</th>
<th class="text-right px-4 py-3 font-medium">
<button on:click={() => setSort('distance_m')} class="hover:text-white transition-colors">
Distance{chevron('distance_m')}
</button>
</th>
<th class="text-right px-4 py-3 font-medium">
<button on:click={() => setSort('elevation_m')} class="hover:text-white transition-colors">
Elevation{chevron('elevation_m')}
</button>
</th>
<th class="text-right px-4 py-3 font-medium">
<button on:click={() => setSort('duration_s')} class="hover:text-white transition-colors">
Time{chevron('duration_s')}
</button>
</th>
<th class="text-right px-4 py-3 font-medium hidden sm:table-cell">
<button on:click={() => setSort('sports')} class="hover:text-white transition-colors">
Sports{chevron('sports')}
</button>
</th>
<th class="text-right px-4 py-3 font-medium hidden md:table-cell">
<button on:click={() => setSort('streak')} class="hover:text-white transition-colors">
Streak{chevron('streak')}
</button>
</th>
</tr>
</thead>
<tbody>
{#each sorted as u, i}
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/30 transition-colors">
<td class="px-4 py-3 text-zinc-600 tabular-nums">{i + 1}</td>
<td class="px-4 py-3">
<a href="{base}u/{u.handle}/" class="text-white font-medium hover:text-[--accent] transition-colors">
{u.display_name}
</a>
<span class="text-zinc-600 text-xs ml-1">@{u.handle}</span>
</td>
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.count}</td>
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.distance_m > 0 ? formatDistance(u.distance_m) : '—'}</td>
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.elevation_m > 0 ? `${Math.round(u.elevation_m).toLocaleString()} m` : '—'}</td>
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.duration_s > 0 ? formatDuration(u.duration_s) : '—'}</td>
<td class="px-4 py-3 text-right hidden sm:table-cell">
{#each u.sports as s}{sportIcon(s)}{/each}
</td>
<td class="px-4 py-3 text-right tabular-nums text-zinc-300 hidden md:table-cell">
{u.streak > 0 ? `${u.streak}d` : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
+96 -11
View File
@@ -5,7 +5,7 @@
export let activityId: string;
export let editUrl: string;
const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void }>();
const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void; deleted: void }>();
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
const STAT_PANELS = [
@@ -21,6 +21,13 @@
let saving = false;
let saveStatus = '';
let saveOk = false;
let confirmDelete = false;
let deleting = false;
// Elevation recalculation
let recalculating: '' | 'dem' | 'hysteresis' = '';
let recalcStatus = '';
let recalcOk = false;
// Form state
let title = '';
@@ -49,9 +56,12 @@
title = d.title ?? '';
sport = d.sport ?? 'cycling';
gear = d.gear ?? '';
description = d.description ?? '';
// Strip any auto-inserted image markdown refs — images are tracked via custom.images
description = (d.description ?? '').replace(/!\[[^\]]*\]\([^)]+\)\n?/g, '').trim();
highlight = d.highlight ?? false;
isPrivate = d.private ?? false;
// d.private is a bool (from the API); d.privacy is the raw field on older
// endpoints. Accept either so the drawer works with both serve and edit servers.
isPrivate = d.private ?? (d.privacy === 'unlisted' || d.privacy === 'private') ?? false;
hideStats = d.hide_stats ?? [];
images = d.images ?? [];
} catch (e: any) {
@@ -93,9 +103,6 @@
if (res.ok) {
const d = await res.json();
if (!images.includes(d.filename)) images = [...images, d.filename];
// Insert markdown reference at cursor or end
const ref = `\n![${d.filename.replace(/\.[^.]+$/, '')}](${d.filename})`;
description = description.trimEnd() + ref;
}
}
} catch (e: any) {
@@ -109,9 +116,6 @@
async function deleteImage(filename: string) {
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
images = images.filter(f => f !== filename);
// Remove the markdown reference — escape filename before using in regex
const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${escaped}\\)`, 'g'), '').trim();
}
function toggleStat(key: string) {
@@ -120,6 +124,42 @@
: [...hideStats, key];
}
async function recalculateElevation(method: 'dem' | 'hysteresis') {
recalculating = method;
recalcStatus = '';
recalcOk = false;
try {
const res = await fetch(`${api}/recalculate-elevation/${method}`, { method: 'POST' });
const d = await res.json();
if (!res.ok) throw new Error(d.detail ?? await res.text());
recalcOk = true;
const gain = d.elevation_gain_m != null ? `↑ ${Math.round(d.elevation_gain_m)} m` : '';
const loss = d.elevation_loss_m != null ? `↓ ${Math.round(d.elevation_loss_m)} m` : '';
recalcStatus = [gain, loss].filter(Boolean).join(' ');
} catch (e: any) {
recalcStatus = e.message;
recalcOk = false;
} finally {
recalculating = '';
}
}
async function deleteActivity() {
if (!confirmDelete) { confirmDelete = true; return; }
deleting = true;
try {
const res = await fetch(api, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
dispatch('deleted');
} catch (e: any) {
saveStatus = `Delete failed: ${e.message}`;
saveOk = false;
confirmDelete = false;
} finally {
deleting = false;
}
}
load();
</script>
@@ -249,6 +289,36 @@
</div>
</div>
<!-- Elevation recalculation -->
<div class="mb-4">
<p class="text-xs text-zinc-500 mb-2">Elevation</p>
<div class="flex gap-2">
<button
type="button"
class="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg border border-zinc-700 text-xs text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 transition-colors disabled:opacity-40"
disabled={recalculating !== ''}
on:click={() => recalculateElevation('hysteresis')}
title="Recompute from the original recorded elevation using noise-filtering (fast, no network)"
>
{recalculating === 'hysteresis' ? 'Computing…' : '📐 Recalculate (hysteresis)'}
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg border border-zinc-700 text-xs text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 transition-colors disabled:opacity-40"
disabled={recalculating !== ''}
on:click={() => recalculateElevation('dem')}
title="Replace elevation with SRTM terrain data from the internet (slower, most accurate for GPS-only devices)"
>
{recalculating === 'dem' ? 'Querying terrain…' : '⛰ Recalculate (DEM)'}
</button>
</div>
{#if recalcStatus}
<p class="text-xs mt-1.5 text-center" class:text-green-400={recalcOk} class:text-red-400={!recalcOk}>
{recalcStatus}
</p>
{/if}
</div>
<!-- Flags -->
<div class="flex gap-3 mb-2">
<button
@@ -273,7 +343,7 @@
style={isPrivate ? 'background:rgba(239,68,68,.1)' : ''}
on:click={() => isPrivate = !isPrivate}
>
Private
Unlisted
</button>
</div>
{/if}
@@ -284,11 +354,26 @@
<div class="px-5 py-4 border-t border-zinc-800 flex items-center gap-3 shrink-0">
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm font-medium rounded-lg transition-colors"
disabled={saving}
disabled={saving || deleting}
on:click={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
<button
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors disabled:opacity-40 ml-auto"
class:border-zinc-700={!confirmDelete}
class:text-zinc-500={!confirmDelete}
class:hover:border-red-600={!confirmDelete}
class:hover:text-red-400={!confirmDelete}
class:border-red-500={confirmDelete}
class:text-red-400={confirmDelete}
class:bg-red-950={confirmDelete}
disabled={deleting}
on:click={deleteActivity}
on:blur={() => confirmDelete = false}
>
{deleting ? 'Deleting…' : confirmDelete ? 'Confirm delete?' : 'Delete'}
</button>
{#if saveStatus}
<span class="text-xs" class:text-green-400={saveOk} class:text-red-400={!saveOk}>
{saveStatus}
+15 -2
View File
@@ -82,6 +82,10 @@
$: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
function getAxisColor() {
return document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa';
}
function renderChart(data: typeof plotData, cmap: typeof colorMap) {
if (!chartEl) return;
chartEl.innerHTML = '';
@@ -95,16 +99,18 @@
height: 320,
marginLeft: 52,
marginBottom: 40,
style: { background: 'transparent', color: '#e4e4e7' },
style: { background: 'transparent', color: getAxisColor() },
x: {
type: 'log',
label: 'Duration',
tickFormat: (d: number) => formatDuration(d),
grid: true,
domain: [data[0]?.d ?? 1, Math.max(7200, ...data.map(d => d.d))],
},
y: {
label: 'Avg power (W)',
grid: true,
zero: true,
},
color: {
domain: selectedKeys,
@@ -158,7 +164,9 @@
onMount(() => {
const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap));
ro.observe(chartEl);
return () => ro.disconnect();
const mo = new MutationObserver(() => renderChart(currentPlotData, currentColorMap));
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { ro.disconnect(); mo.disconnect(); };
});
// ── Toggle helpers ─────────────────────────────────────────────────────────
@@ -179,6 +187,11 @@
];
</script>
<style>
/* Plot tooltips always have a white background — force black text for contrast */
:global(.plot-tip text) { fill: #18181b !important; }
</style>
<!-- Range selector pills -->
<div class="flex flex-wrap gap-2 mb-4">
{#each allRangeKeys as key, i}
+10 -6
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
import { formatDistance, formatDuration, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndex } from '../lib/dataloader';
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
@@ -35,7 +35,7 @@
mounted = true;
try {
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
all = index.activities.filter(a => !isUnlisted(a.privacy) && a.distance_m);
} catch (e: any) {
error = e.message;
}
@@ -74,10 +74,14 @@
function updatePos(e: MouseEvent) {
const vw = window.innerWidth;
const vh = window.innerHeight;
tooltipPos = {
x: e.clientX > vw - 310 ? e.clientX - 305 : e.clientX + 14,
y: Math.min(e.clientY - 8, vh - 260),
};
const tw = 280; // matches w-[280px]
const th = 260; // approximate tooltip height
const gap = 14;
let x = e.clientX + gap;
if (x + tw > vw) x = e.clientX - gap - tw;
x = Math.max(4, Math.min(x, vw - tw - 4));
const y = Math.max(4, Math.min(e.clientY - 8, vh - th - 4));
tooltipPos = { x, y };
}
function onCellEnter(date: string, e: MouseEvent) {
+550 -41
View File
@@ -31,7 +31,7 @@ try {
instancePrivate = root?.instance?.private === true;
const shards: Array<{ handle?: string }> = root?.shards ?? [];
const handles = shards.map(s => s.handle).filter(Boolean);
if (handles.length === 1) singleHandle = handles[0] as string;
if (handles.length === 1 && !instancePrivate) singleHandle = handles[0] as string;
}
} catch { /* non-fatal */ }
---
@@ -138,8 +138,12 @@ try {
/* ── Base reset ─────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; }
body { margin: 0; overflow-x: hidden; }
.maplibregl-canvas { outline: none; }
/* Nav links scroll horizontally on narrow screens without a scrollbar */
.nav-links { scrollbar-width: none; -ms-overflow-style: none; }
.nav-links::-webkit-scrollbar { display: none; }
</style>
</head>
<body
@@ -151,36 +155,66 @@ try {
class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur"
style="border-color: var(--border)"
>
<div class="max-w-7xl mx-auto px-4 h-12 flex items-center gap-6">
<a href={baseUrl} class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
Bincio<span class="text-[--accent]">Activity</span>
<div class="max-w-7xl mx-auto px-4 h-12 flex items-center gap-3">
<!-- Logo: always visible, never shrinks. Full name on sm+, abbreviated on mobile. -->
<a href={baseUrl} class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors shrink-0">
<span class="hidden sm:inline">Bincio<span class="text-[--accent]">Activity</span></span>
<span class="sm:hidden">B<span class="text-[--accent]">A</span></span>
</a>
{!isPublicPage && (
<>
<!-- Links: scroll horizontally on mobile, no visible scrollbar -->
<div class="nav-links flex items-center gap-5 overflow-x-auto flex-1 min-w-0">
<!-- Feed tab: only shown for multi-user (more than one shard) -->
{!singleHandle && (
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
<a id="nav-feed" href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Feed</a>
)}
<!-- Single-user: static handle link. Multi-user: populated by user-widget script. -->
{singleHandle
? <a href={`${baseUrl}u/${singleHandle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">@{singleHandle}</a>
: <a id="nav-me" href="#" style="display:none" class="text-sm text-[--accent] hover:text-white transition-colors"></a>
? <a href={`${baseUrl}u/${singleHandle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">@{singleHandle}</a>
: <a id="nav-me" href="#" style="display:none" class="text-sm text-[--accent] hover:text-white transition-colors shrink-0"></a>
}
<!-- Per-user nav links — updated by user-widget script in multi-user mode -->
<a id="nav-stats" href={singleHandle ? `${baseUrl}u/${singleHandle}/stats/` : `${baseUrl}stats/`} data-user-path="stats/" class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a id="nav-athlete" href={singleHandle ? `${baseUrl}u/${singleHandle}/athlete/` : `${baseUrl}athlete/`} data-user-path="athlete/" class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
{mobileApp && (
<a id="nav-record" href={singleHandle ? `${baseUrl}u/${singleHandle}/record/` : `${baseUrl}record/`} data-user-path="record/" class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
<a id="nav-stats" href={singleHandle ? `${baseUrl}u/${singleHandle}/stats/` : `${baseUrl}stats/`} data-user-path="stats/" class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Stats</a>
<a id="nav-athlete" href={singleHandle ? `${baseUrl}u/${singleHandle}/athlete/` : `${baseUrl}athlete/`} data-user-path="athlete/" class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Athlete</a>
{!singleHandle && (
<a id="nav-community" href={`${baseUrl}community/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Community</a>
)}
{mobileApp && (
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
<a id="nav-record" href={singleHandle ? `${baseUrl}u/${singleHandle}/record/` : `${baseUrl}record/`} data-user-path="record/" class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Record</a>
)}
</>
{mobileApp && (
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Convert</a>
)}
<a id="nav-about" href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">About</a>
</div>
)}
<div class="ml-auto flex items-center gap-1">
<!-- Actions: always visible, never shrinks -->
<div class="ml-auto shrink-0 flex items-center gap-1">
{!isPublicPage && (
<>
<!-- Admin: active upload jobs badge (hidden until jobs exist) -->
<span
id="admin-jobs-badge"
style="display:none"
title=""
class="text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300 border border-amber-700/50 animate-pulse cursor-default"
></span>
<!-- Settings link — hidden until logged in -->
<a
id="nav-settings"
href={`${baseUrl}settings/`}
style="display:none"
class="text-xs text-zinc-500 hover:text-white transition-colors px-1"
>Settings</a>
<!-- Admin link — hidden until confirmed admin -->
<a
id="nav-admin"
href={`${baseUrl}admin/`}
style="display:none"
class="text-xs text-zinc-500 hover:text-white transition-colors px-1"
>Admin</a>
<!-- Logout button — hidden until logged in -->
<button
id="nav-logout"
@@ -246,6 +280,26 @@ try {
<p id="strava-choose-sub" class="text-xs text-zinc-500">Checking…</p>
</div>
</button>
<button
id="upload-choose-zip"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">📦</span>
<div>
<p class="text-sm font-medium text-white">Strava export ZIP</p>
<p class="text-xs text-zinc-500">Import your full Strava archive</p>
</div>
</button>
<button
id="upload-choose-garmin"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">⌚</span>
<div>
<p class="text-sm font-medium text-white">Sync from Garmin Connect</p>
<p id="garmin-choose-sub" class="text-xs text-zinc-500">Checking…</p>
</div>
</button>
</div>
</div>
@@ -256,9 +310,31 @@ try {
id="upload-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
>
<div id="upload-label">Drop a FIT, GPX, or TCX file<br/>or click to browse</div>
<input id="upload-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" />
<div id="upload-label">Drop FIT, GPX, TCX, or activities.csv<br/>or click to browse</div>
<input id="upload-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz,.gz,.csv,application/gpx+xml,application/vnd.garmin.tcx+xml,application/gzip,application/x-gzip,application/octet-stream" class="hidden" multiple />
</div>
<label class="flex items-start gap-2 mt-3 cursor-pointer group">
<input
id="upload-keep-original"
type="checkbox"
class="mt-0.5 accent-blue-500 shrink-0"
/>
<span class="text-xs text-zinc-500 group-hover:text-zinc-300 transition-colors leading-snug">
Keep original file on server
<span class="text-zinc-600 block mt-0.5">Lets you reprocess if the format changes. See the <a href={`${baseUrl}about/`} class="underline hover:text-zinc-400">About page</a> for details.</span>
</span>
</label>
<label class="flex items-start gap-2 mt-2 cursor-pointer group">
<input
id="upload-overwrite"
type="checkbox"
class="mt-0.5 accent-amber-500 shrink-0"
/>
<span class="text-xs text-zinc-500 group-hover:text-zinc-300 transition-colors leading-snug">
Overwrite existing activities
<span class="text-zinc-600 block mt-0.5">Re-extract and replace any duplicate found on the server. Use to fix a corrupted or mis-parsed activity.</span>
</span>
</label>
<p id="upload-status" class="mt-3 text-xs text-center" style="color: var(--text-5); min-height: 1.25rem"></p>
</div>
@@ -294,6 +370,68 @@ try {
</div>
<p id="strava-status" class="mt-3 text-xs text-center" style="min-height: 1.25rem"></p>
</div>
<!-- View: Strava ZIP upload -->
<div id="upload-view-zip" style="display:none">
<button id="upload-back-zip" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div class="rounded-lg border border-amber-800/50 bg-amber-950/30 p-3 mb-4 text-xs text-amber-300 leading-relaxed">
⚠ The ZIP will be processed and <strong>immediately deleted</strong> from the server — originals are not kept. Make sure you keep your own copy.
</div>
<div
id="zip-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-6 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
>
<div id="zip-label">Drop your Strava export .zip<br/>or click to browse</div>
<input id="zip-input" type="file" accept=".zip" class="hidden" />
</div>
<label class="flex items-center gap-2 mt-3 text-xs text-zinc-400 cursor-pointer select-none">
<input id="zip-private" type="checkbox" class="accent-blue-500" />
Mark all imported activities as unlisted
<span class="text-zinc-600">(not shown in feed; GPS track still accessible by URL)</span>
</label>
<p id="zip-status" class="mt-3 text-xs text-center leading-relaxed" style="min-height: 1.25rem"></p>
</div>
<!-- View: Garmin Connect sync -->
<div id="upload-view-garmin" style="display:none">
<button id="upload-back-garmin" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div class="rounded-lg border border-amber-800/50 bg-amber-950/30 p-3 mb-4 text-xs text-amber-300 leading-relaxed">
⚠ Garmin Connect has no official API. Your credentials are encrypted at rest and used to log in on your behalf. <a href={`${baseUrl}about/`} class="underline hover:text-amber-100">Learn more</a>.
</div>
<!-- Not connected -->
<div id="garmin-connect-area" style="display:none">
<p class="text-sm text-zinc-400 mb-3">Enter your Garmin Connect credentials to sync activities.</p>
<input
id="garmin-email"
type="email"
placeholder="Email"
class="w-full mb-2 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500"
/>
<input
id="garmin-password"
type="password"
placeholder="Password"
class="w-full mb-3 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500"
/>
<button
id="garmin-connect-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-blue-600 hover:bg-blue-500 text-white transition-colors"
>Connect</button>
</div>
<!-- Connected -->
<div id="garmin-sync-area" style="display:none">
<p class="text-xs text-zinc-500 mb-1">Last sync: <span id="garmin-last-sync">never</span></p>
<button
id="garmin-sync-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors mt-2"
>Sync now</button>
<button
id="garmin-disconnect-btn"
class="w-full mt-2 py-1.5 px-3 rounded-lg text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
>Disconnect</button>
</div>
<p id="garmin-status" class="mt-3 text-xs text-center" style="min-height: 1.25rem"></p>
</div>
</div>
</div>
)}
@@ -363,9 +501,61 @@ try {
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
});
// Show logout button
// Show settings + logout links
const settingsEl = document.getElementById('nav-settings');
if (settingsEl) settingsEl.style.display = '';
const logoutEl = document.getElementById('nav-logout');
if (logoutEl) logoutEl.style.display = '';
// Pre-populate the "keep original" checkbox from the instance default
const chk = document.getElementById('upload-keep-original');
if (chk && user.store_originals_default) chk.checked = true;
// Apply nav visibility prefs
try {
const pr = await fetch('/api/me/prefs', { credentials: 'include' });
if (pr.ok) {
const prefs = await pr.json();
const navHideMap = {
'nav_hide_feed': 'nav-feed',
'nav_hide_community': 'nav-community',
'nav_hide_about': 'nav-about',
};
for (const [key, elId] of Object.entries(navHideMap)) {
if (prefs[key] === 'true') {
const el = document.getElementById(elId);
if (el) el.style.display = 'none';
}
}
}
} catch (_) {}
// Admin: show admin link and poll for active jobs
if (user.is_admin) {
const adminLink = document.getElementById('nav-admin');
if (adminLink) adminLink.style.display = '';
const badge = document.getElementById('admin-jobs-badge');
async function pollJobs() {
try {
const jr = await fetch('/api/admin/jobs', { credentials: 'include' });
if (!jr.ok) return;
const jobs = await jr.json();
if (!badge) return;
if (jobs.length === 0) {
badge.style.display = 'none';
} else {
const summary = jobs.map(j =>
`@${j.user}: ${j.done}/${j.total} files`
).join(' · ');
badge.title = summary;
badge.textContent = `${jobs.length} upload${jobs.length > 1 ? 's' : ''} running`;
badge.style.display = '';
}
} catch (_) {}
}
pollJobs();
setInterval(pollJobs, 5000);
}
} catch (_) {}
})();
@@ -384,13 +574,26 @@ try {
const viewChoose = document.getElementById('upload-view-choose');
const viewFile = document.getElementById('upload-view-file');
const viewStrava = document.getElementById('upload-view-strava');
const viewZip = document.getElementById('upload-view-zip');
const viewGarmin = document.getElementById('upload-view-garmin');
const chooseFile = document.getElementById('upload-choose-file');
const chooseStrava = document.getElementById('upload-choose-strava');
const chooseZip = document.getElementById('upload-choose-zip');
const chooseGarmin = document.getElementById('upload-choose-garmin');
const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava');
const backZip = document.getElementById('upload-back-zip');
const backGarmin = document.getElementById('upload-back-garmin');
const zipDrop = document.getElementById('zip-drop');
const zipInput = document.getElementById('zip-input');
const zipLabel = document.getElementById('zip-label');
const zipStatus = document.getElementById('zip-status');
const zipPrivate = document.getElementById('zip-private');
const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input');
const label = document.getElementById('upload-label');
const keepOriginalChk = document.getElementById('upload-keep-original');
const overwriteChk = document.getElementById('upload-overwrite');
const fileStatus = document.getElementById('upload-status');
const stravaStatus = document.getElementById('strava-status');
const stravaConnect = document.getElementById('strava-connect-area');
@@ -401,12 +604,24 @@ try {
const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn');
const stravaLastSync = document.getElementById('strava-last-sync');
const stravaChooseSub = document.getElementById('strava-choose-sub');
const garminStatus = document.getElementById('garmin-status');
const garminConnect = document.getElementById('garmin-connect-area');
const garminSync = document.getElementById('garmin-sync-area');
const garminEmail = document.getElementById('garmin-email');
const garminPassword = document.getElementById('garmin-password');
const garminConnBtn = document.getElementById('garmin-connect-btn');
const garminSyncBtn = document.getElementById('garmin-sync-btn');
const garminDisconnBtn = document.getElementById('garmin-disconnect-btn');
const garminLastSync = document.getElementById('garmin-last-sync');
const garminChooseSub = document.getElementById('garmin-choose-sub');
// ── view helpers ──────────────────────────────────────────────────────
function showView(name) {
viewChoose.style.display = name === 'choose' ? '' : 'none';
viewFile.style.display = name === 'file' ? '' : 'none';
viewStrava.style.display = name === 'strava' ? '' : 'none';
viewZip.style.display = name === 'zip' ? '' : 'none';
viewGarmin.style.display = name === 'garmin' ? '' : 'none';
}
function openModal() {
@@ -426,8 +641,11 @@ try {
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); });
chooseFile.addEventListener('click', () => showView('file'));
chooseZip.addEventListener('click', () => showView('zip'));
backFile.addEventListener('click', () => showView('choose'));
backStrava.addEventListener('click', () => showView('choose'));
backZip.addEventListener('click', () => showView('choose'));
backGarmin.addEventListener('click', () => showView('choose'));
// ── file upload ───────────────────────────────────────────────────────
drop.addEventListener('click', () => input.click());
@@ -437,30 +655,84 @@ try {
e.preventDefault();
drop.style.borderColor = '';
drop.style.color = '';
if (e.dataTransfer?.files[0]) doUpload(e.dataTransfer.files[0]);
if (e.dataTransfer?.files.length) doUpload(e.dataTransfer.files);
});
input.addEventListener('change', () => { if (input.files?.[0]) doUpload(input.files[0]); });
input.addEventListener('change', () => { if (input.files?.length) doUpload(input.files); });
async function doUpload(file) {
label.textContent = file.name;
fileStatus.textContent = 'Uploading…';
function doUpload(files) {
const n = files.length;
label.textContent = n === 1 ? files[0].name : `${n} files selected`;
fileStatus.textContent = `Uploading…`;
fileStatus.style.color = 'var(--text-4)';
drop.style.pointerEvents = 'none';
const fd = new FormData();
fd.append('file', file);
for (const f of files) fd.append('files', f);
fd.append('store_original', keepOriginalChk?.checked ? 'true' : 'false');
fd.append('overwrite', overwriteChk?.checked ? 'true' : 'false');
const xhr = new XMLHttpRequest();
xhr.open('POST', `${editUrl}/api/upload`);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'text/event-stream');
let buf = '';
let added = 0, overwrittenCount = 0, dupes = 0, errors = 0, csvUpdates = 0;
xhr.onprogress = () => {
const newText = xhr.responseText.slice(buf.length);
buf = xhr.responseText;
for (const line of newText.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
fileStatus.textContent = 'Done! Opening activity…';
fileStatus.style.color = '#4ade80';
setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600);
} catch (e) {
fileStatus.textContent = 'Error: ' + e.message;
const ev = JSON.parse(line.slice(6));
if (ev.type === 'progress') {
const pct = Math.round((ev.n / ev.total) * 100);
const icon = ev.status === 'imported' ? '↓' : ev.status === 'overwritten' ? '↺' : ev.status === 'duplicate' ? '·' : '✗';
fileStatus.textContent = `${icon} ${ev.n}/${ev.total} (${pct}%) — ${ev.name}`;
if (ev.status === 'imported') added++;
else if (ev.status === 'overwritten') overwrittenCount++;
else if (ev.status === 'duplicate') dupes++;
else errors++;
} else if (ev.type === 'csv') {
csvUpdates = ev.updates;
} else if (ev.type === 'done') {
added = ev.added; overwrittenCount = ev.overwritten ?? 0; dupes = ev.duplicates; errors = ev.errors; csvUpdates = ev.csv_updates;
const parts = [];
if (added > 0) parts.push(`${added} added`);
if (overwrittenCount > 0) parts.push(`${overwrittenCount} overwritten`);
if (csvUpdates > 0) parts.push(`${csvUpdates} updated from CSV`);
if (dupes) parts.push(`${dupes} duplicate${dupes > 1 ? 's' : ''}`);
if (errors) parts.push(`${errors} failed`);
if (parts.length === 0) parts.push('nothing to add');
fileStatus.textContent = parts.join(', ');
const anyGood = added > 0 || overwrittenCount > 0 || csvUpdates > 0;
fileStatus.style.color = anyGood ? '#4ade80' : '#a1a1aa';
if (anyGood) setTimeout(() => window.location.reload(), 1200);
else drop.style.pointerEvents = '';
input.value = '';
}
} catch (_) {}
}
};
xhr.onload = () => {
if (xhr.status !== 200) {
fileStatus.textContent = `Upload failed (${xhr.status}).`;
fileStatus.style.color = '#f87171';
drop.style.pointerEvents = '';
input.value = '';
}
};
xhr.onerror = () => {
fileStatus.textContent = 'Upload failed — check your connection.';
fileStatus.style.color = '#f87171';
drop.style.pointerEvents = '';
input.value = '';
};
xhr.send(fd);
}
// ── Strava ────────────────────────────────────────────────────────────
@@ -533,26 +805,51 @@ try {
}
});
stravaSyncBtn.addEventListener('click', async () => {
stravaSyncBtn.addEventListener('click', () => {
stravaSyncBtn.disabled = true;
stravaSyncBtn.textContent = 'Syncing…';
stravaStatus.textContent = '';
try {
const r = await fetch(`${editUrl}/api/strava/sync`, { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
stravaStatus.style.color = '';
const es = new EventSource(`${editUrl}/api/strava/sync/stream`, { withCredentials: true });
let imported = 0;
es.onmessage = (e) => {
const d = JSON.parse(e.data);
if (d.type === 'fetching') {
stravaStatus.textContent = 'Fetching activity list from Strava…';
} else if (d.type === 'progress') {
const pct = Math.round((d.n / d.total) * 100);
const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·';
stravaStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`;
if (d.status === 'imported') imported++;
} else if (d.type === 'done') {
es.close();
stravaLastSync.textContent = new Date().toLocaleString();
const errNote = d.error_count ? `, ${d.error_count} errors` : '';
stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`;
stravaStatus.style.color = '#4ade80';
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
if (d.imported > 0) setTimeout(() => window.location.reload(), 1500);
} catch (e) {
stravaStatus.textContent = 'Error: ' + e.message;
} else if (d.type === 'error') {
es.close();
stravaStatus.textContent = 'Error: ' + d.message;
stravaStatus.style.color = '#f87171';
} finally {
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
}
};
es.onerror = () => {
es.close();
if (stravaSyncBtn.disabled) {
stravaStatus.textContent = 'Connection lost. Check logs.';
stravaStatus.style.color = '#f87171';
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
}
};
});
async function stravaReset(mode) {
@@ -585,6 +882,218 @@ try {
stravaResetSoftBtn.addEventListener('click', () => stravaReset('soft'));
stravaResetHardBtn.addEventListener('click', () => stravaReset('hard'));
// ── Strava ZIP upload ─────────────────────────────────────────────────
function doZipUpload(file) {
if (!file) return;
zipLabel.textContent = file.name;
zipStatus.textContent = 'Uploading…';
zipStatus.style.color = '';
const fd = new FormData();
fd.append('file', file);
fd.append('private', zipPrivate?.checked ? 'true' : 'false');
// POST the file; server responds with SSE stream immediately after receiving body
const xhr = new XMLHttpRequest();
xhr.open('POST', `${editUrl}/api/upload/strava-zip`);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'text/event-stream');
let buf = '';
let imported = 0;
xhr.onprogress = () => {
// Parse SSE lines from the incrementally received response text
const newText = xhr.responseText.slice(buf.length);
buf = xhr.responseText;
for (const line of newText.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === 'validating') {
zipStatus.textContent = 'Validating ZIP structure…';
} else if (ev.type === 'extracting_csv') {
zipStatus.textContent = 'Reading activities.csv…';
} else if (ev.type === 'progress') {
const pct = Math.round((ev.n / ev.total) * 100);
const icon = ev.status === 'imported' ? '↓' : ev.status === 'error' ? '✗' : '·';
zipStatus.textContent = `${icon} ${ev.n}/${ev.total} (${pct}%) — ${ev.name}`;
if (ev.status === 'imported') imported++;
} else if (ev.type === 'done') {
const errNote = ev.error_count ? `, ${ev.error_count} errors` : '';
zipStatus.textContent = `Done — ${ev.imported} imported, ${ev.skipped} already up to date${errNote}.`;
zipStatus.style.color = '#4ade80';
zipInput.value = '';
if (ev.imported > 0) setTimeout(() => window.location.reload(), 1500);
} else if (ev.type === 'error') {
zipStatus.textContent = 'Error: ' + ev.message;
zipStatus.style.color = '#f87171';
zipInput.value = '';
}
} catch (_) {}
}
};
xhr.onload = () => {
// Fires when the request completes. If we already got a 'done' or 'error'
// SSE event via onprogress the status is already set. If not (e.g. a non-SSE
// error response), surface the failure.
if (xhr.status !== 200) {
zipStatus.textContent = `Upload failed (${xhr.status}).`;
zipStatus.style.color = '#f87171';
zipInput.value = '';
}
};
xhr.onerror = () => {
zipStatus.textContent = 'Upload failed — check your connection.';
zipStatus.style.color = '#f87171';
};
xhr.send(fd);
}
zipDrop.addEventListener('click', () => zipInput.click());
zipInput.addEventListener('change', () => doZipUpload(zipInput.files?.[0]));
zipDrop.addEventListener('dragover', e => { e.preventDefault(); zipDrop.classList.add('border-zinc-400'); });
zipDrop.addEventListener('dragleave', () => zipDrop.classList.remove('border-zinc-400'));
zipDrop.addEventListener('drop', e => {
e.preventDefault();
zipDrop.classList.remove('border-zinc-400');
doZipUpload(e.dataTransfer?.files?.[0]);
});
// ── Garmin Connect ────────────────────────────────────────────────────
async function loadGarminStatus() {
try {
const r = await fetch(`${editUrl}/api/garmin/status`, { credentials: 'include' });
if (!r.ok) throw new Error();
const d = await r.json();
garminChooseSub.textContent = d.connected ? 'Connected' : 'Not connected';
garminConnect.style.display = d.connected ? 'none' : '';
garminSync.style.display = d.connected ? '' : 'none';
if (d.last_sync) garminLastSync.textContent = new Date(d.last_sync).toLocaleString();
} catch (_) {
garminChooseSub.textContent = 'Unavailable';
}
}
loadGarminStatus();
chooseGarmin.addEventListener('click', () => {
garminStatus.textContent = '';
showView('garmin');
});
garminConnBtn.addEventListener('click', async () => {
const email = garminEmail.value.trim();
const password = garminPassword.value;
if (!email || !password) {
garminStatus.textContent = 'Enter email and password.';
garminStatus.style.color = '#f87171';
return;
}
garminConnBtn.disabled = true;
garminConnBtn.textContent = 'Connecting…';
garminStatus.textContent = 'Contacting Garmin — this may take up to a minute…';
garminStatus.style.color = '#a1a1aa';
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 90_000);
const r = await fetch(`${editUrl}/api/garmin/connect`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
signal: controller.signal,
});
clearTimeout(timeout);
const d = await r.json();
if (!r.ok) {
garminStatus.textContent = 'Error: ' + (d.detail || 'Login failed');
garminStatus.style.color = '#f87171';
} else {
garminPassword.value = '';
garminStatus.textContent = `Connected as ${d.display_name || email}!`;
garminStatus.style.color = '#4ade80';
garminConnect.style.display = 'none';
garminSync.style.display = '';
garminLastSync.textContent = 'never';
garminChooseSub.textContent = 'Connected';
}
} catch (e) {
const msg = e.name === 'AbortError'
? 'Timed out — Garmin login is taking too long. Try again later.'
: 'Error: ' + e.message;
garminStatus.textContent = msg;
garminStatus.style.color = '#f87171';
} finally {
garminConnBtn.disabled = false;
garminConnBtn.textContent = 'Connect';
}
});
garminSyncBtn.addEventListener('click', () => {
garminSyncBtn.disabled = true;
garminSyncBtn.textContent = 'Syncing…';
garminStatus.textContent = '';
garminStatus.style.color = '';
const es = new EventSource(`${editUrl}/api/garmin/sync/stream`, { withCredentials: true });
es.onmessage = e => {
try {
const d = JSON.parse(e.data);
if (d.type === 'fetching') {
garminStatus.textContent = 'Fetching activity list from Garmin…';
} else if (d.type === 'progress') {
const pct = Math.round((d.n / d.total) * 100);
const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·';
garminStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`;
} else if (d.type === 'done') {
es.close();
garminLastSync.textContent = new Date().toLocaleString();
const errNote = d.error_count ? `, ${d.error_count} errors` : '';
garminStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`;
garminStatus.style.color = '#4ade80';
garminSyncBtn.disabled = false;
garminSyncBtn.textContent = 'Sync now';
} else if (d.type === 'error') {
es.close();
garminStatus.textContent = 'Error: ' + d.message;
garminStatus.style.color = '#f87171';
garminSyncBtn.disabled = false;
garminSyncBtn.textContent = 'Sync now';
}
} catch (_) {}
};
es.onerror = () => {
if (garminSyncBtn.disabled) {
garminStatus.textContent = 'Connection lost. Check logs.';
garminStatus.style.color = '#f87171';
garminSyncBtn.disabled = false;
garminSyncBtn.textContent = 'Sync now';
}
es.close();
};
});
garminDisconnBtn.addEventListener('click', async () => {
garminDisconnBtn.disabled = true;
garminStatus.textContent = '';
try {
await fetch(`${editUrl}/api/garmin/disconnect`, { method: 'POST', credentials: 'include' });
garminSync.style.display = 'none';
garminConnect.style.display = '';
garminStatus.textContent = 'Disconnected.';
garminStatus.style.color = '#a1a1aa';
garminChooseSub.textContent = 'Not connected';
} catch (e) {
garminStatus.textContent = 'Error: ' + e.message;
garminStatus.style.color = '#f87171';
} finally {
garminDisconnBtn.disabled = false;
}
});
// Handle ?strava= param set by the callback redirect (popup scenario)
const sp = new URLSearchParams(window.location.search);
if (sp.has('strava')) {
+163 -9
View File
@@ -55,6 +55,24 @@ function emptyIndex(): BASIndex {
};
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function isYearShardUrl(url: string): boolean {
return /(?:^|\/)index-\d{4}\.json$/.test(url);
}
function rewriteActivityUrls(a: ActivitySummary, shardBase: string): ActivitySummary {
// Skip if URL is already absolute (http:// or root-relative /) — avoids
// double-rewriting when shards are nested (e.g. user shard → year shard).
const needsRewrite = (url: string | null | undefined): boolean =>
!!url && !url.startsWith('http') && !url.startsWith('/');
return {
...a,
detail_url: needsRewrite(a.detail_url) ? `${shardBase}${a.detail_url}` : a.detail_url,
track_url: needsRewrite(a.track_url) ? `${shardBase}${a.track_url}` : a.track_url,
};
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
@@ -87,18 +105,19 @@ async function resolveShards(
// Rewrite relative detail_url / track_url to be absolute so they can be
// fetched correctly regardless of where the root index lives.
return activities.map(a => ({
...a,
...rewriteActivityUrls(a, shardBase),
...(shard.handle ? { handle: shard.handle } : {}),
detail_url: a.detail_url && !a.detail_url.startsWith('http')
? `${shardBase}${a.detail_url}`
: a.detail_url,
track_url: a.track_url && !a.track_url.startsWith('http')
? `${shardBase}${a.track_url}`
: a.track_url,
}));
}),
);
// Log shard fetch failures to help diagnose missing-activity issues
shardResults.forEach((r, i) => {
if (r.status === 'rejected') {
console.error('[bincio] shard fetch failed:', index.shards[i]?.url, r.reason);
}
});
const own = index.activities ?? [];
const fromShards = shardResults.flatMap(r => r.status === 'fulfilled' ? r.value : []);
return [...own, ...fromShards];
@@ -143,6 +162,141 @@ export async function loadIndex(baseUrl: string, indexUrl?: string): Promise<BAS
};
}
/**
* Like loadIndex but only fetches the most-recent year shard immediately.
* Returns the first-page activities plus a list of remaining shard URLs that
* can be fetched on demand (e.g. when the user clicks "Load more").
*
* Falls back to full eager loading for non-year shard manifests (multi-user
* combined feed) so the behaviour is identical to loadIndex in those cases.
*/
export async function loadIndexPaged(
baseUrl: string,
indexUrl?: string,
): Promise<{ index: BASIndex; pendingShards: string[] }> {
indexUrl = indexUrl ?? `${baseUrl}data/index.json`;
const [serverResult, localResult] = await Promise.allSettled([
fetchJSON<BASIndex>(indexUrl),
listLocalActivities(),
]);
const server = serverResult.status === 'fulfilled' ? serverResult.value : null;
const local = localResult.status === 'fulfilled' ? localResult.value : [];
if (!server && local.length === 0) return { index: emptyIndex(), pendingShards: [] };
const base = indexUrl.substring(0, indexUrl.lastIndexOf('/') + 1);
const allShards = server?.shards ?? [];
const yearShards = allShards.filter(s => isYearShardUrl(s.url));
const otherShards = allShards.filter(s => !isYearShardUrl(s.url));
// ── Year-sharded index (single-user or profile page) ───────────────────────
// Load only the first (most-recent) year shard; return the rest as pending.
let yearFirstActivities: ActivitySummary[] = [];
let pendingShards: string[] = [];
if (yearShards.length > 0) {
const sorted = [...yearShards].sort((a, b) => b.url.localeCompare(a.url));
const firstUrl = sorted[0].url.startsWith('http') ? sorted[0].url : `${base}${sorted[0].url}`;
const shardBase = firstUrl.substring(0, firstUrl.lastIndexOf('/') + 1);
try {
const first = await fetchJSON<BASIndex>(firstUrl);
yearFirstActivities = (first.activities ?? []).map(a => rewriteActivityUrls(a, shardBase));
} catch (e) {
console.error('[bincio] first year shard failed:', sorted[0].url, e);
}
pendingShards = sorted.slice(1).map(s =>
s.url.startsWith('http') ? s.url : `${base}${s.url}`,
);
}
// ── Non-year shards (multi-user manifest) — loaded eagerly as before ───────
let otherActivities: ActivitySummary[] = [];
if (otherShards.length > 0) {
const otherIndex: BASIndex = { ...(server ?? emptyIndex()), shards: otherShards };
otherActivities = await resolveShards(otherIndex, indexUrl);
}
// ── Own activities (legacy flat index with no shards) ──────────────────────
const ownActivities = allShards.length === 0 ? (server?.activities ?? []) : [];
// Merge: server + local (local overrides server for same id)
const serverActivities = [...ownActivities, ...otherActivities, ...yearFirstActivities];
const merged = new Map<string, ActivitySummary>();
for (const a of serverActivities) merged.set(a.id, a);
for (const a of local as ActivitySummary[]) merged.set(a.id, a);
return {
index: {
...(server ?? emptyIndex()),
activities: [...merged.values()].sort(
(a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? ''),
),
},
pendingShards,
};
}
/**
* Fetch activities from a single year shard URL (absolute).
* Used by ActivityFeed to lazily load older years when "Load more" is clicked.
*/
export async function loadShardActivities(shardUrl: string): Promise<ActivitySummary[]> {
try {
const data = await fetchJSON<BASIndex>(shardUrl);
const base = shardUrl.substring(0, shardUrl.lastIndexOf('/') + 1);
return (data.activities ?? []).map(a => rewriteActivityUrls(a, base));
} catch {
return [];
}
}
interface FeedPage {
page: number;
total_pages: number;
total_activities: number;
activities: ActivitySummary[];
}
/**
* Load the combined feed (multi-user global feed). Returns the first page of
* activities pre-sorted across all users, plus remaining page count.
*
* Falls back to the full shard-resolution path if feed.json doesn't exist
* (single-user installs, older data).
*/
export async function loadCombinedFeed(
baseUrl: string,
): Promise<{ activities: ActivitySummary[]; remainingPages: number; totalActivities: number } | null> {
try {
const feed = await fetchJSON<FeedPage>(`${baseUrl}data/feed.json`);
return {
activities: feed.activities ?? [],
remainingPages: (feed.total_pages ?? 1) - 1,
totalActivities: feed.total_activities ?? 0,
};
} catch {
return null;
}
}
/**
* Load a subsequent page of the combined feed (feed-2.json, feed-3.json, etc.).
*/
export async function loadCombinedFeedPage(
baseUrl: string,
page: number,
): Promise<ActivitySummary[]> {
try {
const feed = await fetchJSON<FeedPage>(`${baseUrl}data/feed-${page}.json`);
return feed.activities ?? [];
} catch {
return [];
}
}
/**
* Load a single activity detail, checking IndexedDB first so locally-converted
* activities are available offline.
@@ -161,7 +315,7 @@ export async function loadActivity(
if (cached) return cached;
try {
const url = detailUrl.startsWith('http')
const url = detailUrl.startsWith('http') || detailUrl.startsWith('/')
? detailUrl
: `${baseUrl}data/${detailUrl}`;
return await fetchJSON<ActivityDetail>(url);
@@ -192,7 +346,7 @@ export async function loadTimeseries(
if (timeseriesUrl.startsWith('http')) {
url = timeseriesUrl;
} else if (detailUrl.startsWith('http')) {
} else if (detailUrl.startsWith('http') || detailUrl.startsWith('/')) {
// absolute detailUrl (browser shard resolution) → same directory
const dir = detailUrl.substring(0, detailUrl.lastIndexOf('/') + 1);
url = `${dir}${filename}`;
+7 -1
View File
@@ -1,4 +1,10 @@
import type { Sport } from './types';
import type { Privacy, Sport } from './types';
/** True for "unlisted" activities (and the legacy "private" alias).
* Use this everywhere instead of comparing against 'private' directly. */
export function isUnlisted(privacy: Privacy | string | null | undefined): boolean {
return privacy === 'unlisted' || privacy === 'private';
}
export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = 'metric'): string {
if (m == null) return '—';
+11
View File
@@ -22,6 +22,17 @@ export interface ShardHandle {
url: string;
}
export function isInstancePrivate(): boolean {
try {
const dataDir = findDataDir();
if (!dataDir) return false;
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
return root?.instance?.private === true;
} catch {
return false;
}
}
export function readShardHandles(): ShardHandle[] {
try {
const dataDir = findDataDir();
+3 -1
View File
@@ -2,7 +2,9 @@
export type Sport = "cycling" | "running" | "hiking" | "walking" | "swimming" | "skiing" | "other";
export type SubSport = "road" | "mountain" | "gravel" | "indoor" | "trail" | "track" | "nordic" | "alpine" | "open_water" | "pool" | null;
export type Privacy = "public" | "blur_start" | "no_gps" | "private";
/** "unlisted" = not shown in the public feed; GPS track still published (security by obscurity).
* "private" is the legacy alias for "unlisted" accepted when reading old data. */
export type Privacy = "public" | "blur_start" | "no_gps" | "unlisted" | "private";
/** [duration_s, avg_watts] pairs, sorted by duration ascending. */
export type MmpCurve = [number, number][];
+239
View File
@@ -0,0 +1,239 @@
---
import Base from '../../../layouts/Base.astro';
const baseUrl = import.meta.env.BASE_URL ?? '/';
const labels = {
community: 'Comunitat',
members: 'membre',
members_pl: 'membres',
day: 'dia',
days: 'dies',
invited_by: 'convidat per',
founder: 'fundador',
};
---
<Base title="Sobre el projecte — BincioActivity" public={true}>
<div class="max-w-2xl mx-auto">
<div class="flex items-baseline justify-between mb-1">
<h1 class="text-2xl font-bold text-white">Sobre BincioActivity</h1>
<div class="flex gap-3 text-xs text-zinc-500">
<a href={`${baseUrl}about/`} class="hover:text-white transition-colors">EN</a>
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
<span class="text-zinc-300 font-medium">CA</span>
</div>
</div>
<p class="text-sm text-zinc-500 mb-4">Seguiment d'activitats de codi obert i allotjament propi</p>
<div class="flex flex-wrap gap-2 mb-8">
<a
href="https://ko-fi.com/brutsalvadi"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
style="background:#FF5E5B; color:#fff;"
>
☕ Dona suport a Ko-fi
</a>
<a
id="feedback-btn"
href="/feedback/"
style="display:none"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
>
💬 Envia comentaris
</a>
</div>
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<section id="stats-section" style="display:none">
<h2 class="text-base font-semibold text-white mb-3">Comunitat</h2>
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="stats-tree" class="text-sm"></div>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Què és això?</h2>
<p>
BincioActivity és una plataforma gratuïta i de codi obert per registrar les teves
activitats a l'aire lliure: ciclisme, córrer, senderisme i més. Està dissenyada per
ser allotjada pel propi usuari: tu (o algú de confiança) gestioneu el servidor, i
les teves dades resten sota el teu control.
</p>
<p class="mt-2">
Les activitats s'emmagatzemen en un format JSON obert anomenat BAS (BincioActivity Schema),
dissenyat per ser llegible i portable. La plataforma no té analítiques ocultes,
no inclou publicitat i no comparteix dades amb tercers.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Registre i invitacions</h2>
<p>
Aquesta instància és només per invitació. Per registrar-te necessites un enllaç
d'invitació d'un membre existent — cada enllaç és d'un sol ús i està vinculat a
un codi únic.
</p>
<p class="mt-2">
Un cop tinguis un compte, pots generar fins a <strong class="text-zinc-300">3 enllaços d'invitació</strong> per
compartir amb persones de confiança. Gestiona les teves invitacions des de la <a id="invites-link" href="invites/" class="text-blue-400 hover:text-blue-300 transition-colors">pàgina d'invitacions</a>
(cal iniciar sessió).
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Les teves dades en aquest servidor</h2>
<p>
Quan puges un fitxer FIT, GPX o TCX, el servidor el converteix al format BAS.
Per defecte, el fitxer font original també es desa a la carpeta
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> del teu compte.
Pots desactivar aquesta opció en el moment de la pujada desmarcant
<em>"Conserva el fitxer original al servidor"</em>.
</p>
<p class="mt-2">
Es recomana conservar els originals durant aquestes primeres etapes del projecte:
si la cadena de processament millora (millor suavitzat d'elevació, càlcul de velocitat,
detecció de voltes, etc.) podràs tornar a importar els fitxers per aprofitar els canvis.
Si has triat no conservar els originals, hauràs de tornar a pujar els fitxers manualment.
</p>
<p class="mt-2">
En sincronitzar amb Strava, les dades brutes obtingudes de l'API de Strava també
es poden emmagatzemar localment. Això ho controla una configuració global del servidor
establerta per l'operador.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Programari en fase inicial</h2>
<p>
BincioActivity està en desenvolupament actiu. El format de dades, la cadena de
processament i l'API del servidor poden canviar entre versions. Els canvis
incompatibles són possibles, especialment en aquesta etapa. Quan es produeixin,
tornar a importar els fitxers originals és la manera més segura d'actualitzar
les teves dades.
</p>
<p class="mt-2">
No hi ha cap garantia de disponibilitat, integritat de les dades ni compatibilitat
futura per a cap versió en particular. Fes servir aquest programari sota la teva
pròpia responsabilitat i conserva les teves pròpies còpies de seguretat de les
dades importants.
</p>
</section>
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
<h2 class="text-base font-semibold text-white mb-2">Limitació de responsabilitat</h2>
<p>
BincioActivity es proporciona <strong class="text-zinc-300">"tal com és"</strong>, sense
cap garantia de cap mena. Els autors i operadors del servidor no accepten cap
responsabilitat per:
</p>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Pèrdua, corrupció o accés no autoritzat a les teves dades d'activitat</li>
<li>Dades exposades per una configuració incorrecta del servidor o la infraestructura</li>
<li>Inexactituds en les estadístiques calculades (distància, desnivell, freqüència cardíaca, etc.)</li>
<li>Qualsevol conseqüència derivada d'actuar sobre la informació mostrada per aquesta aplicació</li>
</ul>
<p class="mt-3">
Ets responsable de protegir el teu compte amb una contrasenya segura, de revisar
quines dades comparteixes i de fer les teves pròpies còpies de seguretat. Les dades
de GPS i salut poden ser sensibles — reflexiona sobre el que puges i qui ho pot veure.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Codi obert</h2>
<p>
BincioActivity és programari de codi obert. Ets lliure d'inspeccionar el codi,
allotjar la teva pròpia instància i contribuir amb millores.
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
const feedbackBtn = document.getElementById('feedback-btn');
if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
const invLink = document.getElementById('invites-link');
if (invLink) invLink.href = '/invites/';
if (!data.user_count) return;
const section = document.getElementById('stats-section');
const summary = document.getElementById('stats-summary');
const treeEl = document.getElementById('stats-tree');
const n = data.user_count;
summary.textContent = `${n} ${n === 1 ? labels.members : labels.members_pl}`;
section.style.display = '';
const byHandle = {};
for (const m of data.members) byHandle[m.handle] = m;
const children = {};
const roots = [];
for (const m of data.members) {
if (m.invited_by && byHandle[m.invited_by]) {
(children[m.invited_by] ??= []).push(m.handle);
} else {
roots.push(m.handle);
}
}
function formatDuration(days) {
if (days < 1) return `< 1 ${labels.day}`;
if (days === 1) return `1 ${labels.day}`;
if (days < 30) return `${days} ${labels.days}`;
const months = Math.floor(days / 30);
return months === 1 ? `1 mo` : `${months} mo`;
}
function renderNode(handle, depth) {
const m = byHandle[handle];
const indent = depth * 20;
const isRoot = !m.invited_by;
const sub = isRoot ? labels.founder : `${labels.invited_by} @${m.invited_by}`;
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${indent}px`;
if (depth > 0) {
const connector = document.createElement('span');
connector.className = 'text-zinc-700 shrink-0';
connector.textContent = '└';
row.appendChild(connector);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const handle_el = document.createElement('span');
handle_el.className = 'text-zinc-600 text-xs';
handle_el.textContent = `@${handle}`;
row.appendChild(handle_el);
const spacer = document.createElement('span');
spacer.className = 'flex-1';
row.appendChild(spacer);
const meta = document.createElement('span');
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
meta.innerHTML = `${formatDuration(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
row.appendChild(meta);
treeEl.appendChild(row);
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
}
for (const root of roots) renderNode(root, 0);
})();
</script>
+238
View File
@@ -0,0 +1,238 @@
---
import Base from '../../../layouts/Base.astro';
const baseUrl = import.meta.env.BASE_URL ?? '/';
const labels = {
community: 'Comunidad',
members: 'miembro',
members_pl: 'miembros',
day: 'día',
days: 'días',
invited_by: 'invitado por',
founder: 'fundador',
};
---
<Base title="Acerca de — BincioActivity" public={true}>
<div class="max-w-2xl mx-auto">
<div class="flex items-baseline justify-between mb-1">
<h1 class="text-2xl font-bold text-white">Acerca de BincioActivity</h1>
<div class="flex gap-3 text-xs text-zinc-500">
<a href={`${baseUrl}about/`} class="hover:text-white transition-colors">EN</a>
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
<span class="text-zinc-300 font-medium">ES</span>
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
</div>
</div>
<p class="text-sm text-zinc-500 mb-4">Seguimiento de actividades open-source y autoalojado</p>
<div class="flex flex-wrap gap-2 mb-8">
<a
href="https://ko-fi.com/brutsalvadi"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
style="background:#FF5E5B; color:#fff;"
>
☕ Apoya en Ko-fi
</a>
<a
id="feedback-btn"
href="/feedback/"
style="display:none"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
>
💬 Enviar comentarios
</a>
</div>
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<section id="stats-section" style="display:none">
<h2 class="text-base font-semibold text-white mb-3">Comunidad</h2>
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="stats-tree" class="text-sm"></div>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">¿Qué es esto?</h2>
<p>
BincioActivity es una plataforma gratuita y de código abierto para registrar tus
actividades al aire libre: ciclismo, running, senderismo y más. Está diseñada para
ser autoalojada: tú (o alguien de confianza) gestionas el servidor, y tus datos
permanecen bajo tu control.
</p>
<p class="mt-2">
Las actividades se almacenan en un formato JSON abierto llamado BAS (BincioActivity Schema),
diseñado para ser legible y portable. La plataforma no tiene analíticas ocultas,
no incluye publicidad y no comparte datos con terceros.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Registro e invitaciones</h2>
<p>
Esta instancia es solo por invitación. Para registrarte necesitas un enlace de
invitación de un miembro existente — cada enlace es de un solo uso y está vinculado
a un código único.
</p>
<p class="mt-2">
Una vez que tengas una cuenta, puedes generar hasta <strong class="text-zinc-300">3 enlaces de invitación</strong> para
compartir con personas de confianza. Gestiona tus invitaciones desde la <a id="invites-link" href="invites/" class="text-blue-400 hover:text-blue-300 transition-colors">página de invitaciones</a>
(requiere inicio de sesión).
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Tus datos en este servidor</h2>
<p>
Cuando subes un archivo FIT, GPX o TCX, el servidor lo convierte al formato BAS.
Por defecto, el archivo fuente original también se guarda en la carpeta
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> de tu cuenta.
Puedes desactivar esta opción en el momento de la subida desmarcando
<em>"Conservar el archivo original en el servidor"</em>.
</p>
<p class="mt-2">
Se recomienda conservar los originales durante estas primeras etapas del proyecto:
si la cadena de procesamiento mejora (mejor suavizado de elevación, cálculo de velocidad,
detección de vueltas, etc.) podrás volver a importar tus archivos para aprovechar los
cambios. Si elegiste no conservar los originales, tendrías que subir los archivos
de nuevo manualmente.
</p>
<p class="mt-2">
Al sincronizar con Strava, los datos brutos obtenidos de la API de Strava también
pueden almacenarse localmente. Esto lo controla una configuración global del servidor
establecida por el operador.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Software en fase temprana</h2>
<p>
BincioActivity está en desarrollo activo. El formato de datos, la cadena de procesamiento
y la API del servidor pueden cambiar entre versiones. Los cambios incompatibles son
posibles, especialmente en esta etapa. Cuando ocurran, volver a importar los archivos
originales es la forma más segura de actualizar tus datos.
</p>
<p class="mt-2">
No existe ninguna garantía de disponibilidad, integridad de datos ni compatibilidad
futura para ninguna versión en particular. Usa este software bajo tu propia
responsabilidad y mantén tus propias copias de seguridad de los datos importantes.
</p>
</section>
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
<h2 class="text-base font-semibold text-white mb-2">Descargo de responsabilidad</h2>
<p>
BincioActivity se proporciona <strong class="text-zinc-300">"tal cual"</strong>, sin
garantía de ningún tipo. Los autores y operadores del servidor no aceptan ninguna
responsabilidad por:
</p>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Pérdida, corrupción o acceso no autorizado a tus datos de actividad</li>
<li>Datos expuestos por una mala configuración del servidor o la infraestructura</li>
<li>Inexactitudes en las estadísticas calculadas (distancia, elevación, frecuencia cardíaca, etc.)</li>
<li>Cualquier consecuencia derivada de actuar sobre la información mostrada por esta aplicación</li>
</ul>
<p class="mt-3">
Eres responsable de proteger tu cuenta con una contraseña segura, de revisar qué
datos compartes y de realizar tus propias copias de seguridad. Los datos de GPS y
salud pueden ser sensibles — reflexiona sobre qué subes y quién puede verlo.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Código abierto</h2>
<p>
BincioActivity es software de código abierto. Eres libre de inspeccionar el código,
alojar tu propia instancia y contribuir con mejoras.
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
const feedbackBtn = document.getElementById('feedback-btn');
if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
const invLink = document.getElementById('invites-link');
if (invLink) invLink.href = '/invites/';
if (!data.user_count) return;
const section = document.getElementById('stats-section');
const summary = document.getElementById('stats-summary');
const treeEl = document.getElementById('stats-tree');
const n = data.user_count;
summary.textContent = `${n} ${n === 1 ? labels.members : labels.members_pl}`;
section.style.display = '';
const byHandle = {};
for (const m of data.members) byHandle[m.handle] = m;
const children = {};
const roots = [];
for (const m of data.members) {
if (m.invited_by && byHandle[m.invited_by]) {
(children[m.invited_by] ??= []).push(m.handle);
} else {
roots.push(m.handle);
}
}
function formatDuration(days) {
if (days < 1) return `< 1 ${labels.day}`;
if (days === 1) return `1 ${labels.day}`;
if (days < 30) return `${days} ${labels.days}`;
const months = Math.floor(days / 30);
return months === 1 ? `1 mo` : `${months} mo`;
}
function renderNode(handle, depth) {
const m = byHandle[handle];
const indent = depth * 20;
const isRoot = !m.invited_by;
const sub = isRoot ? labels.founder : `${labels.invited_by} @${m.invited_by}`;
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${indent}px`;
if (depth > 0) {
const connector = document.createElement('span');
connector.className = 'text-zinc-700 shrink-0';
connector.textContent = '└';
row.appendChild(connector);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const handle_el = document.createElement('span');
handle_el.className = 'text-zinc-600 text-xs';
handle_el.textContent = `@${handle}`;
row.appendChild(handle_el);
const spacer = document.createElement('span');
spacer.className = 'flex-1';
row.appendChild(spacer);
const meta = document.createElement('span');
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
meta.innerHTML = `${formatDuration(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
row.appendChild(meta);
treeEl.appendChild(row);
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
}
for (const root of roots) renderNode(root, 0);
})();
</script>
+246
View File
@@ -0,0 +1,246 @@
---
import Base from '../../layouts/Base.astro';
const baseUrl = import.meta.env.BASE_URL ?? '/';
const labels = {
community: 'Community',
members: 'member',
members_pl: 'members',
day: 'day',
days: 'days',
invited_by: 'invited by',
founder: 'founder',
loading: 'Loading…',
};
---
<Base title="About — BincioActivity" public={true}>
<div class="max-w-2xl mx-auto">
<div class="flex items-baseline justify-between mb-1">
<h1 class="text-2xl font-bold text-white">About BincioActivity</h1>
<div class="flex gap-3 text-xs text-zinc-500">
<span class="text-zinc-300 font-medium">EN</span>
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
</div>
</div>
<p class="text-sm text-zinc-500 mb-4">Open-source, self-hosted activity tracking</p>
<div class="flex flex-wrap gap-2 mb-8">
<a
href="https://ko-fi.com/brutsalvadi"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
style="background:#FF5E5B; color:#fff;"
>
☕ Support on Ko-fi
</a>
<a
id="feedback-btn"
href="/feedback/"
style="display:none"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
>
💬 Send feedback
</a>
</div>
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<!-- Community stats (shown only in multi-user mode) -->
<section id="stats-section" style="display:none">
<h2 class="text-base font-semibold text-white mb-3">Community</h2>
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="stats-tree" class="text-sm"></div>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
<p>
BincioActivity is a free, open-source platform for tracking your outdoor activities —
cycling, running, hiking, and more. It is designed to be self-hosted: you (or someone
you trust) run the server, and your data stays under your control.
</p>
<p class="mt-2">
Activities are stored in an open JSON format called BAS (BincioActivity Schema),
which is designed to be readable and portable. The platform has no hidden analytics,
no advertising, and no third-party data sharing.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Joining &amp; invitations</h2>
<p>
This instance is invite-only. To join, you need an invite link from an existing
member — each link is single-use and tied to a unique code.
</p>
<p class="mt-2">
Once you have an account, you can generate up to <strong class="text-zinc-300">3 invite links</strong> to
share with people you trust. You can manage your invites from the <a id="invites-link" href="invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>
(requires login).
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Your data on this server</h2>
<p>
When you upload a FIT, GPX, or TCX file, the server converts it to BAS format.
By default the original source file is also kept in your account's
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> folder.
You can opt out of this at upload time by unchecking <em>"Keep original file on server"</em>.
</p>
<p class="mt-2">
Keeping originals is recommended during these early stages of the project: if the
processing pipeline improves (better elevation smoothing, speed calculation, lap
detection, etc.) you can re-import your files to take advantage of the changes.
If you chose not to keep originals, you would need to upload the files again manually.
</p>
<p class="mt-2">
When syncing from Strava, the raw activity data fetched from the Strava API can
similarly be stored locally. This is controlled by an instance-wide setting
configured by the server operator.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Early-stage software</h2>
<p>
BincioActivity is under active development. The data format, processing pipeline,
and server API may change between versions. Breaking changes are possible, especially
at this stage. When they occur, re-importing your original files is the safest way
to bring your data up to date.
</p>
<p class="mt-2">
There is no guarantee of uptime, data integrity, or forward compatibility for
any particular version. Use this software at your own risk, and keep your own
backups of important data.
</p>
</section>
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
<h2 class="text-base font-semibold text-white mb-2">Disclaimer</h2>
<p>
BincioActivity is provided <strong class="text-zinc-300">"as is"</strong>, without
warranty of any kind. The authors and server operators accept no responsibility for:
</p>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Loss, corruption, or unauthorised access to your activity data</li>
<li>Data exposed through misconfiguration of the server or infrastructure</li>
<li>Inaccuracies in computed statistics (distance, elevation, heart rate, etc.)</li>
<li>Any consequences of acting on information displayed by this application</li>
</ul>
<p class="mt-3">
You are responsible for securing your account with a strong password, reviewing
what data you share, and making your own backups. GPS and health data can be
sensitive — think carefully about what you upload and who can see it.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Open source</h2>
<p>
BincioActivity is open-source software. You are free to inspect the code,
self-host your own instance, and contribute improvements.
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return; // not logged in — hide community section
const feedbackBtn = document.getElementById('feedback-btn');
if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return; // single-user mode — no stats endpoint
data = await r.json();
} catch { return; }
// Fix invites link to use absolute base URL
const invLink = document.getElementById('invites-link');
if (invLink) invLink.href = '/invites/';
if (!data.user_count) return;
const section = document.getElementById('stats-section');
const summary = document.getElementById('stats-summary');
const treeEl = document.getElementById('stats-tree');
const n = data.user_count;
summary.textContent = `${n} ${n === 1 ? labels.members : labels.members_pl}`;
section.style.display = '';
// Build adjacency map: handle → [children]
const byHandle = {};
for (const m of data.members) byHandle[m.handle] = m;
const children = {};
const roots = [];
for (const m of data.members) {
if (m.invited_by && byHandle[m.invited_by]) {
(children[m.invited_by] ??= []).push(m.handle);
} else {
roots.push(m.handle);
}
}
function formatDuration(days) {
if (days < 1) return `< 1 ${labels.day}`;
if (days === 1) return `1 ${labels.day}`;
if (days < 30) return `${days} ${labels.days}`;
const months = Math.floor(days / 30);
return months === 1 ? `1 mo` : `${months} mo`;
}
function renderNode(handle, depth) {
const m = byHandle[handle];
const indent = depth * 20;
const isRoot = !m.invited_by;
const sub = isRoot
? labels.founder
: `${labels.invited_by} @${m.invited_by}`;
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${indent}px`;
if (depth > 0) {
const connector = document.createElement('span');
connector.className = 'text-zinc-700 shrink-0';
connector.textContent = '└';
row.appendChild(connector);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const handle_el = document.createElement('span');
handle_el.className = 'text-zinc-600 text-xs';
handle_el.textContent = `@${handle}`;
row.appendChild(handle_el);
const spacer = document.createElement('span');
spacer.className = 'flex-1';
row.appendChild(spacer);
const meta = document.createElement('span');
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
meta.innerHTML = `${formatDuration(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
row.appendChild(meta);
treeEl.appendChild(row);
for (const child of (children[handle] ?? [])) {
renderNode(child, depth + 1);
}
}
for (const root of roots) renderNode(root, 0);
})();
</script>
+238
View File
@@ -0,0 +1,238 @@
---
import Base from '../../../layouts/Base.astro';
const baseUrl = import.meta.env.BASE_URL ?? '/';
const labels = {
community: 'Comunità',
members: 'membro',
members_pl: 'membri',
day: 'giorno',
days: 'giorni',
invited_by: 'invitato da',
founder: 'fondatore',
};
---
<Base title="Informazioni — BincioActivity" public={true}>
<div class="max-w-2xl mx-auto">
<div class="flex items-baseline justify-between mb-1">
<h1 class="text-2xl font-bold text-white">Informazioni su BincioActivity</h1>
<div class="flex gap-3 text-xs text-zinc-500">
<a href={`${baseUrl}about/`} class="hover:text-white transition-colors">EN</a>
<span class="text-zinc-300 font-medium">IT</span>
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
</div>
</div>
<p class="text-sm text-zinc-500 mb-4">Tracciamento attività open-source e self-hosted</p>
<div class="flex flex-wrap gap-2 mb-8">
<a
href="https://ko-fi.com/brutsalvadi"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
style="background:#FF5E5B; color:#fff;"
>
☕ Supporta su Ko-fi
</a>
<a
id="feedback-btn"
href="/feedback/"
style="display:none"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
>
💬 Invia feedback
</a>
</div>
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<section id="stats-section" style="display:none">
<h2 class="text-base font-semibold text-white mb-3">Comunità</h2>
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="stats-tree" class="text-sm"></div>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Cos'è?</h2>
<p>
BincioActivity è una piattaforma gratuita e open-source per tracciare le tue attività
all'aperto — ciclismo, corsa, escursionismo e altro. È progettata per essere
self-hosted: tu (o qualcuno di cui ti fidi) gestisci il server, e i tuoi dati
rimangono sotto il tuo controllo.
</p>
<p class="mt-2">
Le attività vengono salvate in un formato JSON aperto chiamato BAS (BincioActivity Schema),
progettato per essere leggibile e portabile. La piattaforma non ha analytics nascosti,
nessuna pubblicità e nessuna condivisione di dati con terze parti.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Iscrizione e inviti</h2>
<p>
Questa istanza è accessibile solo su invito. Per registrarti hai bisogno di un link
di invito da parte di un membro già registrato — ogni link è monouso e associato a
un codice univoco.
</p>
<p class="mt-2">
Una volta registrato, puoi generare fino a <strong class="text-zinc-300">3 link di invito</strong> da
condividere con persone di fiducia. Gestisci i tuoi inviti dalla <a id="invites-link" href="invites/" class="text-blue-400 hover:text-blue-300 transition-colors">pagina inviti</a>
(richiede il login).
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">I tuoi dati su questo server</h2>
<p>
Quando carichi un file FIT, GPX o TCX, il server lo converte nel formato BAS.
Di default, il file sorgente originale viene conservato nella cartella
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> del tuo account.
Puoi disattivare questa opzione al momento del caricamento deselezionando
<em>"Mantieni il file originale sul server"</em>.
</p>
<p class="mt-2">
Conservare i file originali è consigliato in questa fase iniziale del progetto: se la
pipeline di elaborazione migliorasse (migliore smoothing del dislivello, calcolo della
velocità, rilevamento dei giri, ecc.) potrai reimportare i file per beneficiare delle
modifiche. Se hai scelto di non conservare gli originali, dovrai ricaricare i file
manualmente.
</p>
<p class="mt-2">
Durante la sincronizzazione con Strava, i dati grezzi dell'attività ottenuti dall'API
Strava possono essere conservati localmente. Questo è controllato da un'impostazione
a livello di istanza configurata dall'operatore del server.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Software in fase iniziale</h2>
<p>
BincioActivity è in sviluppo attivo. Il formato dei dati, la pipeline di elaborazione
e le API del server potrebbero cambiare tra una versione e l'altra. Modifiche
incompatibili sono possibili, soprattutto in questa fase. Quando si verificano,
reimportare i file originali è il modo più sicuro per aggiornare i propri dati.
</p>
<p class="mt-2">
Non vi è alcuna garanzia di uptime, integrità dei dati o compatibilità futura per
nessuna versione specifica. Usa questo software a tuo rischio e pericolo, e conserva
sempre i tuoi backup dei dati importanti.
</p>
</section>
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
<h2 class="text-base font-semibold text-white mb-2">Limitazione di responsabilità</h2>
<p>
BincioActivity è fornito <strong class="text-zinc-300">"così com'è"</strong>, senza
garanzie di alcun tipo. Gli autori e gli operatori del server non si assumono alcuna
responsabilità per:
</p>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Perdita, corruzione o accesso non autorizzato ai tuoi dati di attività</li>
<li>Dati esposti a causa di una configurazione errata del server o dell'infrastruttura</li>
<li>Imprecisioni nelle statistiche calcolate (distanza, dislivello, frequenza cardiaca, ecc.)</li>
<li>Qualsiasi conseguenza derivante dall'utilizzo delle informazioni visualizzate dall'applicazione</li>
</ul>
<p class="mt-3">
Sei responsabile di proteggere il tuo account con una password robusta, di verificare
quali dati condividi e di eseguire i tuoi backup. I dati GPS e sanitari possono essere
sensibili — rifletti attentamente su cosa carichi e su chi può vederlo.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Open source</h2>
<p>
BincioActivity è software open-source. Sei libero di esaminare il codice,
ospitare la tua istanza e contribuire con miglioramenti.
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
const feedbackBtn = document.getElementById('feedback-btn');
if (feedbackBtn) feedbackBtn.style.display = '';
} catch { return; }
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
const invLink = document.getElementById('invites-link');
if (invLink) invLink.href = '/invites/';
if (!data.user_count) return;
const section = document.getElementById('stats-section');
const summary = document.getElementById('stats-summary');
const treeEl = document.getElementById('stats-tree');
const n = data.user_count;
summary.textContent = `${n} ${n === 1 ? labels.members : labels.members_pl}`;
section.style.display = '';
const byHandle = {};
for (const m of data.members) byHandle[m.handle] = m;
const children = {};
const roots = [];
for (const m of data.members) {
if (m.invited_by && byHandle[m.invited_by]) {
(children[m.invited_by] ??= []).push(m.handle);
} else {
roots.push(m.handle);
}
}
function formatDuration(days) {
if (days < 1) return `< 1 ${labels.day}`;
if (days === 1) return `1 ${labels.day}`;
if (days < 30) return `${days} ${labels.days}`;
const months = Math.floor(days / 30);
return months === 1 ? `1 mo` : `${months} mo`;
}
function renderNode(handle, depth) {
const m = byHandle[handle];
const indent = depth * 20;
const isRoot = !m.invited_by;
const sub = isRoot ? labels.founder : `${labels.invited_by} @${m.invited_by}`;
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${indent}px`;
if (depth > 0) {
const connector = document.createElement('span');
connector.className = 'text-zinc-700 shrink-0';
connector.textContent = '└';
row.appendChild(connector);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const handle_el = document.createElement('span');
handle_el.className = 'text-zinc-600 text-xs';
handle_el.textContent = `@${handle}`;
row.appendChild(handle_el);
const spacer = document.createElement('span');
spacer.className = 'flex-1';
row.appendChild(spacer);
const meta = document.createElement('span');
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
meta.innerHTML = `${formatDuration(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
row.appendChild(meta);
treeEl.appendChild(row);
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
}
for (const root of roots) renderNode(root, 0);
})();
</script>
+12 -2
View File
@@ -6,6 +6,15 @@ import ActivityDetail from '../../components/ActivityDetail.svelte';
import type { BASIndex, ActivitySummary, AthleteZones } from '../../lib/types';
export async function getStaticPaths() {
// Activity pages are not pre-built — all /activity/{id}/ URLs are served
// by the activity/index.html shell via nginx try_files and loaded dynamically
// by ActivityDetailLoader. Pre-building thousands of pages at build time
// exhausts server memory. The shell handles public, unlisted, and local
// activities identically with no loss of functionality.
return [];
// Dead code below — kept for reference only.
/* eslint-disable no-unreachable */
try {
const candidates = [
process.env.BINCIO_DATA_DIR,
@@ -52,7 +61,7 @@ export async function getStaticPaths() {
// Build the map from the index first
const byId = new Map(
activities
.filter(a => a.privacy !== 'private' && a.id)
.filter(a => a.privacy !== 'private' && a.privacy !== 'unlisted' && a.id)
.map(a => [a.id, { activity: a, athlete }])
);
@@ -80,7 +89,7 @@ export async function getStaticPaths() {
if (byId.has(id)) continue; // already covered by the index
try {
const detail = JSON.parse(readFileSync(join(actsDir, file), 'utf-8'));
if (detail.privacy === 'private') continue;
if (detail.privacy === 'private' || detail.privacy === 'unlisted') continue;
// Build a minimal ActivitySummary from the detail file
const a: ActivitySummary = {
id,
@@ -119,6 +128,7 @@ export async function getStaticPaths() {
} catch {
return [];
}
/* eslint-enable no-unreachable */
}
const { activity, athlete } = Astro.props as { activity: ActivitySummary; athlete: AthleteZones | null };
+7
View File
@@ -0,0 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import ActivityDetailLoader from '../../components/ActivityDetailLoader.svelte';
---
<Base title="Activity — BincioActivity">
<ActivityDetailLoader base={import.meta.env.BASE_URL} client:only="svelte" />
</Base>
+397
View File
@@ -0,0 +1,397 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Admin — BincioActivity">
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-white mb-8">Admin</h1>
<!-- Disk overview -->
<div id="disk-overview" class="mb-8 p-4 rounded-lg bg-zinc-900 border border-zinc-800 text-sm">
<p class="text-zinc-500">Loading disk info…</p>
</div>
<!-- User table -->
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">Users</h2>
<div class="overflow-x-auto rounded-lg border border-zinc-800">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-zinc-500 text-xs border-b border-zinc-800">
<th class="px-4 py-2 font-medium">Handle</th>
<th class="px-4 py-2 font-medium text-right">Total</th>
<th class="px-4 py-2 font-medium text-right">Activities</th>
<th class="px-4 py-2 font-medium text-right">Originals</th>
<th class="px-4 py-2 font-medium text-right">Merged</th>
<th class="px-4 py-2 font-medium text-right">Images</th>
<th class="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody id="user-list">
<tr><td colspan="7" class="px-4 py-6 text-zinc-500 text-center">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Re-extract progress modal -->
<dialog id="reextract-dialog" class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-2xl w-full backdrop:bg-black/60">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-sm">Re-extract from Strava originals — <span id="reextract-handle" class="text-zinc-400 font-mono"></span></h3>
<button id="reextract-close" class="text-zinc-500 hover:text-zinc-200 text-xs px-2 py-1 rounded bg-zinc-800" disabled>Close</button>
</div>
<div id="reextract-summary" class="text-xs text-zinc-400 mb-2"></div>
<div class="bg-zinc-950 rounded p-3 h-64 overflow-y-auto" id="reextract-log"></div>
</dialog>
<!-- Diag modal -->
<dialog id="diag-dialog" class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-2xl w-full backdrop:bg-black/60">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-sm">Data directory snapshot — <span id="diag-handle" class="text-zinc-400 font-mono"></span></h3>
<button id="diag-close" class="text-zinc-500 hover:text-zinc-200 text-xs px-2 py-1 rounded bg-zinc-800">Close</button>
</div>
<pre id="diag-output" class="text-xs font-mono bg-zinc-950 rounded p-4 overflow-auto max-h-96 text-green-300 whitespace-pre-wrap"></pre>
</dialog>
<!-- Confirmation dialog -->
<dialog id="confirm-dialog" class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-sm w-full backdrop:bg-black/60">
<p class="text-sm text-zinc-300 mb-1">Reset all data for <strong id="confirm-handle" class="text-white"></strong>?</p>
<p class="text-xs text-zinc-500 mb-5">Removes all activities, originals, edits, and images. The account is kept. This cannot be undone.</p>
<div class="flex gap-3 justify-end">
<button id="confirm-cancel" class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">Cancel</button>
<button id="confirm-ok" class="px-4 py-2 rounded-lg text-sm bg-red-700 hover:bg-red-600 text-white font-medium transition-colors">Reset</button>
</div>
</dialog>
</div>
</Base>
<script>
const overviewEl = document.getElementById('disk-overview')!;
const tbodyEl = document.getElementById('user-list')!;
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
const confirmH = document.getElementById('confirm-handle')!;
const diagDialog = document.getElementById('diag-dialog') as HTMLDialogElement;
const diagHandle = document.getElementById('diag-handle')!;
const diagOutput = document.getElementById('diag-output')!;
document.getElementById('diag-close')!.addEventListener('click', () => diagDialog.close());
diagDialog.addEventListener('click', e => { if (e.target === diagDialog) diagDialog.close(); });
const reextractDialog = document.getElementById('reextract-dialog') as HTMLDialogElement;
const reextractHandle = document.getElementById('reextract-handle')!;
const reextractSummary = document.getElementById('reextract-summary')!;
const reextractLog = document.getElementById('reextract-log')!;
const reextractClose = document.getElementById('reextract-close') as HTMLButtonElement;
reextractClose.addEventListener('click', () => { reextractDialog.close(); load(); });
const confirmOk = document.getElementById('confirm-ok')!;
const confirmCancel = document.getElementById('confirm-cancel')!;
let pendingHandle = '';
function fmt(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
if (mb >= 1) return mb.toFixed(0) + ' MB';
return (mb * 1024).toFixed(0) + ' KB';
}
function bar(pct: number): string {
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-blue-500';
return `<div class="w-full bg-zinc-800 rounded-full h-1.5 mt-1"><div class="${color} h-1.5 rounded-full" style="width:${Math.min(pct,100)}%"></div></div>`;
}
async function load() {
const r = await fetch('/api/admin/disk', { credentials: 'include' });
if (!r.ok) {
overviewEl.innerHTML = '<p class="text-red-400">Not authorised or server unavailable.</p>';
return;
}
const { disk, users } = await r.json();
// Disk overview
const pct = disk.percent;
const color = pct >= 90 ? 'text-red-400' : pct >= 70 ? 'text-amber-400' : 'text-green-400';
overviewEl.innerHTML = `
<div class="flex items-center justify-between mb-2">
<span class="text-zinc-300 font-medium">Disk usage</span>
<span class="${color} font-semibold">${pct}%</span>
</div>
${bar(pct)}
<p class="text-zinc-500 mt-2">${disk.used_gb} GB used of ${disk.total_gb} GB — ${disk.free_gb} GB free</p>
`;
// User rows
const maxMb = Math.max(...users.map((u: any) => u.total_mb), 1);
tbodyEl.innerHTML = users.map((u: any) => {
const rowPct = Math.round(u.total_mb / maxMb * 100);
const leaked = u.leaked_zips_count > 0
? `<span class="text-red-400 font-medium ml-2" title="${u.leaked_zips_count} orphaned temp ZIP(s)">⚠ ${fmt(u.leaked_zips_mb)} leaked</span>`
: '';
const ghostBadge = !u.in_db
? `<span class="text-amber-500 text-xs ml-1" title="No account in database">ghost</span>`
: '';
const stravaNote = u.originals_strava_mb > 0
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
: '';
const actionButtons = u.in_db
? `<button
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
title="Show diagnostic snapshot of this user's data directory"
>Diag</button>
<button
class="reextract-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-amber-900 hover:text-amber-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Re-extract activities from stored Strava originals (no API call)"
>Re-extract</button>
<button
class="rebuild-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
title="Re-run merge_all and trigger a site rebuild"
>Rebuild</button>
<button
class="pwreset-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
title="Generate a one-time password reset code for this user"
>Reset pwd</button>
<button
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Wipe all activities, originals, edits and images — account is kept"
>Reset data</button>`
: `<button
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
title="Show diagnostic snapshot of this user's data directory"
>Diag</button>
<button
class="rmdir-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Delete the entire directory for this ghost user (no DB account)"
>Delete dir</button>`;
return `
<tr class="border-b border-zinc-800/50 hover:bg-zinc-900/40" data-handle="${u.handle}">
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
${ghostBadge}${leaked}
</div>
${bar(rowPct)}
</td>
<td class="px-4 py-3 text-right text-zinc-300 font-medium tabular-nums">${fmt(u.total_mb)}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${fmt(u.activities_mb)}
<span class="text-zinc-600 text-xs block">${u.activities_count} files</span>
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">
${u.originals_mb > 0 ? fmt(u.originals_mb) : '—'}
${stravaNote}
</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.merged_mb > 0 ? fmt(u.merged_mb) : '—'}</td>
<td class="px-4 py-3 text-right text-zinc-400 tabular-nums">${u.images_mb > 0 ? fmt(u.images_mb) : '—'}</td>
<td class="px-4 py-3 text-right">
<div class="flex gap-2 justify-end">
${actionButtons}
</div>
</td>
</tr>
`;
}).join('');
tbodyEl.querySelectorAll<HTMLButtonElement>('.reextract-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
reextractHandle.textContent = h;
reextractLog.innerHTML = '';
reextractSummary.textContent = 'Starting…';
reextractClose.disabled = true;
reextractDialog.showModal();
let imported = 0, skipped = 0, errors = 0;
try {
const r = await fetch(`/api/admin/users/${h}/reextract-originals`, {
method: 'POST', credentials: 'include',
});
if (!r.ok) {
const errText = await r.text().catch(() => r.status.toString());
reextractSummary.textContent = `Error ${r.status}: ${errText}`;
reextractClose.disabled = false;
return;
}
const reader = r.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n\n');
buf = lines.pop() ?? '';
for (const chunk of lines) {
const dataLine = chunk.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const ev = JSON.parse(dataLine.slice(6));
if (ev.type === 'status') {
reextractSummary.textContent = ev.message;
} else if (ev.type === 'progress') {
const color = ev.status === 'imported' ? 'text-green-400'
: ev.status === 'error' ? 'text-red-400'
: 'text-zinc-500';
const line = document.createElement('div');
line.className = `text-xs font-mono ${color}`;
line.textContent = `[${ev.n}/${ev.total}] ${ev.status.padEnd(8)} ${ev.name}${ev.detail ? ' — ' + ev.detail : ''}`;
reextractLog.appendChild(line);
reextractLog.scrollTop = reextractLog.scrollHeight;
if (ev.status === 'imported') imported++;
else if (ev.status === 'error') errors++;
else skipped++;
reextractSummary.textContent = `Processing… ${ev.n}/${ev.total} — imported: ${imported}, skipped: ${skipped}, errors: ${errors}`;
} else if (ev.type === 'done') {
reextractSummary.textContent = `Done — imported: ${ev.imported}, skipped: ${ev.skipped}, errors: ${ev.errors}`;
}
}
}
} catch (err) {
reextractSummary.textContent = 'Error: ' + String(err);
} finally {
reextractClose.disabled = false;
}
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.diag-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
diagHandle.textContent = h;
diagOutput.textContent = 'Loading…';
diagDialog.showModal();
try {
const r = await fetch(`/api/admin/users/${h}/diag`, { credentials: 'include' });
const d = await r.json();
diagOutput.textContent = JSON.stringify(d, null, 2);
} catch (err) {
diagOutput.textContent = String(err);
}
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.rebuild-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
btn.disabled = true;
btn.textContent = 'Queued…';
try {
const r = await fetch(`/api/admin/users/${h}/rebuild`, {
method: 'POST',
credentials: 'include',
});
if (r.ok) {
btn.textContent = 'Rebuilding…';
btn.classList.add('text-blue-400');
// Rebuild is async — reload sizes after a delay
setTimeout(() => load(), 8000);
} else {
btn.disabled = false;
btn.textContent = 'Error';
btn.classList.add('text-red-400');
}
} catch {
btn.disabled = false;
btn.textContent = 'Error';
btn.classList.add('text-red-400');
}
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.pwreset-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/admin/users/${h}/reset-password-code`, {
method: 'POST',
credentials: 'include',
});
const d = await r.json();
if (r.ok) {
btn.textContent = d.code;
btn.title = `Code for ${h} — valid 24 h. Click to copy.`;
btn.classList.add('text-yellow-300', 'font-mono');
btn.addEventListener('click', () => navigator.clipboard.writeText(d.code), { once: true });
} else {
btn.textContent = 'Error';
btn.classList.add('text-red-400');
btn.disabled = false;
}
} catch {
btn.textContent = 'Error';
btn.classList.add('text-red-400');
btn.disabled = false;
}
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
pendingHandle = btn.dataset.handle!;
confirmH.textContent = pendingHandle;
dialog.showModal();
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.rmdir-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
if (!confirm(`Delete entire directory for ghost user "${h}"? This cannot be undone.`)) return;
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const r = await fetch(`/api/admin/users/${h}/directory`, {
method: 'DELETE',
credentials: 'include',
});
const d = await r.json();
if (r.ok) {
btn.textContent = 'Deleted';
btn.classList.add('text-green-500');
setTimeout(() => load(), 1500);
} else {
btn.disabled = false;
btn.textContent = 'Error: ' + (d.detail ?? 'failed');
btn.classList.add('text-red-400');
}
} catch {
btn.disabled = false;
btn.textContent = 'Error';
btn.classList.add('text-red-400');
}
});
});
}
confirmCancel.addEventListener('click', () => dialog.close());
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
confirmOk.addEventListener('click', async () => {
dialog.close();
const row = tbodyEl.querySelector(`[data-handle="${pendingHandle}"]`);
const btn = row?.querySelector<HTMLButtonElement>('.delete-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
try {
const r = await fetch(`/api/admin/users/${pendingHandle}/activities`, {
method: 'DELETE',
credentials: 'include',
});
const d = await r.json();
if (r.ok) {
if (btn) { btn.textContent = `Deleted (${d.deleted})`; btn.classList.add('text-green-500'); }
// Reload to refresh sizes
setTimeout(() => load(), 1500);
} else {
if (btn) { btn.disabled = false; btn.textContent = 'Error: ' + (d.detail ?? 'failed'); btn.classList.add('text-red-400'); }
}
} catch {
if (btn) { btn.disabled = false; btn.textContent = 'Error'; btn.classList.add('text-red-400'); }
}
});
load();
</script>
+1 -1
View File
@@ -13,5 +13,5 @@ const handle = shards[0]?.handle ?? null;
window.location.replace(base + 'u/' + handle + '/athlete/');
</script>
) : (
<p>No data found. Run <code>bincio extract</code> first.</p>
<p>No data found. Upload activities to get started.</p>
)}
+7
View File
@@ -0,0 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import CommunityView from '../../components/CommunityView.svelte';
---
<Base title="Community — BincioActivity">
<CommunityView base={import.meta.env.BASE_URL} client:only="svelte" />
</Base>
+162
View File
@@ -0,0 +1,162 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Feedback — BincioActivity">
<div class="max-w-lg mx-auto mt-12 px-4">
<h1 class="text-2xl font-bold text-white mb-2">Send feedback</h1>
<p class="text-sm text-zinc-500 mb-6">Report a bug, suggest a feature, or share anything useful. Plain text only — no account details needed.</p>
<form id="feedback-form" class="space-y-4">
<div>
<textarea
id="fb-text"
rows="6"
placeholder="What's on your mind?"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-[--accent] resize-none"
></textarea>
</div>
<!-- Image upload -->
<div>
<p class="text-xs text-zinc-500 mb-2">Attach up to 3 screenshots (max 2 MB each)</p>
<div
id="fb-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-5 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
>
<span id="fb-drop-label">Drop images or click to browse</span>
<input id="fb-input" type="file" accept="image/*" multiple class="hidden" />
</div>
<div id="fb-previews" class="flex gap-2 flex-wrap mt-2"></div>
</div>
<p id="fb-error" class="text-red-400 text-sm hidden"></p>
<button
type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium text-sm transition-opacity"
>Send feedback</button>
</form>
<div id="fb-success" class="hidden text-center mt-12">
<p class="text-2xl mb-2">Thanks!</p>
<p class="text-zinc-400 text-sm">Your feedback has been received.</p>
</div>
</div>
</Base>
<script>
const MAX_IMAGES = 3;
const MAX_BYTES = 2 * 1024 * 1024;
const form = document.getElementById('feedback-form') as HTMLFormElement;
const drop = document.getElementById('fb-drop')!;
const input = document.getElementById('fb-input') as HTMLInputElement;
const previews = document.getElementById('fb-previews')!;
const errEl = document.getElementById('fb-error')!;
const success = document.getElementById('fb-success')!;
let selectedFiles: File[] = [];
function showError(msg: string) {
errEl.textContent = msg;
errEl.classList.remove('hidden');
}
function clearError() {
errEl.classList.add('hidden');
}
function renderPreviews() {
previews.innerHTML = '';
for (let i = 0; i < selectedFiles.length; i++) {
const f = selectedFiles[i];
const url = URL.createObjectURL(f);
const wrap = document.createElement('div');
wrap.className = 'relative';
wrap.innerHTML = `
<img src="${url}" class="w-20 h-20 object-cover rounded-lg border border-zinc-700" />
<button type="button" data-i="${i}"
class="remove-btn absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 hover:text-white text-xs flex items-center justify-center leading-none">
×
</button>`;
previews.appendChild(wrap);
}
previews.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', () => {
const i = parseInt((btn as HTMLElement).dataset.i ?? '0');
selectedFiles.splice(i, 1);
renderPreviews();
clearError();
});
});
}
function addFiles(newFiles: FileList | File[]) {
clearError();
for (const f of Array.from(newFiles)) {
if (selectedFiles.length >= MAX_IMAGES) {
showError(`Maximum ${MAX_IMAGES} images.`);
break;
}
if (f.size > MAX_BYTES) {
showError(`"${f.name}" exceeds 2 MB.`);
continue;
}
selectedFiles.push(f);
}
renderPreviews();
input.value = '';
}
drop.addEventListener('click', () => input.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; });
drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; });
drop.addEventListener('drop', e => {
e.preventDefault();
drop.style.borderColor = '';
if (e.dataTransfer?.files.length) addFiles(e.dataTransfer.files);
});
input.addEventListener('change', () => { if (input.files?.length) addFiles(input.files); });
form.addEventListener('submit', async e => {
e.preventDefault();
clearError();
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
if (!text && selectedFiles.length === 0) {
showError('Please write something or attach an image.');
return;
}
const fd = new FormData();
fd.append('text', text);
for (const f of selectedFiles) fd.append('images', f);
const btn = form.querySelector('button[type=submit]') as HTMLButtonElement;
btn.disabled = true;
btn.textContent = 'Sending…';
try {
const r = await fetch('/api/feedback', { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail ?? `Server error ${r.status}`);
}
form.classList.add('hidden');
success.classList.remove('hidden');
} catch (err: any) {
showError(err.message);
btn.disabled = false;
btn.textContent = 'Send feedback';
}
});
// Redirect to login if not authenticated
(async () => {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (r.status === 401) window.location.href = `/login/?next=${encodeURIComponent(window.location.pathname)}`;
} catch (_) {}
})();
</script>
+2 -2
View File
@@ -1,11 +1,11 @@
---
import Base from '../layouts/Base.astro';
import ActivityFeed from '../components/ActivityFeed.svelte';
import { readShardHandles } from '../lib/manifest';
import { readShardHandles, isInstancePrivate } from '../lib/manifest';
const base = import.meta.env.BASE_URL;
const shards = readShardHandles();
const isSingleUser = shards.length === 1;
const isSingleUser = shards.length === 1 && !isInstancePrivate();
const singleHandle = isSingleUser ? shards[0].handle : null;
---
{isSingleUser ? (
+19 -1
View File
@@ -47,6 +47,17 @@ import Base from '../../layouts/Base.astro';
return li;
}
function fallbackCopy(text: string, done: () => void) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand('copy'); done(); } catch (_) {}
document.body.removeChild(ta);
}
async function loadInvites() {
try {
const r = await fetch('/api/invites', { credentials: 'include' });
@@ -66,9 +77,16 @@ import Base from '../../layouts/Base.astro';
// Copy link buttons
listEl.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText((btn as HTMLElement).dataset.link ?? '');
const text = (btn as HTMLElement).dataset.link ?? '';
const done = () => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy link'; }, 2000);
};
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else {
fallbackCopy(text, done);
}
});
});
} catch (e: any) {
+6
View File
@@ -4,6 +4,9 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
---
<Base title="Login — BincioActivity" public={true}>
<div class="max-w-sm mx-auto mt-16 px-4">
<p class="text-center text-zinc-600 text-sm italic mb-8 leading-relaxed">
mangia<br/>bevi<br/>stai calmo<br/>non strappare
</p>
<h1 class="text-2xl font-bold text-white mb-6 text-center">Sign in</h1>
<form id="login-form" class="space-y-4">
@@ -30,6 +33,9 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
<p class="text-center text-zinc-500 text-sm mt-6">
Have an invite? <a href="/register/" class="text-[--accent] hover:underline">Create account</a>
</p>
<p class="text-center text-zinc-600 text-sm mt-2">
<a href="/reset-password/" class="hover:text-zinc-400 transition-colors">Forgot password?</a>
</p>
)}
</div>
</Base>
+87
View File
@@ -0,0 +1,87 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Reset password — BincioActivity" public={true}>
<div class="max-w-sm mx-auto mt-16 px-4">
<h1 class="text-2xl font-bold text-white mb-2 text-center">Reset password</h1>
<p class="text-zinc-500 text-sm text-center mb-2">Enter the reset code you received from the admin.</p>
<p class="text-zinc-600 text-xs text-center mb-6">Don't have a code? Contact the instance admin — they can generate one for you from the admin panel. Codes expire after 24 hours.</p>
<form id="reset-form" class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1" for="code">Reset code</label>
<input id="code" name="code" type="text" autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white font-mono uppercase tracking-widest placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="XXXXXXXX" maxlength="8" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="handle">Handle</label>
<input id="handle" name="handle" type="text" autocomplete="username"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
placeholder="your handle" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="password">New password</label>
<input id="password" name="password" type="password" autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white focus:outline-none focus:border-[--accent]"
minlength="8" required />
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
</div>
<p id="reset-error" class="text-red-400 text-sm hidden"></p>
<p id="reset-ok" class="text-green-400 text-sm hidden">Password updated. <a href="/login/" class="underline">Sign in</a></p>
<button type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
Set new password
</button>
</form>
<p class="text-center text-zinc-500 text-sm mt-6">
<a href="/login/" class="text-[--accent] hover:underline">Back to sign in</a>
</p>
</div>
</Base>
<script>
// Pre-fill code and handle from query params if provided
const params = new URLSearchParams(window.location.search);
const codeParam = params.get('code');
const handleParam = params.get('handle');
if (codeParam) (document.getElementById('code') as HTMLInputElement).value = codeParam.toUpperCase();
if (handleParam) (document.getElementById('handle') as HTMLInputElement).value = handleParam;
document.getElementById('reset-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const errEl = document.getElementById('reset-error')!;
const okEl = document.getElementById('reset-ok')!;
errEl.classList.add('hidden');
okEl.classList.add('hidden');
const body = {
code: (form.querySelector('#code') as HTMLInputElement).value.trim().toUpperCase(),
handle: (form.querySelector('#handle') as HTMLInputElement).value.trim().toLowerCase(),
password: (form.querySelector('#password') as HTMLInputElement).value,
};
try {
const r = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errEl.textContent = d.detail ?? 'Reset failed';
errEl.classList.remove('hidden');
return;
}
okEl.classList.remove('hidden');
(e.target as HTMLFormElement).querySelectorAll('input, button').forEach(
el => (el as HTMLInputElement).disabled = true
);
} catch {
errEl.textContent = 'Could not reach server';
errEl.classList.remove('hidden');
}
});
</script>
+518
View File
@@ -0,0 +1,518 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Settings — BincioActivity">
<div class="max-w-lg mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-white mb-8">Settings</h1>
<!-- Storage card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Storage</h2>
<div id="storage-loading" class="text-zinc-500 text-sm">Loading…</div>
<div id="storage-content" class="hidden space-y-2">
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Activities</span>
<span id="st-activities" class="text-white tabular-nums"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Original files</span>
<span id="st-originals" class="text-white tabular-nums"></span>
</div>
<div id="st-strava-row" class="flex justify-between text-sm pl-4 hidden">
<span class="text-zinc-500">↳ Strava originals</span>
<span id="st-strava" class="text-zinc-400 tabular-nums"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Photos</span>
<span id="st-images" class="text-white tabular-nums"></span>
</div>
<div class="border-t border-zinc-800 mt-2 pt-2 flex justify-between text-sm font-medium">
<span class="text-zinc-300">Total</span>
<span id="st-total" class="text-white tabular-nums"></span>
</div>
</div>
</section>
<!-- Profile card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Profile</h2>
<form id="display-name-form" class="flex gap-2 items-end">
<div class="flex-1">
<label class="block text-xs text-zinc-500 mb-1" for="display-name-input">Display name</label>
<input id="display-name-input" type="text" maxlength="60" autocomplete="name"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent] text-sm"
placeholder="Your name" />
</div>
<button type="submit"
class="px-4 py-2 rounded-lg text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors shrink-0">
Save
</button>
</form>
<p id="display-name-status" class="text-xs mt-2 hidden"></p>
</section>
<!-- Password card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Password</h2>
<form id="password-form" class="space-y-3">
<div>
<label class="block text-xs text-zinc-500 mb-1" for="pw-current">Current password</label>
<input id="pw-current" type="password" autocomplete="current-password" required
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-[--accent] text-sm" />
</div>
<div>
<label class="block text-xs text-zinc-500 mb-1" for="pw-new">New password</label>
<input id="pw-new" type="password" autocomplete="new-password" minlength="8" required
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-[--accent] text-sm" />
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
</div>
<p id="pw-status" class="text-xs hidden"></p>
<button type="submit"
class="px-4 py-2 rounded-lg text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
Change password
</button>
</form>
</section>
<!-- Navigation visibility card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-1">Navigation</h2>
<p class="text-xs text-zinc-600 mb-4">Hide items from the top nav bar. Affects only your view.</p>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer group">
<input id="nav-hide-feed" type="checkbox" class="accent-[--accent]" />
<span class="text-sm text-zinc-300 group-hover:text-white transition-colors">Hide Feed</span>
</label>
<label class="flex items-center gap-3 cursor-pointer group">
<input id="nav-hide-community" type="checkbox" class="accent-[--accent]" />
<span class="text-sm text-zinc-300 group-hover:text-white transition-colors">Hide Community</span>
</label>
<label class="flex items-center gap-3 cursor-pointer group">
<input id="nav-hide-about" type="checkbox" class="accent-[--accent]" />
<span class="text-sm text-zinc-300 group-hover:text-white transition-colors">Hide About</span>
</label>
</div>
<p id="nav-prefs-status" class="text-xs mt-3 hidden"></p>
</section>
<!-- Strava credentials card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-1">Strava API credentials</h2>
<p id="strava-creds-desc" class="text-xs text-zinc-600 mb-4">Loading…</p>
<form id="strava-creds-form" class="space-y-3">
<div>
<label class="block text-xs text-zinc-500 mb-1" for="strava-client-id">Client ID</label>
<input id="strava-client-id" type="text" autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent] text-sm"
placeholder="123456" />
</div>
<div>
<label class="block text-xs text-zinc-500 mb-1" for="strava-client-secret">Client secret</label>
<input id="strava-client-secret" type="password" autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent] text-sm"
placeholder="Leave blank to keep existing" />
</div>
<p id="strava-creds-status" class="text-xs hidden"></p>
<div class="flex gap-2">
<button type="submit"
class="px-4 py-2 rounded-lg text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
Save
</button>
<button type="button" id="strava-creds-clear"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors">
Use instance credentials
</button>
</div>
</form>
</section>
<!-- Danger zone -->
<section class="rounded-xl bg-zinc-900 border border-red-900/40 p-5">
<h2 class="text-sm font-semibold text-red-400/70 uppercase tracking-wider mb-4">Danger zone</h2>
<!-- Delete original files -->
<div class="mb-5">
<p class="text-sm text-zinc-300 font-medium mb-1">Delete original files</p>
<p class="text-xs text-zinc-500 mb-3">Removes the raw source files kept for reprocessing (originals/). Your extracted activities, edits, and photos are not affected.</p>
<button id="del-originals-btn"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-amber-900 hover:text-amber-300 text-zinc-400 transition-colors">
Delete original files
</button>
<p id="del-originals-status" class="text-xs mt-2 hidden"></p>
</div>
<!-- Delete all activity data -->
<div class="border-t border-zinc-800 pt-5 mb-5">
<p class="text-sm text-zinc-300 font-medium mb-1">Delete all activity data</p>
<p class="text-xs text-zinc-500 mb-3">Wipes all extracted activities, edits, and photos. Your account and original files are kept. Cannot be undone.</p>
<button id="del-activities-btn"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors">
Delete all activities
</button>
</div>
<div class="border-t border-zinc-800 pt-5">
<p class="text-sm text-zinc-300 font-medium mb-1">Delete account</p>
<p class="text-xs text-zinc-500 mb-3">Permanently deletes your account and all data. Cannot be undone.</p>
<button id="del-account-btn"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors">
Delete account
</button>
</div>
</section>
</div>
<!-- Confirmation modal (shared for activities + account deletion) -->
<dialog id="confirm-dialog"
class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-sm w-full backdrop:bg-black/60">
<p id="confirm-title" class="text-sm text-zinc-300 mb-1 font-medium"></p>
<p id="confirm-desc" class="text-xs text-zinc-500 mb-4"></p>
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="confirm-password">Confirm with your password</label>
<input id="confirm-password" type="password" autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-red-500 text-sm" />
</div>
<p id="confirm-error" class="text-red-400 text-xs mb-3 hidden"></p>
<div class="flex gap-3 justify-end">
<button id="confirm-cancel"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">Cancel</button>
<button id="confirm-ok"
class="px-4 py-2 rounded-lg text-sm bg-red-700 hover:bg-red-600 text-white font-medium transition-colors">Confirm</button>
</div>
</dialog>
</Base>
<script>
function fmtMb(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(2) + ' GB';
if (mb >= 1) return mb.toFixed(0) + ' MB';
return (mb * 1024).toFixed(0) + ' KB';
}
function setStatus(el: HTMLElement, msg: string, ok: boolean) {
el.textContent = msg;
el.style.color = ok ? '#4ade80' : '#f87171';
el.classList.remove('hidden');
}
// ── Storage ─────────────────────────────────────────────────────────────────
async function loadStorage() {
const loading = document.getElementById('storage-loading')!;
const content = document.getElementById('storage-content')!;
try {
const r = await fetch('/api/me/storage', { credentials: 'include' });
if (r.status === 401) { window.location.href = `/login/?next=/settings/`; return; }
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
document.getElementById('st-activities')!.textContent =
`${fmtMb(d.activities_mb)} (${d.activities_count} activities)`;
document.getElementById('st-originals')!.textContent = fmtMb(d.originals_mb);
document.getElementById('st-images')!.textContent = fmtMb(d.images_mb);
document.getElementById('st-total')!.textContent = fmtMb(d.total_mb);
if (d.strava_originals_mb > 0) {
document.getElementById('st-strava')!.textContent =
`${fmtMb(d.strava_originals_mb)} (${d.strava_originals_count} files)`;
document.getElementById('st-strava-row')!.classList.remove('hidden');
}
loading.classList.add('hidden');
content.classList.remove('hidden');
} catch (e: any) {
loading.textContent = 'Could not load storage info.';
}
}
document.getElementById('del-originals-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('del-originals-btn') as HTMLButtonElement;
const statusEl = document.getElementById('del-originals-status')!;
if (!confirm('Delete all original files? The extracted activities are not affected.')) return;
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const r = await fetch('/api/me/originals', { method: 'DELETE', credentials: 'include' });
const d = await r.json();
if (r.ok) {
setStatus(statusEl, `Freed ${fmtMb(d.freed_mb)}.`, true);
btn.disabled = true;
btn.textContent = 'Already deleted';
loadStorage();
} else {
btn.disabled = false;
btn.textContent = 'Delete original files';
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
btn.disabled = false;
btn.textContent = 'Delete original files';
setStatus(statusEl, 'Could not reach server', false);
}
});
// ── Display name ─────────────────────────────────────────────────────────────
async function loadMe() {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (!r.ok) return;
const d = await r.json();
(document.getElementById('display-name-input') as HTMLInputElement).value = d.display_name ?? '';
} catch {}
}
document.getElementById('display-name-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const statusEl = document.getElementById('display-name-status')!;
const val = (document.getElementById('display-name-input') as HTMLInputElement).value.trim();
try {
const r = await fetch('/api/me/display-name', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: val }),
});
const d = await r.json();
if (r.ok) {
setStatus(statusEl, 'Saved.', true);
setTimeout(() => statusEl.classList.add('hidden'), 3000);
} else {
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
});
// ── Password ─────────────────────────────────────────────────────────────────
document.getElementById('password-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const statusEl = document.getElementById('pw-status')!;
const btn = (e.target as HTMLFormElement).querySelector('button[type=submit]') as HTMLButtonElement;
const current = (document.getElementById('pw-current') as HTMLInputElement).value;
const newPw = (document.getElementById('pw-new') as HTMLInputElement).value;
statusEl.classList.add('hidden');
btn.disabled = true;
try {
const r = await fetch('/api/me/password', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: current, new_password: newPw }),
});
const d = await r.json();
if (r.ok) {
setStatus(statusEl, 'Password changed.', true);
(document.getElementById('pw-current') as HTMLInputElement).value = '';
(document.getElementById('pw-new') as HTMLInputElement).value = '';
} else {
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
btn.disabled = false;
});
// ── Confirmation modal ────────────────────────────────────────────────────────
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
const confirmTitle = document.getElementById('confirm-title')!;
const confirmDesc = document.getElementById('confirm-desc')!;
const confirmPw = document.getElementById('confirm-password') as HTMLInputElement;
const confirmErr = document.getElementById('confirm-error')!;
const confirmOk = document.getElementById('confirm-ok') as HTMLButtonElement;
const confirmCancel = document.getElementById('confirm-cancel')!;
let pendingAction: 'activities' | 'account' | null = null;
function openConfirm(action: 'activities' | 'account') {
pendingAction = action;
confirmErr.classList.add('hidden');
confirmPw.value = '';
if (action === 'activities') {
confirmTitle.textContent = 'Delete all activity data?';
confirmDesc.textContent = 'Removes all extracted activities, edits, and photos. Your account is kept.';
} else {
confirmTitle.textContent = 'Delete your account?';
confirmDesc.textContent = 'Permanently deletes your account and all associated data. This cannot be undone.';
}
dialog.showModal();
setTimeout(() => confirmPw.focus(), 50);
}
confirmCancel.addEventListener('click', () => dialog.close());
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
dialog.addEventListener('keydown', e => { if (e.key === 'Escape') dialog.close(); });
confirmOk.addEventListener('click', async () => {
confirmErr.classList.add('hidden');
const password = confirmPw.value;
if (!password) {
confirmErr.textContent = 'Enter your password to confirm.';
confirmErr.classList.remove('hidden');
return;
}
confirmOk.disabled = true;
confirmOk.textContent = 'Working…';
const url = pendingAction === 'account' ? '/api/me' : '/api/me/activities';
try {
const r = await fetch(url, {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const d = await r.json();
if (r.ok) {
dialog.close();
if (pendingAction === 'account') {
window.location.href = '/';
} else {
window.location.reload();
}
} else {
confirmErr.textContent = d.detail ?? 'Failed';
confirmErr.classList.remove('hidden');
confirmOk.disabled = false;
confirmOk.textContent = 'Confirm';
}
} catch {
confirmErr.textContent = 'Could not reach server';
confirmErr.classList.remove('hidden');
confirmOk.disabled = false;
confirmOk.textContent = 'Confirm';
}
});
document.getElementById('del-activities-btn')?.addEventListener('click', () => openConfirm('activities'));
document.getElementById('del-account-btn')?.addEventListener('click', () => openConfirm('account'));
// ── Navigation prefs ─────────────────────────────────────────────────────────
const NAV_PREF_KEYS: Record<string, string> = {
'nav-hide-feed': 'nav_hide_feed',
'nav-hide-community': 'nav_hide_community',
'nav-hide-about': 'nav_hide_about',
};
async function loadNavPrefs() {
try {
const r = await fetch('/api/me/prefs', { credentials: 'include' });
if (!r.ok) return;
const prefs = await r.json();
for (const [elId, key] of Object.entries(NAV_PREF_KEYS)) {
const el = document.getElementById(elId) as HTMLInputElement | null;
if (el) el.checked = prefs[key] === 'true';
}
} catch {}
}
async function saveNavPref(key: string, value: boolean) {
const statusEl = document.getElementById('nav-prefs-status')!;
try {
const r = await fetch('/api/me/prefs', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: String(value) }),
});
if (r.ok) {
setStatus(statusEl, 'Saved.', true);
setTimeout(() => statusEl.classList.add('hidden'), 2000);
} else {
const d = await r.json();
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
}
for (const [elId, key] of Object.entries(NAV_PREF_KEYS)) {
document.getElementById(elId)?.addEventListener('change', (e) => {
saveNavPref(key, (e.target as HTMLInputElement).checked);
});
}
// ── Strava credentials ────────────────────────────────────────────────────────
async function loadStravaCreds() {
const desc = document.getElementById('strava-creds-desc')!;
try {
const r = await fetch('/api/me/strava-credentials', { credentials: 'include' });
if (!r.ok) { desc.textContent = 'Not available.'; return; }
const d = await r.json();
if (d.has_user_creds) {
desc.textContent = `Using your own credentials (Client ID: ${d.client_id}).`;
(document.getElementById('strava-client-id') as HTMLInputElement).value = d.client_id ?? '';
} else if (d.instance_configured) {
desc.textContent = 'Using instance-level credentials. Enter your own below to override.';
} else {
desc.textContent = 'Strava is not configured on this instance. You can set your own API credentials below.';
}
} catch {
desc.textContent = 'Could not load Strava settings.';
}
}
document.getElementById('strava-creds-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const statusEl = document.getElementById('strava-creds-status')!;
const clientId = (document.getElementById('strava-client-id') as HTMLInputElement).value.trim();
const clientSecret = (document.getElementById('strava-client-secret') as HTMLInputElement).value.trim();
if (!clientId) {
setStatus(statusEl, 'Client ID is required.', false);
return;
}
try {
const body: Record<string, string> = { client_id: clientId };
if (clientSecret) body.client_secret = clientSecret;
const r = await fetch('/api/me/strava-credentials', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (r.ok) {
setStatus(statusEl, 'Saved.', true);
(document.getElementById('strava-client-secret') as HTMLInputElement).value = '';
loadStravaCreds();
} else {
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
});
document.getElementById('strava-creds-clear')?.addEventListener('click', async () => {
const statusEl = document.getElementById('strava-creds-status')!;
if (!confirm('Remove your custom Strava credentials and fall back to instance credentials?')) return;
try {
const r = await fetch('/api/me/strava-credentials', { method: 'DELETE', credentials: 'include' });
if (r.ok) {
setStatus(statusEl, 'Cleared — using instance credentials.', true);
(document.getElementById('strava-client-id') as HTMLInputElement).value = '';
(document.getElementById('strava-client-secret') as HTMLInputElement).value = '';
loadStravaCreds();
} else {
const d = await r.json();
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
});
// ── Init ─────────────────────────────────────────────────────────────────────
loadMe();
loadStorage();
loadNavPrefs();
loadStravaCreds();
</script>
+1 -1
View File
@@ -14,5 +14,5 @@ const handle = shards[0]?.handle ?? null;
window.location.replace(base + 'u/' + handle + '/stats/');
</script>
) : (
<p>No data found. Run <code>bincio extract</code> first.</p>
<p>No data found. Upload activities to get started.</p>
)}
@@ -20,7 +20,7 @@ const indexUrl = `${mergedBase}index.json`;
const athleteUrl = `${mergedBase}athlete.json`;
---
<Base title={`@${handle} Athlete — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
<div class="pb-2">
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
+1 -1
View File
@@ -20,7 +20,7 @@ const { handle, shardUrl } = Astro.props as { handle: string; shardUrl: string }
const base = import.meta.env.BASE_URL;
---
<Base title={`@${handle} — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2 flex items-center gap-4">
<div class="flex items-center gap-4 mb-2">
<div>
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
<nav id="profile-subnav" class="flex gap-4 mt-1">
+1 -1
View File
@@ -18,7 +18,7 @@ const base = import.meta.env.BASE_URL;
const indexUrl = `${base}data/${handle}/_merged/index.json`;
---
<Base title={`@${handle} Stats — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
<div class="pb-2">
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
<nav id="profile-subnav" class="flex gap-4 mt-1 mb-6">
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
+232
View File
@@ -0,0 +1,232 @@
"""Tests for bincio.extract.dem — pure functions and file-level hysteresis.
No API calls, no extract pipeline, no large data.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import pytest
from bincio.extract.dem import (
_hysteresis_gain_loss,
_median_filter,
_moving_average,
recalculate_elevation_hysteresis,
)
# ── _moving_average ───────────────────────────────────────────────────────────
def test_moving_average_flat():
data = [5.0] * 20
result = _moving_average(data, 5)
assert result == pytest.approx(data)
def test_moving_average_ramp():
# A perfect ramp should be preserved (MA of linear is linear).
data = [float(i) for i in range(20)]
result = _moving_average(data, 5)
# Interior points should be exact; edges shrink the window so they may
# differ slightly — just check the middle is right.
for i in range(2, 18):
assert result[i] == pytest.approx(data[i], abs=1e-9)
def test_moving_average_spike():
# A single spike should be strongly attenuated.
data = [100.0] * 60
data[30] = 200.0 # +100 m spike
result = _moving_average(data, 30)
# At the spike position the average over 30 samples pulls it down a lot
assert result[30] < 110.0
def test_moving_average_length_preserved():
data = [1.0, 2.0, 3.0, 4.0, 5.0]
assert len(_moving_average(data, 3)) == 5
def test_moving_average_single():
assert _moving_average([42.0], 5) == [42.0]
# ── _median_filter ────────────────────────────────────────────────────────────
def test_median_filter_flat():
data = [10.0] * 30
assert _median_filter(data, 5) == pytest.approx(data)
def test_median_filter_spike_removed():
data = [100.0] * 61
data[30] = 300.0 # outlier spike
result = _median_filter(data, 45)
# The spike should be completely removed by the median
assert result[30] == pytest.approx(100.0)
def test_median_filter_length_preserved():
data = list(range(10, 20, 1))
assert len(_median_filter([float(x) for x in data], 5)) == 10
# ── _hysteresis_gain_loss ─────────────────────────────────────────────────────
def test_hysteresis_flat():
data = [100.0] * 100
gain, loss = _hysteresis_gain_loss(data, 5.0)
assert gain == 0.0
assert loss == 0.0
def test_hysteresis_single_climb():
# 50 m climb, well above any threshold.
data = [0.0] * 50 + [50.0] * 50
gain, loss = _hysteresis_gain_loss(data, 5.0)
assert gain == pytest.approx(50.0)
assert loss == pytest.approx(0.0)
def test_hysteresis_up_and_down():
data = [0.0, 20.0, 0.0]
gain, loss = _hysteresis_gain_loss(data, 5.0)
assert gain == pytest.approx(20.0)
assert loss == pytest.approx(20.0)
def test_hysteresis_noise_suppressed():
# Oscillation below threshold → nothing accumulates.
data = [100.0 + (3.0 if i % 2 == 0 else 0.0) for i in range(100)]
gain, loss = _hysteresis_gain_loss(data, 5.0)
assert gain == 0.0
assert loss == 0.0
def test_hysteresis_noise_passes_low_threshold():
# Same oscillation does accumulate with a threshold below it.
data = [100.0 + (3.0 if i % 2 == 0 else 0.0) for i in range(100)]
gain, loss = _hysteresis_gain_loss(data, 1.0)
assert gain > 0.0
def test_hysteresis_both_positive():
data = [0.0, 30.0, 10.0, 40.0]
gain, loss = _hysteresis_gain_loss(data, 5.0)
assert gain > 0.0
assert loss > 0.0
# ── recalculate_elevation_hysteresis (file-level) ─────────────────────────────
def _write_activity(tmp_path: Path, activity_id: str, elevations: list[float],
altitude_source: str = "barometric",
with_original_backup: bool = False) -> Path:
"""Write minimal activity + timeseries JSON files for testing."""
acts = tmp_path / "activities"
acts.mkdir()
detail = {
"id": activity_id,
"elevation_gain_m": 0.0,
"elevation_loss_m": 0.0,
"altitude_source": altitude_source,
}
(acts / f"{activity_id}.json").write_text(json.dumps(detail))
ts: dict = {"t": list(range(len(elevations))), "elevation_m": elevations}
if with_original_backup:
ts["elevation_m_original"] = elevations
(acts / f"{activity_id}.timeseries.json").write_text(json.dumps(ts))
return tmp_path
def test_hysteresis_recalc_barometric(tmp_path):
# Long ramp (1800 s = 30 min, +1 m/s) so the 30s MA edge effect is small.
# Edge effect ≈ window/2 metres on each side = ~15 m total on 1800 m climb.
elevations = [float(i) for i in range(1801)] # 0→1800 m
_write_activity(tmp_path, "test-act", elevations, altitude_source="barometric")
result = recalculate_elevation_hysteresis(tmp_path, "test-act")
assert result["altitude_source"] == "barometric"
assert result["threshold_m"] == pytest.approx(1.0)
# Edge effect is ≤1% on a 30-min ramp
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
assert result["elevation_loss_m"] == pytest.approx(0.0, abs=1.0)
def test_hysteresis_recalc_gps(tmp_path):
elevations = [float(i) for i in range(1801)]
_write_activity(tmp_path, "test-act", elevations, altitude_source="gps")
result = recalculate_elevation_hysteresis(tmp_path, "test-act")
assert result["threshold_m"] == pytest.approx(3.0)
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
def test_hysteresis_recalc_uses_original_backup(tmp_path):
# Simulate: DEM already replaced elevation_m with flat terrain,
# but elevation_m_original holds the real barometric climb.
acts = tmp_path / "activities"
acts.mkdir()
aid = "test-act"
original = [float(i) for i in range(1801)] # real 1800 m climb
dem_flat = [900.0] * 1801 # DEM said flat
detail = {"id": aid, "elevation_gain_m": 0.0, "elevation_loss_m": 0.0,
"altitude_source": "barometric"}
(acts / f"{aid}.json").write_text(json.dumps(detail))
ts = {"t": list(range(1801)), "elevation_m": dem_flat,
"elevation_m_original": original}
(acts / f"{aid}.timeseries.json").write_text(json.dumps(ts))
result = recalculate_elevation_hysteresis(tmp_path, aid)
# Should use the original backup (1800 m climb), not the flat DEM array (0 m)
assert result["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
def test_hysteresis_recalc_patches_detail_json(tmp_path):
elevations = [float(i) for i in range(101)]
_write_activity(tmp_path, "test-act", elevations)
recalculate_elevation_hysteresis(tmp_path, "test-act")
detail = json.loads((tmp_path / "activities" / "test-act.json").read_text())
assert "elevation_gain_m" in detail
assert detail["elevation_gain_m"] > 0
def test_hysteresis_recalc_patches_index(tmp_path):
elevations = [float(i) for i in range(101)]
_write_activity(tmp_path, "test-act", elevations)
index = {"activities": [{"id": "test-act", "elevation_gain_m": 0.0}]}
(tmp_path / "index.json").write_text(json.dumps(index))
recalculate_elevation_hysteresis(tmp_path, "test-act")
updated = json.loads((tmp_path / "index.json").read_text())
assert updated["activities"][0]["elevation_gain_m"] > 0
def test_hysteresis_recalc_missing_activity(tmp_path):
(tmp_path / "activities").mkdir()
with pytest.raises(FileNotFoundError):
recalculate_elevation_hysteresis(tmp_path, "nonexistent")
def test_hysteresis_recalc_no_timeseries(tmp_path):
acts = tmp_path / "activities"
acts.mkdir()
(acts / "test-act.json").write_text(json.dumps({"id": "test-act"}))
with pytest.raises(ValueError, match="timeseries"):
recalculate_elevation_hysteresis(tmp_path, "test-act")
+158
View File
@@ -0,0 +1,158 @@
"""API tests for the /recalculate-elevation/* endpoints in bincio.edit.server.
Uses httpx TestClient no real network, no uvicorn process.
The module-level `data_dir` variable is patched to a tmp_path fixture.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import bincio.edit.server as edit_server
from bincio.edit.server import app
CLIENT = TestClient(app, raise_server_exceptions=False)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_activity(
data_dir: Path,
activity_id: str,
elevations: list[float],
altitude_source: str = "barometric",
elevation_m_original: list[float] | None = None,
) -> None:
acts = data_dir / "activities"
acts.mkdir(exist_ok=True)
detail = {
"id": activity_id,
"elevation_gain_m": 0.0,
"elevation_loss_m": 0.0,
"altitude_source": altitude_source,
}
(acts / f"{activity_id}.json").write_text(json.dumps(detail))
ts: dict = {"t": list(range(len(elevations))), "elevation_m": elevations}
if elevation_m_original is not None:
ts["elevation_m_original"] = elevation_m_original
(acts / f"{activity_id}.timeseries.json").write_text(json.dumps(ts))
# Minimal index.json so merge_one doesn't crash
index_path = data_dir / "index.json"
if not index_path.exists():
index_path.write_text(json.dumps({"activities": [
{"id": activity_id, "elevation_gain_m": 0.0}
]}))
@pytest.fixture(autouse=True)
def patch_data_dir(tmp_path, monkeypatch):
monkeypatch.setattr(edit_server, "data_dir", tmp_path)
return tmp_path
# ── /recalculate-elevation/hysteresis ─────────────────────────────────────────
class TestHysteresisEndpoint:
AID = "2024-01-01T080000Z-test-climb"
def test_returns_200_with_gain_loss(self, tmp_path):
elevations = [float(i) for i in range(1801)]
_make_activity(tmp_path, self.AID, elevations, altitude_source="barometric")
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
assert r.status_code == 200
body = r.json()
assert "elevation_gain_m" in body
assert "elevation_loss_m" in body
assert body["elevation_gain_m"] > 0
assert body["altitude_source"] == "barometric"
assert body["threshold_m"] == pytest.approx(1.0)
def test_gps_source_uses_3m_threshold(self, tmp_path):
elevations = [float(i) for i in range(1801)]
_make_activity(tmp_path, self.AID, elevations, altitude_source="gps")
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
assert r.status_code == 200
assert r.json()["threshold_m"] == pytest.approx(3.0)
def test_unknown_source_falls_back_to_gps_threshold(self, tmp_path):
elevations = [float(i) for i in range(1801)]
_make_activity(tmp_path, self.AID, elevations, altitude_source="unknown")
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
assert r.status_code == 200
assert r.json()["threshold_m"] == pytest.approx(3.0)
def test_uses_original_elevation_when_dem_backup_present(self, tmp_path):
original = [float(i) for i in range(1801)] # real 1800 m climb
dem_flat = [900.0] * 1801 # DEM flattened it
_make_activity(tmp_path, self.AID, dem_flat,
altitude_source="barometric",
elevation_m_original=original)
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
assert r.status_code == 200
assert r.json()["elevation_gain_m"] == pytest.approx(1800.0, rel=0.02)
def test_patches_detail_json_on_disk(self, tmp_path):
elevations = [float(i) for i in range(1801)]
_make_activity(tmp_path, self.AID, elevations)
CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/hysteresis")
detail = json.loads(
(tmp_path / "activities" / f"{self.AID}.json").read_text()
)
assert detail["elevation_gain_m"] > 0
def test_404_for_missing_activity(self, tmp_path):
(tmp_path / "activities").mkdir()
r = CLIENT.post("/api/activity/2024-01-01T080000Z-no-such/recalculate-elevation/hysteresis")
assert r.status_code == 404
def test_422_for_missing_timeseries(self, tmp_path):
acts = tmp_path / "activities"
acts.mkdir()
aid = self.AID
(acts / f"{aid}.json").write_text(json.dumps({"id": aid, "altitude_source": "gps"}))
# No timeseries file
r = CLIENT.post(f"/api/activity/{aid}/recalculate-elevation/hysteresis")
assert r.status_code == 422
def test_400_for_invalid_id(self):
r = CLIENT.post("/api/activity/../etc/passwd/recalculate-elevation/hysteresis")
assert r.status_code in (400, 404, 422)
# ── /recalculate-elevation/dem ────────────────────────────────────────────────
class TestDemEndpoint:
AID = "2024-01-01T080000Z-test-climb"
def test_503_when_dem_url_not_configured(self, tmp_path, monkeypatch):
monkeypatch.setattr(edit_server, "dem_url", "")
r = CLIENT.post(f"/api/activity/{self.AID}/recalculate-elevation/dem")
assert r.status_code == 503
def test_404_for_missing_activity(self, tmp_path, monkeypatch):
monkeypatch.setattr(edit_server, "dem_url", "https://api.open-elevation.com")
(tmp_path / "activities").mkdir()
r = CLIENT.post("/api/activity/2024-01-01T080000Z-no-such/recalculate-elevation/dem")
assert r.status_code == 404
def test_400_for_invalid_id(self, monkeypatch):
monkeypatch.setattr(edit_server, "dem_url", "https://api.open-elevation.com")
r = CLIENT.post("/api/activity/../../evil/recalculate-elevation/dem")
assert r.status_code in (400, 404, 422)
+23 -7
View File
@@ -9,6 +9,18 @@ import pytest
from bincio.render.merge import apply_sidecar, merge_all, merge_one, parse_sidecar
def _load_merged_activities(merged_dir: Path) -> dict:
"""Load all activities from year-sharded merged index. Returns id→dict map."""
root = json.loads((merged_dir / "index.json").read_text())
all_acts = list(root.get("activities", []))
for shard in root.get("shards", []):
shard_path = merged_dir / shard["url"]
if shard_path.exists():
sub = json.loads(shard_path.read_text())
all_acts.extend(sub.get("activities", []))
return {a["id"]: a for a in all_acts}
# ── parse_sidecar ─────────────────────────────────────────────────────────────
@@ -88,7 +100,7 @@ def test_apply_sidecar_body_takes_precedence_over_fm_description():
def test_apply_sidecar_private_flag():
result = apply_sidecar(BASE_DETAIL, {"private": True}, "")
assert result["privacy"] == "private"
assert result["privacy"] == "unlisted"
def test_apply_sidecar_highlight():
@@ -176,10 +188,11 @@ def test_merge_all_private_filtered_from_index(data_dir):
(edits / "2024-01-01T080000Z-morning-ride.md").write_text("---\nprivate: true\n---\n")
merge_all(data_dir)
index = json.loads((data_dir / "_merged" / "index.json").read_text())
ids = [a["id"] for a in index["activities"]]
assert "2024-01-01T080000Z-morning-ride" not in ids
assert "2024-01-02T090000Z-easy-run" in ids
activities = _load_merged_activities(data_dir / "_merged")
# unlisted activities are kept in the index; filtering is client-side
assert "2024-01-01T080000Z-morning-ride" in activities
assert activities["2024-01-01T080000Z-morning-ride"]["privacy"] == "unlisted"
assert "2024-01-02T090000Z-easy-run" in activities
def test_merge_all_highlight_sorts_first(data_dir):
@@ -189,8 +202,11 @@ def test_merge_all_highlight_sorts_first(data_dir):
(edits / "2024-01-01T080000Z-morning-ride.md").write_text("---\nhighlight: true\n---\n")
merge_all(data_dir)
index = json.loads((data_dir / "_merged" / "index.json").read_text())
ids = [a["id"] for a in index["activities"]]
# Highlighted activity must be first within its year shard
merged_dir = data_dir / "_merged"
root = json.loads((merged_dir / "index.json").read_text())
shard_path = merged_dir / root["shards"][0]["url"]
ids = [a["id"] for a in json.loads(shard_path.read_text())["activities"]]
assert ids[0] == "2024-01-01T080000Z-morning-ride"
+72
View File
@@ -8,6 +8,7 @@ import pytest
from bincio.extract.metrics import (
MMP_DURATIONS_S,
_best_climb,
_elevation,
_fastest_time_for_distance,
_haversine_m,
compute,
@@ -126,6 +127,77 @@ def test_compute_no_elevation():
assert m.elevation_loss_m is None
# ── elevation hysteresis ──────────────────────────────────────────────────────
def _ele_pts(elevations: list[float]) -> list[DataPoint]:
return [_pt(i, elevation_m=e) for i, e in enumerate(elevations)]
def test_elevation_hysteresis_large_step_always_counted():
# A single 50m step is way above any threshold — both sources should count it.
pts = _ele_pts([100.0, 150.0])
gain_baro, _ = _elevation(pts, "barometric")
gain_gps, _ = _elevation(pts, "gps")
assert gain_baro == 50.0
assert gain_gps == 50.0
def test_elevation_hysteresis_flat_gps_noise_suppressed():
# Flat coastal route: 16m of GPS noise oscillating within ±8m.
# All steps are sub-1m — hysteresis should return ~0 gain.
import math
n = 1000
elevations = [100.0 + 3.0 * math.sin(i * 0.1) for i in range(n)]
pts = _ele_pts(elevations)
gain, loss = _elevation(pts, "gps")
# With threshold=10m no oscillation within ±3m should ever commit.
assert gain == 0.0
assert loss == 0.0
def test_elevation_hysteresis_barometric_threshold_lower():
# Steps of exactly 7m — above barometric (5m) but below GPS (10m) threshold.
elevations = [0.0, 7.0, 0.0, 7.0]
pts = _ele_pts(elevations)
gain_baro, _ = _elevation(pts, "barometric")
gain_gps, _ = _elevation(pts, "gps")
assert gain_baro == 14.0 # both 7m steps committed
assert gain_gps == 0.0 # 7m < 10m threshold → suppressed
def test_elevation_hysteresis_real_climb_approximated():
# Simulate a 200m climb with 0.2m barometric quantization noise.
# Build a staircase: 1000 steps, mostly 0.2m up/down noise, with a 200m net climb.
import random
random.seed(42)
elevations = [0.0]
for i in range(999):
# Mostly quantization noise, but drift upward at 0.2 m/step net
step = random.choice([-0.2, 0.0, 0.0, 0.2, 0.2, 0.4])
elevations.append(elevations[-1] + step)
# Force net gain ~200m by scaling
scale = 200.0 / (elevations[-1] - elevations[0]) if elevations[-1] != elevations[0] else 1
elevations = [e * scale for e in elevations]
pts = _ele_pts(elevations)
gain, _ = _elevation(pts, "barometric")
# Hysteresis should produce substantially less than naive accumulation
# and land reasonably close to the 200m net climb.
assert gain is not None
assert gain < 500.0 # not inflated like naive sum
assert gain > 100.0 # not zero either — real climbing exists
def test_elevation_hysteresis_unknown_treated_as_gps():
# "unknown" should apply the same 10m threshold as "gps"
elevations = [0.0, 7.0, 0.0, 7.0] # 7m steps
pts = _ele_pts(elevations)
gain_unknown, _ = _elevation(pts, "unknown")
gain_gps, _ = _elevation(pts, "gps")
assert gain_unknown == gain_gps
def test_compute_hr_stats():
pts = [
_pt(0, lat=48.0, lon=11.0, hr_bpm=120),
+9 -4
View File
@@ -83,10 +83,15 @@ class TestPipeline:
merge_all(data_root / "brut")
for handle in ("dave", "brut"):
merged = json.loads((data_root / handle / "_merged" / "index.json").read_text())
assert len(merged["activities"]) >= 8, (
f"Expected ≥8 merged activities for {handle}"
)
merged_dir = data_root / handle / "_merged"
root = json.loads((merged_dir / "index.json").read_text())
# Root index now has year shards; collect all activities across them
all_acts: list = list(root.get("activities", []))
for shard in root.get("shards", []):
sp = merged_dir / shard["url"]
if sp.exists():
all_acts.extend(json.loads(sp.read_text()).get("activities", []))
assert len(all_acts) >= 8, f"Expected ≥8 merged activities for {handle}"
def test_root_manifest(self, data_root):
from bincio.render.cli import _user_dirs, _write_root_manifest

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