Compare commits

...
Author SHA1 Message Date
a37259688a Merge pull request 'fix: remove hardcoded API token from SettingsScreen' (#16) from fix/issue-2-remove-hardcoded-token into main 2026-08-05 21:32:45 +02:00
eddy
a27a96957d fix: remove hardcoded API token from SettingsScreen
- Replace hardcoded Bearer token default with empty string
- Remove auto-save in useEffect that wrote defaults to SecureStore
  without user consent on first app launch

Fixes #2
2026-08-05 21:20:34 +02:00
eddy
aca968bbda 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
2026-07-25 17:27:54 +02:00
eddy
d09951c493 fix: code review cleanup — filter deletedAt, deduplicate, use toast util
- getMeasurementByDate: AND deletedAt IS NULL (soft-deleted meting triggerde dubbele-datum alert)
- Dashboard/History: verwijder redundante JS .filter(m => !m.deletedAt) (SQL filterde al)
- HistoryScreen confirmDelete: fix dubbele load() call
- NewMeasurementScreen: gebruik toast utility i.p.v. direct ToastAndroid
2026-07-25 15:54:10 +02:00
eddy
df05fb75dd fix: sync refactor — fix 6 bugs + cache refresh na sync
- setDeletedAt update nu ook updatedAt (bug 2)
- getAllMeasurements filtert deletedAt IS NULL, nieuwe getAllMeasurementsForSync voor sync
- mergeMeasurements detecteert server deleted:true → deletedAt (bug 1)
- syncAll: pull-push split met remoteUpdatedMap, geen double-push (bug 3)
- hard delete alleen na API-succes (bug 4)
- DashboardScreen: refreshCache() na syncAll zodat UI niet op Laden blijft hangen
- deleteMeasurement() dead code verwijderd (bug 5)
- tests: 3 nieuwe testcases voor deleted → deletedAt mapping, 15/15 pass
2026-07-25 15:33:22 +02:00
17 changed files with 527 additions and 146 deletions

View file

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

View file

@ -0,0 +1,139 @@
# Sync refactor — Implementation Plan ✅ (completed 2026-07-25)
> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync.
>
> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd.
## Bug Overzicht
| # | Bug | Oorzaak | Fix |
|---|-----|---------|-----|
| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` |
| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE |
| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt |
| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes |
| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact |
| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` |
## Sync Protocol (vastgelegd)
```
lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY)
── PULL ──
remote[] = GET /api/v1/measurements?since=lastSyncAt
local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted)
remoteUpdatedMap: Map<id, updatedAt> // track welke IDs + timestamps we binnenhaalden
for each r in remote:
remoteUpdatedMap.set(r.id, r.updatedAt)
if (r as any).deleted:
r.deletedAt = r.updatedAt // Bug 1 fix
── MERGE ──
remoteDeletedIds: Set<id> // track welke remote records 'deleted' zijn
for each r in remote:
if (r as any).deleted:
remoteDeletedIds.add(r.id)
merged = mergeMeasurements(local, remote)
for each m in merged:
remoteUA = remoteUpdatedMap.get(m.id)
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
// remote won de merge
if remoteDeletedIds.has(m.id):
removeMeasurement(m.id) // hard-delete lokaal (server heeft 'm al)
else:
upsertMeasurement(m) // remote gewijzigd, niet verwijderd
else:
upsertMeasurement(m) // local won of nieuw → gewoon bewaren
── PUSH ──
cutoff = lastSyncAt ?? '1970-01-01T00:00:00.000Z'
localChanges[] = getMeasurementsUpdatedSince(cutoff)
for each m in localChanges:
remoteUA = remoteUpdatedMap.get(m.id)
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
continue // remote won merge → niet terugpushen (Bug 3 fix)
if m.deletedAt:
DELETE /api/v1/measurements/:id
if success:
removeMeasurement(m.id) // Bug 4 fix: alleen hard-delete bij API-succes
else:
POST /api/v1/measurements
SecureStore.setItem(LAST_SYNC_KEY, now())
```
## Delete Protocol (vastgelegd)
```
Lokaal: setDeletedAt(id) + updatedAt = now() (Bug 2 fix)
Cache: removeFromCache(id)
API: DELETE /api/v1/measurements/:id
if success:
Lokaal: removeMeasurement(id) (Bug 4 fix: alleen bij succes)
// bij falen: blijft met deletedAt in DB → syncAll pakt op
```
## Files om te wijzigen
### 1. `src/sync/syncService.ts` — REWRITE syncAll + mergeMeasurements
**Wijzigingen:**
- `mergeMeasurements`: detecteer `(r as any).deleted` → zet `deletedAt = r.updatedAt`
- `syncAll`:
- Track `remoteUpdatedMap` in pull-fase
- Merge via bestaande `mergeMeasurements`
- Push: skip records waar remote.updatedAt ≥ local.updatedAt (remote won)
- Alleen `removeMeasurement` na succesvolle API DELETE
- Exporteer `cleanupDeletedRecord(id)` voor gebruik door screens (DRY)
### 2. `src/database/db.ts` — setDeletedAt fix + query filters
**Wijzigingen:**
- `setDeletedAt(id)`: voeg `updatedAt = ?` toe aan SQL
- `getAllMeasurements()`: voeg `WHERE deletedAt IS NULL` toe
- `getLatestMeasurement()`: voeg `WHERE deletedAt IS NULL` toe
- Nieuw: `getAllMeasurementsForSync()` — ongefilterd (voor syncService)
- Verwijder: `deleteMeasurement()` (dode code, duplicate van `removeMeasurement`)
### 3. `src/screens/HistoryScreen.tsx` — delete fix
**Wijzigingen:**
- `confirmDelete`: alleen `removeMeasurement()` na API-succes
- `load`: gebruik `getMeasurementsSince('1970-01-01')` i.p.v. `getAllMeasurements()` (of de nieuwe `getAllMeasurements` die filtert)
### 4. `src/screens/MeasurementDetailScreen.tsx` — delete fix
**Wijzigingen:**
- `handleDelete`: alleen `removeMeasurement()` na API-succes
### 5. `src/store/measurementStore.ts` — geen wijzigingen nodig
De store gebruikt `getLatestMeasurements(count)` en `getMeasurementsSince(date)` die al filteren. `/loadFromDb` gebruikt `getAllMeasurements()` → die gaat nu ook filteren. ✓
### 6. `tests/syncService.test.ts` — nieuwe test cases
**Wijzigingen:**
- Test: `deleted` van server wordt correct naar `deletedAt` gemapt
- Test: syncAll push skips remote-won records
- Test: syncAll push stuurt local-won records wel
### 7. `tests/database.test.ts` — update voor nieuwe functies
**Wijzigingen:**
- Test `setDeletedAt` update ook `updatedAt`
- Test `getAllMeasurementsForSync` returned deleted records
## Uitvoeringsvolgorde
1. `db.ts` — setDeletedAt fix + query filters
2. `syncService.ts` — merge fix + syncAll refactor
3. `HistoryScreen.tsx` + `MeasurementDetailScreen.tsx` — delete fix
4. Tests updaten + TypeScript check + `npm test`
5. Build APK, testen

View file

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

View file

@ -30,8 +30,8 @@
}, },
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"test": "jest" "test": "jest"
}, },

View file

@ -78,6 +78,9 @@ function prepareLabels(dataPoints: Measurement[]) {
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) { function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
const labels = prepareLabels(dataPoints) const labels = prepareLabels(dataPoints)
const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0)
const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0)
return ( return (
<View style={styles.page}> <View style={styles.page}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str
color={COLORS.weight} color={COLORS.weight}
unit=" kg" unit=" kg"
/> />
{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>
) )
} }

View file

@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite'
import { Measurement } from '../types' import { Measurement } from '../types'
let db: SQLite.SQLiteDatabase | null = null let db: SQLite.SQLiteDatabase | null = null
let dbReady = false
export async function initDatabase(): Promise<void> { export async function initDatabase(): Promise<void> {
if (db) return if (db) return
@ -20,13 +21,21 @@ export async function initDatabase(): Promise<void> {
measuredAt TEXT NOT NULL, measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'local', syncStatus TEXT NOT NULL DEFAULT 'pending',
deletedAt TEXT deletedAt TEXT
); );
`) `)
try { try {
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT') await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
} catch {} } catch {}
// Migrate existing records to crossbreed syncStatus model
if (!dbReady) {
try {
await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'")
} catch {}
dbReady = true
}
} }
export async function insertMeasurement(m: Measurement): Promise<void> { export async function insertMeasurement(m: Measurement): Promise<void> {
@ -38,6 +47,13 @@ export async function insertMeasurement(m: Measurement): Promise<void> {
} }
export async function getAllMeasurements(): Promise<Measurement[]> { export async function getAllMeasurements(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC'
)
return rows as Measurement[]
}
export async function getAllMeasurementsForSync(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC' 'SELECT * FROM measurements ORDER BY measuredAt DESC'
) )
@ -46,15 +62,31 @@ export async function getAllMeasurements(): Promise<Measurement[]> {
export async function getLatestMeasurement(): Promise<Measurement | null> { export async function getLatestMeasurement(): Promise<Measurement | null> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1' 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
) )
return rows.length > 0 ? (rows[0] as Measurement) : null return rows.length > 0 ? (rows[0] as Measurement) : null
} }
export async function getLatestMeasurements(count: number): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?',
[count]
)
return rows as Measurement[]
}
export async function getMeasurementsSince(date: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE measuredAt >= ? AND deletedAt IS NULL ORDER BY measuredAt DESC',
[date]
)
return rows as Measurement[]
}
export async function getMeasurementByDate(date: string): Promise<Measurement | null> { export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
const day = date.slice(0, 10) const day = date.slice(0, 10)
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE measuredAt LIKE ? LIMIT 1", "SELECT * FROM measurements WHERE measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1",
[`${day}%`] [`${day}%`]
) )
return rows.length > 0 ? (rows[0] as Measurement) : null return rows.length > 0 ? (rows[0] as Measurement) : null
@ -68,10 +100,6 @@ export async function upsertMeasurement(m: Measurement): Promise<void> {
) )
} }
export async function deleteMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
}
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> { export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC', 'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
@ -81,12 +109,24 @@ export async function getMeasurementsUpdatedSince(since: string): Promise<Measur
} }
export async function setDeletedAt(id: string): Promise<void> { export async function setDeletedAt(id: string): Promise<void> {
const now = new Date().toISOString()
await db!.runAsync( await db!.runAsync(
'UPDATE measurements SET deletedAt = ? WHERE id = ?', 'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
[new Date().toISOString(), id] [now, now, id]
) )
} }
export async function removeMeasurement(id: string): Promise<void> { export async function removeMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
} }
export async function updateSyncStatus(id: string, status: string): Promise<void> {
await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id])
}
export async function getPendingRecords(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
)
return rows as Measurement[]
}

View file

@ -1,8 +1,9 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { initDatabase, getAllMeasurements } from '../database/db' 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,23 +12,76 @@ 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 [loading, setLoading] = useState(true) const [chartKey, setChartKey] = useState(0)
const seenVersion = useRef(0)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true
const load = async () => { const load = async () => {
setLoading(true)
await initDatabase() await initDatabase()
const allM = await getAllMeasurements()
const visible = allM.filter(m => !m.deletedAt) const two = await getLatestMeasurements(2)
setAll(visible) if (!active) return
setLoading(false) setLatestTwo(two)
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')
}
} }
load() load()
return () => { active = false }
}, []) }, [])
) )
if (all.length === 0 && latestTwo.length === 0) {
return (
<View style={styles.outer}>
<View style={styles.navBar}>
<TouchableOpacity onPress={() => navigation.navigate('History')}>
<Text style={styles.navLink}>Historie</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
<Text style={styles.navLink}>Instellingen</Text>
</TouchableOpacity>
</View>
<View style={styles.loadingContainer}>
<Text style={styles.loading}>Laden...</Text>
</View>
<TouchableOpacity
style={[styles.fab, { marginBottom: insets.bottom + 16 }]}
onPress={() => navigation.navigate('NewMeasurement')}
>
<Text style={styles.fabText}>+ Nieuwe meting</Text>
</TouchableOpacity>
</View>
)
}
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}>
@ -39,14 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView style={styles.container}> <ScrollView style={styles.container}>
{loading ? ( <LatestMeasurementCard measurements={display} />
<Text style={styles.loading}>Laden...</Text> {all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
) : (
<>
<LatestMeasurementCard measurements={all} />
<WeightChart measurements={all} />
</>
)}
</ScrollView> </ScrollView>
<TouchableOpacity <TouchableOpacity
@ -66,5 +114,6 @@ const styles = StyleSheet.create({
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' }, navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' }, fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' }, fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
loading: { fontSize: 16, color: '#999', textAlign: 'center', padding: 40 }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loading: { fontSize: 16, color: '#999' },
}) })

