Compare commits

...

10 Commits

Author SHA1 Message Date
Davide Scaini e24d290127 fix: show reset URL inline in admin table (was hidden in tooltip)
CI / Python tests (push) Waiting to run
CI / Frontend build (push) Waiting to run
2026-06-03 12:05:10 +02:00
Davide Scaini 7e8545f8db fix: redirect back to activity.bincio.org after login via bincio.org
Pass ?next=<current URL> when bouncing to bincio.org/login/ so the user
lands back on the activity page they came from instead of bincio.org/.
2026-06-03 11:33:37 +02:00
Davide Scaini ae2737fed1 fix: admin page script crashes — define:vars bypasses TypeScript compilation; use data attribute instead 2026-06-03 11:28:38 +02:00
Davide Scaini 4641ca9b72 fix: show Select button only on own profile feed, not community feed 2026-06-03 11:11:47 +02:00
Davide Scaini 7cec9541e2 fix: restore broken <a> tag in nav after Ideas removal 2026-06-03 11:09:26 +02:00
Davide Scaini 37e91af5bd feat: move Ideas into Support page tab; remove Ideas from nav 2026-06-03 11:07:35 +02:00
Davide Scaini 08f451ec71 feat: recurring budget entries (lazy materialise) + preferred Satispay badge 2026-06-03 10:47:58 +02:00
Davide Scaini cf7ce027b1 fix: support page — donate default tab, split community tab, require login 2026-06-03 10:41:59 +02:00
Davide Scaini fa14d91359 feat: Support page with budget transparency (replaces About) 2026-06-03 10:34:18 +02:00
Davide Scaini b781193d44 feat: bulk delete + merge activities in feed
- Select mode in ActivityFeed: toggle with Select button (logged-in only),
  cards become clickable with checkmark indicator, action bar fixed at bottom
- Bulk delete: calls existing DELETE /api/activity/{id} for each selected,
  removes from local feed state immediately
- Bulk merge: POST /api/merge sorts by started_at (earliest = primary),
  sums distance/duration/elevation, weighted-averages HR/power, concatenates
  geojson and timeseries; backs up originals to _merge_backup/ for recovery
- GET /api/merges returns per-user hidden list; feed filters secondaries
  client-side on load so static shards don't need a rebuild to hide them
- POST /api/unmerge/{id} restores primary from backup, unhides secondaries
- ActivityDetail: shows "Merged (N)" badge + Unmerge button for owners
- Fix: edit button now works from personal profile feed (handle was missing
  from year-shard activities; injected from filterHandle on sessionStorage write)
2026-06-03 10:32:02 +02:00
11 changed files with 1302 additions and 288 deletions
+174
View File
@@ -0,0 +1,174 @@
"""Budget transparency endpoints (/api/budget)."""
from __future__ import annotations
import json
import uuid
from datetime import date
from pathlib import Path
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from bincio.serve import deps
from bincio.serve.db import User
router = APIRouter()
_BUDGET_FILE = "budget.json"
def _budget_path() -> Path:
return deps._get_data_dir() / _BUDGET_FILE
def _load() -> dict:
p = _budget_path()
if not p.exists():
return {"monthly_target_eur": None, "entries": []}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {"monthly_target_eur": None, "entries": []}
def _save(data: dict) -> None:
_budget_path().write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
def _materialise_recurring(data: dict) -> bool:
"""Auto-add a copy of each recurring entry for the current month if absent.
Returns True if data was modified (caller should save).
Copies reference the template via recurring_from so they're not re-generated.
"""
current = date.today().strftime("%Y-%m")
templates = [e for e in data.get("entries", []) if e.get("recurring")]
if not templates:
return False
modified = False
for t in templates:
if t["month"] == current:
continue # template is already this month
already = any(
e.get("recurring_from") == t["id"] and e["month"] == current
for e in data["entries"]
)
if not already:
data["entries"].append({
"id": str(uuid.uuid4())[:8],
"type": t["type"],
"label": t["label"],
"amount_eur": t["amount_eur"],
"month": current,
"note": t.get("note", ""),
"recurring_from": t["id"],
})
modified = True
return modified
@router.get("/api/budget")
async def get_budget() -> JSONResponse:
data = _load()
if _materialise_recurring(data):
_save(data)
return JSONResponse(data)
@router.post("/api/budget/settings")
async def update_settings(
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
data = _load()
if "monthly_target_eur" in body:
v = body["monthly_target_eur"]
data["monthly_target_eur"] = round(float(v), 2) if v is not None else None
_save(data)
return JSONResponse({"ok": True})
@router.post("/api/budget/entries")
async def add_entry(
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
entry_type = body.get("type")
if entry_type not in ("donation", "expense"):
raise HTTPException(400, "type must be 'donation' or 'expense'")
label = str(body.get("label", "")).strip()
if not label:
raise HTTPException(400, "label is required")
try:
amount = round(float(body["amount_eur"]), 2)
except (KeyError, TypeError, ValueError):
raise HTTPException(400, "amount_eur must be a number")
month = str(body.get("month", "")).strip()
if len(month) != 7 or month[4] != "-":
raise HTTPException(400, "month must be YYYY-MM")
note = str(body.get("note", "")).strip()
entry: dict = {
"id": str(uuid.uuid4())[:8],
"type": entry_type,
"label": label,
"amount_eur": amount,
"month": month,
"note": note,
}
if body.get("recurring"):
entry["recurring"] = True
data = _load()
data.setdefault("entries", []).append(entry)
_save(data)
return JSONResponse(entry, status_code=201)
@router.patch("/api/budget/entries/{entry_id}")
async def update_entry(
entry_id: str,
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
data = _load()
entry = next((e for e in data.get("entries", []) if e["id"] == entry_id), None)
if not entry:
raise HTTPException(404, "Entry not found")
if "label" in body:
entry["label"] = str(body["label"]).strip()
if "type" in body:
if body["type"] not in ("donation", "expense"):
raise HTTPException(400, "type must be 'donation' or 'expense'")
entry["type"] = body["type"]
if "amount_eur" in body:
entry["amount_eur"] = round(float(body["amount_eur"]), 2)
if "month" in body:
entry["month"] = str(body["month"]).strip()
if "note" in body:
entry["note"] = str(body["note"]).strip()
if "recurring" in body:
if body["recurring"]:
entry["recurring"] = True
else:
entry.pop("recurring", None)
_save(data)
return JSONResponse(entry)
@router.delete("/api/budget/entries/{entry_id}")
async def delete_entry(
entry_id: str,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
data = _load()
before = len(data.get("entries", []))
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
if len(data["entries"]) == before:
raise HTTPException(404, "Entry not found")
_save(data)
return JSONResponse({"ok": True})
+270
View File
@@ -0,0 +1,270 @@
"""Merge and unmerge activity endpoints."""
from __future__ import annotations
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from bincio.serve import deps, tasks
from bincio.serve.db import User
router = APIRouter()
_MERGES_FILE = "_merges.json"
_BACKUP_DIR = "_merge_backup"
def _read_merges(user_dir: Path) -> dict:
p = user_dir / _MERGES_FILE
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {"hidden": [], "merges": {}}
def _write_merges(user_dir: Path, data: dict) -> None:
(user_dir / _MERGES_FILE).write_text(
json.dumps(data, ensure_ascii=False), encoding="utf-8"
)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@router.get("/api/merges")
async def get_merges(user: User = Depends(deps._require_auth)) -> JSONResponse:
user_dir = deps._get_data_dir() / user.handle
return JSONResponse(_read_merges(user_dir))
class MergeRequest(BaseModel):
activity_ids: list[str]
@router.post("/api/merge")
async def merge_activities(
body: MergeRequest,
user: User = Depends(deps._require_auth),
) -> JSONResponse:
if len(body.activity_ids) < 2:
raise HTTPException(400, "Need at least 2 activities to merge")
dd = deps._get_data_dir()
user_dir = dd / user.handle
acts_dir = user_dir / "activities"
activities: list[dict] = []
for aid in body.activity_ids:
deps._check_id(aid)
p = acts_dir / f"{aid}.json"
if not p.exists():
raise HTTPException(404, f"Activity {aid} not found")
try:
a = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raise HTTPException(500, f"Could not read activity {aid}")
a["_id"] = aid
activities.append(a)
sports = {a.get("sport") for a in activities}
if len(sports) > 1:
raise HTTPException(400, "Cannot merge activities of different sports")
activities.sort(key=lambda a: a.get("started_at", ""))
primary = activities[0]
secondaries = activities[1:]
primary_id = primary["_id"]
secondary_ids = [a["_id"] for a in secondaries]
def _sum(key: str) -> float | None:
vals = [a.get(key) for a in activities if a.get(key) is not None]
return sum(vals) if vals else None
def _wavg(key: str) -> float | None:
pairs = [(a.get(key), a.get("moving_time_s")) for a in activities]
pairs = [(v, w) for v, w in pairs if v is not None and w and w > 0]
if not pairs:
return None
total_w = sum(w for _, w in pairs)
return sum(v * w for v, w in pairs) / total_w if total_w > 0 else None
distance_m = _sum("distance_m")
duration_s = _sum("duration_s")
moving_time_s = _sum("moving_time_s")
elevation_gain_m = _sum("elevation_gain_m")
avg_speed_kmh = (distance_m / moving_time_s * 3.6) if distance_m and moving_time_s else None
avg_hr_bpm_val = _wavg("avg_hr_bpm")
avg_power_w_val = _wavg("avg_power_w")
backup_dir = user_dir / _BACKUP_DIR
backup_dir.mkdir(exist_ok=True)
for suffix in (".json", ".geojson", ".timeseries.json"):
src = acts_dir / f"{primary_id}{suffix}"
if src.exists():
shutil.copy2(src, backup_dir / f"{primary_id}{suffix}.bak")
primary_data: dict[str, Any] = {k: v for k, v in primary.items() if not k.startswith("_")}
primary_data["distance_m"] = distance_m
primary_data["duration_s"] = duration_s
primary_data["moving_time_s"] = moving_time_s
primary_data["elevation_gain_m"] = elevation_gain_m
if avg_speed_kmh is not None:
primary_data["avg_speed_kmh"] = round(avg_speed_kmh, 3)
if avg_hr_bpm_val is not None:
primary_data["avg_hr_bpm"] = round(avg_hr_bpm_val)
if avg_power_w_val is not None:
primary_data["avg_power_w"] = round(avg_power_w_val)
primary_data["merged_ids"] = secondary_ids
(acts_dir / f"{primary_id}.json").write_text(
json.dumps(primary_data, ensure_ascii=False, indent=2), encoding="utf-8"
)
_merge_geojson(acts_dir, primary_id, secondary_ids)
_merge_timeseries(acts_dir, primary_id, secondary_ids, activities)
merges = _read_merges(user_dir)
for sid in secondary_ids:
if sid not in merges["hidden"]:
merges["hidden"].append(sid)
merges.setdefault("merges", {})[primary_id] = {
"secondary_ids": secondary_ids,
"merged_at": _now_iso(),
}
_write_merges(user_dir, merges)
from bincio.render.merge import merge_one
merge_one(user_dir, primary_id)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "primary_id": primary_id, "hidden": secondary_ids})
@router.post("/api/unmerge/{primary_id}")
async def unmerge_activity(
primary_id: str,
user: User = Depends(deps._require_auth),
) -> JSONResponse:
deps._check_id(primary_id)
dd = deps._get_data_dir()
user_dir = dd / user.handle
acts_dir = user_dir / "activities"
backup_dir = user_dir / _BACKUP_DIR
merges = _read_merges(user_dir)
merge_info = merges.get("merges", {}).get(primary_id)
if not merge_info:
raise HTTPException(404, "No merge record found for this activity")
secondary_ids: list[str] = merge_info.get("secondary_ids", [])
for suffix in (".json", ".geojson", ".timeseries.json"):
bak = backup_dir / f"{primary_id}{suffix}.bak"
if bak.exists():
shutil.copy2(bak, acts_dir / f"{primary_id}{suffix}")
bak.unlink()
merges["hidden"] = [h for h in merges.get("hidden", []) if h not in secondary_ids]
merges["merges"].pop(primary_id, None)
_write_merges(user_dir, merges)
from bincio.render.merge import merge_one
merge_one(user_dir, primary_id)
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "restored": secondary_ids})
def _merge_geojson(acts_dir: Path, primary_id: str, secondary_ids: list[str]) -> None:
primary_path = acts_dir / f"{primary_id}.geojson"
if not primary_path.exists():
return
try:
geo = json.loads(primary_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
features: list = list(geo.get("features", []))
for sid in secondary_ids:
p = acts_dir / f"{sid}.geojson"
if not p.exists():
continue
try:
sec = json.loads(p.read_text(encoding="utf-8"))
features.extend(sec.get("features", []))
except (OSError, json.JSONDecodeError):
continue
geo["features"] = features
primary_path.write_text(json.dumps(geo, ensure_ascii=False), encoding="utf-8")
def _merge_timeseries(
acts_dir: Path,
primary_id: str,
secondary_ids: list[str],
activities: list[dict],
) -> None:
primary_path = acts_dir / f"{primary_id}.timeseries.json"
if not primary_path.exists():
return
try:
merged = json.loads(primary_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
merged = {k: list(v) if isinstance(v, list) else v for k, v in merged.items()}
_ARRAY_KEYS = ("lat", "lon", "elevation_m", "speed_kmh", "hr_bpm", "cadence_rpm", "power_w", "temperature_c")
for i, sid in enumerate(secondary_ids):
sec_path = acts_dir / f"{sid}.timeseries.json"
if not sec_path.exists():
continue
try:
sec = json.loads(sec_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
sec_t: list = sec.get("t") or []
if not sec_t:
continue
try:
p_start = datetime.fromisoformat(
activities[0].get("started_at", "").replace("Z", "+00:00")
).timestamp()
s_start = datetime.fromisoformat(
activities[i + 1].get("started_at", "").replace("Z", "+00:00")
).timestamp()
offset = s_start - p_start
except (ValueError, TypeError):
cur_t: list = merged.get("t") or []
offset = (cur_t[-1] + 1) if cur_t else 0
adjusted_t = [offset + t for t in sec_t]
primary_len = len(merged.get("t") or [])
if merged.get("t") is None:
merged["t"] = []
merged["t"].extend(adjusted_t)
for key in _ARRAY_KEYS:
sec_vals = sec.get(key)
if sec_vals is None:
if merged.get(key) is not None:
merged[key] = merged[key] + [None] * len(sec_t)
else:
if merged.get(key) is None:
merged[key] = [None] * primary_len + list(sec_vals)
else:
merged[key] = merged[key] + list(sec_vals)
primary_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
+5 -1
View File
@@ -20,12 +20,14 @@ from bincio.serve.routers import (
activities,
admin,
auth,
budget,
download,
feed,
garmin,
gear,
ideas,
me,
merge,
ogimage,
segments,
strava,
@@ -53,7 +55,7 @@ app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org",
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE"],
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type"],
)
@@ -63,6 +65,8 @@ for _router in [
me.router,
admin.router,
activities.router,
merge.router,
budget.router,
download.router,
uploads.router,
segments.router,
+33
View File
@@ -36,6 +36,25 @@
pr_elapsed_s: number;
}
let segmentEfforts: SegmentEffortHit[] = [];
let unmergeWorking = false;
async function _unmerge() {
if (!confirm('Unmerge this activity? The original tracks will be restored and the secondary activities will reappear in your feed.')) return;
unmergeWorking = true;
try {
const r = await fetch(`/api/unmerge/${activity.id}`, { method: 'POST', credentials: 'include' });
if (r.ok) {
window.location.reload();
} else {
const d = await r.json();
alert(d.detail ?? 'Unmerge failed');
}
} catch {
alert('Unmerge failed');
} finally {
unmergeWorking = false;
}
}
// Local overrides applied immediately after a save (no re-fetch needed)
let localTitle = '';
@@ -381,6 +400,20 @@
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
>+ segment</a>
{/if}
{#if detail?.merged_ids?.length}
<span class="text-xs px-2 py-0.5 rounded border border-zinc-600 text-zinc-400 shrink-0">
Merged ({detail.merged_ids.length})
</span>
{#if editEnabled}
<button
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0 disabled:opacity-40"
disabled={unmergeWorking}
on:click={_unmerge}
>
{unmergeWorking ? 'Working…' : 'Unmerge'}
</button>
{/if}
{/if}
</div>
{#if descriptionHtml}
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
+143 -4
View File
@@ -59,6 +59,73 @@
/** Logged-in handle — resolved async via bincio:me event. */
let me: string = '';
// ── Bulk / select mode ────────────────────────────────────────────────────
let selectMode = false;
let selected: Set<string> = new Set();
let hiddenIds: Set<string> = new Set();
let bulkWorking = false;
async function _loadMerges() {
try {
const r = await fetch('/api/merges', { credentials: 'include' });
if (r.ok) {
const data = await r.json();
hiddenIds = new Set(data.hidden ?? []);
}
} catch { /* non-critical */ }
}
function _toggleSelect(id: string) {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
selected = s;
}
function _exitSelect() {
selectMode = false;
selected = new Set();
}
async function _deleteSelected() {
const ids = [...selected];
if (!confirm(`Delete ${ids.length} activit${ids.length === 1 ? 'y' : 'ies'}? This cannot be undone.`)) return;
bulkWorking = true;
for (const id of ids) {
try {
const r = await fetch(`/api/activity/${id}`, { method: 'DELETE', credentials: 'include' });
if (r.ok) all = all.filter(a => a.id !== id);
} catch { /* continue */ }
}
bulkWorking = false;
_exitSelect();
}
async function _mergeSelected() {
const ids = [...selected];
if (ids.length < 2) return;
bulkWorking = true;
try {
const r = await fetch('/api/merge', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activity_ids: ids }),
});
const data = await r.json();
if (r.ok) {
for (const hid of data.hidden ?? []) hiddenIds.add(hid);
hiddenIds = new Set(hiddenIds);
_exitSelect();
} else {
alert(data.detail ?? 'Merge failed');
}
} catch {
alert('Merge failed');
} finally {
bulkWorking = false;
}
}
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
if (preset === 'all') return { dateFrom: '', dateTo: '' };
if (/^\d{4}$/.test(preset)) {
@@ -74,11 +141,14 @@
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T`, dateTo: '' };
}
// Filter out secondary activities hidden by a merge.
$: withMerge = all.filter(a => !hiddenIds.has(a.id));
// 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 => {
$: withPrivacy = withMerge.filter(a => {
if (isUnlisted(a.privacy)) {
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
}
@@ -196,8 +266,12 @@
// 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;
_loadMerges();
} else {
window.addEventListener('bincio:me', (e: Event) => { me = (e as CustomEvent).detail; }, { once: true });
window.addEventListener('bincio:me', (e: Event) => {
me = (e as CustomEvent).detail;
_loadMerges();
}, { once: true });
}
try {
@@ -287,6 +361,17 @@
on:click={() => viewMode = 'map'}
>Map</button>
</div>
{#if isOwner}
<button
class="px-3 py-2 rounded-lg border text-sm transition-colors shrink-0"
class:border-zinc-700={!selectMode}
class:text-zinc-400={!selectMode}
class:border-[--accent]={selectMode}
class:text-white={selectMode}
style={selectMode ? 'background:var(--accent-dim)' : ''}
on:click={() => { if (selectMode) _exitSelect(); else selectMode = true; }}
>{selectMode ? 'Cancel' : 'Select'}</button>
{/if}
</div>
</div>
@@ -368,8 +453,15 @@
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each visible as a (a.id)}
{@const isSelected = selected.has(a.id)}
<!-- relative + isolate so the stretched activity link stays below the handle link -->
<div class="relative rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="relative rounded-xl bg-zinc-900 border p-4 transition-all group {isSelected ? 'border-[--accent]' : 'border-zinc-800'} {selectMode ? 'cursor-pointer' : 'hover:border-zinc-600 hover:bg-zinc-800/80'}"
style={isSelected ? 'background:var(--accent-dim)' : ''}
on:click={selectMode ? () => _toggleSelect(a.id) : undefined}
>
<!-- header -->
<div class="flex items-start justify-between gap-2 mb-3">
<div class="flex-1 min-w-0">
@@ -384,19 +476,34 @@
{#if isUnlisted(a.privacy)}
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
{/if}
{#if selectMode}
<span class="truncate">{a.title}</span>
{:else}
<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-[''] truncate"
on:click={() => { try { sessionStorage.setItem(`bincio:activity:${a.id}`, JSON.stringify(a)); } catch {} }}
on:click={() => { try { sessionStorage.setItem(`bincio:activity:${a.id}`, JSON.stringify(filterHandle && !a.handle ? { ...a, handle: filterHandle } : a)); } catch {} }}
>{a.title}</a>
{/if}
</h3>
</div>
{#if selectMode}
<div
class="shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors"
class:border-zinc-600={!isSelected}
class:border-[--accent]={isSelected}
style={isSelected ? 'background:var(--accent)' : ''}
>
{#if isSelected}<span class="text-[10px] text-white leading-none"></span>{/if}
</div>
{:else}
<span
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
>
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
</span>
{/if}
</div>
<!-- track thumbnail -->
@@ -466,3 +573,35 @@
</div>
{/if}
{/if}
<!-- Bulk action bar — fixed at bottom when select mode is active -->
{#if selectMode}
<div class="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-3 px-4 py-4 bg-zinc-950/95 backdrop-blur border-t border-zinc-800">
<span class="text-sm text-zinc-400 mr-2">
{selected.size} selected
</span>
<button
class="px-4 py-2 rounded-lg border border-red-700 text-red-400 hover:bg-red-900/30 disabled:opacity-40 transition-colors text-sm"
disabled={selected.size === 0 || bulkWorking}
on:click={_deleteSelected}
>
Delete ({selected.size})
</button>
<button
class="px-4 py-2 rounded-lg border border-zinc-600 text-zinc-300 hover:bg-zinc-800 disabled:opacity-40 transition-colors text-sm"
disabled={selected.size < 2 || bulkWorking}
on:click={_mergeSelected}
>
Merge ({selected.size})
</button>
<button
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-500 hover:text-zinc-300 transition-colors text-sm"
on:click={_exitSelect}
>
Cancel
</button>
{#if bulkWorking}
<span class="text-xs text-zinc-500">Working…</span>
{/if}
</div>
{/if}
+6 -13
View File
@@ -104,7 +104,8 @@ try {
fetch('/api/me', { credentials: 'include' })
.then(r => {
if (r.status === 401 || r.status === 404) {
window.location.replace(authUrl ? authUrl + '/login/' : '/login/');
const next = authUrl ? '?next=' + encodeURIComponent(window.location.href) : '';
window.location.replace(authUrl ? authUrl + '/login/' + next : '/login/');
} else {
document.body.removeAttribute('data-auth-pending');
}
@@ -257,15 +258,10 @@ try {
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
>Planner</a>
)}
<!-- Ideas / About — always visible on desktop -->
<a
href={`${baseUrl}ideas/`}
href={`${baseUrl}support/`}
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
>Ideas</a>
<a
href={`${baseUrl}about/`}
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
>About</a>
>Support</a>
<!-- Settings link — hidden until logged in -->
<a
id="nav-settings"
@@ -330,12 +326,9 @@ try {
style="display:none; color: var(--text-4)"
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors">Planner</a>
)}
<a href={`${baseUrl}ideas/`}
<a href={`${baseUrl}support/`}
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
style="color: var(--text-4)">Ideas</a>
<a href={`${baseUrl}about/`}
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
style="color: var(--text-4)">About</a>
style="color: var(--text-4)">Support</a>
<a href={`${baseUrl}settings/`}
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
style="color: var(--text-4)">Settings</a>
+2
View File
@@ -130,6 +130,8 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
source_file?: string | null;
download_disabled?: boolean;
custom: Record<string, unknown>;
/** IDs of secondary activities merged into this one (set by /api/merge). */
merged_ids?: string[] | null;
}
export interface Lap {
+3 -249
View File
@@ -1,252 +1,6 @@
---
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…',
};
const target = `${baseUrl}support/`;
---
<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-4">
<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
href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
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:#E3162C; color:#fff;"
>
Satispay @brutsalvadi
</a>
</div>
<div class="mb-8">
<img
src="/satispay-qr.jpg"
alt="Satispay QR code — @brutsalvadi"
class="w-36 h-36 rounded-xl"
/>
</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
} 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>
<meta http-equiv="refresh" content={`0;url=${target}`} />
<script define:vars={{ target }}>window.location.replace(target);</script>
+23 -6
View File
@@ -3,7 +3,7 @@ import Base from '../../layouts/Base.astro';
const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
---
<Base title="Admin — BincioActivity">
<div class="max-w-3xl mx-auto px-4 py-10">
<div class="max-w-3xl mx-auto px-4 py-10" data-auth-url={authUrl}>
<h1 class="text-2xl font-bold text-white mb-8">Admin</h1>
<!-- Disk overview -->
@@ -113,7 +113,8 @@ const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
</div>
</Base>
<script define:vars={{ authUrl }}>
<script>
const authUrl = (document.querySelector<HTMLElement>('.max-w-3xl[data-auth-url]')?.dataset.authUrl) ?? '';
const overviewEl = document.getElementById('disk-overview')!;
const tbodyEl = document.getElementById('user-list')!;
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
@@ -407,10 +408,26 @@ const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
const resetUrl = authUrl
? `${authUrl}/reset-password/?code=${d.code}`
: `/reset-password/?code=${d.code}`;
btn.textContent = '🔗 Copy reset link';
btn.title = resetUrl;
btn.classList.add('text-yellow-300');
btn.addEventListener('click', () => navigator.clipboard.writeText(resetUrl), { once: true });
// Show the URL inline so the admin can always see and copy it
const cell = btn.closest('td')!;
btn.remove();
cell.innerHTML = `
<div class="flex flex-col gap-1">
<input readonly value="${resetUrl}"
class="w-full px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-yellow-300 text-xs font-mono focus:outline-none focus:border-zinc-500 cursor-text"
onclick="this.select()" />
<button class="copy-url-btn text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition-colors">Copy</button>
</div>`;
const input = cell.querySelector<HTMLInputElement>('input')!;
const copyBtn = cell.querySelector<HTMLButtonElement>('.copy-url-btn')!;
input.select();
navigator.clipboard.writeText(resetUrl).catch(() => {});
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resetUrl).catch(() => {});
input.select();
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
});
} else {
btn.textContent = 'Error';
btn.classList.add('text-red-400');
+3 -5
View File
@@ -1,7 +1,5 @@
---
import Base from '../../layouts/Base.astro';
import IdeasPage from '../../components/IdeasPage.svelte';
const target = (import.meta.env.BASE_URL ?? '/') + 'support/#ideas';
---
<Base title="Ideas — BincioActivity">
<IdeasPage client:only="svelte" />
</Base>
<meta http-equiv="refresh" content={`0;url=${target}`} />
<script define:vars={{ target }}>window.location.replace(target);</script>
+630
View File
@@ -0,0 +1,630 @@
---
import Base from '../../layouts/Base.astro';
import IdeasPage from '../../components/IdeasPage.svelte';
const baseUrl = import.meta.env.BASE_URL ?? '/';
---
<Base title="Support — BincioActivity">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-1">Support</h1>
<p class="text-sm text-zinc-500 mb-5">Open-source, self-hosted activity tracking</p>
<!-- Tab bar -->
<div class="flex gap-1 border-b border-zinc-800 mb-6 overflow-x-auto">
<button data-tab="donate" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Donate</button>
<button data-tab="ideas" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Ideas</button>
<button data-tab="feedback" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Feedback</button>
<button data-tab="community" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">Community</button>
<button data-tab="about" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors shrink-0">About</button>
</div>
<!-- ── DONATE ─────────────────────────────────────────────────────── -->
<div id="tab-donate" class="tab-panel">
<div class="flex flex-wrap gap-2 mb-4">
<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 hover:opacity-90 transition-opacity"
style="background:#FF5E5B; color:#fff;">
☕ Support on Ko-fi
</a>
<div class="relative">
<a href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
style="background:#E3162C; color:#fff;">
Satispay @brutsalvadi
</a>
<span class="absolute -top-2 -right-2 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-green-500 text-white leading-none">
preferred
</span>
</div>
</div>
<div class="mb-8">
<img src="/satispay-qr.jpg" alt="Satispay QR code — @brutsalvadi" class="w-36 h-36 rounded-xl" />
</div>
<!-- Budget section -->
<div class="border-t border-zinc-800 pt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Running costs</h2>
<p class="text-xs text-zinc-500 mt-0.5">Transparent breakdown of donations and expenses</p>
</div>
<div id="budget-admin-controls" class="hidden flex gap-2">
<button id="budget-set-goal-btn"
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-white text-xs font-medium transition-colors border border-zinc-700">
Set monthly goal
</button>
<button id="budget-add-btn"
class="px-3 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-xs font-medium transition-opacity">
+ Add entry
</button>
</div>
</div>
<!-- Set-goal form (admin, hidden by default) -->
<form id="budget-goal-form" class="hidden mb-4 p-3 rounded-xl bg-zinc-900 border border-zinc-800 text-sm">
<label class="block text-zinc-400 mb-1 text-xs">Monthly goal (€)</label>
<div class="flex gap-2">
<input id="budget-goal-input" type="number" min="0" step="0.01" placeholder="e.g. 20"
class="flex-1 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Save</button>
<button type="button" id="budget-goal-cancel" class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm transition-colors">Cancel</button>
</div>
<p id="budget-goal-err" class="text-red-400 text-xs mt-1 hidden"></p>
</form>
<!-- Add-entry form (admin, hidden by default) -->
<form id="budget-add-form" class="hidden mb-4 p-3 rounded-xl bg-zinc-900 border border-zinc-800 text-sm space-y-2">
<div class="flex gap-2">
<button type="button" data-type="donation" class="entry-type-btn flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors">💚 Donation</button>
<button type="button" data-type="expense" class="entry-type-btn flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors">🔴 Expense</button>
</div>
<input id="entry-label" type="text" placeholder="Label (e.g. VPS hosting)"
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<div class="flex gap-2">
<div class="flex-1 relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">€</span>
<input id="entry-amount" type="number" min="0" step="0.01" placeholder="0.00"
class="w-full pl-7 pr-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
</div>
<input id="entry-month" type="month"
class="flex-1 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent] [color-scheme:dark]" />
</div>
<input id="entry-note" type="text" placeholder="Note (optional)"
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<label class="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none">
<input id="entry-recurring" type="checkbox" class="accent-[--accent] w-3.5 h-3.5" />
Repeat every month
</label>
<p id="budget-add-err" class="text-red-400 text-xs hidden"></p>
<div class="flex gap-2 pt-1">
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Add</button>
<button type="button" id="budget-add-cancel" class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm transition-colors">Cancel</button>
</div>
</form>
<div id="budget-list">
<p class="text-zinc-500 text-sm text-center py-6">Loading…</p>
</div>
</div>
</div>
<!-- ── IDEAS ────────────────────────────────────────────────────────── -->
<div id="tab-ideas" class="tab-panel" style="display:none">
<IdeasPage client:only="svelte" />
</div>
<!-- ── ABOUT ──────────────────────────────────────────────────────── -->
<div id="tab-about" class="tab-panel" style="display:none">
<div class="flex justify-end gap-3 text-xs text-zinc-500 mb-5">
<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 class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<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 href="/invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>.
</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.
</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>
<!-- ── COMMUNITY ──────────────────────────────────────────────────── -->
<div id="tab-community" class="tab-panel" style="display:none">
<p id="community-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="community-tree" class="text-sm"></div>
<p id="community-empty" class="text-zinc-500 text-sm text-center py-6" style="display:none">Community stats not available.</p>
</div>
<!-- ── FEEDBACK ───────────────────────────────────────────────────── -->
<div id="tab-feedback" class="tab-panel" style="display:none">
<h2 class="text-base font-semibold text-white mb-1">Send feedback</h2>
<p class="text-sm text-zinc-500 mb-5">Report a bug, suggest a feature, or share anything useful.</p>
<form id="feedback-form" class="space-y-4">
<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>
<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>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>
</div>
</Base>
<script>
// ── Tabs ──────────────────────────────────────────────────────────────────────
const TABS = ['donate', 'ideas', 'feedback', 'community', 'about'] as const;
type TabName = typeof TABS[number];
function showTab(name: TabName) {
TABS.forEach(t => {
const panel = document.getElementById(`tab-${t}`)!;
const btn = document.querySelector<HTMLElement>(`.tab-btn[data-tab="${t}"]`)!;
const active = t === name;
panel.style.display = active ? '' : 'none';
btn.classList.toggle('text-white', active);
btn.classList.toggle('border-b-2', active);
btn.classList.toggle('border-[--accent]', active);
btn.classList.toggle('-mb-px', active);
btn.classList.toggle('text-zinc-400', !active);
});
if (history.replaceState) history.replaceState(null, '', '#' + name);
}
const hash = location.hash.slice(1) as TabName;
showTab(TABS.includes(hash) ? hash : 'donate');
document.querySelectorAll<HTMLElement>('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => showTab(btn.dataset.tab as TabName));
});
// ── Community tree ────────────────────────────────────────────────────────────
let communityLoaded = false;
async function loadCommunity() {
const summaryEl = document.getElementById('community-summary')!;
const treeEl = document.getElementById('community-tree')!;
const emptyEl = document.getElementById('community-empty')!;
let data: any;
try {
const r = await fetch('/api/stats');
if (!r.ok) { emptyEl.style.display = ''; return; }
data = await r.json();
} catch { emptyEl.style.display = ''; return; }
if (!data.user_count) { emptyEl.style.display = ''; return; }
const n = data.user_count;
summaryEl.textContent = `${n} ${n === 1 ? 'member' : 'members'}`;
const byHandle: Record<string, any> = {};
for (const m of data.members) byHandle[m.handle] = m;
const children: Record<string, string[]> = {};
const roots: string[] = [];
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 formatDays(days: number) {
if (days < 1) return '< 1 day';
if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'}`;
const mo = Math.floor(days / 30);
return mo === 1 ? '1 mo' : `${mo} mo`;
}
function renderNode(handle: string, depth: number) {
const m = byHandle[handle];
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${depth * 20}px`;
if (depth > 0) {
const c = document.createElement('span');
c.className = 'text-zinc-700 shrink-0';
c.textContent = '└';
row.appendChild(c);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const hEl = document.createElement('span');
hEl.className = 'text-zinc-600 text-xs';
hEl.textContent = `@${handle}`;
row.appendChild(hEl);
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';
const sub = m.invited_by ? `invited by @${m.invited_by}` : 'founder';
meta.innerHTML = `${formatDays(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);
}
document.querySelector<HTMLElement>('.tab-btn[data-tab="community"]')!.addEventListener('click', () => {
if (!communityLoaded) { communityLoaded = true; loadCommunity(); }
});
if (location.hash.slice(1) === 'community') { communityLoaded = true; loadCommunity(); }
// ── Budget ────────────────────────────────────────────────────────────────────
let budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null;
let isAdmin = false;
let addEntryType: 'donation' | 'expense' = 'donation';
async function loadBudget() {
try {
const [br, mr] = await Promise.all([
fetch('/api/budget'),
fetch('/api/me', { credentials: 'include' }),
]);
budgetData = br.ok ? await br.json() : { monthly_target_eur: null, entries: [] };
if (mr.ok) { const me = await mr.json(); isAdmin = !!me.is_admin; }
} catch {
budgetData = { monthly_target_eur: null, entries: [] };
}
renderBudget();
}
function fmtEur(n: number) {
return `€${n.toFixed(2).replace('.00', '')}`;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-');
return new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('default', { month: 'long', year: 'numeric' });
}
function renderBudget() {
const listEl = document.getElementById('budget-list')!;
const adminCtrls = document.getElementById('budget-admin-controls')!;
const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement;
if (isAdmin) {
adminCtrls.classList.remove('hidden');
if (budgetData?.monthly_target_eur != null) goalInput.value = String(budgetData.monthly_target_eur);
}
const entries = budgetData?.entries ?? [];
if (entries.length === 0) {
listEl.innerHTML = `<p class="text-zinc-500 text-sm text-center py-6">${isAdmin ? 'No entries yet. Add the first one above.' : 'No budget entries yet.'}</p>`;
return;
}
const byMonth: Record<string, any[]> = {};
for (const e of entries) (byMonth[e.month] ??= []).push(e);
const months = Object.keys(byMonth).sort().reverse();
let html = '';
for (const ym of months) {
const items = byMonth[ym];
const donated = items.filter(e => e.type === 'donation').reduce((s, e) => s + e.amount_eur, 0);
const spent = items.filter(e => e.type === 'expense' ).reduce((s, e) => s + e.amount_eur, 0);
const balance = donated - spent;
const target = budgetData?.monthly_target_eur;
const pct = target ? Math.min(100, Math.round(donated / target * 100)) : null;
html += `<div class="mb-6">`;
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`;
if (pct !== null) {
html += `<div class="mb-3">
<div class="flex justify-between text-xs text-zinc-500 mb-1">
<span>Donations toward monthly goal</span>
<span>${pct}% of ${fmtEur(target!)}</span>
</div>
<div class="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
<div class="h-full rounded-full transition-all" style="width:${pct}%;background:var(--accent)"></div>
</div>
</div>`;
}
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
for (const e of items) {
const icon = e.type === 'donation' ? '💚' : '🔴';
const sign = e.type === 'donation' ? '+' : '';
const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400';
const recurBadge = e.recurring
? `<span class="text-zinc-500 text-xs shrink-0" title="Repeats every month">↻</span>`
: e.recurring_from
? `<span class="text-zinc-600 text-xs shrink-0" title="Auto-added (recurring)">↻</span>`
: '';
const adminBtns = isAdmin
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button>
<button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>`
: '';
html += `<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 text-sm" data-entry-id="${e.id}">
<span class="text-base shrink-0">${icon}</span>
<span class="flex-1 text-zinc-300 min-w-0">
${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}
</span>
${recurBadge}
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
${adminBtns}
</div>`;
}
html += `</div>`;
html += `<div class="flex gap-4 mt-2 text-xs text-zinc-500 px-1">
<span>Donated: <span class="text-green-400">${fmtEur(donated)}</span></span>
<span>Spent: <span class="text-red-400">${fmtEur(spent)}</span></span>
<span>Balance: <span class="${balance >= 0 ? 'text-zinc-300' : 'text-red-400'}">${balance >= 0 ? '+' : ''}${fmtEur(Math.abs(balance))}</span></span>
</div>`;
html += `</div>`;
}
listEl.innerHTML = html;
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this entry?')) return;
const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' });
if (r.ok) { budgetData!.entries = budgetData!.entries.filter(e => e.id !== btn.dataset.del); renderBudget(); }
});
});
listEl.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.edit!;
const entry = budgetData!.entries.find(e => e.id === id);
if (!entry) return;
const row = listEl.querySelector<HTMLElement>(`[data-entry-id="${id}"]`)!;
row.innerHTML = `
<form class="inline-edit-form flex flex-wrap gap-2 items-center w-full py-1" data-id="${id}">
<select name="type" class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none">
<option value="donation" ${entry.type==='donation'?'selected':''}>💚 Donation</option>
<option value="expense" ${entry.type==='expense' ?'selected':''}>🔴 Expense</option>
</select>
<input name="label" value="${entry.label}" placeholder="Label"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="amount_eur" type="number" value="${entry.amount_eur}" step="0.01" min="0"
class="w-20 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="month" type="month" value="${entry.month}"
class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" />
<input name="note" value="${entry.note}" placeholder="Note"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<label class="flex items-center gap-1 text-xs text-zinc-400 cursor-pointer">
<input type="checkbox" name="recurring" ${entry.recurring?'checked':''} class="accent-[--accent] w-3 h-3" />↻
</label>
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button>
<button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button>
</form>`;
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target as HTMLFormElement);
const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note'), recurring: fd.get('recurring') === 'on' };
const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); }
});
row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget);
});
});
}
// Load budget on page load (donate is the default tab)
loadBudget();
// Goal form
const goalForm = document.getElementById('budget-goal-form') as HTMLFormElement;
const goalBtn = document.getElementById('budget-set-goal-btn')!;
const goalCancel = document.getElementById('budget-goal-cancel')!;
const goalErr = document.getElementById('budget-goal-err')!;
goalBtn.addEventListener('click', () => goalForm.classList.toggle('hidden'));
goalCancel.addEventListener('click', () => goalForm.classList.add('hidden'));
goalForm.addEventListener('submit', async e => {
e.preventDefault();
goalErr.classList.add('hidden');
const v = parseFloat((document.getElementById('budget-goal-input') as HTMLInputElement).value);
const r = await fetch('/api/budget/settings', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monthly_target_eur: isNaN(v) ? null : v }) });
if (r.ok) { if (budgetData) budgetData.monthly_target_eur = isNaN(v) ? null : v; goalForm.classList.add('hidden'); renderBudget(); }
else { goalErr.textContent = 'Failed to save goal.'; goalErr.classList.remove('hidden'); }
});
// Add-entry form
const addForm = document.getElementById('budget-add-form') as HTMLFormElement;
const addBtn = document.getElementById('budget-add-btn')!;
const addCancel = document.getElementById('budget-add-cancel')!;
const addErr = document.getElementById('budget-add-err')!;
const now = new Date();
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
addEntryType = btn.dataset.type as 'donation' | 'expense';
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(b => {
const a = b.dataset.type === addEntryType;
b.classList.toggle('border-[--accent]', a); b.classList.toggle('text-white', a); b.classList.toggle('bg-zinc-800', a);
b.classList.toggle('border-zinc-700', !a); b.classList.toggle('text-zinc-400', !a);
});
});
});
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
addBtn.addEventListener('click', () => { addForm.classList.toggle('hidden'); addErr.classList.add('hidden'); });
addCancel.addEventListener('click', () => addForm.classList.add('hidden'));
addForm.addEventListener('submit', async e => {
e.preventDefault();
addErr.classList.add('hidden');
const label = (document.getElementById('entry-label') as HTMLInputElement).value.trim();
const amount = parseFloat((document.getElementById('entry-amount') as HTMLInputElement).value);
const month = (document.getElementById('entry-month') as HTMLInputElement).value;
const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim();
if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; }
if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; }
const recurring = (document.getElementById('entry-recurring') as HTMLInputElement).checked;
const r = await fetch('/api/budget/entries', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note, recurring: recurring || undefined }) });
if (r.ok) {
budgetData!.entries.push(await r.json());
addForm.classList.add('hidden'); addForm.reset();
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
(document.getElementById('entry-recurring') as HTMLInputElement).checked = false;
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
renderBudget();
} else {
const d = await r.json().catch(() => ({}));
addErr.textContent = d.detail ?? 'Failed to add entry.'; addErr.classList.remove('hidden');
}
});
// ── Feedback ──────────────────────────────────────────────────────────────────
const MAX_IMAGES = 3;
const MAX_BYTES = 2 * 1024 * 1024;
const fbForm = document.getElementById('feedback-form') as HTMLFormElement;
const fbDrop = document.getElementById('fb-drop')!;
const fbInput = document.getElementById('fb-input') as HTMLInputElement;
const fbPreviews = document.getElementById('fb-previews')!;
const fbErr = document.getElementById('fb-error')!;
const fbSuccess = document.getElementById('fb-success')!;
let fbFiles: File[] = [];
function fbShowErr(msg: string) { fbErr.textContent = msg; fbErr.classList.remove('hidden'); }
function fbClearErr() { fbErr.classList.add('hidden'); }
function fbRenderPreviews() {
fbPreviews.innerHTML = '';
fbFiles.forEach((f, i) => {
const wrap = document.createElement('div');
wrap.className = 'relative';
wrap.innerHTML = `<img src="${URL.createObjectURL(f)}" class="w-20 h-20 object-cover rounded-lg border border-zinc-700" />
<button type="button" data-i="${i}" class="rm-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">×</button>`;
fbPreviews.appendChild(wrap);
});
fbPreviews.querySelectorAll<HTMLElement>('.rm-btn').forEach(b => {
b.addEventListener('click', () => { fbFiles.splice(parseInt(b.dataset.i!), 1); fbRenderPreviews(); fbClearErr(); });
});
}
function fbAddFiles(files: FileList | File[]) {
fbClearErr();
for (const f of Array.from(files)) {
if (fbFiles.length >= MAX_IMAGES) { fbShowErr(`Max ${MAX_IMAGES} images.`); break; }
if (f.size > MAX_BYTES) { fbShowErr(`"${f.name}" exceeds 2 MB.`); continue; }
fbFiles.push(f);
}
fbRenderPreviews(); fbInput.value = '';
}
fbDrop.addEventListener('click', () => fbInput.click());
fbDrop.addEventListener('dragover', e => { e.preventDefault(); fbDrop.style.borderColor = 'var(--accent)'; });
fbDrop.addEventListener('dragleave', () => { fbDrop.style.borderColor = ''; });
fbDrop.addEventListener('drop', e => { e.preventDefault(); fbDrop.style.borderColor = ''; if (e.dataTransfer?.files.length) fbAddFiles(e.dataTransfer.files); });
fbInput.addEventListener('change', () => { if (fbInput.files?.length) fbAddFiles(fbInput.files); });
fbForm.addEventListener('submit', async e => {
e.preventDefault(); fbClearErr();
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
if (!text && fbFiles.length === 0) { fbShowErr('Please write something or attach an image.'); return; }
const fd = new FormData(); fd.append('text', text);
for (const f of fbFiles) fd.append('images', f);
const btn = fbForm.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 ?? `Error ${r.status}`); }
fbForm.classList.add('hidden'); fbSuccess.classList.remove('hidden');
} catch (err: any) { fbShowErr(err.message); btn.disabled = false; btn.textContent = 'Send feedback'; }
});
</script>