diff --git a/bincio/edit/server.py b/bincio/edit/server.py index e9ddb1a..7e7b516 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -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, diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index 55e97c7..370d619 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -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) diff --git a/bincio/render/merge.py b/bincio/render/merge.py index fb8bfb6..8064776 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -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() diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 58f3bc1..3b992a4 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -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+)?", diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 5025455..18bae3d 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -2,12 +2,12 @@ import { onMount } from 'svelte'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; - import type { ActivitySummary, ActivityDetail, AthleteZones } from '../lib/types'; + import type { ActivitySummary, ActivityDetail, AthleteZones, Timeseries } from '../lib/types'; import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format'; import ActivityMap from './ActivityMap.svelte'; import ActivityCharts from './ActivityCharts.svelte'; import EditDrawer from './EditDrawer.svelte'; - import { loadActivity } from '../lib/dataloader'; + import { loadActivity, loadTimeseries } from '../lib/dataloader'; export let activity: ActivitySummary; export let base: string = '/'; @@ -17,6 +17,8 @@ const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true'; let detail: ActivityDetail | null = null; + let timeseries: Timeseries | null = null; + let timeseriesLoading = false; let error = ''; let hoveredIdx: number | null = null; let editOpen = false; @@ -31,8 +33,17 @@ try { detail = await loadActivity(activity.id, activity.detail_url ?? '', base); if (!detail) throw new Error('Activity not found'); + // Use embedded timeseries (IDB activities) or lazy-fetch from URL + if (detail.timeseries) { + timeseries = detail.timeseries; + } else if (detail.timeseries_url) { + timeseriesLoading = true; + timeseries = await loadTimeseries(detail.timeseries_url, activity.detail_url ?? '', base); + timeseriesLoading = false; + } } catch (e: any) { error = e.message; + timeseriesLoading = false; } }); @@ -231,7 +242,7 @@ {#if trackUrl} {#if error}

{error}

-{:else if detail?.timeseries && detail.timeseries.t.length > 0} +{:else if timeseries && timeseries.t.length > 0}
- +
-{:else if !detail} +{:else if !detail || timeseriesLoading}
{/if} diff --git a/site/src/lib/dataloader.ts b/site/src/lib/dataloader.ts index 3933a59..45a998e 100644 --- a/site/src/lib/dataloader.ts +++ b/site/src/lib/dataloader.ts @@ -15,7 +15,7 @@ * for anything the user recorded or converted on this device). */ -import type { ActivityDetail, ActivitySummary, BASIndex } from './types'; +import type { ActivityDetail, ActivitySummary, BASIndex, Timeseries } from './types'; import { listLocalActivities } from './localstore'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -170,6 +170,40 @@ export async function loadActivity( } } +/** + * Fetch the timeseries for an activity. Called lazily when the charts section + * is shown, so the initial detail load stays small (~1 KB instead of ~600 KB). + * + * @param timeseriesUrl Relative path from the detail JSON (e.g. "activities/id.timeseries.json") + * @param detailUrl The URL from which the detail JSON was fetched — used to resolve + * relative paths correctly in both single- and multi-user modes. + * @param baseUrl Site base URL — fallback when detailUrl is empty + */ +export async function loadTimeseries( + timeseriesUrl: string, + detailUrl: string, + baseUrl: string, +): Promise { + try { + let url: string; + if (timeseriesUrl.startsWith('http')) { + url = timeseriesUrl; + } else if (detailUrl.startsWith('http')) { + // detailUrl is absolute — resolve timeseries relative to its directory + const dir = detailUrl.substring(0, detailUrl.lastIndexOf('/') + 1); + // timeseriesUrl is "activities/id.timeseries.json" — strip leading "activities/" + // because dir already ends with "activities/" + const filename = timeseriesUrl.replace(/^activities\//, ''); + url = `${dir}${filename}`; + } else { + url = `${baseUrl}data/${timeseriesUrl}`; + } + return await fetchJSON(url); + } catch { + return null; + } +} + /** * Load athlete profile. Athlete data is not stored locally yet, so this is * always a network fetch with a graceful null on failure. diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 7c5293b..774377f 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -114,7 +114,10 @@ export interface ActivityDetail extends Omit