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,
updatedAt: now,
syncStatus: 'local',
deleted: 0,
}
onSubmit(m)
}

View file

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

View file

@ -20,29 +20,36 @@ export async function initDatabase(): Promise<void> {
measuredAt TEXT NOT NULL,
createdAt 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> {
await db!.runAsync(
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
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]
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
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, 0]
)
}
function activeOnly(): string {
return 'deleted = 0'
}
export async function getAllMeasurements(): Promise<Measurement[]> {
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[]
}
export async function getLatestMeasurement(): Promise<Measurement | null> {
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
}
@ -50,7 +57,7 @@ export async function getLatestMeasurement(): Promise<Measurement | null> {
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
const day = date.slice(0, 10)
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}%`]
)
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> {
await db!.runAsync(
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
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]
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
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.deleted]
)
}
@ -79,5 +86,15 @@ export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
}
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 {
const { syncAll } = await import('../sync/syncService')
await syncAll()
} catch {}
} catch (e) {
console.warn('Sync failed after delete:', e)
}
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?`,
[
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) },
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing) },
]
)
return
@ -74,9 +74,10 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
doSave(m)
}
const doSave = async (m: Measurement, existingId?: string) => {
if (existingId) {
m.id = existingId
const doSave = async (m: Measurement, existing?: Measurement) => {
if (existing) {
m.id = existing.id
m.createdAt = existing.createdAt
await upsertMeasurement(m)
} else {
await insertMeasurement(m)

View file

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

View file

@ -14,6 +14,7 @@ export type Measurement = {
createdAt: string
updatedAt: string
syncStatus: SyncStatus
deleted: number
}
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',
updatedAt: '2024-01-01T08:00:00.000Z',
syncStatus: 'local',
deleted: 0,
}
describe('database', () => {
@ -28,6 +29,8 @@ describe('database', () => {
let getAllMeasurements: any
let getLatestMeasurement: any
let updateMeasurement: any
let deleteMeasurement: any
let getAllMeasurementsIncludingDeleted: any
beforeEach(async () => {
jest.resetModules()
@ -35,8 +38,8 @@ describe('database', () => {
openDatabaseAsync: jest.fn(),
}))
db = {
execAsync: jest.fn(),
runAsync: jest.fn(),
execAsync: jest.fn(() => Promise.resolve()),
runAsync: jest.fn(() => Promise.resolve()),
getAllAsync: jest.fn(),
}
const sqlite = require('expo-sqlite')
@ -47,6 +50,8 @@ describe('database', () => {
getAllMeasurements = mod.getAllMeasurements
getLatestMeasurement = mod.getLatestMeasurement
updateMeasurement = mod.updateMeasurement
deleteMeasurement = mod.deleteMeasurement
getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted
})
it('initDatabase creates the measurements table', async () => {
@ -91,4 +96,33 @@ describe('database', () => {
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',
updatedAt: '2024-06-01T08:00:00.000Z',
syncStatus: 'local',
deleted: 0,
}
describe('mergeMeasurements', () => {
@ -54,4 +55,48 @@ describe('mergeMeasurements', () => {
expect(result).toHaveLength(1)
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'])
})
})