fix: sync deletion propagation, add base64 util, timeout, and merge improvements

Critical:
- Add deleted column: deleteMeasurement now soft-deletes, merge filters
  out deleted items based on updatedAt (was: deletions were resurrected)
- Replace btoa with UTF-8-safe encodeBase64 utility for Hermes compat

Important:
- Preserve createdAt when overwriting duplicate-date measurements
- Add 15s AbortController timeout to all WebDAV fetch calls
- Revert broken date prefilling regression (MeasurementForm)
- getAllMeasurements/latest/byDate now filter deleted records
- Replace Dimensions with useWindowDimensions for rotation awareness
- Replace silent catch with console.warn in HistoryScreen

Tests: 8 new tests for deletion merge cases and soft-delete queries
This commit is contained in:
Eddy de Vink 2026-08-05 20:55:14 +02:00
commit 0513db46ac
10 changed files with 207 additions and 58 deletions

View file

@ -72,6 +72,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
syncStatus: 'local', syncStatus: 'local',
deleted: 0,
} }
onSubmit(m) onSubmit(m)
} }

View file

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { View, Text, StyleSheet, Dimensions } from 'react-native' import { View, Text, StyleSheet, useWindowDimensions } from 'react-native'
import { LineChart } from 'react-native-chart-kit' import { LineChart } from 'react-native-chart-kit'
import { Measurement } from '../types' import { Measurement } from '../types'
@ -24,12 +24,13 @@ function SingleChart({
color: string color: string
unit: string unit: string
}) { }) {
const { width } = useWindowDimensions()
return ( return (
<View style={styles.chartBlock}> <View style={styles.chartBlock}>
<Text style={styles.chartTitle}>{title}</Text> <Text style={styles.chartTitle}>{title}</Text>
<LineChart <LineChart
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }} data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
width={Dimensions.get('window').width - 32} width={width - 32}
height={180} height={180}
yAxisSuffix={unit} yAxisSuffix={unit}
chartConfig={{ chartConfig={{

View file

@ -20,29 +20,36 @@ export async function initDatabase(): Promise<void> {
measuredAt TEXT NOT NULL, measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'local' syncStatus TEXT NOT NULL DEFAULT 'local',
deleted INTEGER NOT NULL DEFAULT 0
); );
`) `)
await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`)
.catch(() => {})
} }
export async function insertMeasurement(m: Measurement): Promise<void> { export async function insertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync( await db!.runAsync(
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus) `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus] [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, 0]
) )
} }
function activeOnly(): string {
return 'deleted = 0'
}
export async function getAllMeasurements(): Promise<Measurement[]> { export async function getAllMeasurements(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC' `SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC`
) )
return rows as Measurement[] return rows as Measurement[]
} }
export async function getLatestMeasurement(): Promise<Measurement | null> { export async function getLatestMeasurement(): Promise<Measurement | null> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1' `SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC LIMIT 1`
) )
return rows.length > 0 ? (rows[0] as Measurement) : null return rows.length > 0 ? (rows[0] as Measurement) : null
} }
@ -50,7 +57,7 @@ export async function getLatestMeasurement(): Promise<Measurement | null> {
export async function getMeasurementByDate(date: string): Promise<Measurement | null> { export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
const day = date.slice(0, 10) const day = date.slice(0, 10)
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE measuredAt LIKE ? LIMIT 1", `SELECT * FROM measurements WHERE ${activeOnly()} AND measuredAt LIKE ? LIMIT 1`,
[`${day}%`] [`${day}%`]
) )
return rows.length > 0 ? (rows[0] as Measurement) : null return rows.length > 0 ? (rows[0] as Measurement) : null
@ -65,9 +72,9 @@ export async function updateMeasurement(id: string, syncStatus: SyncStatus): Pro
export async function upsertMeasurement(m: Measurement): Promise<void> { export async function upsertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync( await db!.runAsync(
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus) `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus] [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deleted]
) )
} }
@ -79,5 +86,15 @@ export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
} }
export async function deleteMeasurement(id: string): Promise<void> { export async function deleteMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) await db!.runAsync(
"UPDATE measurements SET deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?",
[new Date().toISOString(), id]
)
}
export async function getAllMeasurementsIncludingDeleted(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC'
)
return rows as Measurement[]
} }

View file

@ -65,7 +65,9 @@ export default function HistoryScreen({ navigation }: Props) {
try { try {
const { syncAll } = await import('../sync/syncService') const { syncAll } = await import('../sync/syncService')
await syncAll() await syncAll()
} catch {} } catch (e) {
console.warn('Sync failed after delete:', e)
}
load() load()
}, },
}, },

View file

@ -66,7 +66,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`, `Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
[ [
{ text: 'Annuleren', style: 'cancel' }, { text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) }, { text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing) },
] ]
) )
return return
@ -74,9 +74,10 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
doSave(m) doSave(m)
} }
const doSave = async (m: Measurement, existingId?: string) => { const doSave = async (m: Measurement, existing?: Measurement) => {
if (existingId) { if (existing) {
m.id = existingId m.id = existing.id
m.createdAt = existing.createdAt
await upsertMeasurement(m) await upsertMeasurement(m)
} else { } else {
await insertMeasurement(m) await insertMeasurement(m)

View file

@ -1,8 +1,10 @@
import { Measurement } from '../types' import { Measurement } from '../types'
import { initDatabase, getAllMeasurements, upsertMeasurement } from '../database/db' import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db'
import { getWebDAVConfig } from '../screens/SettingsScreen' import { getWebDAVConfig } from '../screens/SettingsScreen'
import { encodeBase64 } from '../utils/base64'
const WEBDAV_PATH = 'weegschaal/measurements.json' const WEBDAV_PATH = 'weegschaal/measurements.json'
const SYNC_TIMEOUT_MS = 15_000
type SyncResult = { local: number; remote: number; merged: number } type SyncResult = { local: number; remote: number; merged: number }
@ -15,46 +17,66 @@ export function mergeMeasurements(local: Measurement[], remote: Measurement[]):
for (const m of remote) { for (const m of remote) {
const existing = map.get(m.id) const existing = map.get(m.id)
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) { if (!existing) {
map.set(m.id, { ...m, syncStatus: 'synced' }) map.set(m.id, { ...m, syncStatus: 'synced' })
} else {
const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt)
if (remoteNewer) {
map.set(m.id, { ...m, syncStatus: 'synced' })
}
} }
} }
return Array.from(map.values()) return Array.from(map.values()).filter(m => !m.deleted)
} }
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> { async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> {
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
const response = await fetch(url, { const auth = encodeBase64(`${config.username}:${config.password}`)
headers: {
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
},
})
if (response.status === 404) { const controller = new AbortController()
return [] const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { Authorization: `Basic ${auth}` },
signal: controller.signal,
})
if (response.status === 404) {
return []
}
if (!response.ok) {
throw new Error(`WebDAV fetch failed: ${response.status}`)
}
return response.json()
} finally {
clearTimeout(timer)
} }
if (!response.ok) {
throw new Error(`WebDAV fetch failed: ${response.status}`)
}
return response.json()
} }
async function createDir(config: { url: string; username: string; password: string }): Promise<void> { async function createDir(config: { url: string; username: string; password: string }): Promise<void> {
const base = config.url.replace(/\/+$/, '') const base = config.url.replace(/\/+$/, '')
const dirUrl = `${base}/weegschaal` const dirUrl = `${base}/weegschaal`
const auth = encodeBase64(`${config.username}:${config.password}`)
const response = await fetch(dirUrl, { const controller = new AbortController()
method: 'MKCOL', const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
headers: {
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
},
})
if (response.status !== 201 && response.status !== 405) { try {
throw new Error(`WebDAV directory create failed: ${response.status}`) const response = await fetch(dirUrl, {
method: 'MKCOL',
headers: { Authorization: `Basic ${auth}` },
signal: controller.signal,
})
if (response.status !== 201 && response.status !== 405) {
throw new Error(`WebDAV directory create failed: ${response.status}`)
}
} finally {
clearTimeout(timer)
} }
} }
@ -65,20 +87,28 @@ async function uploadRemote(
const base = config.url.replace(/\/+$/, '') const base = config.url.replace(/\/+$/, '')
const url = `${base}/${WEBDAV_PATH}` const url = `${base}/${WEBDAV_PATH}`
const body = JSON.stringify(measurements) const body = JSON.stringify(measurements)
const auth = encodeBase64(`${config.username}:${config.password}`)
await createDir(config) await createDir(config)
const response = await fetch(url, { const controller = new AbortController()
method: 'PUT', const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
headers: {
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
'Content-Type': 'application/json',
},
body,
})
if (!response.ok) { try {
throw new Error(`WebDAV upload failed: ${response.status}`) const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`WebDAV upload failed: ${response.status}`)
}
} finally {
clearTimeout(timer)
} }
} }
@ -90,7 +120,7 @@ export async function syncAll(): Promise<SyncResult> {
await initDatabase() await initDatabase()
const local = await getAllMeasurements() const local = await getAllMeasurementsIncludingDeleted()
const remote = await fetchRemote(config) const remote = await fetchRemote(config)
const merged = mergeMeasurements(local, remote) const merged = mergeMeasurements(local, remote)
@ -102,8 +132,8 @@ export async function syncAll(): Promise<SyncResult> {
await uploadRemote(config, merged) await uploadRemote(config, merged)
return { return {
local: local.length, local: local.filter(m => !m.deleted).length,
remote: remote.length, remote: remote.filter(m => !m.deleted).length,
merged: merged.length, merged: merged.length,
} }
} }

View file

@ -14,6 +14,7 @@ export type Measurement = {
createdAt: string createdAt: string
updatedAt: string updatedAt: string
syncStatus: SyncStatus syncStatus: SyncStatus
deleted: number
} }
export type WebDAVConfig = { export type WebDAVConfig = {

17
src/utils/base64.ts Normal file
View file

@ -0,0 +1,17 @@
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
export function encodeBase64(str: string): string {
const bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match, p1) => String.fromCharCode(parseInt(p1, 16)))
let result = ''
let i = 0
while (i < bytes.length) {
const a = bytes.charCodeAt(i++) || 0
const b = bytes.charCodeAt(i++) || 0
const c = bytes.charCodeAt(i++) || 0
result += CHARS.charAt(a >> 2)
result += CHARS.charAt(((a & 3) << 4) | (b >> 4))
result += CHARS.charAt(isNaN(b) ? CHARS.length - 1 : ((b & 15) << 2) | (c >> 6))
result += CHARS.charAt(isNaN(c) ? CHARS.length - 1 : c & 63)
}
return result
}

View file

@ -19,6 +19,7 @@ const mockMeasurement: Measurement = {
createdAt: '2024-01-01T08:00:00.000Z', createdAt: '2024-01-01T08:00:00.000Z',
updatedAt: '2024-01-01T08:00:00.000Z', updatedAt: '2024-01-01T08:00:00.000Z',
syncStatus: 'local', syncStatus: 'local',
deleted: 0,
} }
describe('database', () => { describe('database', () => {
@ -28,6 +29,8 @@ describe('database', () => {
let getAllMeasurements: any let getAllMeasurements: any
let getLatestMeasurement: any let getLatestMeasurement: any
let updateMeasurement: any let updateMeasurement: any
let deleteMeasurement: any
let getAllMeasurementsIncludingDeleted: any
beforeEach(async () => { beforeEach(async () => {
jest.resetModules() jest.resetModules()
@ -35,8 +38,8 @@ describe('database', () => {
openDatabaseAsync: jest.fn(), openDatabaseAsync: jest.fn(),
})) }))
db = { db = {
execAsync: jest.fn(), execAsync: jest.fn(() => Promise.resolve()),
runAsync: jest.fn(), runAsync: jest.fn(() => Promise.resolve()),
getAllAsync: jest.fn(), getAllAsync: jest.fn(),
} }
const sqlite = require('expo-sqlite') const sqlite = require('expo-sqlite')
@ -47,6 +50,8 @@ describe('database', () => {
getAllMeasurements = mod.getAllMeasurements getAllMeasurements = mod.getAllMeasurements
getLatestMeasurement = mod.getLatestMeasurement getLatestMeasurement = mod.getLatestMeasurement
updateMeasurement = mod.updateMeasurement updateMeasurement = mod.updateMeasurement
deleteMeasurement = mod.deleteMeasurement
getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted
}) })
it('initDatabase creates the measurements table', async () => { it('initDatabase creates the measurements table', async () => {
@ -91,4 +96,33 @@ describe('database', () => {
expect.arrayContaining(['synced', mockMeasurement.id]) expect.arrayContaining(['synced', mockMeasurement.id])
) )
}) })
it('deleteMeasurement marks measurement as deleted', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await deleteMeasurement(mockMeasurement.id)
expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('UPDATE measurements SET deleted = 1'),
expect.arrayContaining([mockMeasurement.id])
)
})
it('getAllMeasurements filters out deleted records', async () => {
await initDatabase()
await getAllMeasurements()
expect(db.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining('deleted = 0')
)
})
it('getAllMeasurementsIncludingDeleted includes deleted records', async () => {
await initDatabase()
await getAllMeasurementsIncludingDeleted()
expect(db.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM measurements')
)
expect(db.getAllAsync).not.toHaveBeenCalledWith(
expect.stringContaining('deleted = 0')
)
})
}) })

View file

@ -19,6 +19,7 @@ const base: Measurement = {
createdAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z', updatedAt: '2024-06-01T08:00:00.000Z',
syncStatus: 'local', syncStatus: 'local',
deleted: 0,
} }
describe('mergeMeasurements', () => { describe('mergeMeasurements', () => {
@ -54,4 +55,48 @@ describe('mergeMeasurements', () => {
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0].weight).toBe(77) expect(result[0].weight).toBe(77)
}) })
it('filters out locally deleted items even if present on remote', () => {
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(0)
})
it('filters out remotely deleted items when remote is newer', () => {
const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' }
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(0)
})
it('keeps local active item when local is newer than remote deletion', () => {
const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' }
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(78)
})
it('keeps active item when local deleted is older than remote active', () => {
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' }
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(75)
})
it('merges multiple active and deleted items correctly', () => {
const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' }
const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const }
const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const result = mergeMeasurements(
[localActive, localDeleted],
[remoteActive, remoteDeleted]
)
expect(result).toHaveLength(2)
expect(result.map(m => m.id).sort()).toEqual(['a', 'c'])
})
}) })