local activity storage and convert page fixes

- Replace rdp dependency with inline pure-Python RDP implementation
    so the bincio wheel runs in Pyodide (no pure-Python wheel existed for rdp)
  - Fix convert page script: remove define:vars so Vite bundles it and
    TypeScript imports (localstore, format) work correctly
  - Rename wheel to proper PEP 427 filename (bincio-0.1.0-py3-none-any.whl)
  - Use en-GB date format on convert result, consistent with the feed
  - Add /activity/local/ page + LocalActivityDetail for IDB-only activities;
    feed links local activities there instead of the SSG route
  - Fix getStaticPaths: try public/data symlink as fallback, never crash on
    missing index.json
  - Fix ActivityDetail.onMount: load detail even when detail_url is absent
    so locally converted activities show map and charts
  - Derive track_url and detail_url from id in toSummary() since they are
    not present in the detail JSON
  - Reload on bfcache restore (pageshow) so client:only components re-mount
    after back navigation
This commit is contained in:
Davide Scaini
2026-04-08 14:14:42 +02:00
parent 5bf0f3636c
commit 083c67d018
8 changed files with 77 additions and 16 deletions
+1 -2
View File
@@ -27,9 +27,8 @@
$: displayTitle = localTitle || activity.title;
onMount(async () => {
if (!activity.detail_url) return;
try {
detail = await loadActivity(activity.id, activity.detail_url, base);
detail = await loadActivity(activity.id, activity.detail_url ?? '', base);
if (!detail) throw new Error('Activity not found');
} catch (e: any) {
error = e.message;
+1 -1
View File
@@ -111,7 +111,7 @@
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each visible as a (a.id)}
<a
href={`${import.meta.env.BASE_URL}activity/${a.id}/`}
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
class="block rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group"
>
<!-- header -->
@@ -0,0 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getLocalActivity } from '../lib/localstore';
import type { ActivitySummary } from '../lib/types';
import ActivityDetail from './ActivityDetail.svelte';
export let base: string = '/';
let activity: ActivitySummary | null = null;
let error = '';
onMount(async () => {
const id = new URLSearchParams(window.location.search).get('id');
if (!id) { error = 'No activity ID in URL.'; return; }
const found = await getLocalActivity(id);
if (!found) { error = `Activity "${id}" not found on this device.`; return; }
activity = found;
});
</script>
{#if error}
<p class="text-red-400 text-sm py-12 text-center">{error}</p>
{:else if activity}
<ActivityDetail {activity} {base} athlete={null} />
{:else}
<div class="h-32 rounded-xl bg-zinc-900 border border-zinc-800 animate-pulse mt-4"></div>
{/if}
+7
View File
@@ -21,6 +21,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
document.documentElement.setAttribute('data-theme', t);
</script>
<!-- Reload on bfcache restore so client:only components re-mount -->
<script is:inline>
window.addEventListener('pageshow', function(e) {
if (e.persisted) window.location.reload();
});
</script>
<style is:global>
/* ── Theme tokens ─────────────────────────────────────────────────────── */
:root, [data-theme="dark"] {
+12 -1
View File
@@ -65,6 +65,12 @@ export async function listLocalActivities(): Promise<ActivitySummary[]> {
return (await idbGet<ActivitySummary[]>('/data/local-index')) ?? [];
}
/** Return the summary for a single locally-stored activity, or null. */
export async function getLocalActivity(id: string): Promise<ActivitySummary | null> {
const list = await listLocalActivities();
return list.find(a => a.id === id) ?? null;
}
/** Return true if at least one activity is stored locally. */
export async function hasLocalActivities(): Promise<boolean> {
const list = await listLocalActivities();
@@ -83,7 +89,12 @@ const SUMMARY_KEYS = [
] as const;
function toSummary(detail: Record<string, unknown>): ActivitySummary {
return Object.fromEntries(
const id = detail.id as string;
const summary = Object.fromEntries(
SUMMARY_KEYS.filter(k => k in detail).map(k => [k, detail[k]])
);
// These live in the index summary, not the detail JSON — derive from id
if (!summary.detail_url) summary.detail_url = `activities/${id}.json`;
if (!summary.track_url && detail.bbox) summary.track_url = `activities/${id}.geojson`;
return summary;
}
+18 -10
View File
@@ -6,17 +6,25 @@ import ActivityDetail from '../../components/ActivityDetail.svelte';
import type { BASIndex, ActivitySummary, AthleteZones } from '../../lib/types';
export async function getStaticPaths() {
const dataDir = process.env.BINCIO_DATA_DIR
?? resolve(process.cwd(), '..', 'bincio_data');
const raw = readFileSync(join(dataDir, 'index.json'), 'utf-8');
const index: BASIndex = JSON.parse(raw);
try {
const candidates = [
process.env.BINCIO_DATA_DIR,
resolve(process.cwd(), 'public', 'data'), // symlinked by `bincio render`
resolve(process.cwd(), '..', 'bincio_data'),
].filter(Boolean) as string[];
const dataDir = candidates.find(d => { try { readFileSync(join(d, 'index.json')); return true; } catch { return false; } })!;
const raw = readFileSync(join(dataDir, 'index.json'), 'utf-8');
const index: BASIndex = JSON.parse(raw);
return index.activities
.filter(a => a.privacy !== 'private' && a.id)
.map(a => ({
params: { id: a.id },
props: { activity: a, athlete: index.owner.athlete ?? null },
}));
return index.activities
.filter(a => a.privacy !== 'private' && a.id)
.map(a => ({
params: { id: a.id },
props: { activity: a, athlete: index.owner.athlete ?? null },
}));
} catch {
return [];
}
}
const { activity, athlete } = Astro.props as { activity: ActivitySummary; athlete: AthleteZones | null };
@@ -0,0 +1,8 @@
---
import Base from '../../../layouts/Base.astro';
import LocalActivityDetail from '../../../components/LocalActivityDetail.svelte';
const base = import.meta.env.BASE_URL;
---
<Base title="Activity — BincioActivity">
<LocalActivityDetail {base} client:only="svelte" />
</Base>
+3 -2
View File
@@ -92,6 +92,7 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
<script>
import { saveActivityLocally } from '../../lib/localstore';
import { formatDate, formatTime } from '../../lib/format';
// ── Config ──────────────────────────────────────────────────────────────────
const _cfg = document.getElementById('conv-config') as HTMLElement;
@@ -269,7 +270,7 @@ json.dumps({'id': act_id, 'detail': detail, 'geojson': geojson})
const d = r.detail;
document.getElementById('res-sport-icon').textContent = SPORT_ICONS[d.sport] ?? '⚡';
document.getElementById('res-title').textContent = d.title || 'Untitled';
document.getElementById('res-date').textContent = d.started_at ? new Date(d.started_at).toLocaleString() : '';
document.getElementById('res-date').textContent = d.started_at ? `${formatDate(d.started_at)} · ${formatTime(d.started_at)}` : '';
const distKm = d.distance_m ? (d.distance_m / 1000).toFixed(1) + ' km' : '—';
const dur = d.moving_time_s ?? d.duration_s;
@@ -299,7 +300,7 @@ json.dumps({'id': act_id, 'detail': detail, 'geojson': geojson})
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.innerHTML = `Activity saved. <a href="${baseUrl}activity/local/?id=${lastResult.id}" style="color:#86efac;text-decoration:underline">View activity →</a>`;
saveStatus.style.color = '#4ade80';
} catch (e) {
btn.disabled = false;