Files
bincio-activity/mobile/app/(tabs)/index.tsx
T
Davide Scaini 02726034c7 fix: read activity shards in GET /api/feed; improve sync feedback
_merged/index.json is a shard manifest with activities:[] when the user
has >FEED_PAGE_SIZE activities. The endpoint now collects from all
index-{year}.json shard files before returning.

SyncResult gains a `total` field (activities received from server) so the
feed screen can distinguish "server returned nothing" from "all already
stored locally". Messages: "No activities on instance" / "Up to date (N)"
/ "X of N activities synced".
2026-04-24 15:07:52 +02:00

168 lines
5.9 KiB
TypeScript

import { useSQLiteContext } from 'expo-sqlite';
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useActivities, type ActivitySummary } from '@/db/queries';
import { syncFeed } from '@/db/sync';
export default function FeedScreen() {
const db = useSQLiteContext();
const activities = useActivities();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const doSync = useCallback(async () => {
setSyncing(true);
setSyncMsg(null);
const result = await syncFeed(db);
setSyncing(false);
if (result.error) {
setSyncMsg(result.error);
} else if (result.total === 0) {
setSyncMsg('No activities on instance');
} else if (result.synced === 0) {
setSyncMsg(`Up to date (${result.total} activities)`);
} else {
setSyncMsg(`${result.synced} of ${result.total} activities synced`);
}
setTimeout(() => setSyncMsg(null), 3500);
}, [db]);
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.header}>Feed</Text>
<Pressable
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
onPress={syncing ? undefined : doSync}
>
<Text style={styles.syncText}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
</Pressable>
</View>
{syncMsg && (
<Text style={styles.syncMsg}>{syncMsg}</Text>
)}
{activities.length === 0 && !syncing ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text>
<Text style={styles.emptyTitle}>No activities yet</Text>
<Text style={styles.emptyBody}>
Import a file or tap Sync to pull from your instance.
</Text>
</View>
) : (
<FlatList
data={activities}
keyExtractor={(a) => a.id}
renderItem={({ item }) => <ActivityCard activity={item} />}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={doSync}
tintColor="#60a5fa"
/>
}
/>
)}
</View>
);
}
function ActivityCard({ activity }: { activity: ActivitySummary }) {
const router = useRouter();
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
});
return (
<Pressable
style={styles.card}
onPress={() => router.push(`/activity/${activity.id}`)}
>
<View style={styles.cardTop}>
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
<View style={styles.cardMeta}>
<Text style={styles.cardDate}>{date}</Text>
{activity.origin === 'remote'
? <Text style={styles.remoteBadge}>cloud</Text>
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
}
</View>
</View>
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
<View style={styles.cardStats}>
{km && <Stat label="km" value={km} />}
{elev != null && <Stat label="m↑" value={String(elev)} />}
</View>
</Pressable>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<View style={styles.stat}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
function sportIcon(sport: string): string {
const icons: Record<string, string> = {
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
};
return icons[sport] ?? '🏅';
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
},
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
syncButton: {
backgroundColor: '#1e3a5f', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
},
syncButtonDisabled: { opacity: 0.5 },
syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' },
syncMsg: {
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
paddingHorizontal: 16, paddingBottom: 8,
},
list: { padding: 16, gap: 12 },
card: {
backgroundColor: '#18181b', borderRadius: 12,
padding: 16, borderWidth: 1, borderColor: '#27272a',
},
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
sportIcon: { fontSize: 20 },
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
cardDate: { color: '#71717a', fontSize: 12 },
remoteBadge: {
color: '#60a5fa', fontSize: 10, borderWidth: 1,
borderColor: '#1e3a5f', borderRadius: 4, paddingHorizontal: 4,
},
localBadge: {
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
},
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
cardStats: { flexDirection: 'row', gap: 16 },
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
statLabel: { color: '#71717a', fontSize: 12 },
empty: {
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
},
emptyIcon: { fontSize: 48, marginBottom: 16 },
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
});