View file

@ -1,10 +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 { 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 }
@ -16,7 +15,7 @@ export default function HistoryScreen({ navigation }: Props) {
const load = useCallback(async () => { const load = useCallback(async () => {
await initDatabase() await initDatabase()
const all = await getAllMeasurements() const all = await getAllMeasurements()
setMeasurements(all.filter(m => !m.deletedAt)) setMeasurements(all)
}, []) }, [])
useFocusEffect( useFocusEffect(
@ -64,13 +63,8 @@ export default function HistoryScreen({ navigation }: Props) {
onPress: async () => { onPress: async () => {
await initDatabase() await initDatabase()
await setDeletedAt(m.id) await setDeletedAt(m.id)
try { await updateSyncStatus(m.id, 'pending-delete')
const config = await getApiConfig() await refreshCache()
if (config) {
await createApi(config).deleteMeasurement(m.id)
await removeMeasurement(m.id)
}
} catch {}
load() load()
}, },
}, },

View file

@ -2,10 +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 { 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
@ -60,13 +59,8 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) {
onPress: async () => { onPress: async () => {
await initDatabase() await initDatabase()
await setDeletedAt(measurementId) await setDeletedAt(measurementId)
try { await updateSyncStatus(measurementId, 'pending-delete')
const config = await getApiConfig() await refreshCache()
if (config) {
await createApi(config).deleteMeasurement(measurementId)
await removeMeasurement(measurementId)
}
} catch {}
navigation.goBack() navigation.goBack()
}, },
}, },

View file

@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useState } from 'react'
import { View, Text, Alert } from 'react-native' import { View, Text, Alert } from 'react-native'
import { useFocusEffect } from '@react-navigation/native' 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 { uploadMeasurement } from '../sync/syncService' import { refreshCache } from '../store/measurementStore'
import { showSuccess, showError } from '../utils/toast'
type Props = { type Props = {
navigation: any navigation: any
@ -60,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))
}}, }},
] ]
) )
@ -68,19 +69,22 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
} }
await insertMeasurement(m) await insertMeasurement(m)
} }
doSave(m) await doSave(m)
} }
const doSave = async (m: Measurement) => { const doSave = async (m: Measurement) => {
await refreshCache()
navigation.goBack()
uploadInBackground(m)
}
const uploadInBackground = async (m: Measurement) => {
try { try {
const { uploadMeasurement } = await import('../sync/syncService')
await uploadMeasurement(m) await uploadMeasurement(m)
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [ showSuccess('Opgeslagen & gesynct')
{ text: 'OK', onPress: () => navigation.goBack() },
])
} catch { } catch {
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [ showError('Opgeslagen (sync mislukt)')
{ text: 'OK', onPress: () => navigation.goBack() },
])
} }
} }

View file

