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:
+3
-31
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user