Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0513db46ac |
10 changed files with 207 additions and 58 deletions
|
|
@ -72,6 +72,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
syncStatus: 'local',
|
||||
deleted: 0,
|
||||
}
|
||||
onSubmit(m)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
17
src/utils/base64.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue