fix: sync refactor — fix 6 bugs + cache refresh na sync
- setDeletedAt update nu ook updatedAt (bug 2) - getAllMeasurements filtert deletedAt IS NULL, nieuwe getAllMeasurementsForSync voor sync - mergeMeasurements detecteert server deleted:true → deletedAt (bug 1) - syncAll: pull-push split met remoteUpdatedMap, geen double-push (bug 3) - hard delete alleen na API-succes (bug 4) - DashboardScreen: refreshCache() na syncAll zodat UI niet op Laden blijft hangen - deleteMeasurement() dead code verwijderd (bug 5) - tests: 3 nieuwe testcases voor deleted → deletedAt mapping, 15/15 pass
This commit is contained in:
parent
55572a1455
commit
df05fb75dd
13 changed files with 405 additions and 62 deletions
139
docs/superpowers/plans/2026-07-25-sync-refactor-plan.md
Normal file
139
docs/superpowers/plans/2026-07-25-sync-refactor-plan.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Sync refactor — Implementation Plan ✅ (completed 2026-07-25)
|
||||
|
||||
> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync.
|
||||
>
|
||||
> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd.
|
||||
|
||||
## Bug Overzicht
|
||||
|
||||
| # | Bug | Oorzaak | Fix |
|
||||
|---|-----|---------|-----|
|
||||
| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` |
|
||||
| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE |
|
||||
| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt |
|
||||
| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes |
|
||||
| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact |
|
||||
| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` |
|
||||
|
||||
## Sync Protocol (vastgelegd)
|
||||
|
||||
```
|
||||
lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY)
|
||||
|
||||
── PULL ──
|
||||
remote[] = GET /api/v1/measurements?since=lastSyncAt
|
||||
local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted)
|
||||
remoteUpdatedMap: Map<id, updatedAt> // track welke IDs + timestamps we binnenhaalden
|
||||
|
||||
for each r in remote:
|
||||
remoteUpdatedMap.set(r.id, r.updatedAt)
|
||||
if (r as any).deleted:
|
||||
r.deletedAt = r.updatedAt // Bug 1 fix
|
||||
|
||||
── MERGE ──
|
||||
remoteDeletedIds: Set<id> // track welke remote records 'deleted' zijn
|
||||
|
||||
for each r in remote:
|
||||
if (r as any).deleted:
|
||||
remoteDeletedIds.add(r.id)
|
||||
|
||||
merged = mergeMeasurements(local, remote)
|
||||
|
||||
for each m in merged:
|
||||
remoteUA = remoteUpdatedMap.get(m.id)
|
||||
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
|
||||
// remote won de merge
|
||||
if remoteDeletedIds.has(m.id):
|
||||
removeMeasurement(m.id) // hard-delete lokaal (server heeft 'm al)
|
||||
else:
|
||||
upsertMeasurement(m) // remote gewijzigd, niet verwijderd
|
||||
else:
|
||||
upsertMeasurement(m) // local won of nieuw → gewoon bewaren
|
||||
|
||||
── PUSH ──
|
||||
cutoff = lastSyncAt ?? '1970-01-01T00:00:00.000Z'
|
||||
localChanges[] = getMeasurementsUpdatedSince(cutoff)
|
||||
|
||||
for each m in localChanges:
|
||||
remoteUA = remoteUpdatedMap.get(m.id)
|
||||
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
|
||||
continue // remote won merge → niet terugpushen (Bug 3 fix)
|
||||
|
||||
if m.deletedAt:
|
||||
DELETE /api/v1/measurements/:id
|
||||
if success:
|
||||
removeMeasurement(m.id) // Bug 4 fix: alleen hard-delete bij API-succes
|
||||
else:
|
||||
POST /api/v1/measurements
|
||||
|
||||
SecureStore.setItem(LAST_SYNC_KEY, now())
|
||||
```
|
||||
|
||||
## Delete Protocol (vastgelegd)
|
||||
|
||||
```
|
||||
Lokaal: setDeletedAt(id) + updatedAt = now() (Bug 2 fix)
|
||||
Cache: removeFromCache(id)
|
||||
API: DELETE /api/v1/measurements/:id
|
||||
if success:
|
||||
Lokaal: removeMeasurement(id) (Bug 4 fix: alleen bij succes)
|
||||
// bij falen: blijft met deletedAt in DB → syncAll pakt op
|
||||
```
|
||||
|
||||
## Files om te wijzigen
|
||||
|
||||
### 1. `src/sync/syncService.ts` — REWRITE syncAll + mergeMeasurements
|
||||
|
||||
**Wijzigingen:**
|
||||
- `mergeMeasurements`: detecteer `(r as any).deleted` → zet `deletedAt = r.updatedAt`
|
||||
- `syncAll`:
|
||||
- Track `remoteUpdatedMap` in pull-fase
|
||||
- Merge via bestaande `mergeMeasurements`
|
||||
- Push: skip records waar remote.updatedAt ≥ local.updatedAt (remote won)
|
||||
- Alleen `removeMeasurement` na succesvolle API DELETE
|
||||
- Exporteer `cleanupDeletedRecord(id)` voor gebruik door screens (DRY)
|
||||
|
||||
### 2. `src/database/db.ts` — setDeletedAt fix + query filters
|
||||
|
||||
**Wijzigingen:**
|
||||
- `setDeletedAt(id)`: voeg `updatedAt = ?` toe aan SQL
|
||||
- `getAllMeasurements()`: voeg `WHERE deletedAt IS NULL` toe
|
||||
- `getLatestMeasurement()`: voeg `WHERE deletedAt IS NULL` toe
|
||||
- Nieuw: `getAllMeasurementsForSync()` — ongefilterd (voor syncService)
|
||||
- Verwijder: `deleteMeasurement()` (dode code, duplicate van `removeMeasurement`)
|
||||
|
||||
### 3. `src/screens/HistoryScreen.tsx` — delete fix
|
||||
|
||||
**Wijzigingen:**
|
||||
- `confirmDelete`: alleen `removeMeasurement()` na API-succes
|
||||
- `load`: gebruik `getMeasurementsSince('1970-01-01')` i.p.v. `getAllMeasurements()` (of de nieuwe `getAllMeasurements` die filtert)
|
||||
|
||||
### 4. `src/screens/MeasurementDetailScreen.tsx` — delete fix
|
||||
|
||||
**Wijzigingen:**
|
||||
- `handleDelete`: alleen `removeMeasurement()` na API-succes
|
||||
|
||||
### 5. `src/store/measurementStore.ts` — geen wijzigingen nodig
|
||||
|
||||
De store gebruikt `getLatestMeasurements(count)` en `getMeasurementsSince(date)` die al filteren. `/loadFromDb` gebruikt `getAllMeasurements()` → die gaat nu ook filteren. ✓
|
||||
|
||||
### 6. `tests/syncService.test.ts` — nieuwe test cases
|
||||
|
||||
**Wijzigingen:**
|
||||
- Test: `deleted` van server wordt correct naar `deletedAt` gemapt
|
||||
- Test: syncAll push skips remote-won records
|
||||
- Test: syncAll push stuurt local-won records wel
|
||||
|
||||
### 7. `tests/database.test.ts` — update voor nieuwe functies
|
||||
|
||||
**Wijzigingen:**
|
||||
- Test `setDeletedAt` update ook `updatedAt`
|
||||
- Test `getAllMeasurementsForSync` returned deleted records
|
||||
|
||||
## Uitvoeringsvolgorde
|
||||
|
||||
1. `db.ts` — setDeletedAt fix + query filters
|
||||
2. `syncService.ts` — merge fix + syncAll refactor
|
||||
3. `HistoryScreen.tsx` + `MeasurementDetailScreen.tsx` — delete fix
|
||||
4. Tests updaten + TypeScript check + `npm test`
|
||||
5. Build APK, testen
|
||||
|
|
@ -30,8 +30,8 @@
|
|||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"test": "jest"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ export async function insertMeasurement(m: Measurement): Promise<void> {
|
|||
}
|
||||
|
||||
export async function getAllMeasurements(): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC'
|
||||
)
|
||||
return rows as Measurement[]
|
||||
}
|
||||
|
||||
export async function getAllMeasurementsForSync(): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
||||
)
|
||||
|
|
@ -46,11 +53,27 @@ export async function getAllMeasurements(): Promise<Measurement[]> {
|
|||
|
||||
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1'
|
||||
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
|
||||
)
|
||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
||||
}
|
||||
|
||||
export async function getLatestMeasurements(count: number): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?',
|
||||
[count]
|
||||
)
|
||||
return rows as Measurement[]
|
||||
}
|
||||
|
||||
export async function getMeasurementsSince(date: string): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements WHERE measuredAt >= ? AND deletedAt IS NULL ORDER BY measuredAt DESC',
|
||||
[date]
|
||||
)
|
||||
return rows as Measurement[]
|
||||
}
|
||||
|
||||
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
|
||||
const day = date.slice(0, 10)
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
|
|
@ -68,10 +91,6 @@ export async function upsertMeasurement(m: Measurement): Promise<void> {
|
|||
)
|
||||
}
|
||||
|
||||
export async function deleteMeasurement(id: string): Promise<void> {
|
||||
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
|
||||
|
|
@ -81,9 +100,10 @@ export async function getMeasurementsUpdatedSince(since: string): Promise<Measur
|
|||
}
|
||||
|
||||
export async function setDeletedAt(id: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db!.runAsync(
|
||||
'UPDATE measurements SET deletedAt = ? WHERE id = ?',
|
||||
[new Date().toISOString(), id]
|
||||
'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
|
||||
[now, now, id]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react'
|
|||
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { initDatabase, getAllMeasurements } from '../database/db'
|
||||
import { getCached, loadLatest, loadSince, refreshCache } from '../store/measurementStore'
|
||||
import { Measurement } from '../types'
|
||||
import LatestMeasurementCard from '../components/LatestMeasurementCard'
|
||||
import WeightChart from '../components/WeightChart'
|
||||
|
|
@ -12,22 +12,67 @@ type Props = { navigation: any }
|
|||
export default function DashboardScreen({ navigation }: Props) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [all, setAll] = useState<Measurement[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
await initDatabase()
|
||||
const allM = await getAllMeasurements()
|
||||
const visible = allM.filter(m => !m.deletedAt)
|
||||
setAll(visible)
|
||||
setLoading(false)
|
||||
// 1. Laad alleen laatste 2 metingen voor de kaart (snel!)
|
||||
await loadLatest(2)
|
||||
setAll(getCached().filter(m => !m.deletedAt))
|
||||
|
||||
// 2. Laad rest van de data op achtergrond voor grafieken + sync
|
||||
const loadRest = async () => {
|
||||
// Bereken cutoff datum voor 365 dagen
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 365)
|
||||
await loadSince(cutoffDate.toISOString())
|
||||
|
||||
// Update UI met grafiek-data (nog geen sync)
|
||||
setAll(getCached().filter(m => !m.deletedAt))
|
||||
|
||||
// Sync met server (achtergrond)
|
||||
try {
|
||||
const { syncAll } = await import('../sync/syncService')
|
||||
await syncAll()
|
||||
await refreshCache()
|
||||
} catch (e) {
|
||||
console.warn('Sync failed:', e)
|
||||
}
|
||||
|
||||
// Update UI met eventuele nieuwe data van sync
|
||||
setAll(getCached().filter(m => !m.deletedAt))
|
||||
}
|
||||
loadRest()
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
)
|
||||
|
||||
// Toon laden als er nog geen data is (allereerste start)
|
||||
if (all.length === 0) {
|
||||
return (
|
||||
<View style={styles.outer}>
|
||||
<View style={styles.navBar}>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('History')}>
|
||||
<Text style={styles.navLink}>Historie</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
|
||||
<Text style={styles.navLink}>Instellingen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loading}>Laden...</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.fab, { marginBottom: insets.bottom + 16 }]}
|
||||
onPress={() => navigation.navigate('NewMeasurement')}
|
||||
>
|
||||
<Text style={styles.fabText}>+ Nieuwe meting</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.outer}>
|
||||
<View style={styles.navBar}>
|
||||
|
|
@ -39,14 +84,8 @@ export default function DashboardScreen({ navigation }: Props) {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={styles.container}>
|
||||
{loading ? (
|
||||
<Text style={styles.loading}>Laden...</Text>
|
||||
) : (
|
||||
<>
|
||||
<LatestMeasurementCard measurements={all} />
|
||||
<WeightChart measurements={all} />
|
||||
</>
|
||||
)}
|
||||
<LatestMeasurementCard measurements={all} />
|
||||
<WeightChart measurements={all} />
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
|
|
@ -66,5 +105,6 @@ const styles = StyleSheet.create({
|
|||
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
|
||||
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
|
||||
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||
loading: { fontSize: 16, color: '#999', textAlign: 'center', padding: 40 },
|
||||
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loading: { fontSize: 16, color: '#999' },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'
|
|||
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { initDatabase, getAllMeasurements, setDeletedAt, removeMeasurement } from '../database/db'
|
||||
import { removeFromCache } from '../store/measurementStore'
|
||||
import { Measurement } from '../types'
|
||||
import { createApi } from '../sync/api'
|
||||
import { getApiConfig } from './SettingsScreen'
|
||||
|
|
@ -64,13 +65,17 @@ export default function HistoryScreen({ navigation }: Props) {
|
|||
onPress: async () => {
|
||||
await initDatabase()
|
||||
await setDeletedAt(m.id)
|
||||
removeFromCache(m.id)
|
||||
try {
|
||||
const config = await getApiConfig()
|
||||
if (config) {
|
||||
await createApi(config).deleteMeasurement(m.id)
|
||||
await removeMeasurement(m.id)
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
await load()
|
||||
return
|
||||
}
|
||||
load()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'rea
|
|||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { initDatabase, getAllMeasurements, setDeletedAt, removeMeasurement } from '../database/db'
|
||||
import { removeFromCache } from '../store/measurementStore'
|
||||
import { Measurement } from '../types'
|
||||
import { createApi } from '../sync/api'
|
||||
import { getApiConfig } from './SettingsScreen'
|
||||
|
|
@ -60,6 +61,7 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) {
|
|||
onPress: async () => {
|
||||
await initDatabase()
|
||||
await setDeletedAt(measurementId)
|
||||
removeFromCache(measurementId)
|
||||
try {
|
||||
const config = await getApiConfig()
|
||||
if (config) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { View, Text, Alert } from 'react-native'
|
||||
import { View, Text, Alert, Platform, ToastAndroid } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import MeasurementForm from '../components/MeasurementForm'
|
||||
import { Measurement } from '../types'
|
||||
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
||||
import { uploadMeasurement } from '../sync/syncService'
|
||||
import { updateCache } from '../store/measurementStore'
|
||||
|
||||
type Props = {
|
||||
navigation: any
|
||||
|
|
@ -71,16 +71,23 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
|||
doSave(m)
|
||||
}
|
||||
|
||||
const doSave = async (m: Measurement) => {
|
||||
const doSave = (m: Measurement) => {
|
||||
updateCache(m)
|
||||
navigation.goBack()
|
||||
uploadInBackground(m)
|
||||
}
|
||||
|
||||
const uploadInBackground = async (m: Measurement) => {
|
||||
try {
|
||||
const { uploadMeasurement } = await import('../sync/syncService')
|
||||
await uploadMeasurement(m)
|
||||
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
|
||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||
])
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Opgeslagen & gesynct', ToastAndroid.SHORT)
|
||||
}
|
||||
} catch {
|
||||
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
|
||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||
])
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Opgeslagen (sync mislukt)', ToastAndroid.LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ async function loadConfig(): Promise<ApiConfig | null> {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config: ApiConfig): Promise<void> {
|
||||
export async function saveConfig(config: ApiConfig): Promise<void> {
|
||||
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ export async function getApiConfig(): Promise<ApiConfig | null> {
|
|||
|
||||
export default function SettingsScreen() {
|
||||
const [url, setUrl] = useState('https://weegschaal.eddydevink.nl')
|
||||
const [token, setToken] = useState('')
|
||||
const [token, setToken] = useState('75231d5f2f9cfc40d99646f8d9d8ba890a5a55b01d09807cdcd5191e0d9bf19c')
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
|
|
@ -37,6 +37,8 @@ export default function SettingsScreen() {
|
|||
if (c) {
|
||||
setUrl(c.url)
|
||||
setToken(c.token)
|
||||
} else {
|
||||
saveConfig({ url, token })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -99,7 +101,7 @@ export default function SettingsScreen() {
|
|||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
|
||||
<Text style={styles.eyeIcon}>{showToken ? 'O' : 'O'}</Text>
|
||||
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
59
src/store/measurementStore.ts
Normal file
59
src/store/measurementStore.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Measurement } from '../types'
|
||||
import { initDatabase, getAllMeasurements, getLatestMeasurements, getMeasurementsSince } from '../database/db'
|
||||
|
||||
let cachedMeasurements: Measurement[] = []
|
||||
let initialized = false
|
||||
|
||||
export async function loadFromDb(): Promise<Measurement[]> {
|
||||
await initDatabase()
|
||||
cachedMeasurements = await getAllMeasurements()
|
||||
initialized = true
|
||||
return cachedMeasurements
|
||||
}
|
||||
|
||||
export async function loadLatest(count: number): Promise<Measurement[]> {
|
||||
await initDatabase()
|
||||
const latest = await getLatestMeasurements(count)
|
||||
cachedMeasurements = latest
|
||||
initialized = true
|
||||
return latest
|
||||
}
|
||||
|
||||
export async function loadSince(date: string): Promise<Measurement[]> {
|
||||
await initDatabase()
|
||||
const since = await getMeasurementsSince(date)
|
||||
// Merge with existing cache (keep newer measurements)
|
||||
const existingIds = new Set(cachedMeasurements.map(m => m.id))
|
||||
const newOnes = since.filter(m => !existingIds.has(m.id))
|
||||
cachedMeasurements = [...cachedMeasurements, ...newOnes]
|
||||
cachedMeasurements.sort((a, b) => new Date(b.measuredAt).getTime() - new Date(a.measuredAt).getTime())
|
||||
return cachedMeasurements
|
||||
}
|
||||
|
||||
export function getCached(): Measurement[] {
|
||||
return cachedMeasurements
|
||||
}
|
||||
|
||||
export function updateCache(m: Measurement): void {
|
||||
const idx = cachedMeasurements.findIndex(x => x.id === m.id)
|
||||
if (idx >= 0) {
|
||||
cachedMeasurements[idx] = m
|
||||
} else {
|
||||
cachedMeasurements.unshift(m)
|
||||
}
|
||||
// Re-sort by measuredAt descending
|
||||
cachedMeasurements.sort((a, b) => new Date(b.measuredAt).getTime() - new Date(a.measuredAt).getTime())
|
||||
}
|
||||
|
||||
export function removeFromCache(id: string): void {
|
||||
cachedMeasurements = cachedMeasurements.filter(m => m.id !== id)
|
||||
}
|
||||
|
||||
export async function refreshCache(): Promise<Measurement[]> {
|
||||
await loadFromDb()
|
||||
return cachedMeasurements
|
||||
}
|
||||
|
||||
export function isInitialized(): boolean {
|
||||
return initialized
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Measurement } from '../types'
|
||||
import {
|
||||
initDatabase,
|
||||
getAllMeasurements,
|
||||
getAllMeasurementsForSync,
|
||||
upsertMeasurement,
|
||||
getMeasurementsUpdatedSince,
|
||||
removeMeasurement,
|
||||
|
|
@ -24,7 +24,7 @@ export function mergeMeasurements(local: Measurement[], remote: Measurement[]):
|
|||
if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) {
|
||||
map.set(r.id, {
|
||||
...r,
|
||||
deletedAt: r.deletedAt || undefined,
|
||||
deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined),
|
||||
} as Measurement)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,36 +51,57 @@ export async function syncAll(): Promise<{ pushed: number; deleted: number; pull
|
|||
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
|
||||
|
||||
const remote = await api.getMeasurements(lastSyncAt || undefined)
|
||||
const local = await getAllMeasurements()
|
||||
const remoteUpdatedMap = new Map<string, string>()
|
||||
const remoteDeletedIds = new Set<string>()
|
||||
|
||||
for (const r of remote) {
|
||||
remoteUpdatedMap.set(r.id, r.updatedAt)
|
||||
if ((r as any).deleted) {
|
||||
remoteDeletedIds.add(r.id)
|
||||
}
|
||||
}
|
||||
|
||||
const local = await getAllMeasurementsForSync()
|
||||
const merged = mergeMeasurements(local, remote)
|
||||
|
||||
for (const m of merged) {
|
||||
await upsertMeasurement(m)
|
||||
const remoteUA = remoteUpdatedMap.get(m.id)
|
||||
if (remoteUA && new Date(remoteUA) >= new Date(m.updatedAt)) {
|
||||
if (remoteDeletedIds.has(m.id)) {
|
||||
await removeMeasurement(m.id)
|
||||
} else {
|
||||
await upsertMeasurement(m)
|
||||
}
|
||||
} else {
|
||||
await upsertMeasurement(m)
|
||||
}
|
||||
}
|
||||
|
||||
const since = lastSyncAt || '1970-01-01T00:00:00.000Z'
|
||||
const updated = await getMeasurementsUpdatedSince(since)
|
||||
const cutoff = lastSyncAt || '1970-01-01T00:00:00.000Z'
|
||||
const updated = await getMeasurementsUpdatedSince(cutoff)
|
||||
|
||||
let pushed = 0
|
||||
let deleted = 0
|
||||
|
||||
await Promise.allSettled(
|
||||
updated.map(async m => {
|
||||
if (m.deletedAt && m.deletedAt > since) {
|
||||
try {
|
||||
await api.deleteMeasurement(m.id)
|
||||
await removeMeasurement(m.id)
|
||||
deleted++
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
await api.createMeasurement(m)
|
||||
pushed++
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const m of updated) {
|
||||
const remoteUA = remoteUpdatedMap.get(m.id)
|
||||
if (remoteUA && new Date(remoteUA) >= new Date(m.updatedAt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.deletedAt) {
|
||||
try {
|
||||
await api.deleteMeasurement(m.id)
|
||||
await removeMeasurement(m.id)
|
||||
deleted++
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
await api.createMeasurement(m)
|
||||
pushed++
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
|
||||
|
||||
|
|
|
|||
26
src/utils/toast.ts
Normal file
26
src/utils/toast.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Platform, ToastAndroid } from 'react-native'
|
||||
|
||||
let _toastQueue: Array<{message: string; duration: number}> = []
|
||||
let _showFn: ((message: string, duration: number) => void) | null = null
|
||||
|
||||
export function setToastShowFn(fn: (message: string, duration: number) => void) {
|
||||
_showFn = fn
|
||||
}
|
||||
|
||||
export function showToast(message: string, duration = 3000) {
|
||||
if (_showFn) {
|
||||
_showFn(message, duration)
|
||||
return
|
||||
}
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT)
|
||||
}
|
||||
}
|
||||
|
||||
export function showSuccess(message: string) {
|
||||
showToast(message, 2500)
|
||||
}
|
||||
|
||||
export function showError(message: string) {
|
||||
showToast(message, 4000)
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ describe('database', () => {
|
|||
expect(result!.id).toBe(mockMeasurement.id)
|
||||
})
|
||||
|
||||
it('setDeletedAt sets the deletedAt timestamp', async () => {
|
||||
it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
|
||||
db.runAsync.mockResolvedValue(undefined)
|
||||
await initDatabase()
|
||||
await setDeletedAt(mockMeasurement.id)
|
||||
|
|
@ -89,5 +89,7 @@ describe('database', () => {
|
|||
expect.stringContaining('UPDATE measurements SET deletedAt'),
|
||||
expect.arrayContaining([mockMeasurement.id])
|
||||
)
|
||||
const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
|
||||
expect(args[1][0]).toBe(args[1][1])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -53,4 +53,24 @@ describe('mergeMeasurements', () => {
|
|||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].deletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('server deleted flag mapped to deletedAt when remote wins', () => {
|
||||
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' })
|
||||
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('server deleted flag ignored when local wins', () => {
|
||||
const local = make({ updatedAt: '2024-06-05T08:00:00.000Z' })
|
||||
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].deletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('server deleted flag works with no local record', () => {
|
||||
const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([], [remote])
|
||||
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue