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*_
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
3
eas.json
3
eas.json
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 5.9.1"
|
||||
"version": ">= 5.9.1",
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export type Measurement = {
|
|||
measuredAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
syncStatus?: string
|
||||
deletedAt?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue