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:
+68
-2
@@ -487,6 +487,67 @@ async def get_activity_timeseries(
|
|||||||
raise HTTPException(404, "Timeseries not found")
|
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")
|
@app.post("/api/upload/bas")
|
||||||
async def upload_bas_activity(
|
async def upload_bas_activity(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -527,10 +588,13 @@ async def upload_bas_activity(
|
|||||||
if not ts_path.exists():
|
if not ts_path.exists():
|
||||||
ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8")
|
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"
|
gj_path = acts_dir / f"{activity_id}.geojson"
|
||||||
if not gj_path.exists():
|
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
|
from bincio.render.merge import merge_all, write_combined_feed
|
||||||
merge_all(user_dir)
|
merge_all(user_dir)
|
||||||
@@ -622,6 +686,8 @@ async def upload_raw_activity(
|
|||||||
if geojson and not (acts_dir / f"{act_id}.geojson").exists():
|
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")
|
(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
|
from bincio.render.merge import merge_all, write_combined_feed
|
||||||
merge_all(user_dir)
|
merge_all(user_dir)
|
||||||
write_combined_feed(_get_data_dir())
|
write_combined_feed(_get_data_dir())
|
||||||
|
|||||||
+16
-12
@@ -695,23 +695,27 @@ Implemented in `mobile/db/sync.ts` → `syncFeed()`.
|
|||||||
|
|
||||||
### Upload (local → server)
|
### 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.
|
`sync_upload = "true"` in settings, or triggered explicitly via the ↑ Upload button.
|
||||||
|
|
||||||
1. Query `activities WHERE origin = 'local' AND synced_at IS NULL`.
|
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. For each: parse `detail_json` from the DB row and construct `{ id: row.id, ...detail }`.
|
2. Query `activities WHERE origin = 'local' AND synced_at IS NULL`.
|
||||||
3. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`.
|
3. For each: parse `detail_json` from the DB row and construct `{ id: row.id, ...detail }`.
|
||||||
4. On 200 (including `{ status: "duplicate" }`): set `synced_at = unixepoch()`.
|
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
|
The UI shows live progress ("Uploading N / M…") during the batch and reports failures separately ("X uploaded, Y failed").
|
||||||
`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 server endpoint (`bincio/serve/server.py` → `POST /api/upload/bas`) accepts
|
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
|
pre-extracted BAS JSON rather than raw FIT/GPX/TCX. It writes the activity file,
|
||||||
if the activity file already exists, writes geojson and timeseries if provided, then
|
**updates `user_dir/index.json`** with a summary entry (so `merge_all` can include
|
||||||
calls `merge_all()` to refresh the server's merged feed.
|
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
|
### Conflict handling
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,19 @@ export default function FeedScreen() {
|
|||||||
const doUpload = useCallback(async () => {
|
const doUpload = useCallback(async () => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
const result = await uploadFeed(db);
|
const result = await uploadFeed(db, (n, total) => {
|
||||||
|
setStatusMsg({ ok: true, text: `Uploading ${n} / ${total}…` });
|
||||||
|
});
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
showMsg(false, result.error);
|
showMsg(false, result.error);
|
||||||
} else if (!result.uploaded) {
|
} else if (!result.uploaded && !result.failed) {
|
||||||
showMsg(true, 'Nothing to upload');
|
showMsg(true, 'Nothing to upload');
|
||||||
} else {
|
} 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]);
|
}, [db]);
|
||||||
|
|
||||||
|
|||||||
+51
-9
@@ -6,6 +6,7 @@ export type SyncResult = {
|
|||||||
total: number;
|
total: number;
|
||||||
fetched?: number;
|
fetched?: number;
|
||||||
uploaded?: number;
|
uploaded?: number;
|
||||||
|
failed?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,13 +96,39 @@ export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|||||||
return { synced, total: activities.length, fetched };
|
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);
|
const creds = await resolveCredentials(db);
|
||||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||||
const { instanceUrl, token } = creds;
|
const { instanceUrl, token } = creds;
|
||||||
|
|
||||||
const uploaded = await uploadLocalActivities(db, instanceUrl, token);
|
// Reconcile local synced_at against what the server actually has.
|
||||||
return { synced: 0, total: 0, uploaded };
|
// 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> {
|
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 };
|
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(
|
async function uploadLocalActivities(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
instanceUrl: string,
|
instanceUrl: string,
|
||||||
token: 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 }>(
|
const rows = db.getAllSync<{ id: string; detail_json: string; timeseries_json: string | null; geojson: string | null }>(
|
||||||
`SELECT id, detail_json, timeseries_json, geojson
|
`SELECT id, detail_json, timeseries_json, geojson
|
||||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
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' };
|
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||||
let uploaded = 0;
|
let uploaded = 0;
|
||||||
|
let failed = 0;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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 {
|
try {
|
||||||
const detail = JSON.parse(row.detail_json);
|
const detail = JSON.parse(row.detail_json);
|
||||||
// /api/upload/bas expects { activity: { id, ...detail }, timeseries?, geojson? }
|
|
||||||
const activity = { id: row.id, ...detail };
|
const activity = { id: row.id, ...detail };
|
||||||
|
|
||||||
const body: Record<string, unknown> = { activity };
|
const body: Record<string, unknown> = { activity };
|
||||||
@@ -151,13 +189,17 @@ async function uploadLocalActivities(
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
||||||
uploaded++;
|
uploaded++;
|
||||||
|
} else {
|
||||||
|
console.warn(`upload/bas ${row.id}: HTTP ${resp.status}`);
|
||||||
|
failed++;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// skip failed activities, continue with others
|
console.warn(`upload/bas ${row.id}:`, err);
|
||||||
|
failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return uploaded;
|
return { uploaded, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteSummary = {
|
type RemoteSummary = {
|
||||||
|
|||||||
Reference in New Issue
Block a user