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:
eddy 2026-07-25 17:27:54 +02:00
commit a77b31f95c

View file

@ -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>()
type SyncResult = {
pushed: number
deleted: number
pulled: number
errors: string[]
}
for (const m of local) {
map.set(m.id, m)
}
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, {
...r,
deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined),
} as Measurement)
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(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 }
}