feat: outbox-pattern sync + progressive dashboard loading

- Sync herschreven: push-vóór-pull, outbox met syncStatus (pending/pending-delete/synced), local-first merge
- Dashboard: progressieve loading — laatste 2 metingen direct, grafieken volgen daarna
- measurementStore versimpeld naar refreshCache/getCached/getVersion (20 regels)
- db.ts: getPendingRecords(), updateSyncStatus(), migratie local→synced, syncStatus DEFAULT 'pending'
- WeightChart: filtert 0-waarden uit vetmassa/spiermassa, verbergt chart bij <2 datapunten
- HistoryScreen/MeasurementDetailScreen: delete flow naar DB-only (setDeletedAt + updateSyncStatus)
- eas.json: appVersionSource toegevoegd tegen waarschuwing
- README bijgewerkt met outbox-pattern documentatie
- 16/16 tests, TypeScript clean
This commit is contained in:
eddy 2026-07-25 17:27:54 +02:00
commit a77b31f95c
13 changed files with 257 additions and 213 deletions

View file

@ -2,7 +2,7 @@
_*Gemaakt met vibecoding — DeepSeek V4 Pro*_ _*Gemaakt met vibecoding — DeepSeek V4 Pro*_
React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met timestamp-based delta-sync. React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met outbox-pattern sync.
## Inhoud ## Inhoud
@ -20,7 +20,7 @@ React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen
## Functionaliteit ## Functionaliteit
- **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling - **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling
- **Dashboard** met laatste en voorlaatste meting, gewichtsverschil, en grafiek - **Dashboard** met laatste en voorlaatste meting (direct zichtbaar via progressieve loading), gewichtsverschil, en grafieken (gewicht, vetmassa, spiermassa)
- **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc. - **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc.
- **Historie** van alle metingen, gesorteerd op datum - **Historie** van alle metingen, gesorteerd op datum
- **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd - **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd
@ -30,7 +30,7 @@ React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen
| Scherm | Beschrijving | | Scherm | Beschrijving |
|--------|-------------| |--------|-------------|
| Dashboard | Laatste meting, voorlaatste meting met gewichtsverschil, grafiek, deel-knop | | Dashboard | Progressive loading: laatste 2 metingen direct zichtbaar, grafieken volgen daarna |
| Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting | | Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting |
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen | | Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
| Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail | | Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail |
@ -42,23 +42,19 @@ De app synchroniseert via een zelfgebouwde REST API. Geen WebDAV, geen bestandss
### Hoe het werkt ### Hoe het werkt
1. **Delta ophalen**: `GET /api/v1/measurements?since=lastSyncAt` — alleen gewijzigde records van de server 1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server)
2. **Mergen**: last-writer-wins op `updatedAt` 2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald
3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` → POST naar API 3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd
4. **Verwijderen**: soft delete via `deletedAt` timestamp → DELETE naar API bij volgende sync 4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase
5. **High-water mark**: `lastSyncAt` bijgewerkt na elke succesvolle sync 5. **Cursor tracking**: `lastSyncAt` (SecureStore) voor delta-pull, alleen records sinds laatste sync
### Waarom timestamp-based? ### Waarom outbox pattern?
Geen `syncStatus` enum, geen speciale gevallen. Hetzelfde patroon dat CouchDB en PouchDB gebruiken: Betrouwbaarder dan timestamp-only push: `syncStatus` markeert expliciet wat gepusht moet worden, onafhankelijk van klokverschillen. Push vóór pull voorkomt dat eigen wijzigingen overschreven worden door oudere remote data. Local-first merge garandeert dat de gebruiker nooit data verliest.
- `updatedAt` op elke wijziging (client-tijd)
- `deletedAt` voor soft deletes
- `lastSyncAt` (opgeslagen in SecureStore) voor delta-tracking
- Merge: last-writer-wins, zonder uitzonderingen
### Offline resilience ### Offline resilience
Bij netwerkfout tijdens opslaan blijft de meting lokaal staan. De volgende sync (`updatedAt > lastSyncAt`) pikt hem automatisch op. Geen handmatige tussenkomst nodig. Bij netwerkfout tijdens opslaan blijft de meting lokaal staan met `syncStatus='pending'`. De outbox (`getPendingRecords()`) pikt hem automatisch op bij de volgende sync of dashboard-focus.
## Server-side API ## Server-side API
@ -141,11 +137,10 @@ Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
## Database migratie ## Database migratie
Bestaande SQLite-databases worden automatisch gemigreerd. `initDatabase()` voegt de `deletedAt` kolom toe via `ALTER TABLE` als deze nog niet bestaat: Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
```sql - `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat
ALTER TABLE measurements ADD COLUMN deletedAt TEXT - `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'`
```
## Testen ## Testen

View file

@ -1,6 +1,7 @@
{ {
"cli": { "cli": {
"version": ">= 5.9.1" "version": ">= 5.9.1",
"appVersionSource": "local"
}, },
"build": { "build": {
"development": { "development": {

View file

@ -78,6 +78,9 @@ function prepareLabels(dataPoints: Measurement[]) {
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) { function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
const labels = prepareLabels(dataPoints) const labels = prepareLabels(dataPoints)
const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0)
const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0)
return ( return (
<View style={styles.page}> <View style={styles.page}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str
color={COLORS.weight} color={COLORS.weight}
unit=" kg" unit=" kg"
/> />
<SingleChart {fatMassPoints.length >= 2 && (
title="Vetmassa" <SingleChart
data={dataPoints.map(m => m.fatMass)} title="Vetmassa"
labels={labels} data={fatMassPoints.map(m => m.fatMass)}
color={COLORS.fatMass} labels={prepareLabels(fatMassPoints)}
unit=" kg" color={COLORS.fatMass}
/> unit=" kg"
<SingleChart />
title="Spiermassa" )}
data={dataPoints.map(m => m.muscleMass)} {muscleMassPoints.length >= 2 && (
labels={labels} <SingleChart
color={COLORS.muscleMass} title="Spiermassa"
unit=" kg" data={muscleMassPoints.map(m => m.muscleMass)}
/> labels={prepareLabels(muscleMassPoints)}
color={COLORS.muscleMass}
unit=" kg"
/>
)}
</View> </View>
) )
} }

View file

@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite'
import { Measurement } from '../types' import { Measurement } from '../types'
let db: SQLite.SQLiteDatabase | null = null let db: SQLite.SQLiteDatabase | null = null
let dbReady = false
export async function initDatabase(): Promise<void> { export async function initDatabase(): Promise<void> {
if (db) return if (db) return
@ -20,13 +21,21 @@ export async function initDatabase(): Promise<void> {
measuredAt TEXT NOT NULL, measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'local', syncStatus TEXT NOT NULL DEFAULT 'pending',
deletedAt TEXT deletedAt TEXT
); );
`) `)
try { try {
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT') await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
} catch {} } catch {}
// Migrate existing records to crossbreed syncStatus model
if (!dbReady) {
try {
await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'")
} catch {}
dbReady = true
}
} }
export async function insertMeasurement(m: Measurement): Promise<void> { export async function insertMeasurement(m: Measurement): Promise<void> {
@ -110,3 +119,14 @@ export async function setDeletedAt(id: string): Promise<void> {
export async function removeMeasurement(id: string): Promise<void> { export async function removeMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
} }
export async function updateSyncStatus(id: string, status: string): Promise<void> {
await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id])
}
export async function getPendingRecords(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
)
return rows as Measurement[]
}

View file

@ -1,8 +1,9 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { getCached, loadLatest, loadSince, refreshCache } from '../store/measurementStore' import { getCached, getVersion, refreshCache } from '../store/measurementStore'
import { initDatabase, getLatestMeasurements } from '../database/db'
import { Measurement } from '../types' import { Measurement } from '../types'
import LatestMeasurementCard from '../components/LatestMeasurementCard' import LatestMeasurementCard from '../components/LatestMeasurementCard'
import WeightChart from '../components/WeightChart' import WeightChart from '../components/WeightChart'
@ -11,45 +12,51 @@ type Props = { navigation: any }
export default function DashboardScreen({ navigation }: Props) { export default function DashboardScreen({ navigation }: Props) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
const [all, setAll] = useState<Measurement[]>([]) const [all, setAll] = useState<Measurement[]>([])
const [chartKey, setChartKey] = useState(0)
const seenVersion = useRef(0)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true
const load = async () => { const load = async () => {
// 1. Laad alleen laatste 2 metingen voor de kaart (snel!) await initDatabase()
await loadLatest(2)
setAll(getCached())
// 2. Laad rest van de data op achtergrond voor grafieken + sync const two = await getLatestMeasurements(2)
const loadRest = async () => { if (!active) return
// Bereken cutoff datum voor 365 dagen setLatestTwo(two)
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - 365)
await loadSince(cutoffDate.toISOString())
// Update UI met grafiek-data (nog geen sync) await refreshCache()
setAll(getCached()) if (!active) return
const v = getVersion()
// Sync met server (achtergrond) if (v !== seenVersion.current) {
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()) setAll(getCached())
setChartKey(v)
seenVersion.current = v
}
try {
const { syncAll } = await import('../sync/syncService')
const result = await syncAll()
if (!active) return
if (result.deleted > 0 || result.pulled > 0) {
await refreshCache()
const v2 = getVersion()
setAll(getCached())
setChartKey(v2)
seenVersion.current = v2
}
} catch {
console.warn('Sync failed')
} }
loadRest()
} }
load() load()
return () => { active = false }
}, []) }, [])
) )
// Toon laden als er nog geen data is (allereerste start) if (all.length === 0 && latestTwo.length === 0) {
if (all.length === 0) {
return ( return (
<View style={styles.outer}> <View style={styles.outer}>
<View style={styles.navBar}> <View style={styles.navBar}>
@ -73,6 +80,8 @@ export default function DashboardScreen({ navigation }: Props) {
) )
} }
const display = all.length > 0 ? all : latestTwo
return ( return (
<View style={styles.outer}> <View style={styles.outer}>
<View style={styles.navBar}> <View style={styles.navBar}>
@ -84,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView style={styles.container}> <ScrollView style={styles.container}>
<LatestMeasurementCard measurements={all} /> <LatestMeasurementCard measurements={display} />
<WeightChart measurements={all} /> {all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
</ScrollView> </ScrollView>
<TouchableOpacity <TouchableOpacity

View file

@ -1,11 +1,9 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native' import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native'
import { initDatabase, getAllMeasurements, setDeletedAt, removeMeasurement } from '../database/db' import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
import { removeFromCache } from '../store/measurementStore' import { refreshCache } from '../store/measurementStore'
import { Measurement } from '../types' import { Measurement } from '../types'
import { createApi } from '../sync/api'
import { getApiConfig } from './SettingsScreen'
import MeasurementRow from '../components/MeasurementRow' import MeasurementRow from '../components/MeasurementRow'
type Props = { navigation: any } type Props = { navigation: any }
@ -65,17 +63,9 @@ export default function HistoryScreen({ navigation }: Props) {
onPress: async () => { onPress: async () => {
await initDatabase() await initDatabase()
await setDeletedAt(m.id) await setDeletedAt(m.id)
removeFromCache(m.id) await updateSyncStatus(m.id, 'pending-delete')
try { await refreshCache()
const config = await getApiConfig() load()
if (config) {
await createApi(config).deleteMeasurement(m.id)
await removeMeasurement(m.id)
}
load()
} catch {
load()
}
}, },
}, },
] ]

View file

@ -2,11 +2,9 @@ import React, { useCallback, useState } from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native' import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { initDatabase, getAllMeasurements, setDeletedAt, removeMeasurement } from '../database/db' import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
import { removeFromCache } from '../store/measurementStore' import { refreshCache } from '../store/measurementStore'
import { Measurement } from '../types' import { Measurement } from '../types'
import { createApi } from '../sync/api'
import { getApiConfig } from './SettingsScreen'
type Props = { type Props = {
navigation: any navigation: any
@ -61,14 +59,8 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) {
onPress: async () => { onPress: async () => {
await initDatabase() await initDatabase()
await setDeletedAt(measurementId) await setDeletedAt(measurementId)
removeFromCache(measurementId) await updateSyncStatus(measurementId, 'pending-delete')
try { await refreshCache()
const config = await getApiConfig()
if (config) {
await createApi(config).deleteMeasurement(measurementId)
await removeMeasurement(measurementId)
}
} catch {}
navigation.goBack() navigation.goBack()
}, },
}, },

View file

@ -4,7 +4,7 @@ import { useFocusEffect } from '@react-navigation/native'
import MeasurementForm from '../components/MeasurementForm' import MeasurementForm from '../components/MeasurementForm'
import { Measurement } from '../types' import { Measurement } from '../types'
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db' import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
import { updateCache } from '../store/measurementStore' import { refreshCache } from '../store/measurementStore'
import { showSuccess, showError } from '../utils/toast' import { showSuccess, showError } from '../utils/toast'
type Props = { type Props = {
@ -61,7 +61,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
{ text: 'Annuleren', style: 'cancel' }, { text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => { { text: 'Overschrijven', style: 'destructive', onPress: () => {
m.id = existing.id m.id = existing.id
doSave(m) upsertMeasurement(m).then(() => doSave(m))
}}, }},
] ]
) )
@ -69,11 +69,11 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
} }
await insertMeasurement(m) await insertMeasurement(m)
} }
doSave(m) await doSave(m)
} }
const doSave = (m: Measurement) => { const doSave = async (m: Measurement) => {
updateCache(m) await refreshCache()
navigation.goBack() navigation.goBack()
uploadInBackground(m) uploadInBackground(m)
} }

View file

@ -68,7 +68,11 @@ export default function SettingsScreen() {
try { try {
const { syncAll } = await import('../sync/syncService') const { syncAll } = await import('../sync/syncService')
const result = await syncAll() const result = await syncAll()
Alert.alert('Sync voltooid', `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`) let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`
if (result.errors.length > 0) {
msg += `\n\nFouten: ${result.errors.join(', ')}`
}
Alert.alert('Sync voltooid', msg)
} catch (e: any) { } catch (e: any) {
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
} finally { } finally {

View file

@ -1,59 +1,20 @@
import { Measurement } from '../types' import { Measurement } from '../types'
import { initDatabase, getAllMeasurements, getLatestMeasurements, getMeasurementsSince } from '../database/db' import { initDatabase, getAllMeasurements } from '../database/db'
let cachedMeasurements: Measurement[] = [] let cache: Measurement[] = []
let initialized = false let version = 0
export async function loadFromDb(): Promise<Measurement[]> { export async function refreshCache(): Promise<Measurement[]> {
await initDatabase() await initDatabase()
cachedMeasurements = await getAllMeasurements() cache = await getAllMeasurements()
initialized = true version++
return cachedMeasurements return cache
}
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[] { export function getCached(): Measurement[] {
return cachedMeasurements return cache
} }
export function updateCache(m: Measurement): void { export function getVersion(): number {
const idx = cachedMeasurements.findIndex(x => x.id === m.id) return version
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
} }

View file

@ -3,33 +3,57 @@ import {
initDatabase, initDatabase,
getAllMeasurementsForSync, getAllMeasurementsForSync,
upsertMeasurement, upsertMeasurement,
getMeasurementsUpdatedSince, getPendingRecords,
updateSyncStatus,
removeMeasurement, removeMeasurement,
} from '../database/db' } from '../database/db'
import { createApi } from './api' import { createApi, ApiError } from './api'
import { ApiError } from './api'
import * as SecureStore from 'expo-secure-store' import * as SecureStore from 'expo-secure-store'
const LAST_SYNC_KEY = 'weegschaal_last_sync' const LAST_SYNC_KEY = 'weegschaal_last_sync'
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { type SyncResult = {
const map = new Map<string, Measurement>() pushed: number
deleted: number
pulled: number
errors: string[]
}
for (const m of local) { export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
map.set(m.id, m) const localMap = new Map(local.map(m => [m.id, m]))
} const resultMap = new Map(local.map(m => [m.id, m]))
for (const r of remote) { for (const r of remote) {
const existing = map.get(r.id) const localRec = localMap.get(r.id)
if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) { const isDeleted = !!(r as any).deleted
map.set(r.id, { const localPending =
...r, localRec?.syncStatus === 'pending' ||
deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined), localRec?.syncStatus === 'pending-delete'
} as Measurement)
if (isDeleted) {
if (!localRec || localPending) continue
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
resultMap.set(r.id, {
...r,
syncStatus: 'synced',
deletedAt: (r as any).deletedAt || r.updatedAt,
} as Measurement)
}
continue
}
if (localPending) continue
if (localRec) {
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
}
} else {
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
} }
} }
return Array.from(map.values()) return Array.from(resultMap.values())
} }
export async function uploadMeasurement(m: Measurement): Promise<void> { export async function uploadMeasurement(m: Measurement): Promise<void> {
@ -38,9 +62,10 @@ export async function uploadMeasurement(m: Measurement): Promise<void> {
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
const api = createApi(config) const api = createApi(config)
await api.createMeasurement(m) await api.createMeasurement(m)
await updateSyncStatus(m.id, 'synced')
} }
export async function syncAll(): Promise<{ pushed: number; deleted: number; pulled: number }> { export async function syncAll(): Promise<SyncResult> {
const { getApiConfig } = await import('../screens/SettingsScreen') const { getApiConfig } = await import('../screens/SettingsScreen')
const config = await getApiConfig() const config = await getApiConfig()
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
@ -48,62 +73,91 @@ export async function syncAll(): Promise<{ pushed: number; deleted: number; pull
const api = createApi(config) const api = createApi(config)
await initDatabase() await initDatabase()
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) const errors: string[] = []
const remote = await api.getMeasurements(lastSyncAt || undefined)
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) {
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 cutoff = lastSyncAt || '1970-01-01T00:00:00.000Z'
const updated = await getMeasurementsUpdatedSince(cutoff)
let pushed = 0 let pushed = 0
let deleted = 0 let deleted = 0
let pulled = 0
for (const m of updated) { // === PUSH phase — outbox first ===
const remoteUA = remoteUpdatedMap.get(m.id) const pending = await getPendingRecords()
if (remoteUA && new Date(remoteUA) >= new Date(m.updatedAt)) {
continue
}
for (const m of pending) {
if (m.deletedAt) { if (m.deletedAt) {
try { try {
await api.deleteMeasurement(m.id) await api.deleteMeasurement(m.id)
await removeMeasurement(m.id) await removeMeasurement(m.id)
deleted++ deleted++
} catch {} } catch (e: any) {
if (e instanceof ApiError && e.status === 404) {
await removeMeasurement(m.id)
deleted++
} else {
errors.push(e?.message ?? 'Delete failed')
}
}
} else { } else {
try { try {
await api.createMeasurement(m) await api.createMeasurement(m)
await updateSyncStatus(m.id, 'synced')
pushed++ pushed++
} catch {} } catch (e: any) {
errors.push(e?.message ?? 'Create failed')
}
}
}
// === PULL phase — fetch remote changes ===
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
const remote = await api.getMeasurements(lastSyncAt || undefined)
if (remote.length > 0) {
const local = await getAllMeasurementsForSync()
const localMap = new Map(local.map(m => [m.id, m]))
for (const r of remote) {
const localRec = localMap.get(r.id)
const isDeleted = !!(r as any).deleted
if (isDeleted) {
if (localRec) {
if (
localRec.syncStatus === 'pending' ||
localRec.syncStatus === 'pending-delete'
) {
continue
}
await removeMeasurement(r.id)
deleted++
}
continue
}
const localIsPending =
localRec?.syncStatus === 'pending' ||
localRec?.syncStatus === 'pending-delete'
if (localIsPending) {
continue
}
if (localRec) {
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
const record = { ...r, deletedAt: (r as any).deletedAt || undefined } as Measurement
await upsertMeasurement(record)
await updateSyncStatus(r.id, 'synced')
pulled++
}
continue
}
const record = { ...r, deletedAt: (r as any).deletedAt || undefined } as Measurement
await upsertMeasurement(record)
await updateSyncStatus(r.id, 'synced')
pulled++
} }
} }
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
return { pushed, deleted, pulled: remote.length } return { pushed, deleted, pulled, errors }
} }

View file

@ -11,6 +11,7 @@ export type Measurement = {
measuredAt: string measuredAt: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
syncStatus?: string
deletedAt?: string deletedAt?: string
} }

View file

@ -7,7 +7,7 @@ const make = (overrides: Partial<Measurement> = {}): Measurement => ({
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10, id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600, musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z', measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z', ...overrides, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides,
}) })
describe('mergeMeasurements', () => { describe('mergeMeasurements', () => {
@ -40,37 +40,47 @@ describe('mergeMeasurements', () => {
expect(r[0].weight).toBe(77) expect(r[0].weight).toBe(77)
}) })
it('remote deleted wins over older local', () => { it('remote deleted wins over synced local', () => {
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' })
const remote = make({ updatedAt: '2024-06-03T08:00:00.000Z', deletedAt: '2024-06-03T08:00:00.000Z' }) const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
const r = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
}) })
it('local wins over older remote deleted', () => { it('local synced wins over older remote deleted', () => {
const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' }) const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' })
const remote = make({ updatedAt: '2024-06-01T08:00:00.000Z', deletedAt: '2024-06-01T08:00:00.000Z' }) const remote: any = { ...make({ updatedAt: '2024-06-01T08:00:00.000Z' }), deleted: true }
const r = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(r[0].deletedAt).toBeUndefined() expect(r[0].deletedAt).toBeUndefined()
}) })
it('server deleted flag mapped to deletedAt when remote wins', () => { it('local pending-delete wins over remote deleted', () => {
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' })
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
const r = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') expect(r[0].syncStatus).toBe('pending-delete')
expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z')
}) })
it('server deleted flag ignored when local wins', () => { it('local pending wins over remote newer', () => {
const local = make({ updatedAt: '2024-06-05T08:00:00.000Z' }) const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' })
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) }
const r = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(r[0].deletedAt).toBeUndefined() expect(r[0].weight).toBe(75)
expect(r[0].syncStatus).toBe('pending')
}) })
it('server deleted flag works with no local record', () => { it('remote deleted with no local record is skipped', () => {
const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
const r = mergeMeasurements([], [remote]) const r = mergeMeasurements([], [remote])
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') expect(r).toHaveLength(0)
})
it('local without syncStatus treated as synced', () => {
const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' })
delete local.syncStatus
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
const r = mergeMeasurements([local], [remote])
expect(r[0].weight).toBe(76)
}) })
}) })