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)
This commit is contained in:
Davide Scaini
2026-06-03 10:32:02 +02:00
parent 5287b98bc1
commit b781193d44
5 changed files with 462 additions and 14 deletions
+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")
+4
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,
@@ -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">
+153 -14
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 me}
<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}
<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 {} }}
>{a.title}</a>
{#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(filterHandle && !a.handle ? { ...a, handle: filterHandle } : a)); } catch {} }}
>{a.title}</a>
{/if}
</h3>
</div>
<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 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}
+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 {