diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 8d8f474..048fe35 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -636,6 +636,7 @@ async def upload_raw_activity( body = await request.json() filename_hint: str = body.get("filename") or "activity.fit" b64: str = body.get("base64") or "" + user_title: Optional[str] = body.get("user_title") or None if not b64: raise HTTPException(400, "Missing base64 field") @@ -691,6 +692,15 @@ async def upload_raw_activity( _upsert_index_summary(user_dir, act_id, detail, geojson) + if user_title: + import yaml as _yaml + edits_dir = user_dir / "edits" + edits_dir.mkdir(parents=True, exist_ok=True) + (edits_dir / f"{act_id}.md").write_text( + f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n", + encoding="utf-8", + ) + except Exception as exc: log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc) raise HTTPException(422, f"Could not extract activity: {exc}") from exc diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx index edf091b..7b3aeb9 100644 --- a/mobile/app/activity/[id].tsx +++ b/mobile/app/activity/[id].tsx @@ -2,10 +2,10 @@ import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-nati import * as FileSystem from 'expo-file-system'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; -import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'; import { useSQLiteContext } from 'expo-sqlite'; -import { deleteActivity, useActivity, useSetting } from '@/db/queries'; +import { deleteActivity, setActivityTitle, useActivity, useSetting } from '@/db/queries'; import { useTheme } from '@/ThemeContext'; const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; @@ -38,6 +38,8 @@ export default function ActivityScreen() { const [timeseries, setTimeseries] = useState(null); const [loadingMap, setLoadingMap] = useState(false); const [loadingChart, setLoadingChart] = useState(false); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(''); async function confirmDelete() { Alert.alert( @@ -103,6 +105,9 @@ export default function ActivityScreen() { } const detail = JSON.parse(row.detail_json); + const edits = row.edits_json ? JSON.parse(row.edits_json) : {}; + const displayTitle = edits.title ?? detail.title; + const canEdit = row.origin === 'local'; const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null; const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null; const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null; @@ -126,7 +131,30 @@ export default function ActivityScreen() { {detail.sport ?? 'Activity'} - {detail.title} + {editingTitle ? ( + { + const trimmed = e.nativeEvent.text.trim(); + if (trimmed && trimmed !== displayTitle) { + setActivityTitle(db, id, trimmed); + } + setEditingTitle(false); + }} + /> + ) : ( + { setTitleDraft(displayTitle); setEditingTitle(true); } : undefined} + style={styles.titleRow} + > + {displayTitle} + {canEdit && ✎} + + )} {date} {/* Map */} @@ -468,7 +496,10 @@ const styles = StyleSheet.create({ deleteButton: { paddingHorizontal: 16 }, deleteText: { color: '#f87171', fontSize: 15 }, sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 }, - title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 }, + titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 4 }, + title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', flexShrink: 1 }, + titleInput: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: '#3b82f6' }, + editHint: { color: '#52525b', fontSize: 16, marginLeft: 8 }, date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 }, mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' }, map: { flex: 1 }, diff --git a/mobile/components/ActivityCard.tsx b/mobile/components/ActivityCard.tsx index 703b8b5..6206d1f 100644 --- a/mobile/components/ActivityCard.tsx +++ b/mobile/components/ActivityCard.tsx @@ -52,7 +52,7 @@ export function ActivityCard({ } - {activity.title} + {activity.user_title ?? activity.title} {km && } {elev != null && } diff --git a/mobile/db/index.ts b/mobile/db/index.ts index 4736666..97e022b 100644 --- a/mobile/db/index.ts +++ b/mobile/db/index.ts @@ -35,4 +35,13 @@ export async function migrateDb(db: SQLiteDatabase): Promise { } 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. + } } diff --git a/mobile/db/queries.ts b/mobile/db/queries.ts index 33ae589..f698ec8 100644 --- a/mobile/db/queries.ts +++ b/mobile/db/queries.ts @@ -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(` 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, + id: string, + title: string, +): Promise { + await db.runAsync( + `UPDATE activities + SET edits_json = json_set(COALESCE(edits_json, '{}'), '$.title', ?) + WHERE id = ?`, + [title, id], + ); +} + export async function deleteActivities( db: ReturnType, ids: string[], diff --git a/mobile/db/sync.ts b/mobile/db/sync.ts index ab183dd..8e1b81c 100644 --- a/mobile/db/sync.ts +++ b/mobile/db/sync.ts @@ -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 = { 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);