feat(mobile): editable activity title for local activities
Adds edits_json column (migration v3) to store user overrides separately from detail_json so Option A server re-extraction never clobbers them. - Tap the title in the detail screen to edit (local activities only, shown with a ✎ hint). Saves on keyboard dismiss via onEndEditing. - Cards and search display user_title ?? title. - Raw upload: user_title sent to server -> sidecar written so web UI shows the correct title (server re-extracts from FIT, which has Karoo's title). - BAS upload: detail.title overridden before sending, no sidecar needed.
This commit is contained in:
@@ -35,4 +35,13 @@ export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
|
||||
+18
-11
@@ -13,11 +13,13 @@ export type ActivityRow = {
|
||||
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;
|
||||
@@ -34,20 +36,11 @@ const PAGE_SIZE = 50;
|
||||
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const rows = db.getAllSync<{
|
||||
id: string;
|
||||
origin: 'local' | 'remote';
|
||||
synced_at: number | null;
|
||||
title: string;
|
||||
sport: string;
|
||||
started_at: string;
|
||||
distance_m: number | null;
|
||||
duration_s: number | null;
|
||||
elevation_gain_m: number | null;
|
||||
}>(`
|
||||
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,
|
||||
@@ -93,6 +86,7 @@ export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE)
|
||||
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,
|
||||
@@ -194,6 +188,19 @@ export async function deleteActivity(
|
||||
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[],
|
||||
|
||||
+8
-2
@@ -165,8 +165,9 @@ async function uploadLocalActivities(
|
||||
timeseries_json: string | null;
|
||||
geojson: string | null;
|
||||
original_path: string | null;
|
||||
edits_json: string | null;
|
||||
}>(
|
||||
`SELECT id, detail_json, timeseries_json, geojson, original_path
|
||||
`SELECT id, detail_json, timeseries_json, geojson, original_path, edits_json
|
||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
|
||||
@@ -189,6 +190,10 @@ async function uploadLocalActivities(
|
||||
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!, {
|
||||
@@ -197,10 +202,11 @@ async function uploadLocalActivities(
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ filename, base64 }),
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user