diff --git a/README.md b/README.md
index 3ef2acb..f5f11e9 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
_*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
@@ -20,7 +20,7 @@ React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen
## Functionaliteit
- **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.
- **Historie** van alle metingen, gesorteerd op datum
- **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 |
|--------|-------------|
-| 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 |
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
| 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
-1. **Delta ophalen**: `GET /api/v1/measurements?since=lastSyncAt` — alleen gewijzigde records van de server
-2. **Mergen**: last-writer-wins op `updatedAt`
-3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` → POST naar API
-4. **Verwijderen**: soft delete via `deletedAt` timestamp → DELETE naar API bij volgende sync
-5. **High-water mark**: `lastSyncAt` bijgewerkt na elke succesvolle sync
+1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server)
+2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald
+3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd
+4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase
+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:
-- `updatedAt` op elke wijziging (client-tijd)
-- `deletedAt` voor soft deletes
-- `lastSyncAt` (opgeslagen in SecureStore) voor delta-tracking
-- Merge: last-writer-wins, zonder uitzonderingen
+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.
### 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
@@ -141,11 +137,10 @@ Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
## 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
-ALTER TABLE measurements ADD COLUMN deletedAt TEXT
-```
+- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat
+- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'`
## Testen
diff --git a/eas.json b/eas.json
index 5d2510d..ec0c56b 100644
--- a/eas.json
+++ b/eas.json
@@ -1,6 +1,7 @@
{
"cli": {
- "version": ">= 5.9.1"
+ "version": ">= 5.9.1",
+ "appVersionSource": "local"
},
"build": {
"development": {
diff --git a/src/components/WeightChart.tsx b/src/components/WeightChart.tsx
index 5d1a183..8ddebff 100644
--- a/src/components/WeightChart.tsx
+++ b/src/components/WeightChart.tsx
@@ -78,6 +78,9 @@ function prepareLabels(dataPoints: Measurement[]) {
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
const labels = prepareLabels(dataPoints)
+ const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0)
+ const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0)
+
return (
{title}
@@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str
color={COLORS.weight}
unit=" kg"
/>
- m.fatMass)}
- labels={labels}
- color={COLORS.fatMass}
- unit=" kg"
- />
- m.muscleMass)}
- labels={labels}
- color={COLORS.muscleMass}
- unit=" kg"
- />
+ {fatMassPoints.length >= 2 && (
+ m.fatMass)}
+ labels={prepareLabels(fatMassPoints)}
+ color={COLORS.fatMass}
+ unit=" kg"
+ />
+ )}
+ {muscleMassPoints.length >= 2 && (
+ m.muscleMass)}
+ labels={prepareLabels(muscleMassPoints)}
+ color={COLORS.muscleMass}
+ unit=" kg"
+ />
+ )}
)
}
diff --git a/src/database/db.ts b/src/database/db.ts
index 5329850..7b1b42b 100644
--- a/src/database/db.ts
+++ b/src/database/db.ts
@@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite'
import { Measurement } from '../types'
let db: SQLite.SQLiteDatabase | null = null
+let dbReady = false
export async function initDatabase(): Promise {
if (db) return
@@ -20,13 +21,21 @@ export async function initDatabase(): Promise {
measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
- syncStatus TEXT NOT NULL DEFAULT 'local',
+ syncStatus TEXT NOT NULL DEFAULT 'pending',
deletedAt TEXT
);
`)
try {
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
} 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 {
@@ -110,3 +119,14 @@ export async function setDeletedAt(id: string): Promise {
export async function removeMeasurement(id: string): Promise {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
}
+
+export async function updateSyncStatus(id: string, status: string): Promise {
+ await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id])
+}
+
+export async function getPendingRecords(): Promise {
+ const rows = await db!.getAllAsync(
+ "SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
+ )
+ return rows as Measurement[]
+}
diff --git a/src/screens/DashboardScreen.tsx b/src/screens/DashboardScreen.tsx
index 116abf3..741440f 100644
--- a/src/screens/DashboardScreen.tsx
+++ b/src/screens/DashboardScreen.tsx
@@ -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 { useFocusEffect } from '@react-navigation/native'
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 LatestMeasurementCard from '../components/LatestMeasurementCard'
import WeightChart from '../components/WeightChart'
@@ -11,45 +12,51 @@ type Props = { navigation: any }
export default function DashboardScreen({ navigation }: Props) {
const insets = useSafeAreaInsets()
+ const [latestTwo, setLatestTwo] = useState([])
const [all, setAll] = useState([])
+ const [chartKey, setChartKey] = useState(0)
+ const seenVersion = useRef(0)
useFocusEffect(
useCallback(() => {
+ let active = true
const load = async () => {
- // 1. Laad alleen laatste 2 metingen voor de kaart (snel!)
- await loadLatest(2)
- setAll(getCached())
+ await initDatabase()
- // 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())
+ const two = await getLatestMeasurements(2)
+ if (!active) return
+ setLatestTwo(two)
- // Update UI met grafiek-data (nog geen sync)
- setAll(getCached())
-
- // 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
+ await refreshCache()
+ if (!active) return
+ const v = getVersion()
+ if (v !== seenVersion.current) {
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()
+ return () => { active = false }
}, [])
)
- // Toon laden als er nog geen data is (allereerste start)
- if (all.length === 0) {
+ if (all.length === 0 && latestTwo.length === 0) {
return (
@@ -73,6 +80,8 @@ export default function DashboardScreen({ navigation }: Props) {
)
}
+ const display = all.length > 0 ? all : latestTwo
+
return (
@@ -84,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
-
-
+
+ {all.length >= 2 && }
{
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)
- }
- load()
- } catch {
- load()
- }
+ await updateSyncStatus(m.id, 'pending-delete')
+ await refreshCache()
+ load()
},
},
]
diff --git a/src/screens/MeasurementDetailScreen.tsx b/src/screens/MeasurementDetailScreen.tsx
index 09eaf56..8b3236f 100644
--- a/src/screens/MeasurementDetailScreen.tsx
+++ b/src/screens/MeasurementDetailScreen.tsx
@@ -2,11 +2,9 @@ import React, { useCallback, useState } from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
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 { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
+import { refreshCache } from '../store/measurementStore'
import { Measurement } from '../types'
-import { createApi } from '../sync/api'
-import { getApiConfig } from './SettingsScreen'
type Props = {
navigation: any
@@ -61,14 +59,8 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) {
onPress: async () => {
await initDatabase()
await setDeletedAt(measurementId)
- removeFromCache(measurementId)
- try {
- const config = await getApiConfig()
- if (config) {
- await createApi(config).deleteMeasurement(measurementId)
- await removeMeasurement(measurementId)
- }
- } catch {}
+ await updateSyncStatus(measurementId, 'pending-delete')
+ await refreshCache()
navigation.goBack()
},
},
diff --git a/src/screens/NewMeasurementScreen.tsx b/src/screens/NewMeasurementScreen.tsx
index 510ec31..1f249fc 100644
--- a/src/screens/NewMeasurementScreen.tsx
+++ b/src/screens/NewMeasurementScreen.tsx
@@ -4,7 +4,7 @@ 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 { updateCache } from '../store/measurementStore'
+import { refreshCache } from '../store/measurementStore'
import { showSuccess, showError } from '../utils/toast'
type Props = {
@@ -61,7 +61,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
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)
}
- doSave(m)
+ await doSave(m)
}
- const doSave = (m: Measurement) => {
- updateCache(m)
+ const doSave = async (m: Measurement) => {
+ await refreshCache()
navigation.goBack()
uploadInBackground(m)
}
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 51f0f6e..4fd283d 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -68,7 +68,11 @@ export default function SettingsScreen() {
try {
const { syncAll } = await import('../sync/syncService')
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) {
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
} finally {
diff --git a/src/store/measurementStore.ts b/src/store/measurementStore.ts
index 4eb3088..c50fd30 100644
--- a/src/store/measurementStore.ts
+++ b/src/store/measurementStore.ts
@@ -1,59 +1,20 @@
import { Measurement } from '../types'
-import { initDatabase, getAllMeasurements, getLatestMeasurements, getMeasurementsSince } from '../database/db'
+import { initDatabase, getAllMeasurements } from '../database/db'
-let cachedMeasurements: Measurement[] = []
-let initialized = false
+let cache: Measurement[] = []
+let version = 0
-export async function loadFromDb(): Promise {
+export async function refreshCache(): Promise {
await initDatabase()
- cachedMeasurements = await getAllMeasurements()
- initialized = true
- return cachedMeasurements
-}
-
-export async function loadLatest(count: number): Promise {
- await initDatabase()
- const latest = await getLatestMeasurements(count)
- cachedMeasurements = latest
- initialized = true
- return latest
-}
-
-export async function loadSince(date: string): Promise {
- 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
+ cache = await getAllMeasurements()
+ version++
+ return cache
}
export function getCached(): Measurement[] {
- return cachedMeasurements
+ return cache
}
-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 {
- await loadFromDb()
- return cachedMeasurements
-}
-
-export function isInitialized(): boolean {
- return initialized
+export function getVersion(): number {
+ return version
}
diff --git a/src/sync/syncService.ts b/src/sync/syncService.ts
index bf33318..8b2def8 100644
--- a/src/sync/syncService.ts
+++ b/src/sync/syncService.ts
@@ -3,33 +3,57 @@ import {
initDatabase,
getAllMeasurementsForSync,
upsertMeasurement,
- getMeasurementsUpdatedSince,
+ getPendingRecords,
+ updateSyncStatus,
removeMeasurement,
} from '../database/db'
-import { createApi } from './api'
-import { ApiError } from './api'
+import { createApi, ApiError } from './api'
import * as SecureStore from 'expo-secure-store'
const LAST_SYNC_KEY = 'weegschaal_last_sync'
-export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
- const map = new Map()
+type SyncResult = {
+ pushed: number
+ deleted: number
+ pulled: number
+ errors: string[]
+}
- for (const m of local) {
- map.set(m.id, m)
- }
+export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
+ 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) {
- const existing = map.get(r.id)
- if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) {
- map.set(r.id, {
- ...r,
- deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined),
- } as Measurement)
+ const localRec = localMap.get(r.id)
+ const isDeleted = !!(r as any).deleted
+ const localPending =
+ localRec?.syncStatus === 'pending' ||
+ localRec?.syncStatus === 'pending-delete'
+
+ 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 {
@@ -38,9 +62,10 @@ export async function uploadMeasurement(m: Measurement): Promise {
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
const api = createApi(config)
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 {
const { getApiConfig } = await import('../screens/SettingsScreen')
const config = await getApiConfig()
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)
await initDatabase()
- const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
-
- const remote = await api.getMeasurements(lastSyncAt || undefined)
- const remoteUpdatedMap = new Map()
- const remoteDeletedIds = new Set()
-
- 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)
-
+ const errors: string[] = []
let pushed = 0
let deleted = 0
+ let pulled = 0
- for (const m of updated) {
- const remoteUA = remoteUpdatedMap.get(m.id)
- if (remoteUA && new Date(remoteUA) >= new Date(m.updatedAt)) {
- continue
- }
+ // === PUSH phase — outbox first ===
+ const pending = await getPendingRecords()
+ for (const m of pending) {
if (m.deletedAt) {
try {
await api.deleteMeasurement(m.id)
await removeMeasurement(m.id)
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 {
try {
await api.createMeasurement(m)
+ await updateSyncStatus(m.id, 'synced')
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())
- return { pushed, deleted, pulled: remote.length }
+ return { pushed, deleted, pulled, errors }
}
diff --git a/src/types.ts b/src/types.ts
index ea4373d..904cd84 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -11,6 +11,7 @@ export type Measurement = {
measuredAt: string
createdAt: string
updatedAt: string
+ syncStatus?: string
deletedAt?: string
}
diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts
index e07b21a..e33cc27 100644
--- a/tests/syncService.test.ts
+++ b/tests/syncService.test.ts
@@ -7,7 +7,7 @@ const make = (overrides: Partial = {}): Measurement => ({
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
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', () => {
@@ -40,37 +40,47 @@ describe('mergeMeasurements', () => {
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 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])
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 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])
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' })
+ it('local pending-delete wins over remote deleted', () => {
+ 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 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', () => {
- const local = make({ updatedAt: '2024-06-05T08:00:00.000Z' })
- const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
+ it('local pending wins over remote newer', () => {
+ const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' })
+ const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) }
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 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)
})
})