feat: add sync service with WebDAV merge logic

This commit is contained in:
Eddy de Vink 2026-06-27 22:42:01 +02:00
commit 58e1cd0c19
2 changed files with 147 additions and 0 deletions

90
src/sync/syncService.ts Normal file
View file

@ -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<string, Measurement>()
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<Measurement[]> {
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<void> {
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<SyncResult> {
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,
}
}

57
tests/syncService.test.ts Normal file
View file

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