1 — Timeseries split

- writer.py: timeseries is now written to {id}.timeseries.json as a separate file. The detail JSON gets a timeseries_url field instead. finalize_pending and cleanup_pending handle the extra file.
  - merge.py (merge_one): symlinks the .timeseries.json file alongside the detail JSON. merge_all already handles it transparently (the .timeseries.json stem doesn't match any activity
  ID in to_merge, so it falls through to the symlink branch).
  - types.ts: timeseries is now timeseries?: Timeseries | null, and timeseries_url?: string | null added.
  - dataloader.ts: new loadTimeseries(url, detailUrl, base) function that resolves paths correctly in both single- and multi-user modes (uses the fetched detail URL's directory as the base).
  - ActivityDetail.svelte: loads timeseries separately after detail loads; uses detail.timeseries for IDB activities (embedded) or fetches via detail.timeseries_url for server activities. Charts show a pulse placeholder while loading.

 2 — GZip

  - GZipMiddleware (min 1 KB) added to both bincio/serve/server.py and bincio/edit/server.py — all API JSON responses are now gzip-compressed.
  - For static files (the big timeseries JSONs), nginx should be configured with gzip on; gzip_types application/json application/geo+json; — no code change needed on the server side.

  Net effect: opening an activity page now fetches ~1.4 KB (detail) instead of ~586 KB. The timeseries fetches ~60–150 KB gzip-compressed shortly after (it loads concurrently with the map rendering).
This commit is contained in:
Davide Scaini
2026-04-09 14:01:02 +02:00
parent 76b7b9ac7e
commit 8118f6f316
7 changed files with 90 additions and 11 deletions
+22 -3
View File
@@ -50,6 +50,10 @@ def write_activity(
source = _infer_source(activity)
has_gps = metrics.bbox is not None and privacy not in ("no_gps", "private")
# Build timeseries once — written to a separate file to keep detail JSON small
timeseries = build_timeseries(activity.points, activity.started_at, privacy)
tag = activity.source_hash[-8:] if activity.source_hash else "unknown"
# ── detail JSON ──────────────────────────────────────────────────────────
detail: dict = {
"bas_version": "1.0",
@@ -80,7 +84,7 @@ def write_activity(
"best_efforts": metrics.best_efforts,
"best_climb_m": metrics.best_climb_m,
"laps": [_serialise_lap(lap) for lap in activity.laps],
"timeseries": build_timeseries(activity.points, activity.started_at, privacy),
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
"source": source,
"source_file": activity.source_file,
"source_hash": activity.source_hash,
@@ -92,7 +96,6 @@ def write_activity(
if pending:
# Write to a unique pending file — no collision possible
tag = activity.source_hash[-8:] if activity.source_hash else "unknown"
json_path = acts_dir / f"{activity_id}.{tag}.pending.json"
else:
json_path = acts_dir / f"{activity_id}.json"
@@ -104,9 +107,18 @@ def write_activity(
activity_id = f"{activity_id}-{activity.source_hash[-6:]}"
json_path = acts_dir / f"{activity_id}.json"
detail["id"] = activity_id
detail["timeseries_url"] = f"activities/{activity_id}.timeseries.json" if timeseries else None
json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
# ── timeseries JSON (separate file — lazy-loaded by the UI) ─────────────
if timeseries:
if pending:
ts_path = acts_dir / f"{activity_id}.{tag}.pending.timeseries.json"
else:
ts_path = acts_dir / f"{activity_id}.timeseries.json"
ts_path.write_text(json.dumps(timeseries, indent=2, ensure_ascii=False))
# ── GeoJSON track ────────────────────────────────────────────────────────
if has_gps:
geojson = build_geojson(activity.points, activity_id, epsilon=rdp_epsilon)
@@ -150,6 +162,7 @@ def finalize_pending(output_dir: Path, activity_id: str, source_hash: str) -> st
pending_json = acts_dir / f"{activity_id}.{tag}.pending.json"
pending_geojson = acts_dir / f"{activity_id}.{tag}.pending.geojson"
pending_ts = acts_dir / f"{activity_id}.{tag}.pending.timeseries.json"
final_id = activity_id
final_json = acts_dir / f"{final_id}.json"
@@ -165,6 +178,8 @@ def finalize_pending(output_dir: Path, activity_id: str, source_hash: str) -> st
if final_id != activity_id and pending_json.exists():
detail = json.loads(pending_json.read_text(encoding="utf-8"))
detail["id"] = final_id
if detail.get("timeseries_url"):
detail["timeseries_url"] = f"activities/{final_id}.timeseries.json"
pending_json.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
# Atomic rename: pending → final
@@ -180,6 +195,10 @@ def finalize_pending(output_dir: Path, activity_id: str, source_hash: str) -> st
pending_geojson.write_text(json.dumps(geo, indent=2, ensure_ascii=False))
pending_geojson.rename(final_geojson)
final_ts = acts_dir / f"{final_id}.timeseries.json"
if pending_ts.exists():
pending_ts.rename(final_ts)
return final_id
@@ -187,7 +206,7 @@ def cleanup_pending(output_dir: Path, activity_id: str, source_hash: str) -> Non
"""Remove pending files for a losing activity (the one not chosen as canonical)."""
acts_dir = output_dir / "activities"
tag = source_hash[-8:] if source_hash else "unknown"
for suffix in (".pending.json", ".pending.geojson"):
for suffix in (".pending.json", ".pending.geojson", ".pending.timeseries.json"):
p = acts_dir / f"{activity_id}.{tag}{suffix}"
p.unlink(missing_ok=True)