diff --git a/src/components/MeasurementForm.tsx b/src/components/MeasurementForm.tsx index 9888c37..f79293e 100644 --- a/src/components/MeasurementForm.tsx +++ b/src/components/MeasurementForm.tsx @@ -72,6 +72,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) { createdAt: now, updatedAt: now, syncStatus: 'local', + deleted: 0, } onSubmit(m) } diff --git a/src/components/WeightChart.tsx b/src/components/WeightChart.tsx index 7ea663f..14ebf19 100644 --- a/src/components/WeightChart.tsx +++ b/src/components/WeightChart.tsx @@ -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 ( {title} color, strokeWidth: 2 }] }} - width={Dimensions.get('window').width - 32} + width={width - 32} height={180} yAxisSuffix={unit} chartConfig={{ diff --git a/src/database/db.ts b/src/database/db.ts index 77b8fe9..c2e59e8 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -20,29 +20,36 @@ export async function initDatabase(): Promise { 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 { 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 { const rows = await db!.getAllAsync( - '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 { const rows = await db!.getAllAsync( - '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 { export async function getMeasurementByDate(date: string): Promise { const day = date.slice(0, 10) const rows = await db!.getAllAsync( - "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 { 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 { } export async function deleteMeasurement(id: string): Promise { - 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 { + const rows = await db!.getAllAsync( + 'SELECT * FROM measurements ORDER BY measuredAt DESC' + ) + return rows as Measurement[] } diff --git a/src/screens/HistoryScreen.tsx b/src/screens/HistoryScreen.tsx index e84f507..9277740 100644 --- a/src/screens/HistoryScreen.tsx +++ b/src/screens/HistoryScreen.tsx @@ -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() }, }, diff --git a/src/screens/NewMeasurementScreen.tsx b/src/screens/NewMeasurementScreen.tsx index 82eb6f3..12cb0d2 100644 --- a/src/screens/NewMeasurementScreen.tsx +++ b/src/screens/NewMeasurementScreen.tsx @@ -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) diff --git a/src/sync/syncService.ts b/src/sync/syncService.ts index 1bc0991..b232fca 100644 --- a/src/sync/syncService.ts +++ b/src/sync/syncService.ts @@ -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 { 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 { 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 { 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 { 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, } } diff --git a/src/types.ts b/src/types.ts index 1d759d0..3006423 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export type Measurement = { createdAt: string updatedAt: string syncStatus: SyncStatus + deleted: number } export type WebDAVConfig = { diff --git a/src/utils/base64.ts b/src/utils/base64.ts new file mode 100644 index 0000000..ec8d079 --- /dev/null +++ b/src/utils/base64.ts @@ -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 +} diff --git a/tests/database.test.ts b/tests/database.test.ts index 48a7f22..becdf28 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -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') + ) + }) }) diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts index fae08ca..dd2ff6e 100644 --- a/tests/syncService.test.ts +++ b/tests/syncService.test.ts @@ -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']) + }) })