fix(mobile): clear technical debt — real SHA-256, feed pagination, search

source_hash: BAS JSON import now computes SHA-256 via crypto.subtle.digest
instead of the '${id}-${length}' stub. No extra package — Hermes supports
Web Crypto API natively.

Feed pagination: useActivities(query, limit) accepts a LIMIT parameter.
The feed screen starts at 50, calls loadMore() via FlatList onEndReached
(threshold 0.3) to increment by 50 each time. useActivityCount(query)
drives the hasMore guard so loadMore is a no-op at the end of the list.

Feed search: compact TextInput below the header filters by title via
SQLite json_extract LIKE. Changing the query resets limit to PAGE_SIZE
so stale paginated results don't linger.

Docs: close the three resolved debt items; keep only the accepted
background-polling limitation as a known gap.
This commit is contained in:
Davide Scaini
2026-04-27 11:53:43 +02:00
parent 93247d510f
commit 7a65ed2078
4 changed files with 68 additions and 40 deletions
+3 -31
View File
@@ -985,41 +985,13 @@ and re-extracted server-side.
This section documents mismatches between what the plan describes and what is This section documents mismatches between what the plan describes and what is
actually implemented, plus features not yet in the plan. actually implemented, plus features not yet in the plan.
### Remaining stubs ### `auto_import_path` only triggers on app open, not in background
**`source_hash` for BAS JSON import is not SHA-256** (`mobile/app/(tabs)/import.tsx`)
BAS JSON import records `source_hash = "${detail.id}-${text.length}"` — a rough
stand-in. FIT/GPX/TCX imports (via Pyodide) correctly compute SHA-256 of the file
bytes. The BAS JSON path still uses the stub; dedup works in practice (activity IDs
are unique) but the hash is not a real content fingerprint.
**`auto_import_path` only triggers on app open, not in background**
The watch-folder scan runs when the Import tab mounts and when the app comes to The watch-folder scan runs when the Import tab mounts and when the app comes to
foreground (`AppState` → `'active'`). There is no true background task that fires foreground (`AppState` → `'active'`). There is no true background task that fires
while the app is closed. Full background polling would require `expo-background-fetch` while the app is closed. Full background polling would require `expo-background-fetch`
but cannot use the Pyodide WebView (a UI component). but cannot use the Pyodide WebView (a UI component). Accepted limitation — the
on-open trigger covers the primary Karoo use case (open app after a ride).
### Missing from the plan entirely
**Feed pagination**
`useActivities()` in `mobile/db/queries.ts` calls `getAllSync` with no `LIMIT`.
This is fine for tens of activities, but will cause noticeable lag at hundreds.
Should add cursor-based pagination or a virtual list with lazy loading.
**Individual activity deletion**
There is no way to delete a single activity, local or remote. "Reset synced data"
nukes all remote activities at once. A long-press or swipe-to-delete gesture on
activity cards is needed, with a server-side `DELETE /api/activity/{id}` endpoint
for remote activities.
**Feed search and filter**
No search bar, no sport filter, no date picker. The feed is a flat reverse-
chronological list. As the feed grows this becomes a usability problem.
**Token expiry and the reconnect flow** **Token expiry and the reconnect flow**
+6 -1
View File
@@ -10,6 +10,11 @@ import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer'; import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
import { useTheme } from '@/ThemeContext'; import { useTheme } from '@/ThemeContext';
async function sha256hex(text: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
const FIT_EXTENSIONS = ['.fit', '.fit.gz']; const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz']; const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS]; const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
@@ -243,7 +248,7 @@ export default function ImportScreen() {
throw new Error('Not a valid BAS activity JSON (missing id or started_at)'); throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
} }
const hash = detail.source_hash ?? `${detail.id}-${text.length}`; const hash = detail.source_hash ?? await sha256hex(text);
const origDir = `${FileSystem.documentDirectory}originals/`; const origDir = `${FileSystem.documentDirectory}originals/`;
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true }); await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
const dest = `${origDir}${detail.id}.json`; const dest = `${origDir}${detail.id}.json`;
+38 -3
View File
@@ -3,8 +3,8 @@ import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries'; import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE, type ActivitySummary } from '@/db/queries';
import { downloadFeed, uploadFeed } from '@/db/sync'; import { downloadFeed, uploadFeed } from '@/db/sync';
import { useTheme } from '@/ThemeContext'; import { useTheme } from '@/ThemeContext';
@@ -12,7 +12,11 @@ export default function FeedScreen() {
const db = useSQLiteContext(); const db = useSQLiteContext();
const theme = useTheme(); const theme = useTheme();
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const activities = useActivities(); const [searchQuery, setSearchQuery] = useState('');
const [limit, setLimit] = useState(PAGE_SIZE);
const activities = useActivities(searchQuery, limit);
const totalCount = useActivityCount(searchQuery);
const hasMore = activities.length < totalCount;
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null); const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -73,6 +77,15 @@ export default function FeedScreen() {
setRefreshKey(k => k + 1); setRefreshKey(k => k + 1);
} }
function handleSearch(q: string) {
setSearchQuery(q);
setLimit(PAGE_SIZE); // reset pagination when search changes
}
function loadMore() {
if (hasMore) setLimit(l => l + PAGE_SIZE);
}
function toggleSelect(id: string) { function toggleSelect(id: string) {
setSelected(prev => { setSelected(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -158,6 +171,20 @@ export default function FeedScreen() {
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text> <Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
)} )}
{!selecting && (
<View style={styles.searchRow}>
<TextInput
style={styles.searchInput}
value={searchQuery}
onChangeText={handleSearch}
placeholder="Search activities…"
placeholderTextColor="#52525b"
returnKeyType="search"
clearButtonMode="while-editing"
/>
</View>
)}
{activities.length === 0 && !busy ? ( {activities.length === 0 && !busy ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text> <Text style={styles.emptyIcon}>🚴</Text>
@@ -181,6 +208,8 @@ export default function FeedScreen() {
/> />
)} )}
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={false} refreshing={false}
@@ -321,6 +350,12 @@ const styles = StyleSheet.create({
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' }, cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 }, msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 }, msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
searchRow: { paddingHorizontal: 16, paddingBottom: 10 },
searchInput: {
backgroundColor: '#18181b', borderWidth: 1, borderColor: '#27272a',
borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8,
color: '#f4f4f5', fontSize: 14,
},
list: { padding: 16, gap: 12, paddingBottom: 80 }, list: { padding: 16, gap: 12, paddingBottom: 80 },
card: { card: {
backgroundColor: '#18181b', borderRadius: 12, backgroundColor: '#18181b', borderRadius: 12,
+20 -4
View File
@@ -29,10 +29,11 @@ export type ActivitySummary = {
// ── Activities ───────────────────────────────────────────────────────────── // ── Activities ─────────────────────────────────────────────────────────────
export function useActivities(): ActivitySummary[] { const PAGE_SIZE = 50;
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
const db = useSQLiteContext(); const db = useSQLiteContext();
// Summaries are derived from the stored detail_json at query time. const like = `%${searchQuery}%`;
// JSON extraction via SQLite's json_extract keeps the table schema simple.
const rows = db.getAllSync<{ const rows = db.getAllSync<{
id: string; id: string;
origin: 'local' | 'remote'; origin: 'local' | 'remote';
@@ -53,11 +54,26 @@ export function useActivities(): ActivitySummary[] {
json_extract(detail_json, '$.duration_s') AS duration_s, json_extract(detail_json, '$.duration_s') AS duration_s,
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
FROM activities FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)
ORDER BY json_extract(detail_json, '$.started_at') DESC ORDER BY json_extract(detail_json, '$.started_at') DESC
`); LIMIT ?
`, [searchQuery, like, limit]);
return rows; 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 function useActivity(id: string): ActivityRow | null { export function useActivity(id: string): ActivityRow | null {
const db = useSQLiteContext(); const db = useSQLiteContext();
return db.getFirstSync<ActivityRow>( return db.getFirstSync<ActivityRow>(