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*_
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

View file

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

View file

@ -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 (
<View style={styles.page}>
<Text style={styles.title}>{title}</Text>
@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str
color={COLORS.weight}
unit=" kg"
/>
{fatMassPoints.length >= 2 && (
<SingleChart
title="Vetmassa"
data={dataPoints.map(m => m.fatMass)}
labels={labels}
data={fatMassPoints.map(m => m.fatMass)}
labels={prepareLabels(fatMassPoints)}
color={COLORS.fatMass}
unit=" kg"
/>
)}
{muscleMassPoints.length >= 2 && (
<SingleChart
title="Spiermassa"
data={dataPoints.map(m => m.muscleMass)}
labels={labels}
data={muscleMassPoints.map(m => m.muscleMass)}
labels={prepareLabels(muscleMassPoints)}
color={COLORS.muscleMass}
unit=" kg"
/>
)}
</View>
)
}

View file

@ -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<void> {
if (db) return
@ -20,13 +21,21 @@ export async function initDatabase(): Promise<void> {
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<void> {
@ -110,3 +119,14 @@ export async function setDeletedAt(id: string): Promise<void> {
export async function removeMeasurement(id: string): Promise<void> {
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 { 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<Measurement[]>([])
const [all, setAll] = useState<Measurement[]>([])
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)
await initDatabase()
const two = await getLatestMeasurements(2)
if (!active) return
setLatestTwo(two)
await refreshCache()
if (!active) return
const v = getVersion()
if (v !== seenVersion.current) {
setAll(getCached())
setChartKey(v)
seenVersion.current = v
}
// 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())
// Sync met server (achtergrond)
try {
const { syncAll } = await import('../sync/syncService')
await syncAll()
const result = await syncAll()
if (!active) return
if (result.deleted > 0 || result.pulled > 0) {
await refreshCache()
} catch (e) {
console.warn('Sync failed:', e)
}
// Update UI met eventuele nieuwe data van sync
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 (
<View style={styles.outer}>
<View style={styles.navBar}>
@ -73,6 +80,8 @@ export default function DashboardScreen({ navigation }: Props) {
)
}
const display = all.length > 0 ? all : latestTwo
return (
<View style={styles.outer}>
<View style={styles.navBar}>
@ -84,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
</TouchableOpacity>
</View>
<ScrollView style={styles.container}>
<LatestMeasurementCard measurements={all} />
<WeightChart measurements={all} />
<LatestMeasurementCard measurements={display} />
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
</ScrollView>
<TouchableOpacity

View file

@ -1,11 +1,9 @@
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 { 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'
import MeasurementRow from '../components/MeasurementRow'
type Props = { navigation: any }
@ -65,17 +63,9 @@ 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)
}
await updateSyncStatus(m.id, 'pending-delete')
await refreshCache()
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 { 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()
},
},

View file

@ -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)
}

View file

@ -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 {

View file

@ -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<Measurement[]> {
export async function refreshCache(): 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
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<Measurement[]> {
await loadFromDb()
return cachedMeasurements
}
export function isInitialized(): boolean {
return initialized
export function getVersion(): number {
return version
}

View file

@ -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<string, Measurement>()
for (const m of local) {
map.set(m.id, m)
type SyncResult = {
pushed: number
deleted: number
pulled: number
errors: string[]
}
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, {
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,
deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined),
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> {
@ -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.')
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<SyncResult> {
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<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)
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 }
}

View file

@ -11,6 +11,7 @@ export type Measurement = {
measuredAt: string
createdAt: string
updatedAt: string
syncStatus?: 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,
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)
})
})