local conversion
This commit is contained in:
@@ -2,11 +2,51 @@
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from rdp import rdp
|
|
||||||
|
|
||||||
from bincio.extract.models import DataPoint
|
from bincio.extract.models import DataPoint
|
||||||
|
|
||||||
|
|
||||||
|
def _rdp_mask(coords: list[list[float]], epsilon: float) -> list[bool]:
|
||||||
|
"""Pure-Python RDP — returns a boolean keep-mask of the same length as coords."""
|
||||||
|
n = len(coords)
|
||||||
|
if n < 2:
|
||||||
|
return [True] * n
|
||||||
|
|
||||||
|
mask = [False] * n
|
||||||
|
mask[0] = mask[-1] = True
|
||||||
|
|
||||||
|
stack = [(0, n - 1)]
|
||||||
|
while stack:
|
||||||
|
start, end = stack.pop()
|
||||||
|
if end - start < 2:
|
||||||
|
continue
|
||||||
|
x1, y1 = coords[start]
|
||||||
|
x2, y2 = coords[end]
|
||||||
|
dx, dy = x2 - x1, y2 - y1
|
||||||
|
seg_len_sq = dx * dx + dy * dy
|
||||||
|
|
||||||
|
max_dist = -1.0
|
||||||
|
max_idx = start + 1
|
||||||
|
for i in range(start + 1, end):
|
||||||
|
x0, y0 = coords[i]
|
||||||
|
if seg_len_sq == 0:
|
||||||
|
d = ((x0 - x1) ** 2 + (y0 - y1) ** 2) ** 0.5
|
||||||
|
else:
|
||||||
|
t = ((x0 - x1) * dx + (y0 - y1) * dy) / seg_len_sq
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
px, py = x1 + t * dx, y1 + t * dy
|
||||||
|
d = ((x0 - px) ** 2 + (y0 - py) ** 2) ** 0.5
|
||||||
|
if d > max_dist:
|
||||||
|
max_dist = d
|
||||||
|
max_idx = i
|
||||||
|
|
||||||
|
if max_dist >= epsilon:
|
||||||
|
mask[max_idx] = True
|
||||||
|
stack.append((start, max_idx))
|
||||||
|
stack.append((max_idx, end))
|
||||||
|
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
def simplify_track(
|
def simplify_track(
|
||||||
points: list[DataPoint],
|
points: list[DataPoint],
|
||||||
epsilon: float = 0.0001,
|
epsilon: float = 0.0001,
|
||||||
@@ -21,7 +61,7 @@ def simplify_track(
|
|||||||
return [p for p, _, _ in gps_pts]
|
return [p for p, _, _ in gps_pts]
|
||||||
|
|
||||||
coords = [[lon, lat] for _, lat, lon in gps_pts]
|
coords = [[lon, lat] for _, lat, lon in gps_pts]
|
||||||
mask = rdp(coords, epsilon=epsilon, return_mask=True)
|
mask = _rdp_mask(coords, epsilon=epsilon)
|
||||||
return [p for (p, _, _), keep in zip(gps_pts, mask) if keep]
|
return [p for (p, _, _), keep in zip(gps_pts, mask) if keep]
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +80,7 @@ def preview_coords(
|
|||||||
|
|
||||||
# Coarse RDP (larger epsilon = fewer points)
|
# Coarse RDP (larger epsilon = fewer points)
|
||||||
coords = [[lon, lat] for lat, lon in gps]
|
coords = [[lon, lat] for lat, lon in gps]
|
||||||
mask = rdp(coords, epsilon=0.001, return_mask=True)
|
mask = _rdp_mask(coords, epsilon=0.001)
|
||||||
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
|
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
|
||||||
|
|
||||||
# Subsample if still too many — always include last point without exceeding max_points
|
# Subsample if still too many — always include last point without exceeding max_points
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ dependencies = [
|
|||||||
"lxml>=5.0", # TCX (XML)
|
"lxml>=5.0", # TCX (XML)
|
||||||
# Data
|
# Data
|
||||||
"pandas>=2.2",
|
"pandas>=2.2",
|
||||||
# Geo
|
|
||||||
"rdp>=0.8",
|
|
||||||
# Config & CLI
|
# Config & CLI
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"click>=8.1",
|
"click>=8.1",
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* BincioActivity Service Worker
|
||||||
|
*
|
||||||
|
* Intercepts requests for /data/* and serves from IndexedDB when local
|
||||||
|
* activities are present, merging them with the bundled static index.
|
||||||
|
*
|
||||||
|
* IndexedDB schema:
|
||||||
|
* db: 'bincio', store: 'files'
|
||||||
|
* key: file path (e.g. '/data/activities/2024-01-01T120000Z-ride.json')
|
||||||
|
* value: { path, data } — data is the parsed JSON object
|
||||||
|
*
|
||||||
|
* Local activity summaries are kept under the special key '/data/local-index'.
|
||||||
|
* The SW merges these with the static index.json at request time so that
|
||||||
|
* activities from the server and from the device appear together in the feed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DB_NAME = 'bincio';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE = 'files';
|
||||||
|
|
||||||
|
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = e => {
|
||||||
|
e.target.result.createObjectStore(STORE, { keyPath: 'path' });
|
||||||
|
};
|
||||||
|
req.onsuccess = e => resolve(e.target.result);
|
||||||
|
req.onerror = e => reject(e.target.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idbGet(path) {
|
||||||
|
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.result?.data ?? null);
|
||||||
|
req.onerror = e => reject(e.target.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch intercept ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
if (url.pathname === '/data/index.json') {
|
||||||
|
event.respondWith(handleIndex(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/data/activities/')) {
|
||||||
|
event.respondWith(handleActivity(url.pathname, event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local summaries into the server/static index.json
|
||||||
|
async function handleIndex(request) {
|
||||||
|
try {
|
||||||
|
const localSummaries = (await idbGet('/data/local-index')) ?? [];
|
||||||
|
|
||||||
|
if (localSummaries.length === 0) {
|
||||||
|
return fetch(request); // nothing local — pass straight through
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the bundled static index (may fail if offline with no prior cache)
|
||||||
|
let remoteMeta = {};
|
||||||
|
let remoteActivities = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch(request);
|
||||||
|
const raw = await r.json();
|
||||||
|
remoteActivities = raw.activities ?? [];
|
||||||
|
const { activities: _a, ...rest } = raw;
|
||||||
|
remoteMeta = rest;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Local overrides remote for same ID; new local entries appended
|
||||||
|
const merged = new Map();
|
||||||
|
for (const a of remoteActivities) merged.set(a.id, a);
|
||||||
|
for (const a of localSummaries) merged.set(a.id, a);
|
||||||
|
|
||||||
|
const sorted = [...merged.values()].sort(
|
||||||
|
(a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonResponse({ ...remoteMeta, activities: sorted });
|
||||||
|
} catch (_) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve an individual activity file from IDB if present
|
||||||
|
async function handleActivity(path, request) {
|
||||||
|
try {
|
||||||
|
const local = await idbGet(path);
|
||||||
|
if (local !== null) return jsonResponse(local);
|
||||||
|
} catch (_) {}
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(data) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import ActivityMap from './ActivityMap.svelte';
|
import ActivityMap from './ActivityMap.svelte';
|
||||||
import ActivityCharts from './ActivityCharts.svelte';
|
import ActivityCharts from './ActivityCharts.svelte';
|
||||||
import EditDrawer from './EditDrawer.svelte';
|
import EditDrawer from './EditDrawer.svelte';
|
||||||
|
import { loadActivity } from '../lib/dataloader';
|
||||||
|
|
||||||
export let activity: ActivitySummary;
|
export let activity: ActivitySummary;
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
@@ -28,9 +29,8 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!activity.detail_url) return;
|
if (!activity.detail_url) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}data/${activity.detail_url}`);
|
detail = await loadActivity(activity.id, activity.detail_url, base);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!detail) throw new Error('Activity not found');
|
||||||
detail = await res.json();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
|
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||||
|
import { loadIndex } from '../lib/dataloader';
|
||||||
|
|
||||||
/** Render preview_coords as an SVG polyline path string. */
|
/** Render preview_coords as an SVG polyline path string. */
|
||||||
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
|
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
|
||||||
@@ -53,9 +54,7 @@
|
|||||||
sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all';
|
sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all';
|
||||||
mounted = true;
|
mounted = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
const index = await loadIndex(import.meta.env.BASE_URL);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const index: BASIndex = await res.json();
|
|
||||||
all = index.activities.filter(a => a.privacy !== 'private');
|
all = index.activities.filter(a => a.privacy !== 'private');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import MmpChart from './MmpChart.svelte';
|
import MmpChart from './MmpChart.svelte';
|
||||||
import RecordsView from './RecordsView.svelte';
|
import RecordsView from './RecordsView.svelte';
|
||||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
|
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||||
|
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
|
|
||||||
@@ -32,13 +33,12 @@
|
|||||||
activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||||||
mounted = true;
|
mounted = true;
|
||||||
try {
|
try {
|
||||||
const [athleteRes, indexRes] = await Promise.all([
|
const [athleteData, index] = await Promise.all([
|
||||||
fetch(`${import.meta.env.BASE_URL}data/athlete.json`),
|
loadAthlete(import.meta.env.BASE_URL),
|
||||||
fetch(`${import.meta.env.BASE_URL}data/index.json`),
|
loadIndex(import.meta.env.BASE_URL),
|
||||||
]);
|
]);
|
||||||
if (!athleteRes.ok) throw new Error('athlete.json not found — run bincio extract first');
|
if (!athleteData) throw new Error('athlete.json not found — run bincio extract first');
|
||||||
athlete = await athleteRes.json();
|
athlete = athleteData;
|
||||||
const index: BASIndex = await indexRes.json();
|
|
||||||
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
|
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||||
|
import { loadIndex } from '../lib/dataloader';
|
||||||
|
|
||||||
const PAGE_YEARS = 4;
|
const PAGE_YEARS = 4;
|
||||||
|
|
||||||
@@ -30,9 +31,7 @@
|
|||||||
page = parseInt(params.get('page') ?? '0', 10) || 0;
|
page = parseInt(params.get('page') ?? '0', 10) || 0;
|
||||||
mounted = true;
|
mounted = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
const index = await loadIndex(import.meta.env.BASE_URL);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const index: BASIndex = await res.json();
|
|
||||||
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
|
|||||||
@@ -208,6 +208,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Register service worker for local activity storage (offline support)
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const btn = document.getElementById('theme-toggle') as HTMLButtonElement;
|
const btn = document.getElementById('theme-toggle') as HTMLButtonElement;
|
||||||
|
|
||||||
|
|||||||
@@ -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]])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,9 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
<button id="btn-save-local" class="w-full py-2 px-4 rounded-lg text-sm font-medium text-white transition-colors" style="background:#3b82f6">
|
||||||
|
📱 Save to this device
|
||||||
|
</button>
|
||||||
<button id="btn-download-json" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
|
<button id="btn-download-json" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
|
||||||
⬇ Download BAS JSON
|
⬇ Download BAS JSON
|
||||||
</button>
|
</button>
|
||||||
@@ -64,8 +67,8 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
⬇ Download GeoJSON track
|
⬇ Download GeoJSON track
|
||||||
</button>
|
</button>
|
||||||
{editUrl && (
|
{editUrl && (
|
||||||
<button id="btn-save" class="w-full py-2 px-4 rounded-lg text-sm font-medium text-white transition-colors" style="background:#3b82f6">
|
<button id="btn-save" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-800 hover:bg-zinc-700 text-white transition-colors border border-zinc-700">
|
||||||
☁ Save to my bincio
|
☁ Save to cloud instance
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +88,16 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
</div>
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<script define:vars={{ editUrl, baseUrl }}>
|
<div id="conv-config" data-edit-url={editUrl} data-base-url={baseUrl} style="display:none"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { saveActivityLocally } from '../../lib/localstore';
|
||||||
|
|
||||||
|
// ── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
const _cfg = document.getElementById('conv-config') as HTMLElement;
|
||||||
|
const editUrl = _cfg.dataset.editUrl ?? '';
|
||||||
|
const baseUrl = _cfg.dataset.baseUrl ?? '/';
|
||||||
|
|
||||||
// ── DOM refs ────────────────────────────────────────────────────────────────
|
// ── DOM refs ────────────────────────────────────────────────────────────────
|
||||||
const stepPick = document.getElementById('step-pick');
|
const stepPick = document.getElementById('step-pick');
|
||||||
const stepLoading = document.getElementById('step-loading');
|
const stepLoading = document.getElementById('step-loading');
|
||||||
@@ -168,17 +180,17 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
setProgress(20, 'Initialising Python…');
|
setProgress(20, 'Initialising Python…');
|
||||||
pyodide = await window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/' });
|
pyodide = await window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/' });
|
||||||
|
|
||||||
setProgress(40, 'Loading packages (lxml, pyyaml)…');
|
setProgress(40, 'Loading packages (lxml, pyyaml, micropip)…');
|
||||||
await pyodide.loadPackage(['lxml', 'pyyaml']);
|
await pyodide.loadPackage(['lxml', 'pyyaml', 'micropip']);
|
||||||
|
|
||||||
setProgress(60, 'Installing fitdecode, gpxpy, rdp…');
|
setProgress(60, 'Installing fitdecode, gpxpy, rdp…');
|
||||||
await pyodide.runPythonAsync(`
|
await pyodide.runPythonAsync(`
|
||||||
import micropip
|
import micropip
|
||||||
await micropip.install(['fitdecode', 'gpxpy', 'rdp'])
|
await micropip.install(['fitdecode', 'gpxpy'])
|
||||||
`);
|
`);
|
||||||
|
|
||||||
setProgress(80, 'Loading bincio extract pipeline…');
|
setProgress(80, 'Loading bincio extract pipeline…');
|
||||||
const wheelUrl = new URL('/bincio.whl', window.location.origin).href;
|
const wheelUrl = new URL('/bincio-0.1.0-py3-none-any.whl', window.location.origin).href;
|
||||||
await pyodide.runPythonAsync(`
|
await pyodide.runPythonAsync(`
|
||||||
import micropip
|
import micropip
|
||||||
await micropip.install('${wheelUrl}', deps=False)
|
await micropip.install('${wheelUrl}', deps=False)
|
||||||
@@ -276,6 +288,27 @@ json.dumps({'id': act_id, 'detail': detail, 'geojson': geojson})
|
|||||||
showStep('error');
|
showStep('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Save locally (IndexedDB → service worker serves it in the feed) ─────────
|
||||||
|
document.getElementById('btn-save-local').addEventListener('click', async () => {
|
||||||
|
if (!lastResult) return;
|
||||||
|
const btn = document.getElementById('btn-save-local');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving…';
|
||||||
|
saveStatus.style.color = 'var(--text-4)';
|
||||||
|
try {
|
||||||
|
await saveActivityLocally(lastResult.detail, lastResult.geojson ?? null);
|
||||||
|
btn.textContent = '✓ Saved to device';
|
||||||
|
btn.style.background = '#16a34a';
|
||||||
|
saveStatus.textContent = 'Activity saved. Visit the feed to see it.';
|
||||||
|
saveStatus.style.color = '#4ade80';
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '📱 Save to this device';
|
||||||
|
saveStatus.textContent = 'Save failed: ' + e.message;
|
||||||
|
saveStatus.style.color = '#f87171';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Downloads ───────────────────────────────────────────────────────────────
|
// ── Downloads ───────────────────────────────────────────────────────────────
|
||||||
function downloadJson(data, filename) {
|
function downloadJson(data, filename) {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
|||||||
Reference in New Issue
Block a user