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
+17 -6
View File
@@ -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}
<ActivityMap
{trackUrl}
timeseries={detail?.timeseries ?? null}
{timeseries}
bbox={detail?.bbox ?? null}
accentColor={color}
bind:hoveredIdx
@@ -263,11 +274,11 @@
<!-- Charts -->
{#if error}
<p class="text-red-400 text-sm mt-4">{error}</p>
{:else if detail?.timeseries && detail.timeseries.t.length > 0}
{:else if timeseries && timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx {athlete} />
<ActivityCharts {timeseries} bind:hoveredIdx {athlete} />
</div>
{:else if !detail}
{:else if !detail || timeseriesLoading}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>
{/if}
+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.
+4 -1
View File
@@ -114,7 +114,10 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
start_latlng: [number, number] | null;
end_latlng: [number, number] | null;
laps: Lap[];
timeseries: Timeseries;
/** Embedded timeseries — present for IDB-stored (locally converted) activities. */
timeseries?: Timeseries | null;
/** URL to fetch the timeseries — present for server-extracted activities. */
timeseries_url?: string | null;
mmp: MmpCurve | null;
strava_id: string | null;
duplicate_of: string | null;