local conversion

This commit is contained in:
Davide Scaini
2026-04-06 22:25:57 +02:00
parent b633d72258
commit 5bf0f3636c
11 changed files with 426 additions and 28 deletions
+123
View File
@@ -0,0 +1,123 @@
/**
* 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 } 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 ────────────────────────────────────────────────────────────────
/**
* Load the activity index, merging the server's copy with any locally-stored
* activities. Local entries override server entries with the same ID.
*/
export async function loadIndex(baseUrl: string): Promise<BASIndex> {
const [serverResult, localResult] = await Promise.allSettled([
fetchJSON<BASIndex>(`${baseUrl}data/index.json`),
listLocalActivities(),
]);
const server = serverResult.status === 'fulfilled' ? serverResult.value : null;
const local = localResult.status === 'fulfilled' ? localResult.value : [];
if (local.length === 0) return server ?? emptyIndex();
if (!server) return { ...emptyIndex(), activities: local as ActivitySummary[] };
// Local overrides server for the same ID; new local entries are appended
const merged = new Map<string, ActivitySummary>();
for (const a of server.activities ?? []) merged.set(a.id, a);
for (const a of local as ActivitySummary[]) merged.set(a.id, a);
return {
...server,
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 {
return await fetchJSON<ActivityDetail>(`${baseUrl}data/${detailUrl}`);
} 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.
*/
export async function loadAthlete(baseUrl: string): Promise<Record<string, unknown> | null> {
try {
return await fetchJSON(`${baseUrl}data/athlete.json`);
} catch {
return null;
}
}