Files
bincio-activity/site/src/lib/dataloader.ts
T
Davide Scaini f376b24106 fix: handle absolute detail_url paths in loadActivity and loadTimeseries
resolveShards rewrites detail_url to absolute paths (e.g. /data/brut/_merged/activities/{id}.json)
when fetching from a user shard. loadActivity and loadTimeseries only checked for http:// prefixes
and treated /data/... paths as relative, producing double /data//data/... in the fetch URL → 404.

Fix: treat URLs starting with / as already absolute, same as http:// URLs.
2026-04-15 14:44:34 +02:00

238 lines
8.8 KiB
TypeScript

/**
* 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<T>(url: string): Promise<T> {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
}
async function idbGetActivity(id: string): Promise<ActivityDetail | null> {
// 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<ActivitySummary[]> {
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<BASIndex>(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<BASIndex> {
indexUrl = indexUrl ?? `${baseUrl}data/index.json`;
const [serverResult, localResult] = await Promise.allSettled([
fetchJSON<BASIndex>(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<string, ActivitySummary>();
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<ActivityDetail | null> {
// 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<ActivityDetail>(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<Timeseries | null> {
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<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.
*
* @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<Record<string, unknown> | null> {
try {
return await fetchJSON(athleteUrl ?? `${baseUrl}data/athlete.json`);
} catch {
return null;
}
}