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
+35 -1
View File
@@ -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<Timeseries | null> {
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<Timeseries>(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.