/** * Data access abstraction layer. * * All Svelte components load BAS data through these functions instead of * calling fetch() directly. Each function merges server/bundled data with * any activities stored locally in IndexedDB (via localstore.ts), so the * app works the same whether it is connected to a cloud instance, running * offline, or somewhere in between. * * Design notes: * - Server fetch and IDB read run concurrently (Promise.allSettled). * - If the server is unreachable, local-only data is returned. * - If IDB is empty, pure server data is returned — zero overhead. * - Local activities override server ones with the same ID (local is authoritative * for anything the user recorded or converted on this device). */ import type { ActivityDetail, ActivitySummary, BASIndex, Timeseries } from './types'; import { listLocalActivities } from './localstore'; // ── Helpers ─────────────────────────────────────────────────────────────────── async function fetchJSON(url: string): Promise { const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; } async function idbGetActivity(id: string): Promise { // Inline IDB read — avoids importing openDB into every caller return new Promise(resolve => { try { const req = indexedDB.open('bincio', 1); req.onsuccess = e => { const db: IDBDatabase = (e.target as IDBOpenDBRequest).result; const tx = db.transaction('files', 'readonly'); const get = tx.objectStore('files').get(`/data/activities/${id}.json`); get.onsuccess = ge => resolve((ge.target as IDBRequest).result?.data ?? null); get.onerror = () => resolve(null); }; req.onerror = () => resolve(null); } catch { resolve(null); } }); } function emptyIndex(): BASIndex { return { bas_version: '1.0', owner: { handle: 'unknown', display_name: '' }, generated_at: '', shards: [], activities: [], }; } // ── Public API ──────────────────────────────────────────────────────────────── /** * Resolve shards from a BASIndex into a flat activity list. * * Handles two shard types transparently: * - handle shards: multi-user manifest (url = "{handle}/index.json") * - year shards: per-user pagination (url = "index-2025.json") * * Shard URLs are resolved relative to the index URL that declared them. * All shard fetches run concurrently. Errors are silently skipped so a * single unavailable shard doesn't break the whole feed. */ async function resolveShards( index: BASIndex, indexUrl: string, ): Promise { if (!index.shards?.length) return index.activities ?? []; const base = indexUrl.substring(0, indexUrl.lastIndexOf('/') + 1); const shardResults = await Promise.allSettled( index.shards.map(async shard => { const url = shard.url.startsWith('http') ? shard.url : `${base}${shard.url}`; // Base URL of this shard's directory (e.g. "http://…/data/dave/_merged/") const shardBase = url.substring(0, url.lastIndexOf('/') + 1); const sub = await fetchJSON(url); // Recursively resolve nested shards (e.g. user shard that itself paginates) const activities = await resolveShards(sub, url); // Rewrite relative detail_url / track_url to be absolute so they can be // fetched correctly regardless of where the root index lives. return activities.map(a => ({ ...a, ...(shard.handle ? { handle: shard.handle } : {}), detail_url: a.detail_url && !a.detail_url.startsWith('http') ? `${shardBase}${a.detail_url}` : a.detail_url, track_url: a.track_url && !a.track_url.startsWith('http') ? `${shardBase}${a.track_url}` : a.track_url, })); }), ); // Log shard fetch failures to help diagnose missing-activity issues shardResults.forEach((r, i) => { if (r.status === 'rejected') { console.error('[bincio] shard fetch failed:', index.shards[i]?.url, r.reason); } }); const own = index.activities ?? []; const fromShards = shardResults.flatMap(r => r.status === 'fulfilled' ? r.value : []); return [...own, ...fromShards]; } /** * Load the activity index, resolving any shards (multi-user or pagination), * then merging with locally-stored activities from IndexedDB. * * Single-user indexes with no shards work exactly as before — zero overhead. * * @param baseUrl Site base URL (used for IDB local activities) * @param indexUrl Full URL of the index to load (defaults to baseUrl + data/index.json) */ export async function loadIndex(baseUrl: string, indexUrl?: string): Promise { indexUrl = indexUrl ?? `${baseUrl}data/index.json`; const [serverResult, localResult] = await Promise.allSettled([ fetchJSON(indexUrl), listLocalActivities(), ]); const server = serverResult.status === 'fulfilled' ? serverResult.value : null; const local = localResult.status === 'fulfilled' ? localResult.value : []; const serverActivities = server ? await resolveShards(server, indexUrl) : []; if (local.length === 0 && !server) return emptyIndex(); // Local overrides server for the same ID; new local entries are appended const merged = new Map(); for (const a of serverActivities) merged.set(a.id, a); for (const a of local as ActivitySummary[]) merged.set(a.id, a); return { ...(server ?? emptyIndex()), activities: [...merged.values()].sort( (a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? ''), ), }; } /** * Load a single activity detail, checking IndexedDB first so locally-converted * activities are available offline. * * @param id Activity ID (used for the IDB lookup) * @param detailUrl Relative path from the BAS index (e.g. "activities/id.json") * @param baseUrl Site base URL */ export async function loadActivity( id: string, detailUrl: string, baseUrl: string, ): Promise { // IDB first — instant and works offline const cached = await idbGetActivity(id); if (cached) return cached; try { const url = detailUrl.startsWith('http') || detailUrl.startsWith('/') ? detailUrl : `${baseUrl}data/${detailUrl}`; return await fetchJSON(url); } catch { return null; } } /** * 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; // Strip the leading "activities/" from timeseriesUrl so we can append it // to whatever directory the detail JSON lives in. const filename = timeseriesUrl.replace(/^activities\//, ''); if (timeseriesUrl.startsWith('http')) { url = timeseriesUrl; } else if (detailUrl.startsWith('http') || detailUrl.startsWith('/')) { // absolute detailUrl (browser shard resolution) → same directory const dir = detailUrl.substring(0, detailUrl.lastIndexOf('/') + 1); url = `${dir}${filename}`; } else { // relative detailUrl — may be plain ("activities/{id}.json", single-user) // or prefixed ("dave/_merged/activities/{id}.json", multi-user SSG prop). // In both cases, resolve the timeseries file from the same directory. const dir = detailUrl.includes('/') ? detailUrl.substring(0, detailUrl.lastIndexOf('/') + 1) : ''; url = `${baseUrl}data/${dir}${filename}`; } 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. * * @param baseUrl Site base URL (used to build the default path) * @param athleteUrl Explicit full URL — use for per-user pages in multi-user mode */ export async function loadAthlete( baseUrl: string, athleteUrl?: string, ): Promise | null> { try { return await fetchJSON(athleteUrl ?? `${baseUrl}data/athlete.json`); } catch { return null; } }