local conversion
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* IndexedDB helper for local activity storage.
|
||||
*
|
||||
* Activities converted on-device are written here. The service worker (sw.js)
|
||||
* reads from the same database and merges local activities into the feed.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'bincio';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'files';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = e =>
|
||||
(e.target as IDBOpenDBRequest).result.createObjectStore(STORE, { keyPath: 'path' });
|
||||
req.onsuccess = e => resolve((e.target as IDBOpenDBRequest).result);
|
||||
req.onerror = e => reject((e.target as IDBOpenDBRequest).error);
|
||||
});
|
||||
}
|
||||
|
||||
async function idbPut(path: string, data: unknown): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put({ path, data });
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = e => reject((e.target as IDBTransaction).error);
|
||||
});
|
||||
}
|
||||
|
||||
async function idbGet<T>(path: string): Promise<T | null> {
|
||||
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 as IDBRequest).result?.data ?? null);
|
||||
req.onerror = e => reject((e.target as IDBRequest).error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Save a converted activity to IndexedDB and update the local summary index. */
|
||||
export async function saveActivityLocally(
|
||||
detail: Record<string, unknown>,
|
||||
geojson: Record<string, unknown> | null,
|
||||
): Promise<void> {
|
||||
const id = detail.id as string;
|
||||
|
||||
await idbPut(`/data/activities/${id}.json`, detail);
|
||||
if (geojson) {
|
||||
await idbPut(`/data/activities/${id}.geojson`, geojson);
|
||||
}
|
||||
|
||||
// Maintain a flat list of local summaries (read by the service worker)
|
||||
const existing = (await idbGet<ActivitySummary[]>('/data/local-index')) ?? [];
|
||||
const summary = toSummary(detail);
|
||||
const idx = existing.findIndex(a => a.id === id);
|
||||
if (idx >= 0) existing[idx] = summary; else existing.push(summary);
|
||||
await idbPut('/data/local-index', existing);
|
||||
}
|
||||
|
||||
/** Return all locally-stored activity summaries. */
|
||||
export async function listLocalActivities(): Promise<ActivitySummary[]> {
|
||||
return (await idbGet<ActivitySummary[]>('/data/local-index')) ?? [];
|
||||
}
|
||||
|
||||
/** Return true if at least one activity is stored locally. */
|
||||
export async function hasLocalActivities(): Promise<boolean> {
|
||||
const list = await listLocalActivities();
|
||||
return list.length > 0;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActivitySummary = Record<string, unknown>;
|
||||
|
||||
const SUMMARY_KEYS = [
|
||||
'id', 'title', 'sport', 'sub_sport', 'started_at', 'distance_m',
|
||||
'duration_s', 'moving_time_s', 'elevation_gain_m', 'avg_speed_kmh',
|
||||
'avg_hr_bpm', 'avg_cadence_rpm', 'avg_power_w', 'privacy',
|
||||
'detail_url', 'track_url', 'preview_coords',
|
||||
] as const;
|
||||
|
||||
function toSummary(detail: Record<string, unknown>): ActivitySummary {
|
||||
return Object.fromEntries(
|
||||
SUMMARY_KEYS.filter(k => k in detail).map(k => [k, detail[k]])
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user