some basic statistics and invite tree, plus watch new data

This commit is contained in:
Davide Scaini
2026-04-10 13:21:31 +02:00
parent 6b2d31a44a
commit 053da10ab9
9 changed files with 655 additions and 5 deletions
+135
View File
@@ -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
+27 -1
View File
@@ -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
+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,
+27
View File
@@ -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()
+22
View File
@@ -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"
+93 -1
View File
@@ -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',
};
---
<Base title="Sobre el projecte — BincioActivity" public={true}>
<div class="max-w-2xl mx-auto">
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
<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>
@@ -108,7 +123,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
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>
+93 -1
View File
@@ -1,6 +1,15 @@
---
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">
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
<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>
@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
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>
+105 -1
View File
@@ -1,6 +1,16 @@
---
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">
@@ -26,6 +36,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
<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>
@@ -103,7 +120,94 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
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; }
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>
+93 -1
View File
@@ -1,6 +1,15 @@
---
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">
@@ -26,6 +35,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
<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>
@@ -107,7 +122,84 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
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>