feat: Phase 0.5 — remote feed sync via Bearer token auth
Server (bincio/serve/server.py): - Add _require_auth: accepts session cookie OR Authorization: Bearer token - POST /api/auth/token: same as /api/auth/login but returns token in body (password used once, not stored; mobile stores only the session token) - GET /api/feed: auth-gated; reads _merged/index.json for the user and returns the activities array as JSON Mobile: - db/sync.ts: syncFeed(db) fetches /api/feed, upserts each summary into local SQLite as origin='remote'; skips locally-imported activities - db/queries.ts: add upsertRemoteActivity (INSERT ... ON CONFLICT DO UPDATE WHERE origin='remote' — never overwrites local imports); fix feed sort order to started_at DESC instead of insertion order - settings.tsx: Connect section — password field (not persisted) + Connect button calls POST /api/auth/token and stores token; Disconnect clears it - index.tsx: ↓ Sync button + pull-to-refresh both trigger syncFeed; cloud badge on remote activities; empty state updated
This commit is contained in:
+93
-43
@@ -1,43 +1,78 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
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);
|
||||
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🚴</Text>
|
||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Go to Import to add a FIT, GPX, or TCX file.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const doSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncFeed(db);
|
||||
setSyncing(false);
|
||||
if (result.error) {
|
||||
setSyncMsg(result.error);
|
||||
} else if (result.synced === 0) {
|
||||
setSyncMsg('Already up to date');
|
||||
} else {
|
||||
setSyncMsg(`${result.synced} ${result.synced === 1 ? 'activity' : 'activities'} synced`);
|
||||
}
|
||||
setTimeout(() => setSyncMsg(null), 3500);
|
||||
}, [db]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.header}>Feed</Text>
|
||||
<FlatList
|
||||
data={activities}
|
||||
keyExtractor={(a) => a.id}
|
||||
renderItem={({ item }) => <ActivityCard activity={item} />}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
<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 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',
|
||||
});
|
||||
@@ -51,14 +86,15 @@ function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.cardDate}>{date}</Text>
|
||||
{!activity.synced_at && activity.origin === 'local' && (
|
||||
<Text style={styles.unsyncedBadge}>local</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} />}
|
||||
{km && <Stat label="km" value={km} />}
|
||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -83,33 +119,47 @@ function sportIcon(sport: string): string {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
header: {
|
||||
color: '#fff', fontSize: 22, fontWeight: '700',
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
list: { padding: 16, gap: 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 },
|
||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||
sportIcon: { fontSize: 20 },
|
||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
cardDate: { color: '#71717a', fontSize: 12 },
|
||||
unsyncedBadge: {
|
||||
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 },
|
||||
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, backgroundColor: '#09090b',
|
||||
alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||
},
|
||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||
});
|
||||
|
||||
+120
-24
@@ -1,7 +1,7 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Platform, Pressable, ScrollView, StyleSheet,
|
||||
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
|
||||
Text, TextInput, View,
|
||||
} from 'react-native';
|
||||
import { getSetting, setSetting, useSetting } from '@/db/queries';
|
||||
@@ -9,14 +9,19 @@ import { getSetting, setSetting, useSetting } from '@/db/queries';
|
||||
export default function SettingsScreen() {
|
||||
const db = useSQLiteContext();
|
||||
|
||||
const storedUrl = useSetting('instance_url') ?? '';
|
||||
const storedUrl = useSetting('instance_url') ?? '';
|
||||
const storedHandle = useSetting('handle') ?? '';
|
||||
const storedPath = useSetting('auto_import_path') ?? '';
|
||||
const storedPath = useSetting('auto_import_path') ?? '';
|
||||
const storedToken = useSetting('api_token');
|
||||
|
||||
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
||||
const [handle, setHandle] = useState(storedHandle);
|
||||
const [autoPath, setAutoPath] = useState(storedPath);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [handle, setHandle] = useState(storedHandle);
|
||||
const [autoPath, setAutoPath] = useState(storedPath);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
async function save() {
|
||||
await setSetting(db, 'instance_url', instanceUrl.trim());
|
||||
@@ -28,11 +33,51 @@ export default function SettingsScreen() {
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const url = instanceUrl.trim().replace(/\/$/, '');
|
||||
const h = handle.trim();
|
||||
if (!url || !h || !password) {
|
||||
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
|
||||
return;
|
||||
}
|
||||
setConnecting(true);
|
||||
setConnectMsg(null);
|
||||
try {
|
||||
const resp = await fetch(`${url}/api/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handle: h, password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
await setSetting(db, 'instance_url', url);
|
||||
await setSetting(db, 'handle', h);
|
||||
await setSetting(db, 'api_token', data.token);
|
||||
setPassword('');
|
||||
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
|
||||
} catch {
|
||||
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await setSetting(db, 'api_token', '');
|
||||
setConnectMsg(null);
|
||||
}
|
||||
|
||||
const isConnected = !!storedToken;
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.header}>Settings</Text>
|
||||
|
||||
<Section title="Instance (optional)">
|
||||
<Section title="Instance">
|
||||
<Field
|
||||
label="Instance URL"
|
||||
placeholder="https://bincio.org"
|
||||
@@ -49,8 +94,53 @@ export default function SettingsScreen() {
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Leave blank to use the app without a remote instance. When set, you can
|
||||
push activities to the instance and pull the web feed.
|
||||
Connect to a Bincio instance to sync your activities. Leave blank to use
|
||||
the app offline only.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Pressable style={styles.saveButton} onPress={save}>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{saved ? '✓ Saved' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Section title="Connection">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
|
||||
<Pressable style={styles.disconnectButton} onPress={disconnect}>
|
||||
<Text style={styles.disconnectText}>Disconnect</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
autoCapitalize="none"
|
||||
secureTextEntry
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
||||
onPress={connecting ? undefined : connect}
|
||||
>
|
||||
{connecting
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: <Text style={styles.connectText}>Connect</Text>}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
{connectMsg && (
|
||||
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
|
||||
{connectMsg.text}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.hint}>
|
||||
Your password is used once to obtain a session token, then forgotten.
|
||||
The token is stored locally and sent with each sync request.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -70,16 +160,9 @@ export default function SettingsScreen() {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Pressable style={styles.saveButton} onPress={save}>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{saved ? '✓ Saved' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Section title="About">
|
||||
<Row label="Version" value="0.1.0 (Phase 0)" />
|
||||
<Row label="Schema" value="BAS 1.0" />
|
||||
<Row label="Extraction" value="Pyodide (Phase 1)" />
|
||||
<Row label="Version" value="0.1.0 (Phase 0.5)" />
|
||||
<Row label="Schema" value="BAS 1.0" />
|
||||
</Section>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -129,9 +212,9 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
|
||||
section: { marginBottom: 28 },
|
||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
|
||||
section: { marginBottom: 28 },
|
||||
sectionTitle: {
|
||||
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
|
||||
letterSpacing: 0.8, marginBottom: 8,
|
||||
@@ -140,10 +223,10 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#18181b', borderRadius: 10,
|
||||
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
|
||||
},
|
||||
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
|
||||
input: { color: '#f4f4f5', fontSize: 15 },
|
||||
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
|
||||
input: { color: '#f4f4f5', fontSize: 15 },
|
||||
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
|
||||
row: {
|
||||
flexDirection: 'row', justifyContent: 'space-between',
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
@@ -156,4 +239,17 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
||||
},
|
||||
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
connectButton: {
|
||||
backgroundColor: '#059669', borderRadius: 8, margin: 12,
|
||||
paddingVertical: 12, alignItems: 'center',
|
||||
},
|
||||
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
disconnectButton: {
|
||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
||||
},
|
||||
disconnectText: { color: '#71717a', fontSize: 14 },
|
||||
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||
});
|
||||
|
||||
+19
-1
@@ -52,7 +52,7 @@ export function useActivities(): ActivitySummary[] {
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
@@ -85,6 +85,24 @@ export async function insertActivity(
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertRemoteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
detailJson: string,
|
||||
): Promise<boolean> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
|
||||
VALUES (?, ?, ?, 'remote', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
detail_json = excluded.detail_json,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE origin = 'remote'`,
|
||||
[id, id, detailJson, now],
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSetting(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { getSetting, upsertRemoteActivity } from './queries';
|
||||
|
||||
export type SyncResult = { synced: number; error?: string };
|
||||
|
||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||
const token = await getSetting(db, 'api_token');
|
||||
|
||||
if (!instanceUrl || !token) {
|
||||
return { synced: 0, error: 'No instance configured — add one in Settings.' };
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
return { synced: 0, error: 'Could not reach instance — check your connection.' };
|
||||
}
|
||||
|
||||
if (resp.status === 401) {
|
||||
return { synced: 0, error: 'Session expired — reconnect in Settings.' };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { synced: 0, error: `Server error (${resp.status})` };
|
||||
}
|
||||
|
||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
||||
const activities = data.activities ?? [];
|
||||
|
||||
let synced = 0;
|
||||
for (const a of activities) {
|
||||
const detailJson = JSON.stringify({
|
||||
id: a.id,
|
||||
title: a.title ?? a.id,
|
||||
sport: a.sport ?? null,
|
||||
started_at: a.started_at ?? null,
|
||||
distance_m: a.distance_m ?? null,
|
||||
moving_time_s: a.moving_time_s ?? null,
|
||||
elevation_gain_m: a.elevation_gain_m ?? null,
|
||||
avg_speed_kmh: a.avg_speed_kmh ?? null,
|
||||
avg_hr_bpm: a.avg_hr_bpm ?? null,
|
||||
avg_power_w: a.avg_power_w ?? null,
|
||||
});
|
||||
const changed = await upsertRemoteActivity(db, a.id, detailJson);
|
||||
if (changed) synced++;
|
||||
}
|
||||
|
||||
return { synced };
|
||||
}
|
||||
|
||||
type RemoteSummary = {
|
||||
id: string;
|
||||
title?: string;
|
||||
sport?: string;
|
||||
started_at?: string;
|
||||
distance_m?: number | null;
|
||||
moving_time_s?: number | null;
|
||||
elevation_gain_m?: number | null;
|
||||
avg_speed_kmh?: number | null;
|
||||
avg_hr_bpm?: number | null;
|
||||
avg_power_w?: number | null;
|
||||
};
|
||||
Generated
+9641
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,14 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts",
|
||||
"expo-env.d.ts"
|
||||
".expo/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user