init: extract mobile app from bincio_activity
Expo/React Native app with local SQLite archive and bincio-activity sync.
This commit is contained in:
+47
@@ -0,0 +1,47 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
|
||||
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
||||
await db.execAsync('PRAGMA journal_mode = WAL;');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_hash TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
timeseries_json TEXT,
|
||||
geojson TEXT,
|
||||
original_path TEXT,
|
||||
synced_at INTEGER,
|
||||
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
||||
ON activities(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration v2: source_path stores the original filesystem path a file was
|
||||
// imported from (e.g. /sdcard/Karoo/Rides/ride.fit), used for watch-folder
|
||||
// deduplication without re-hashing files.
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE activities ADD COLUMN source_path TEXT');
|
||||
await db.execAsync(
|
||||
'CREATE INDEX IF NOT EXISTS idx_activities_source_path ON activities(source_path)',
|
||||
);
|
||||
} catch {
|
||||
// Column already exists — migration already ran, ignore.
|
||||
}
|
||||
|
||||
// Migration v3: edits_json stores user overrides (e.g. {"title": "My title"})
|
||||
// kept separate from detail_json so server re-extraction (Option A) never
|
||||
// clobbers user edits.
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE activities ADD COLUMN edits_json TEXT');
|
||||
} catch {
|
||||
// Column already exists — migration already ran, ignore.
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ActivityRow = {
|
||||
id: string;
|
||||
source_hash: string;
|
||||
detail_json: string;
|
||||
timeseries_json: string | null;
|
||||
geojson: string | null;
|
||||
original_path: string | null;
|
||||
source_path: string | null;
|
||||
synced_at: number | null;
|
||||
origin: 'local' | 'remote';
|
||||
created_at: number;
|
||||
edits_json: string | null;
|
||||
};
|
||||
|
||||
export type ActivitySummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
user_title: string | null; // from edits_json; takes display priority over title
|
||||
sport: string;
|
||||
started_at: string;
|
||||
distance_m: number | null;
|
||||
duration_s: number | null;
|
||||
elevation_gain_m: number | null;
|
||||
origin: 'local' | 'remote';
|
||||
synced_at: number | null;
|
||||
};
|
||||
|
||||
// ── Activities ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const rows = db.getAllSync<ActivitySummary>(`
|
||||
SELECT
|
||||
id, origin, synced_at,
|
||||
json_extract(detail_json, '$.title') AS title,
|
||||
json_extract(edits_json, '$.title') AS user_title,
|
||||
json_extract(detail_json, '$.sport') AS sport,
|
||||
json_extract(detail_json, '$.started_at') AS started_at,
|
||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)
|
||||
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
||||
LIMIT ?
|
||||
`, [searchQuery, like, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useActivityCount(searchQuery = ''): number {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const row = db.getFirstSync<{ n: number }>(
|
||||
`SELECT COUNT(*) as n FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)`,
|
||||
[searchQuery, like],
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export { PAGE_SIZE };
|
||||
|
||||
export type ActivityFilter = {
|
||||
sport: string; // '' = all sports
|
||||
dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison
|
||||
dateTo: string; // '' = no upper bound
|
||||
sort: 'date' | 'distance' | 'elevation';
|
||||
};
|
||||
|
||||
const SORT_SQL: Record<string, string> = {
|
||||
date: "json_extract(detail_json, '$.started_at') DESC",
|
||||
distance: "json_extract(detail_json, '$.distance_m') DESC",
|
||||
elevation: "json_extract(detail_json, '$.elevation_gain_m') DESC",
|
||||
};
|
||||
|
||||
export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const order = SORT_SQL[filter.sort] ?? SORT_SQL.date;
|
||||
return db.getAllSync<ActivitySummary>(`
|
||||
SELECT
|
||||
id, origin, synced_at,
|
||||
json_extract(detail_json, '$.title') AS title,
|
||||
json_extract(edits_json, '$.title') AS user_title,
|
||||
json_extract(detail_json, '$.sport') AS sport,
|
||||
json_extract(detail_json, '$.started_at') AS started_at,
|
||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
||||
ORDER BY ${order}
|
||||
LIMIT ?
|
||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo, limit]);
|
||||
}
|
||||
|
||||
export function useFilteredCount(filter: ActivityFilter): number {
|
||||
const db = useSQLiteContext();
|
||||
const row = db.getFirstSync<{ n: number }>(`
|
||||
SELECT COUNT(*) as n FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo]);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export function useActivityYears(): string[] {
|
||||
const db = useSQLiteContext();
|
||||
const rows = db.getAllSync<{ year: string }>(
|
||||
`SELECT DISTINCT substr(json_extract(detail_json, '$.started_at'), 1, 4) AS year
|
||||
FROM activities
|
||||
WHERE json_extract(detail_json, '$.started_at') IS NOT NULL
|
||||
ORDER BY year DESC`,
|
||||
);
|
||||
return rows.map(r => r.year).filter(Boolean);
|
||||
}
|
||||
|
||||
export function useActivity(id: string): ActivityRow | null {
|
||||
const db = useSQLiteContext();
|
||||
return db.getFirstSync<ActivityRow>(
|
||||
'SELECT * FROM activities WHERE id = ?',
|
||||
[id],
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export async function insertActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>
|
||||
& { source_path?: string | null },
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`INSERT OR IGNORE INTO activities
|
||||
(id, source_hash, detail_json, timeseries_json, geojson, original_path, source_path, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.source_hash,
|
||||
row.detail_json,
|
||||
row.timeseries_json ?? null,
|
||||
row.geojson ?? null,
|
||||
row.original_path ?? null,
|
||||
row.source_path ?? null,
|
||||
row.origin,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function isSourcePathImported(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
sourcePath: string,
|
||||
): boolean {
|
||||
const row = db.getFirstSync<{ id: string }>(
|
||||
'SELECT id FROM activities WHERE source_path = ?',
|
||||
[sourcePath],
|
||||
);
|
||||
return row != null;
|
||||
}
|
||||
|
||||
export async function upsertRemoteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
detailJson: string,
|
||||
): Promise<boolean> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
|
||||
VALUES (?, ?, ?, 'remote', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
detail_json = excluded.detail_json,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE origin = 'remote'`,
|
||||
[id, id, detailJson, now],
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function deleteRemoteActivities(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
): Promise<number> {
|
||||
const result = await db.runAsync(`DELETE FROM activities WHERE origin = 'remote'`);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function deleteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
): Promise<string | null> {
|
||||
const row = db.getFirstSync<{ original_path: string | null }>(
|
||||
'SELECT original_path FROM activities WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
|
||||
return row?.original_path ?? null;
|
||||
}
|
||||
|
||||
export async function setActivityTitle(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`UPDATE activities
|
||||
SET edits_json = json_set(COALESCE(edits_json, '{}'), '$.title', ?)
|
||||
WHERE id = ?`,
|
||||
[title, id],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteActivities(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
ids: string[],
|
||||
): Promise<Array<string | null>> {
|
||||
if (ids.length === 0) return [];
|
||||
const rows = db.getAllSync<{ original_path: string | null }>(
|
||||
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
|
||||
return rows.map(r => r.original_path ?? null);
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSetting(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setSetting(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[key, value],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSetting(key: string): string | null {
|
||||
const db = useSQLiteContext();
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
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;
|
||||
uploaded?: number;
|
||||
failed?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
||||
return { instanceUrl, token };
|
||||
}
|
||||
|
||||
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
||||
}
|
||||
|
||||
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
||||
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
||||
|
||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
||||
const activities = data.activities ?? [];
|
||||
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
||||
|
||||
let synced = 0;
|
||||
for (const a of activities) {
|
||||
const detailJson = JSON.stringify({
|
||||
id: a.id,
|
||||
title: a.title ?? a.id,
|
||||
sport: a.sport ?? null,
|
||||
started_at: a.started_at ?? null,
|
||||
distance_m: a.distance_m ?? null,
|
||||
moving_time_s: a.moving_time_s ?? null,
|
||||
elevation_gain_m: a.elevation_gain_m ?? null,
|
||||
avg_speed_kmh: a.avg_speed_kmh ?? null,
|
||||
avg_hr_bpm: a.avg_hr_bpm ?? null,
|
||||
avg_power_w: a.avg_power_w ?? null,
|
||||
});
|
||||
const changed = await upsertRemoteActivity(db, a.id, detailJson);
|
||||
if (changed) synced++;
|
||||
}
|
||||
|
||||
if (syncMode !== 'full') return { synced, total: activities.length };
|
||||
|
||||
// Full mode: fetch geojson + timeseries for activities missing them
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
let fetched = 0;
|
||||
for (const a of activities) {
|
||||
const row = db.getFirstSync<{ g: number; t: number }>(
|
||||
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
|
||||
[a.id],
|
||||
);
|
||||
if (row?.g && row?.t) continue;
|
||||
|
||||
let gj: string | null = null;
|
||||
let ts: string | null = null;
|
||||
try {
|
||||
if (!row?.g) {
|
||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
|
||||
if (r.ok) gj = await r.text();
|
||||
}
|
||||
if (!row?.t) {
|
||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
|
||||
if (r.ok) ts = await r.text();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (gj !== null || ts !== null) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities SET
|
||||
geojson = COALESCE(geojson, ?),
|
||||
timeseries_json = COALESCE(timeseries_json, ?)
|
||||
WHERE id = ? AND origin = 'remote'`,
|
||||
[gj, ts, a.id],
|
||||
);
|
||||
fetched++;
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, total: activities.length, fetched };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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> {
|
||||
const dl = await downloadFeed(db);
|
||||
if (dl.error) return dl;
|
||||
|
||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||
let uploaded = 0;
|
||||
if (uploadEnabled) {
|
||||
const ul = await uploadFeed(db);
|
||||
uploaded = ul.uploaded ?? 0;
|
||||
}
|
||||
|
||||
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,
|
||||
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;
|
||||
original_path: string | null;
|
||||
edits_json: string | null;
|
||||
}>(
|
||||
`SELECT id, detail_json, timeseries_json, geojson, original_path, edits_json
|
||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
|
||||
const preferRaw = (await getSetting(db, 'upload_format') ?? 'raw') === 'raw';
|
||||
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 (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
onProgress?.(i + 1, total);
|
||||
try {
|
||||
let resp: Response;
|
||||
|
||||
// When preferRaw is set and the original file is still on disk, send the raw
|
||||
// bytes to /api/upload/raw so the server re-extracts with DEM elevation correction.
|
||||
const useRaw = preferRaw &&
|
||||
row.original_path !== null &&
|
||||
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
||||
|
||||
const userTitle: string | null = row.edits_json
|
||||
? (JSON.parse(row.edits_json).title ?? null)
|
||||
: null;
|
||||
|
||||
if (useRaw) {
|
||||
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
||||
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ filename, base64, ...(userTitle ? { user_title: userTitle } : {}) }),
|
||||
});
|
||||
} else {
|
||||
const detail = JSON.parse(row.detail_json);
|
||||
if (userTitle) detail.title = userTitle;
|
||||
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
||||
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]);
|
||||
// Option A: after a raw upload, update local detail/timeseries/geojson with the
|
||||
// server's DEM-corrected extraction so the app shows better elevation data.
|
||||
if (useRaw) {
|
||||
try {
|
||||
const data = await resp.json() as {
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
source_hash: string;
|
||||
};
|
||||
if (data.id === row.id) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities
|
||||
SET detail_json = ?,
|
||||
timeseries_json = COALESCE(?, timeseries_json),
|
||||
geojson = COALESCE(?, geojson),
|
||||
source_hash = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
JSON.stringify(data.detail),
|
||||
data.timeseries ? JSON.stringify(data.timeseries) : null,
|
||||
data.geojson ? JSON.stringify(data.geojson) : null,
|
||||
data.source_hash,
|
||||
row.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: synced_at is already set, local data stays as-is
|
||||
}
|
||||
}
|
||||
uploaded++;
|
||||
} else {
|
||||
console.warn(`upload ${row.id}: HTTP ${resp.status}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`upload ${row.id}:`, err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, failed };
|
||||
}
|
||||
|
||||
type RemoteSummary = {
|
||||
id: string;
|
||||
title?: string;
|
||||
sport?: string;
|
||||
started_at?: string;
|
||||
distance_m?: number | null;
|
||||
moving_time_s?: number | null;
|
||||
elevation_gain_m?: number | null;
|
||||
avg_speed_kmh?: number | null;
|
||||
avg_hr_bpm?: number | null;
|
||||
avg_power_w?: number | null;
|
||||
};
|
||||
Reference in New Issue
Block a user