weegschaal-app/src/sync/syncService.ts
eddy a77b31f95c 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

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