feat: bidirectional sync — upload local activities to remote instance

- Server: POST /api/upload/bas accepts pre-extracted BAS JSON (activity + optional timeseries/geojson), writes files and triggers merge_all
- sync.ts: uploadLocalActivities reads unsynced local activities by original_path, POSTs to /api/upload/bas, marks synced_at on success
- Settings: Upload toggle (Off / Upload local activities) in Sync section with subLabel dividers for Download / Upload groups
- Feed: sync message includes uploaded count when activities are pushed
This commit is contained in:
Davide Scaini
2026-04-24 22:26:13 +02:00
parent a1e56e5308
commit c7c7fe9395
4 changed files with 143 additions and 5 deletions
+2 -1
View File
@@ -20,12 +20,13 @@ export default function FeedScreen() {
setSyncMsg(result.error);
} else if (result.total === 0) {
setSyncMsg('No activities on instance');
} else if (result.synced === 0 && !result.fetched) {
} else if (result.synced === 0 && !result.fetched && !result.uploaded) {
setSyncMsg(`Up to date (${result.total} activities)`);
} else {
const parts = [];
if (result.synced > 0) parts.push(`${result.synced} new`);
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
}
setTimeout(() => setSyncMsg(null), 3500);
+22 -1
View File
@@ -13,7 +13,8 @@ export default function SettingsScreen() {
const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token');
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
const storedSyncUpload = useSetting('sync_upload') === 'true';
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle);
@@ -176,6 +177,7 @@ export default function SettingsScreen() {
)}
<Section title="Sync">
<Text style={styles.subLabel}>Download</Text>
<View style={styles.modeRow}>
<ModeButton
label="Summaries only"
@@ -193,6 +195,24 @@ export default function SettingsScreen() {
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
</Text>
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
<View style={styles.modeRow}>
<ModeButton
label="Off"
active={!storedSyncUpload}
onPress={() => setSetting(db, 'sync_upload', 'false')}
/>
<ModeButton
label="Upload local activities"
active={storedSyncUpload}
onPress={() => setSetting(db, 'sync_upload', 'true')}
/>
</View>
<Text style={styles.hint}>
{storedSyncUpload
? 'Local activities are uploaded to the instance during sync.'
: 'Local activities stay on device only.'}
</Text>
</Section>
<Section title="Data">
@@ -314,6 +334,7 @@ const styles = StyleSheet.create({
disconnectText: { color: '#71717a', fontSize: 14 },
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
modeButtonActive: { backgroundColor: '#1e3a5f', borderColor: '#2563eb' },
+67 -3
View File
@@ -1,7 +1,14 @@
import * as FileSystem from 'expo-file-system/legacy';
import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
export type SyncResult = { synced: number; total: number; fetched?: number; error?: string };
export type SyncResult = {
synced: number;
total: number;
fetched?: number;
uploaded?: number;
error?: string;
};
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
@@ -50,8 +57,15 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
if (changed) synced++;
}
// Upload local activities to the server if enabled
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
let uploaded = 0;
if (uploadEnabled) {
uploaded = await uploadLocalActivities(db, instanceUrl, token);
}
if (syncMode !== 'full') {
return { synced, total: activities.length };
return { synced, total: activities.length, uploaded: uploaded || undefined };
}
// Full mode: fetch geojson + timeseries for any activity missing them
@@ -89,7 +103,57 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
}
}
return { synced, total: activities.length, fetched };
return { synced, total: activities.length, fetched, uploaded: uploaded || undefined };
}
async function uploadLocalActivities(
db: SQLiteDatabase,
instanceUrl: string,
token: string,
): Promise<number> {
const rows = db.getAllSync<{ id: string; original_path: string | null; timeseries_json: string | null; geojson: string | null }>(
`SELECT id, original_path, timeseries_json, geojson
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
);
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
let uploaded = 0;
const now = Math.floor(Date.now() / 1000);
for (const row of rows) {
try {
let activity: object | null = null;
if (row.original_path) {
const text = await FileSystem.readAsStringAsync(row.original_path);
activity = JSON.parse(text);
}
if (!activity) continue;
const body: Record<string, unknown> = { activity };
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
if (row.geojson) body.geojson = JSON.parse(row.geojson);
const resp = await fetch(`${instanceUrl}/api/upload/bas`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (resp.ok) {
await db.runAsync(
`UPDATE activities SET synced_at = ? WHERE id = ?`,
[now, row.id],
);
uploaded++;
}
} catch {
// skip failed activities, continue with others
}
}
return uploaded;
}
type RemoteSummary = {