@ -14,7 +14,7 @@ async function loadConfig(): Promise<ApiConfig | null> {
} }
} }
async function saveConfig(config: ApiConfig): Promise<void> { export async function saveConfig(config: ApiConfig): Promise<void> {
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
} }
@ -66,7 +66,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 {
@ -99,7 +103,7 @@ export default function SettingsScreen() {
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}> <TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
<Text style={styles.eyeIcon}>{showToken ? 'O' : 'O'}</Text> <Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -0,0 +1,20 @@
import { Measurement } from '../types'
import { initDatabase, getAllMeasurements } from '../database/db'
let cache: Measurement[] = []
let version = 0
export async function refreshCache(): Promise<Measurement[]> {
await initDatabase()
cache = await getAllMeasurements()
version++
return cache
}
export function getCached(): Measurement[] {
return cache
}
export function getVersion(): number {
return version
}

View file

@ -1,35 +1,59 @@
import { Measurement } from '../types' import { Measurement } from '../types'
import { import {
initDatabase, initDatabase,
getAllMeasurements, 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.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,41 +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 local = await getAllMeasurements()
const merged = mergeMeasurements(local, remote)
for (const m of merged) {
await upsertMeasurement(m)
}
const since = lastSyncAt || '1970-01-01T00:00:00.000Z'
const updated = await getMeasurementsUpdatedSince(since)
let pushed = 0 let pushed = 0
let deleted = 0 let deleted = 0
let pulled = 0
await Promise.allSettled( // === PUSH phase — outbox first ===
updated.map(async m => { const pending = await getPendingRecords()
if (m.deletedAt && m.deletedAt > since) {
for (const m of pending) {
if (m.deletedAt) {
try { try {
await api.deleteMeasurement(m.id) await api.deleteMeasurement(m.id)
await removeMeasurement(m.id) await removeMeasurement(m.id)
deleted++ deleted++
} catch {} } catch (e: any) {
if (e instanceof ApiError && e.status === 404) {
await removeMeasurement(m.id)
deleted++
} else {
errors.push(e?.message ?? 'Delete failed')
}
}
} else { } else {
try { try {
await api.createMeasurement(m) await api.createMeasurement(m)
await updateSyncStatus(m.id, 'synced')
pushed++ pushed++
} catch {} } catch (e: any) {
errors.push(e?.message ?? 'Create failed')
}
}
}
// === PULL phase — fetch remote changes ===
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
const remote = await api.getMeasurements(lastSyncAt || undefined)
if (remote.length > 0) {
const local = await getAllMeasurementsForSync()
const localMap = new Map(local.map(m => [m.id, m]))
for (const r of remote) {
const localRec = localMap.get(r.id)
const isDeleted = !!(r as any).deleted
if (isDeleted) {
if (localRec) {
if (
localRec.syncStatus === 'pending' ||
localRec.syncStatus === 'pending-delete'
) {
continue
}
await removeMeasurement(r.id)
deleted++
}
continue
}
const localIsPending =
localRec?.syncStatus === 'pending' ||
localRec?.syncStatus === 'pending-delete'
if (localIsPending) {
continue
}
if (localRec) {
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
const record = { ...r, deletedAt: (r as any).deletedAt || undefined } as Measurement
await upsertMeasurement(record)
await updateSyncStatus(r.id, 'synced')
pulled++
}
continue
}
const record = { ...r, deletedAt: (r as any).deletedAt || undefined } as Measurement
await upsertMeasurement(record)
await updateSyncStatus(r.id, 'synced')
pulled++
}
} }
})
)
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
return { pushed, deleted, pulled: remote.length } return { pushed, deleted, pulled, errors }
} }

View file

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

26
src/utils/toast.ts Normal file
View file

@ -0,0 +1,26 @@
import { Platform, ToastAndroid } from 'react-native'
let _toastQueue: Array<{message: string; duration: number}> = []
let _showFn: ((message: string, duration: number) => void) | null = null
export function setToastShowFn(fn: (message: string, duration: number) => void) {
_showFn = fn
}
export function showToast(message: string, duration = 3000) {
if (_showFn) {
_showFn(message, duration)
return
}
if (Platform.OS === 'android') {
ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT)
}
}
export function showSuccess(message: string) {
showToast(message, 2500)
}
export function showError(message: string) {
showToast(message, 4000)
}

View file

@ -81,7 +81,7 @@ describe('database', () => {
expect(result!.id).toBe(mockMeasurement.id) expect(result!.id).toBe(mockMeasurement.id)
}) })
it('setDeletedAt sets the deletedAt timestamp', async () => { it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
db.runAsync.mockResolvedValue(undefined) db.runAsync.mockResolvedValue(undefined)
await initDatabase() await initDatabase()
await setDeletedAt(mockMeasurement.id) await setDeletedAt(mockMeasurement.id)
@ -89,5 +89,7 @@ describe('database', () => {
expect.stringContaining('UPDATE measurements SET deletedAt'), expect.stringContaining('UPDATE measurements SET deletedAt'),
expect.arrayContaining([mockMeasurement.id]) expect.arrayContaining([mockMeasurement.id])
) )
const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
expect(args[1][0]).toBe(args[1][1])
}) })
}) })

View file

@ -7,7 +7,7 @@ const make = (overrides: Partial<Measurement> = {}): Measurement => ({
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10, id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600, musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z', measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z', ...overrides, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides,
}) })
describe('mergeMeasurements', () => { describe('mergeMeasurements', () => {
@ -40,17 +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('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].syncStatus).toBe('pending-delete')
expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z')
})
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].weight).toBe(75)
expect(r[0].syncStatus).toBe('pending')
})
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).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)
})
}) })