- 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
163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
import { Measurement } from '../types'
|
|
import {
|
|
initDatabase,
|
|
getAllMeasurementsForSync,
|
|
upsertMeasurement,
|
|
getPendingRecords,
|
|
updateSyncStatus,
|
|
removeMeasurement,
|
|
} from '../database/db'
|
|
import { createApi, ApiError } from './api'
|
|
import * as SecureStore from 'expo-secure-store'
|
|
|
|
const LAST_SYNC_KEY = 'weegschaal_last_sync'
|
|
|
|
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 localRec = localMap.get(r.id)
|
|
const isDeleted = !!(r as any).deleted
|
|
const localPending =
|
|
localRec?.syncStatus === 'pending' ||
|
|
localRec?.syncStatus === 'pending-delete'
|
|
|
|
if (isDeleted) {
|
|
if (!localRec || localPending) continue
|
|
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
|
|
resultMap.set(r.id, {
|
|
...r,
|
|
syncStatus: 'synced',
|
|
deletedAt: (r as any).deletedAt || r.updatedAt,
|
|
} as Measurement)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (localPending) continue
|
|
|
|
if (localRec) {
|
|
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
|
|
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
|
|
}
|
|
} else {
|
|
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
|
|
}
|
|
}
|
|
|
|
return Array.from(resultMap.values())
|
|
}
|
|
|
|
export async function uploadMeasurement(m: Measurement): Promise<void> {
|
|
const { getApiConfig } = await import('../screens/SettingsScreen')
|
|
const config = await getApiConfig()
|
|
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<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.')
|
|
|
|
const api = createApi(config)
|
|
await initDatabase()
|
|
|
|
const errors: string[] = []
|
|
let pushed = 0
|
|
let deleted = 0
|
|
let pulled = 0
|
|
|
|
// === 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 (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 (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, errors }
|
|
}
|