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
a32b9e0a88
commit
a77b31f95c
1 changed files with 257 additions and 213 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue