diff --git a/src/sync/syncService.ts b/src/sync/syncService.ts new file mode 100644 index 0000000..9fd05c1 --- /dev/null +++ b/src/sync/syncService.ts @@ -0,0 +1,90 @@ +import { Measurement } from '../types' +import { initDatabase, getAllMeasurements, upsertMeasurement } from '../database/db' +import { getWebDAVConfig } from '../screens/SettingsScreen' + +const WEBDAV_PATH = 'weegschaal/measurements.json' + +type SyncResult = { local: number; remote: number; merged: number } + +export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { + const map = new Map() + + for (const m of local) { + map.set(m.id, m) + } + + for (const m of remote) { + const existing = map.get(m.id) + if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) { + map.set(m.id, { ...m, syncStatus: 'synced' }) + } + } + + return Array.from(map.values()) +} + +async function fetchRemote(config: { url: string; username: string; password: string }): Promise { + const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` + const response = await fetch(url, { + headers: { + Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), + }, + }) + + if (response.status === 404) { + return [] + } + + if (!response.ok) { + throw new Error(`WebDAV fetch failed: ${response.status}`) + } + + return response.json() +} + +async function uploadRemote( + config: { url: string; username: string; password: string }, + measurements: Measurement[] +): Promise { + const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` + const body = JSON.stringify(measurements) + + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), + 'Content-Type': 'application/json', + }, + body, + }) + + if (!response.ok) { + throw new Error(`WebDAV upload failed: ${response.status}`) + } +} + +export async function syncAll(): Promise { + const config = await getWebDAVConfig() + if (!config) { + throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.') + } + + await initDatabase() + + const local = await getAllMeasurements() + const remote = await fetchRemote(config) + + const merged = mergeMeasurements(local, remote) + + for (const m of merged) { + await upsertMeasurement(m) + } + + await uploadRemote(config, merged) + + return { + local: local.length, + remote: remote.length, + merged: merged.length, + } +} diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts new file mode 100644 index 0000000..fae08ca --- /dev/null +++ b/tests/syncService.test.ts @@ -0,0 +1,57 @@ +import { mergeMeasurements } from '../src/sync/syncService' +import { Measurement } from '../src/types' + +jest.mock('expo-sqlite', () => ({ + openDatabaseAsync: jest.fn(), +})) + +const base: 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', + syncStatus: 'local', +} + +describe('mergeMeasurements', () => { + it('returns empty array when both are empty', () => { + expect(mergeMeasurements([], [])).toEqual([]) + }) + + it('keeps local items not on remote', () => { + const result = mergeMeasurements([base], []) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('id-1') + }) + + it('keeps remote items not on local', () => { + const remote = { ...base, syncStatus: 'synced' as const } + const result = mergeMeasurements([], [remote]) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('id-1') + }) + + it('picks the newer version when both have same id', () => { + const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' } + const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(1) + expect(result[0].weight).toBe(76) + }) + + it('picks local when local is newer', () => { + const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' } + const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(1) + expect(result[0].weight).toBe(77) + }) +})