fix(mobile/upload): activities now appear in browser after upload; reconcile synced_at on fresh server

Three bugs fixed:
- /api/upload/bas and /api/upload/raw never updated user_dir/index.json, so
  merge_all couldn't include uploaded activities in year shards — they existed
  on disk but were invisible to the browser feed. Fixed by _upsert_index_summary()
  called before merge_all().
- Silent catch {} in uploadLocalActivities swallowed all per-activity errors;
  replaced with console.warn so failures are visible in Expo logs.
- After a server wipe, synced_at flags on the device caused "Nothing to upload"
  forever. uploadFeed() now reconciles against GET /api/feed at the start of each
  upload: local activities not found on the server get synced_at cleared.

Also: live upload progress ("Uploading N / M…"), failed count in result message,
onProgress callback on uploadFeed(), countPendingUploads() helper.
This commit is contained in:
Davide Scaini
2026-04-27 11:03:00 +02:00
parent b1cf18a2f0
commit 220efb0d05
4 changed files with 143 additions and 26 deletions
+8 -3
View File
@@ -53,14 +53,19 @@ export default function FeedScreen() {
const doUpload = useCallback(async () => {
setUploading(true);
setStatusMsg(null);
const result = await uploadFeed(db);
const result = await uploadFeed(db, (n, total) => {
setStatusMsg({ ok: true, text: `Uploading ${n} / ${total}` });
});
setUploading(false);
if (result.error) {
showMsg(false, result.error);
} else if (!result.uploaded) {
} else if (!result.uploaded && !result.failed) {
showMsg(true, 'Nothing to upload');
} else {
showMsg(true, `Uploaded ${result.uploaded} activit${result.uploaded === 1 ? 'y' : 'ies'}`);
const parts: string[] = [];
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
if (result.failed) parts.push(`${result.failed} failed`);
showMsg(result.failed ? false : true, parts.join(', '));
}
}, [db]);
+51 -9
View File
@@ -6,6 +6,7 @@ export type SyncResult = {
total: number;
fetched?: number;
uploaded?: number;
failed?: number;
error?: string;
};
@@ -95,13 +96,39 @@ export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
return { synced, total: activities.length, fetched };
}
export async function uploadFeed(db: SQLiteDatabase): Promise<SyncResult> {
export async function uploadFeed(
db: SQLiteDatabase,
onProgress?: (n: number, total: number) => void,
): Promise<SyncResult> {
const creds = await resolveCredentials(db);
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
const { instanceUrl, token } = creds;
const uploaded = await uploadLocalActivities(db, instanceUrl, token);
return { synced: 0, total: 0, uploaded };
// Reconcile local synced_at against what the server actually has.
// If the server was wiped/reset, activities we thought were uploaded need
// re-uploading — clear their synced_at so they re-enter the upload queue.
try {
const feedResp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
if (feedResp.ok) {
const feedData: { activities?: { id: string }[] } = await feedResp.json();
const serverIds = new Set((feedData.activities ?? []).map(a => a.id));
const syncedRows = db.getAllSync<{ id: string }>(
`SELECT id FROM activities WHERE origin = 'local' AND synced_at IS NOT NULL`,
);
for (const row of syncedRows) {
if (!serverIds.has(row.id)) {
await db.runAsync(`UPDATE activities SET synced_at = NULL WHERE id = ?`, [row.id]);
}
}
}
} catch {
// Best-effort — proceed with upload even if reconciliation fails
}
const { uploaded, failed } = await uploadLocalActivities(db, instanceUrl, token, onProgress);
return { synced: 0, total: 0, uploaded, failed: failed || undefined };
}
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
@@ -118,11 +145,19 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
return { ...dl, uploaded: uploaded || undefined };
}
export async function countPendingUploads(db: SQLiteDatabase): Promise<number> {
const row = db.getFirstSync<{ n: number }>(
`SELECT COUNT(*) as n FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
);
return row?.n ?? 0;
}
async function uploadLocalActivities(
db: SQLiteDatabase,
instanceUrl: string,
token: string,
): Promise<number> {
onProgress?: (n: number, total: number) => void,
): Promise<{ uploaded: number; failed: number }> {
const rows = db.getAllSync<{ id: string; detail_json: string; timeseries_json: string | null; geojson: string | null }>(
`SELECT id, detail_json, timeseries_json, geojson
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
@@ -130,12 +165,15 @@ async function uploadLocalActivities(
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
let uploaded = 0;
let failed = 0;
const now = Math.floor(Date.now() / 1000);
const total = rows.length;
for (const row of rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
onProgress?.(i + 1, total);
try {
const detail = JSON.parse(row.detail_json);
// /api/upload/bas expects { activity: { id, ...detail }, timeseries?, geojson? }
const activity = { id: row.id, ...detail };
const body: Record<string, unknown> = { activity };
@@ -151,13 +189,17 @@ async function uploadLocalActivities(
if (resp.ok) {
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
uploaded++;
} else {
console.warn(`upload/bas ${row.id}: HTTP ${resp.status}`);
failed++;
}
} catch {
// skip failed activities, continue with others
} catch (err) {
console.warn(`upload/bas ${row.id}:`, err);
failed++;
}
}
return uploaded;
return { uploaded, failed };
}
type RemoteSummary = {