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:
@@ -636,6 +636,7 @@ async def upload_raw_activity(
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
filename_hint: str = body.get("filename") or "activity.fit"
|
filename_hint: str = body.get("filename") or "activity.fit"
|
||||||
b64: str = body.get("base64") or ""
|
b64: str = body.get("base64") or ""
|
||||||
|
user_title: Optional[str] = body.get("user_title") or None
|
||||||
if not b64:
|
if not b64:
|
||||||
raise HTTPException(400, "Missing base64 field")
|
raise HTTPException(400, "Missing base64 field")
|
||||||
|
|
||||||
@@ -691,6 +692,15 @@ async def upload_raw_activity(
|
|||||||
|
|
||||||
_upsert_index_summary(user_dir, act_id, detail, geojson)
|
_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:
|
except Exception as exc:
|
||||||
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
|
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
|
||||||
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
|
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-nati
|
|||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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 Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { deleteActivity, useActivity, useSetting } from '@/db/queries';
|
import { deleteActivity, setActivityTitle, useActivity, useSetting } from '@/db/queries';
|
||||||
import { useTheme } from '@/ThemeContext';
|
import { useTheme } from '@/ThemeContext';
|
||||||
|
|
||||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
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<Timeseries | null>(null);
|
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
|
||||||
const [loadingMap, setLoadingMap] = useState(false);
|
const [loadingMap, setLoadingMap] = useState(false);
|
||||||
const [loadingChart, setLoadingChart] = useState(false);
|
const [loadingChart, setLoadingChart] = useState(false);
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const [titleDraft, setTitleDraft] = useState('');
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -103,6 +105,9 @@ export default function ActivityScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = JSON.parse(row.detail_json);
|
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 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 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;
|
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
|
||||||
@@ -126,7 +131,30 @@ export default function ActivityScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
||||||
<Text style={styles.title}>{detail.title}</Text>
|
{editingTitle ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.titleInput}
|
||||||
|
value={titleDraft}
|
||||||
|
onChangeText={setTitleDraft}
|
||||||
|
autoFocus
|
||||||
|
returnKeyType="done"
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const trimmed = e.nativeEvent.text.trim();
|
||||||
|
if (trimmed && trimmed !== displayTitle) {
|
||||||
|
setActivityTitle(db, id, trimmed);
|
||||||
|
}
|
||||||
|
setEditingTitle(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={canEdit ? () => { setTitleDraft(displayTitle); setEditingTitle(true); } : undefined}
|
||||||
|
style={styles.titleRow}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{displayTitle}</Text>
|
||||||
|
{canEdit && <Text style={styles.editHint}>✎</Text>}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Text style={styles.date}>{date}</Text>
|
<Text style={styles.date}>{date}</Text>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
@@ -468,7 +496,10 @@ const styles = StyleSheet.create({
|
|||||||
deleteButton: { paddingHorizontal: 16 },
|
deleteButton: { paddingHorizontal: 16 },
|
||||||
deleteText: { color: '#f87171', fontSize: 15 },
|
deleteText: { color: '#f87171', fontSize: 15 },
|
||||||
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
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 },
|
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
||||||
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
|
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
|
||||||
map: { flex: 1 },
|
map: { flex: 1 },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function ActivityCard({
|
|||||||
}
|
}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
|
<Text style={styles.cardTitle} numberOfLines={1}>{activity.user_title ?? activity.title}</Text>
|
||||||
<View style={styles.cardStats}>
|
<View style={styles.cardStats}>
|
||||||
{km && <Stat label="km" value={km} />}
|
{km && <Stat label="km" value={km} />}
|
||||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||||
|
|||||||
@@ -35,4 +35,13 @@ export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
// Column already exists — migration already ran, ignore.
|
// 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;
|
synced_at: number | null;
|
||||||
origin: 'local' | 'remote';
|
origin: 'local' | 'remote';
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
edits_json: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActivitySummary = {
|
export type ActivitySummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
user_title: string | null; // from edits_json; takes display priority over title
|
||||||
sport: string;
|
sport: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
distance_m: number | null;
|
distance_m: number | null;
|
||||||
@@ -34,20 +36,11 @@ const PAGE_SIZE = 50;
|
|||||||
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
||||||
const db = useSQLiteContext();
|
const db = useSQLiteContext();
|
||||||
const like = `%${searchQuery}%`;
|
const like = `%${searchQuery}%`;
|
||||||
const rows = db.getAllSync<{
|
const rows = db.getAllSync<ActivitySummary>(`
|
||||||
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;
|
|
||||||
}>(`
|
|
||||||
SELECT
|
SELECT
|
||||||
id, origin, synced_at,
|
id, origin, synced_at,
|
||||||
json_extract(detail_json, '$.title') AS title,
|
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, '$.sport') AS sport,
|
||||||
json_extract(detail_json, '$.started_at') AS started_at,
|
json_extract(detail_json, '$.started_at') AS started_at,
|
||||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||||
@@ -93,6 +86,7 @@ export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE)
|
|||||||
SELECT
|
SELECT
|
||||||
id, origin, synced_at,
|
id, origin, synced_at,
|
||||||
json_extract(detail_json, '$.title') AS title,
|
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, '$.sport') AS sport,
|
||||||
json_extract(detail_json, '$.started_at') AS started_at,
|
json_extract(detail_json, '$.started_at') AS started_at,
|
||||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||||
@@ -194,6 +188,19 @@ export async function deleteActivity(
|
|||||||
return row?.original_path ?? null;
|
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(
|
export async function deleteActivities(
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
db: ReturnType<typeof useSQLiteContext>,
|
||||||
ids: string[],
|
ids: string[],
|
||||||
|
|||||||
+8
-2
@@ -165,8 +165,9 @@ async function uploadLocalActivities(
|
|||||||
timeseries_json: string | null;
|
timeseries_json: string | null;
|
||||||
geojson: string | null;
|
geojson: string | null;
|
||||||
original_path: 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`,
|
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -189,6 +190,10 @@ async function uploadLocalActivities(
|
|||||||
row.original_path !== null &&
|
row.original_path !== null &&
|
||||||
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
||||||
|
|
||||||
|
const userTitle: string | null = row.edits_json
|
||||||
|
? (JSON.parse(row.edits_json).title ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (useRaw) {
|
if (useRaw) {
|
||||||
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
||||||
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
||||||
@@ -197,10 +202,11 @@ async function uploadLocalActivities(
|
|||||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ filename, base64 }),
|
body: JSON.stringify({ filename, base64, ...(userTitle ? { user_title: userTitle } : {}) }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const detail = JSON.parse(row.detail_json);
|
const detail = JSON.parse(row.detail_json);
|
||||||
|
if (userTitle) detail.title = userTitle;
|
||||||
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
||||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||||
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
||||||
|
|||||||
Reference in New Issue
Block a user