diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 8f1864c..920004c 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -487,6 +487,67 @@ async def get_activity_timeseries( raise HTTPException(404, "Timeseries not found") +def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None: + """Add or update an activity summary in user_dir/index.json. + + Called after writing BAS activity files so that merge_all can include the + activity in year shards. Without this, uploaded activities exist on disk + but never appear in the browser feed. + """ + # Build preview coords from geojson if available ([lat, lng] order) + preview: Optional[list] = None + if geojson: + try: + coords = geojson.get("geometry", {}).get("coordinates", []) + if coords: + step = max(1, len(coords) // 9) + preview = [[c[1], c[0]] for c in coords[::step]][:9] + except Exception: + pass + + has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists() + summary = { + "id": activity_id, + "title": activity.get("title", activity_id), + "sport": activity.get("sport"), + "sub_sport": activity.get("sub_sport"), + "started_at": activity.get("started_at"), + "distance_m": activity.get("distance_m"), + "duration_s": activity.get("duration_s"), + "moving_time_s": activity.get("moving_time_s"), + "elevation_gain_m": activity.get("elevation_gain_m"), + "avg_speed_kmh": activity.get("avg_speed_kmh"), + "max_speed_kmh": activity.get("max_speed_kmh"), + "avg_hr_bpm": activity.get("avg_hr_bpm"), + "max_hr_bpm": activity.get("max_hr_bpm"), + "avg_cadence_rpm": activity.get("avg_cadence_rpm"), + "avg_power_w": activity.get("avg_power_w"), + "mmp": activity.get("mmp"), + "best_efforts": activity.get("best_efforts"), + "best_climb_m": activity.get("best_climb_m"), + "source": activity.get("source"), + "privacy": activity.get("privacy", "public"), + "detail_url": f"activities/{activity_id}.json", + "track_url": f"activities/{activity_id}.geojson" if has_track else None, + "preview_coords": preview, + } + + index_path = user_dir / "index.json" + if index_path.exists(): + index_data = json.loads(index_path.read_text(encoding="utf-8")) + else: + index_data = { + "bas_version": "1.0", + "owner": {"handle": user_dir.name}, + "generated_at": None, + "activities": [], + } + existing = {a["id"]: a for a in index_data.get("activities", [])} + existing[activity_id] = summary + index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True) + index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8") + + @app.post("/api/upload/bas") async def upload_bas_activity( request: Request, @@ -527,10 +588,13 @@ async def upload_bas_activity( if not ts_path.exists(): ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8") - if body.get("geojson"): + geojson_body: Optional[dict] = body.get("geojson") or None + if geojson_body: gj_path = acts_dir / f"{activity_id}.geojson" if not gj_path.exists(): - gj_path.write_text(json.dumps(body["geojson"], ensure_ascii=False), encoding="utf-8") + gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8") + + _upsert_index_summary(user_dir, activity_id, activity, geojson_body) from bincio.render.merge import merge_all, write_combined_feed merge_all(user_dir) @@ -622,6 +686,8 @@ async def upload_raw_activity( if geojson and not (acts_dir / f"{act_id}.geojson").exists(): (acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8") + _upsert_index_summary(user_dir, act_id, detail, geojson) + from bincio.render.merge import merge_all, write_combined_feed merge_all(user_dir) write_combined_feed(_get_data_dir()) diff --git a/docs/mobile-app.md b/docs/mobile-app.md index e7f9797..a6aefe7 100644 --- a/docs/mobile-app.md +++ b/docs/mobile-app.md @@ -695,23 +695,27 @@ Implemented in `mobile/db/sync.ts` → `syncFeed()`. ### Upload (local → server) -Implemented in `mobile/db/sync.ts` → `uploadLocalActivities()`. Enabled when +Implemented in `mobile/db/sync.ts` → `uploadFeed()` / `uploadLocalActivities()`. Enabled when `sync_upload = "true"` in settings, or triggered explicitly via the ↑ Upload button. -1. Query `activities WHERE origin = 'local' AND synced_at IS NULL`. -2. For each: parse `detail_json` from the DB row and construct `{ id: row.id, ...detail }`. -3. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`. -4. On 200 (including `{ status: "duplicate" }`): set `synced_at = unixepoch()`. +1. **Reconcile** against the server: fetch `GET /api/feed` and compare its activity IDs against local rows where `synced_at IS NOT NULL`. Any local activity that is marked as synced but absent from the server (e.g. server was wiped) has its `synced_at` cleared so it re-enters the upload queue. This is best-effort — if the feed fetch fails, upload proceeds with whatever is currently queued. +2. Query `activities WHERE origin = 'local' AND synced_at IS NULL`. +3. For each: parse `detail_json` from the DB row and construct `{ id: row.id, ...detail }`. +4. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`. +5. On 200 (including `{ status: "duplicate" }`): set `synced_at = unixepoch()`. On error: log to console, count as failed, continue with the next activity. -**Note:** `original_path` is not used in upload. An earlier implementation tried to read -`original_path` as a JSON file, but `original_path` stores the path to the original binary -FIT/GPX/TCX file — `JSON.parse()` always throws, silently skipping every activity. The correct -approach is to use the already-extracted `detail_json` stored in SQLite. +The UI shows live progress ("Uploading N / M…") during the batch and reports failures separately ("X uploaded, Y failed"). The server endpoint (`bincio/serve/server.py` → `POST /api/upload/bas`) accepts -pre-extracted BAS JSON rather than raw FIT/GPX/TCX. It deduplicates by checking -if the activity file already exists, writes geojson and timeseries if provided, then -calls `merge_all()` to refresh the server's merged feed. +pre-extracted BAS JSON rather than raw FIT/GPX/TCX. It writes the activity file, +**updates `user_dir/index.json`** with a summary entry (so `merge_all` can include +the activity in year shards and the browser feed), writes geojson and timeseries if +provided, then calls `merge_all()` + `write_combined_feed()`. + +> **Bug that was fixed:** earlier versions of both `/api/upload/bas` and `/api/upload/raw` +> wrote activity files to disk but never updated `user_dir/index.json`. Since `merge_all` +> builds year shards from the index, uploaded activities existed on disk but were invisible +> to the browser feed. Fixed by `_upsert_index_summary()` called before `merge_all()`. ### Conflict handling diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index cb62f50..9985505 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -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]); diff --git a/mobile/db/sync.ts b/mobile/db/sync.ts index c2b174f..f17f7d6 100644 --- a/mobile/db/sync.ts +++ b/mobile/db/sync.ts @@ -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 { return { synced, total: activities.length, fetched }; } -export async function uploadFeed(db: SQLiteDatabase): Promise { +export async function uploadFeed( + db: SQLiteDatabase, + onProgress?: (n: number, total: number) => void, +): Promise { 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 { @@ -118,11 +145,19 @@ export async function syncFeed(db: SQLiteDatabase): Promise { return { ...dl, uploaded: uploaded || undefined }; } +export async function countPendingUploads(db: SQLiteDatabase): Promise { + 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 { + 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 = { 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 = {