f376b24106
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.
238 lines
8.8 KiB
TypeScript
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;
|
|
}
|
|
}
|