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:
parent
d09951c493
commit
aca968bbda
13 changed files with 257 additions and 213 deletions
33
README.md
33
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
3
eas.json
3
eas.json
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 5.9.1"
|
"version": ">= 5.9.1",
|
||||||
|
"appVersionSource": "local"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
{fatMassPoints.length >= 2 && (
|
||||||
<SingleChart
|
<SingleChart
|
||||||
title="Vetmassa"
|
title="Vetmassa"
|
||||||
data={dataPoints.map(m => m.fatMass)}
|
data={fatMassPoints.map(m => m.fatMass)}
|
||||||
labels={labels}
|
labels={prepareLabels(fatMassPoints)}
|
||||||
color={COLORS.fatMass}
|
color={COLORS.fatMass}
|
||||||
unit=" kg"
|
unit=" kg"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{muscleMassPoints.length >= 2 && (
|
||||||
<SingleChart
|
<SingleChart
|
||||||
title="Spiermassa"
|
title="Spiermassa"
|
||||||
data={dataPoints.map(m => m.muscleMass)}
|
data={muscleMassPoints.map(m => m.muscleMass)}
|
||||||
labels={labels}
|
labels={prepareLabels(muscleMassPoints)}
|
||||||
color={COLORS.muscleMass}
|
color={COLORS.muscleMass}
|
||||||
unit=" kg"
|
unit=" kg"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
const two = await getLatestMeasurements(2)
|
||||||
|
if (!active) return
|
||||||
|
setLatestTwo(two)
|
||||||
|
|
||||||
|
await refreshCache()
|
||||||
|
if (!active) return
|
||||||
|
const v = getVersion()
|
||||||
|
if (v !== seenVersion.current) {
|
||||||
setAll(getCached())
|
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 {
|
try {
|
||||||
const { syncAll } = await import('../sync/syncService')
|
const { syncAll } = await import('../sync/syncService')
|
||||||
await syncAll()
|
const result = await syncAll()
|
||||||
|
if (!active) return
|
||||||
|
if (result.deleted > 0 || result.pulled > 0) {
|
||||||
await refreshCache()
|
await refreshCache()
|
||||||
} catch (e) {
|
const v2 = getVersion()
|
||||||
console.warn('Sync failed:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI met eventuele nieuwe data van sync
|
|
||||||
setAll(getCached())
|
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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
if (config) {
|
|
||||||
await createApi(config).deleteMeasurement(m.id)
|
|
||||||
await removeMeasurement(m.id)
|
|
||||||
}
|
|
||||||
load()
|
load()
|
||||||
} catch {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
for (const m of local) {
|
pulled: number
|
||||||
map.set(m.id, m)
|
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) {
|
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 =
|
||||||
|
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,
|
...r,
|
||||||
deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined),
|
syncStatus: 'synced',
|
||||||
|
deletedAt: (r as any).deletedAt || r.updatedAt,
|
||||||
} as Measurement)
|
} 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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue