diff --git a/bincio/extract/simplify.py b/bincio/extract/simplify.py
index de9488d..41b184d 100644
--- a/bincio/extract/simplify.py
+++ b/bincio/extract/simplify.py
@@ -2,11 +2,51 @@
from typing import Optional
-from rdp import rdp
-
from bincio.extract.models import DataPoint
+def _rdp_mask(coords: list[list[float]], epsilon: float) -> list[bool]:
+ """Pure-Python RDP — returns a boolean keep-mask of the same length as coords."""
+ n = len(coords)
+ if n < 2:
+ return [True] * n
+
+ mask = [False] * n
+ mask[0] = mask[-1] = True
+
+ stack = [(0, n - 1)]
+ while stack:
+ start, end = stack.pop()
+ if end - start < 2:
+ continue
+ x1, y1 = coords[start]
+ x2, y2 = coords[end]
+ dx, dy = x2 - x1, y2 - y1
+ seg_len_sq = dx * dx + dy * dy
+
+ max_dist = -1.0
+ max_idx = start + 1
+ for i in range(start + 1, end):
+ x0, y0 = coords[i]
+ if seg_len_sq == 0:
+ d = ((x0 - x1) ** 2 + (y0 - y1) ** 2) ** 0.5
+ else:
+ t = ((x0 - x1) * dx + (y0 - y1) * dy) / seg_len_sq
+ t = max(0.0, min(1.0, t))
+ px, py = x1 + t * dx, y1 + t * dy
+ d = ((x0 - px) ** 2 + (y0 - py) ** 2) ** 0.5
+ if d > max_dist:
+ max_dist = d
+ max_idx = i
+
+ if max_dist >= epsilon:
+ mask[max_idx] = True
+ stack.append((start, max_idx))
+ stack.append((max_idx, end))
+
+ return mask
+
+
def simplify_track(
points: list[DataPoint],
epsilon: float = 0.0001,
@@ -21,7 +61,7 @@ def simplify_track(
return [p for p, _, _ in gps_pts]
coords = [[lon, lat] for _, lat, lon in gps_pts]
- mask = rdp(coords, epsilon=epsilon, return_mask=True)
+ mask = _rdp_mask(coords, epsilon=epsilon)
return [p for (p, _, _), keep in zip(gps_pts, mask) if keep]
@@ -40,7 +80,7 @@ def preview_coords(
# Coarse RDP (larger epsilon = fewer points)
coords = [[lon, lat] for lat, lon in gps]
- mask = rdp(coords, epsilon=0.001, return_mask=True)
+ mask = _rdp_mask(coords, epsilon=0.001)
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
# Subsample if still too many — always include last point without exceeding max_points
diff --git a/pyproject.toml b/pyproject.toml
index 6099e03..69dabb5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,8 +18,6 @@ dependencies = [
"lxml>=5.0", # TCX (XML)
# Data
"pandas>=2.2",
- # Geo
- "rdp>=0.8",
# Config & CLI
"pyyaml>=6.0",
"click>=8.1",
diff --git a/site/public/sw.js b/site/public/sw.js
new file mode 100644
index 0000000..df4cf7c
--- /dev/null
+++ b/site/public/sw.js
@@ -0,0 +1,110 @@
+/**
+ * BincioActivity Service Worker
+ *
+ * Intercepts requests for /data/* and serves from IndexedDB when local
+ * activities are present, merging them with the bundled static index.
+ *
+ * IndexedDB schema:
+ * db: 'bincio', store: 'files'
+ * key: file path (e.g. '/data/activities/2024-01-01T120000Z-ride.json')
+ * value: { path, data } — data is the parsed JSON object
+ *
+ * Local activity summaries are kept under the special key '/data/local-index'.
+ * The SW merges these with the static index.json at request time so that
+ * activities from the server and from the device appear together in the feed.
+ */
+
+const DB_NAME = 'bincio';
+const DB_VERSION = 1;
+const STORE = 'files';
+
+// ── IndexedDB helpers ─────────────────────────────────────────────────────────
+
+function openDB() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+ req.onupgradeneeded = e => {
+ e.target.result.createObjectStore(STORE, { keyPath: 'path' });
+ };
+ req.onsuccess = e => resolve(e.target.result);
+ req.onerror = e => reject(e.target.error);
+ });
+}
+
+async function idbGet(path) {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(path);
+ req.onsuccess = e => resolve(e.target.result?.data ?? null);
+ req.onerror = e => reject(e.target.error);
+ });
+}
+
+// ── Fetch intercept ───────────────────────────────────────────────────────────
+
+self.addEventListener('install', () => self.skipWaiting());
+self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
+
+self.addEventListener('fetch', event => {
+ const url = new URL(event.request.url);
+
+ if (url.pathname === '/data/index.json') {
+ event.respondWith(handleIndex(event.request));
+ return;
+ }
+
+ if (url.pathname.startsWith('/data/activities/')) {
+ event.respondWith(handleActivity(url.pathname, event.request));
+ return;
+ }
+});
+
+// Merge local summaries into the server/static index.json
+async function handleIndex(request) {
+ try {
+ const localSummaries = (await idbGet('/data/local-index')) ?? [];
+
+ if (localSummaries.length === 0) {
+ return fetch(request); // nothing local — pass straight through
+ }
+
+ // Fetch the bundled static index (may fail if offline with no prior cache)
+ let remoteMeta = {};
+ let remoteActivities = [];
+ try {
+ const r = await fetch(request);
+ const raw = await r.json();
+ remoteActivities = raw.activities ?? [];
+ const { activities: _a, ...rest } = raw;
+ remoteMeta = rest;
+ } catch (_) {}
+
+ // Local overrides remote for same ID; new local entries appended
+ const merged = new Map();
+ for (const a of remoteActivities) merged.set(a.id, a);
+ for (const a of localSummaries) merged.set(a.id, a);
+
+ const sorted = [...merged.values()].sort(
+ (a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? '')
+ );
+
+ return jsonResponse({ ...remoteMeta, activities: sorted });
+ } catch (_) {
+ return fetch(request);
+ }
+}
+
+// Serve an individual activity file from IDB if present
+async function handleActivity(path, request) {
+ try {
+ const local = await idbGet(path);
+ if (local !== null) return jsonResponse(local);
+ } catch (_) {}
+ return fetch(request);
+}
+
+function jsonResponse(data) {
+ return new Response(JSON.stringify(data), {
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
+ });
+}
diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte
index e5f7014..bf16b09 100644
--- a/site/src/components/ActivityDetail.svelte
+++ b/site/src/components/ActivityDetail.svelte
@@ -7,6 +7,7 @@
import ActivityMap from './ActivityMap.svelte';
import ActivityCharts from './ActivityCharts.svelte';
import EditDrawer from './EditDrawer.svelte';
+ import { loadActivity } from '../lib/dataloader';
export let activity: ActivitySummary;
export let base: string = '/';
@@ -28,9 +29,8 @@
onMount(async () => {
if (!activity.detail_url) return;
try {
- const res = await fetch(`${base}data/${activity.detail_url}`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- detail = await res.json();
+ detail = await loadActivity(activity.id, activity.detail_url, base);
+ if (!detail) throw new Error('Activity not found');
} catch (e: any) {
error = e.message;
}
diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte
index 7390af8..a0e5240 100644
--- a/site/src/components/ActivityFeed.svelte
+++ b/site/src/components/ActivityFeed.svelte
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
+ import { loadIndex } from '../lib/dataloader';
/** Render preview_coords as an SVG polyline path string. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
@@ -53,9 +54,7 @@
sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all';
mounted = true;
try {
- const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const index: BASIndex = await res.json();
+ const index = await loadIndex(import.meta.env.BASE_URL);
all = index.activities.filter(a => a.privacy !== 'private');
} catch (e: any) {
error = e.message;
diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte
index d7b0b36..102e5e1 100644
--- a/site/src/components/AthleteView.svelte
+++ b/site/src/components/AthleteView.svelte
@@ -4,6 +4,7 @@
import MmpChart from './MmpChart.svelte';
import RecordsView from './RecordsView.svelte';
import AthleteDrawer from './AthleteDrawer.svelte';
+ import { loadIndex, loadAthlete } from '../lib/dataloader';
export let base: string = '/';
@@ -32,13 +33,12 @@
activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
mounted = true;
try {
- const [athleteRes, indexRes] = await Promise.all([
- fetch(`${import.meta.env.BASE_URL}data/athlete.json`),
- fetch(`${import.meta.env.BASE_URL}data/index.json`),
+ const [athleteData, index] = await Promise.all([
+ loadAthlete(import.meta.env.BASE_URL),
+ loadIndex(import.meta.env.BASE_URL),
]);
- if (!athleteRes.ok) throw new Error('athlete.json not found — run bincio extract first');
- athlete = await athleteRes.json();
- const index: BASIndex = await indexRes.json();
+ if (!athleteData) throw new Error('athlete.json not found — run bincio extract first');
+ athlete = athleteData;
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
} catch (e: any) {
error = e.message;
diff --git a/site/src/components/StatsView.svelte b/site/src/components/StatsView.svelte
index d1bee17..be226a0 100644
--- a/site/src/components/StatsView.svelte
+++ b/site/src/components/StatsView.svelte
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
+ import { loadIndex } from '../lib/dataloader';
const PAGE_YEARS = 4;
@@ -30,9 +31,7 @@
page = parseInt(params.get('page') ?? '0', 10) || 0;
mounted = true;
try {
- const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const index: BASIndex = await res.json();
+ const index = await loadIndex(import.meta.env.BASE_URL);
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
} catch (e: any) {
error = e.message;
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index 346e2f4..5d78e83 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -208,6 +208,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
+
+