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
+2
View File
@@ -9,6 +9,7 @@ from typing import Any
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID
@@ -21,6 +22,7 @@ strava_client_secret: str = ""
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
app.add_middleware(GZipMiddleware, minimum_size=1024)
# Allow localhost origins only — this server is never meant to be public
app.add_middleware(
CORSMiddleware,
+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)
+8
View File
@@ -109,6 +109,14 @@ def merge_one(data_dir: Path, activity_id: str) -> None:
needs_merge = has_sidecar or bool(image_files)
# Symlink the timeseries file (never merged — always points to the extract output)
ts_src = acts_dir / f"{activity_id}.timeseries.json"
ts_dest = merged_acts / f"{activity_id}.timeseries.json"
if ts_dest.exists() or ts_dest.is_symlink():
ts_dest.unlink()
if ts_src.exists():
ts_dest.symlink_to(ts_src.resolve())
# Remove the old dest (symlink or file) before writing the new one
if dest.exists() or dest.is_symlink():
dest.unlink()
+2
View File
@@ -17,6 +17,7 @@ from typing import Any, Optional
from fastapi import Cookie, FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from bincio.serve.db import (
@@ -61,6 +62,7 @@ def _get_data_dir() -> Path:
app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://localhost(:\d+)?",