diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b6ffc0..16ce436 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,140 @@
# Changelog
+## [Unreleased] — 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.
+
+---
+
## [Unreleased] — 2026-04-06
### New feature — Strava sync from UI
diff --git a/CLAUDE.md b/CLAUDE.md
index feb1c2b..f8249e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -233,9 +233,31 @@ Key facts:
- 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
+## Instance settings (stored in `instance.db` `settings` table)
+
+| Key | Default | Set by | Description |
+|-----|---------|--------|-------------|
+| `max_users` | — (unlimited) | `bincio init --max-users N` or `bincio serve --max-users N` | Cap on registered users; 0 or absent = unlimited |
+| `store_originals` | `true` | `bincio init` (first run only) | Whether uploaded source files and raw Strava API data are kept in `{user_dir}/originals/` |
+
+`get_setting` / `set_setting` in `db.py` are the read/write accessors. Any new instance-wide flag should use this table rather than a new column.
+
+## Original file storage
+
+When a user uploads a FIT/GPX/TCX file the server may keep the source in `{user_dir}/originals/{filename}` rather than always deleting it after extraction. The per-upload `store_original` form field controls the behaviour for a single upload (sent by the UI checkbox). The instance-level `store_originals` setting provides the default that pre-populates the checkbox (read from `GET /api/me` → `store_originals_default`).
+
+For Strava sync, `store_originals=true` causes `POST /api/strava/sync` to save `{"meta":…,"streams":…}` JSON per activity to `{user_dir}/originals/strava/{activity_id}.json`.
+
+## About pages
+
+Static public pages at `/about/` (EN), `/about/it/` (IT), `/about/es/` (ES), `/about/ca/` (CA). All use `public={true}` to bypass the auth wall. Each page:
+- Shows a Ko-fi donation button at the top.
+- Fetches `GET /api/stats` on load and renders a **community/invitation tree** (member count, each user's display name, membership duration, and who invited them). Hidden silently in single-user mode.
+- Contains project description, data storage explanation, early-software caveat, and liability disclaimer.
+
## Known issues / next steps
-- `bincio render --watch` mode not yet implemented
+- `bincio render --watch` mode not yet implemented as a standalone command, but `bincio dev` now watches the data directory via `watchfiles` (bundled with uvicorn) and re-runs `merge_all` automatically when sidecars or activity files change
- 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
@@ -250,6 +272,10 @@ Key facts:
- [ ] Karoo/Garmin Connect importers beyond Strava
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
- [ ] Highlight badge in activity feed cards
+- [x] Per-instance user limit (`max_users` setting, enforced at registration)
+- [x] Original file storage option (per-upload checkbox + `store_originals` instance setting)
+- [x] About page — multilingual (EN/IT/ES/CA), Ko-fi button, community invitation tree
+- [x] `GET /api/stats` — public endpoint with member count and invitation tree
- [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
diff --git a/bincio/dev.py b/bincio/dev.py
index 24b5292..b40d441 100644
--- a/bincio/dev.py
+++ b/bincio/dev.py
@@ -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,
diff --git a/bincio/serve/db.py b/bincio/serve/db.py
index d939ad4..4485a01 100644
--- a/bincio/serve/db.py
+++ b/bincio/serve/db.py
@@ -154,6 +154,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()
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index d6f3f95..0b07c71 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -29,6 +29,7 @@ from bincio.serve.db import (
create_user,
delete_session,
get_invite,
+ get_member_tree,
get_session,
get_setting,
get_user,
@@ -173,6 +174,27 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
})
+@app.get("/api/stats")
+async def stats() -> JSONResponse:
+ """Public endpoint: member count, join dates, and invitation tree."""
+ import time as _time
+ now = int(_time.time())
+ members = get_member_tree(_get_db())
+ return JSONResponse({
+ "user_count": len(members),
+ "members": [
+ {
+ "handle": m["handle"],
+ "display_name": m["display_name"],
+ "member_since": m["created_at"],
+ "member_for_days": (now - m["created_at"]) // 86400,
+ "invited_by": m["invited_by"],
+ }
+ for m in members
+ ],
+ })
+
+
@app.post("/api/auth/login")
async def login(request: Request) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
diff --git a/site/src/pages/about/ca/index.astro b/site/src/pages/about/ca/index.astro
index 5452a76..3d29722 100644
--- a/site/src/pages/about/ca/index.astro
+++ b/site/src/pages/about/ca/index.astro
@@ -1,6 +1,15 @@
---
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',
+};
---
@@ -108,7 +123,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
@@ -103,7 +120,94 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';