diff --git a/README.md b/README.md index 3ef2acb..f5f11e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _*Gemaakt met vibecoding — DeepSeek V4 Pro*_ -React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met timestamp-based delta-sync. +React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met outbox-pattern sync. ## Inhoud @@ -20,7 +20,7 @@ React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen ## Functionaliteit - **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling -- **Dashboard** met laatste en voorlaatste meting, gewichtsverschil, en grafiek +- **Dashboard** met laatste en voorlaatste meting (direct zichtbaar via progressieve loading), gewichtsverschil, en grafieken (gewicht, vetmassa, spiermassa) - **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc. - **Historie** van alle metingen, gesorteerd op datum - **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd @@ -30,7 +30,7 @@ React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen | Scherm | Beschrijving | |--------|-------------| -| Dashboard | Laatste meting, voorlaatste meting met gewichtsverschil, grafiek, deel-knop | +| Dashboard | Progressive loading: laatste 2 metingen direct zichtbaar, grafieken volgen daarna | | Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting | | Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen | | Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail | @@ -42,23 +42,19 @@ De app synchroniseert via een zelfgebouwde REST API. Geen WebDAV, geen bestandss ### Hoe het werkt -1. **Delta ophalen**: `GET /api/v1/measurements?since=lastSyncAt` — alleen gewijzigde records van de server -2. **Mergen**: last-writer-wins op `updatedAt` -3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` → POST naar API -4. **Verwijderen**: soft delete via `deletedAt` timestamp → DELETE naar API bij volgende sync -5. **High-water mark**: `lastSyncAt` bijgewerkt na elke succesvolle sync +1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server) +2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald +3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd +4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase +5. **Cursor tracking**: `lastSyncAt` (SecureStore) voor delta-pull, alleen records sinds laatste sync -### Waarom timestamp-based? +### Waarom outbox pattern? -Geen `syncStatus` enum, geen speciale gevallen. Hetzelfde patroon dat CouchDB en PouchDB gebruiken: -- `updatedAt` op elke wijziging (client-tijd) -- `deletedAt` voor soft deletes -- `lastSyncAt` (opgeslagen in SecureStore) voor delta-tracking -- Merge: last-writer-wins, zonder uitzonderingen +Betrouwbaarder dan timestamp-only push: `syncStatus` markeert expliciet wat gepusht moet worden, onafhankelijk van klokverschillen. Push vóór pull voorkomt dat eigen wijzigingen overschreven worden door oudere remote data. Local-first merge garandeert dat de gebruiker nooit data verliest. ### Offline resilience -Bij netwerkfout tijdens opslaan blijft de meting lokaal staan. De volgende sync (`updatedAt > lastSyncAt`) pikt hem automatisch op. Geen handmatige tussenkomst nodig. +Bij netwerkfout tijdens opslaan blijft de meting lokaal staan met `syncStatus='pending'`. De outbox (`getPendingRecords()`) pikt hem automatisch op bij de volgende sync of dashboard-focus. ## Server-side API @@ -141,11 +137,10 @@ Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14. ## Database migratie -Bestaande SQLite-databases worden automatisch gemigreerd. `initDatabase()` voegt de `deletedAt` kolom toe via `ALTER TABLE` als deze nog niet bestaat: +Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`: -```sql -ALTER TABLE measurements ADD COLUMN deletedAt TEXT -``` +- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat +- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'` ## Testen diff --git a/eas.json b/eas.json index 5d2510d..ec0c56b 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 5.9.1" + "version": ">= 5.9.1", + "appVersionSource": "local" }, "build": { "development": { diff --git a/src/components/WeightChart.tsx b/src/components/WeightChart.tsx index 5d1a183..8ddebff 100644 --- a/src/components/WeightChart.tsx +++ b/src/components/WeightChart.tsx @@ -78,6 +78,9 @@ function prepareLabels(dataPoints: Measurement[]) { function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) { const labels = prepareLabels(dataPoints) + const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0) + const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0) + return ( {title} @@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str color={COLORS.weight} unit=" kg" /> - m.fatMass)} - labels={labels} - color={COLORS.fatMass} - unit=" kg" - /> - m.muscleMass)} - labels={labels} - color={COLORS.muscleMass} - unit=" kg" - /> + {fatMassPoints.length >= 2 && ( + m.fatMass)} + labels={prepareLabels(fatMassPoints)} + color={COLORS.fatMass} + unit=" kg" + /> + )} + {muscleMassPoints.length >= 2 && ( + m.muscleMass)} + labels={prepareLabels(muscleMassPoints)} + color={COLORS.muscleMass} + unit=" kg" + /> + )} ) } diff --git a/src/database/db.ts b/src/database/db.ts index 5329850..7b1b42b 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite' import { Measurement } from '../types' let db: SQLite.SQLiteDatabase | null = null +let dbReady = false export async function initDatabase(): Promise { if (db) return @@ -20,13 +21,21 @@ 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 'pending', deletedAt TEXT ); `) try { await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT') } catch {} + + // Migrate existing records to crossbreed syncStatus model + if (!dbReady) { + try { + await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'") + } catch {} + dbReady = true + } } export async function insertMeasurement(m: Measurement): Promise { @@ -110,3 +119,14 @@ export async function setDeletedAt(id: string): Promise { export async function removeMeasurement(id: string): Promise { await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) } + +export async function updateSyncStatus(id: string, status: string): Promise { + await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id]) +} + +export async function getPendingRecords(): Promise { + const rows = await db!.getAllAsync( + "SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC" + ) + return rows as Measurement[] +} diff --git a/src/screens/DashboardScreen.tsx b/src/screens/DashboardScreen.tsx index 116abf3..741440f 100644 --- a/src/screens/DashboardScreen.tsx +++ b/src/screens/DashboardScreen.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { getCached, loadLatest, loadSince, refreshCache } from '../store/measurementStore' +import { getCached, getVersion, refreshCache } from '../store/measurementStore' +import { initDatabase, getLatestMeasurements } from '../database/db' import { Measurement } from '../types' import LatestMeasurementCard from '../components/LatestMeasurementCard' import WeightChart from '../components/WeightChart' @@ -11,45 +12,51 @@ type Props = { navigation: any } export default function DashboardScreen({ navigation }: Props) { const insets = useSafeAreaInsets() + const [latestTwo, setLatestTwo] = useState([]) const [all, setAll] = useState([]) + const [chartKey, setChartKey] = useState(0) + const seenVersion = useRef(0) useFocusEffect( useCallback(() => { + let active = true const load = async () => { - // 1. Laad alleen laatste 2 metingen voor de kaart (snel!) - await loadLatest(2) - setAll(getCached()) + await initDatabase() - // 2. Laad rest van de data op achtergrond voor grafieken + sync - const loadRest = async () => { - // Bereken cutoff datum voor 365 dagen - const cutoffDate = new Date() - cutoffDate.setDate(cutoffDate.getDate() - 365) - await loadSince(cutoffDate.toISOString()) + const two = await getLatestMeasurements(2) + if (!active) return + setLatestTwo(two) - // Update UI met grafiek-data (nog geen sync) - setAll(getCached()) - - // Sync met server (achtergrond) - try { - const { syncAll } = await import('../sync/syncService') - await syncAll() - await refreshCache() - } catch (e) { - console.warn('Sync failed:', e) - } - - // Update UI met eventuele nieuwe data van sync + await refreshCache() + if (!active) return + const v = getVersion() + if (v !== seenVersion.current) { setAll(getCached()) + setChartKey(v) + seenVersion.current = v + } + + try { + const { syncAll } = await import('../sync/syncService') + const result = await syncAll() + if (!active) return + if (result.deleted > 0 || result.pulled > 0) { + await refreshCache() + const v2 = getVersion() + setAll(getCached()) + setChartKey(v2) + seenVersion.current = v2 + } + } catch { + console.warn('Sync failed') } - loadRest() } load() + return () => { active = false } }, []) ) - // Toon laden als er nog geen data is (allereerste start) - if (all.length === 0) { + if (all.length === 0 && latestTwo.length === 0) { return ( @@ -73,6 +80,8 @@ export default function DashboardScreen({ navigation }: Props) { ) } + const display = all.length > 0 ? all : latestTwo + return ( @@ -84,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) { - - + + {all.length >= 2 && } { await initDatabase() await setDeletedAt(m.id) - removeFromCache(m.id) - try { - const config = await getApiConfig() - if (config) { - await createApi(config).deleteMeasurement(m.id) - await removeMeasurement(m.id) - } - load() - } catch { - load() - } + await updateSyncStatus(m.id, 'pending-delete') + await refreshCache() + load() }, }, ] diff --git a/src/screens/MeasurementDetailScreen.tsx b/src/screens/MeasurementDetailScreen.tsx index 09eaf56..8b3236f 100644 --- a/src/screens/MeasurementDetailScreen.tsx +++ b/src/screens/MeasurementDetailScreen.tsx @@ -2,11 +2,9 @@ import React, { useCallback, useState } from 'react' import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { initDatabase, getAllMeasurements, setDeletedAt, removeMeasurement } from '../database/db' -import { removeFromCache } from '../store/measurementStore' +import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db' +import { refreshCache } from '../store/measurementStore' import { Measurement } from '../types' -import { createApi } from '../sync/api' -import { getApiConfig } from './SettingsScreen' type Props = { navigation: any @@ -61,14 +59,8 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) { onPress: async () => { await initDatabase() await setDeletedAt(measurementId) - removeFromCache(measurementId) - try { - const config = await getApiConfig() - if (config) { - await createApi(config).deleteMeasurement(measurementId) - await removeMeasurement(measurementId) - } - } catch {} + await updateSyncStatus(measurementId, 'pending-delete') + await refreshCache() navigation.goBack() }, }, diff --git a/src/screens/NewMeasurementScreen.tsx b/src/screens/NewMeasurementScreen.tsx index 510ec31..1f249fc 100644 --- a/src/screens/NewMeasurementScreen.tsx +++ b/src/screens/NewMeasurementScreen.tsx @@ -4,7 +4,7 @@ import { useFocusEffect } from '@react-navigation/native' import MeasurementForm from '../components/MeasurementForm' import { Measurement } from '../types' import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db' -import { updateCache } from '../store/measurementStore' +import { refreshCache } from '../store/measurementStore' import { showSuccess, showError } from '../utils/toast' type Props = { @@ -61,7 +61,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) { { text: 'Annuleren', style: 'cancel' }, { text: 'Overschrijven', style: 'destructive', onPress: () => { m.id = existing.id - doSave(m) + upsertMeasurement(m).then(() => doSave(m)) }}, ] ) @@ -69,11 +69,11 @@ export default function NewMeasurementScreen({ navigation, route }: Props) { } await insertMeasurement(m) } - doSave(m) + await doSave(m) } - const doSave = (m: Measurement) => { - updateCache(m) + const doSave = async (m: Measurement) => { + await refreshCache() navigation.goBack() uploadInBackground(m) } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 51f0f6e..4fd283d 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -68,7 +68,11 @@ export default function SettingsScreen() { try { const { syncAll } = await import('../sync/syncService') const result = await syncAll() - Alert.alert('Sync voltooid', `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`) + let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}` + if (result.errors.length > 0) { + msg += `\n\nFouten: ${result.errors.join(', ')}` + } + Alert.alert('Sync voltooid', msg) } catch (e: any) { Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') } finally { diff --git a/src/store/measurementStore.ts b/src/store/measurementStore.ts index 4eb3088..c50fd30 100644 --- a/src/store/measurementStore.ts +++ b/src/store/measurementStore.ts @@ -1,59 +1,20 @@ import { Measurement } from '../types' -import { initDatabase, getAllMeasurements, getLatestMeasurements, getMeasurementsSince } from '../database/db' +import { initDatabase, getAllMeasurements } from '../database/db' -let cachedMeasurements: Measurement[] = [] -let initialized = false +let cache: Measurement[] = [] +let version = 0 -export async function loadFromDb(): Promise { +export async function refreshCache(): Promise { await initDatabase() - cachedMeasurements = await getAllMeasurements() - initialized = true - return cachedMeasurements -} - -export async function loadLatest(count: number): Promise { - await initDatabase() - const latest = await getLatestMeasurements(count) - cachedMeasurements = latest - initialized = true - return latest -} - -export async function loadSince(date: string): Promise { - await initDatabase() - const since = await getMeasurementsSince(date) - // Merge with existing cache (keep newer measurements) - const existingIds = new Set(cachedMeasurements.map(m => m.id)) - const newOnes = since.filter(m => !existingIds.has(m.id)) - cachedMeasurements = [...cachedMeasurements, ...newOnes] - cachedMeasurements.sort((a, b) => new Date(b.measuredAt).getTime() - new Date(a.measuredAt).getTime()) - return cachedMeasurements + cache = await getAllMeasurements() + version++ + return cache } export function getCached(): Measurement[] { - return cachedMeasurements + return cache } -export function updateCache(m: Measurement): void { - const idx = cachedMeasurements.findIndex(x => x.id === m.id) - if (idx >= 0) { - cachedMeasurements[idx] = m - } else { - cachedMeasurements.unshift(m) - } - // Re-sort by measuredAt descending - cachedMeasurements.sort((a, b) => new Date(b.measuredAt).getTime() - new Date(a.measuredAt).getTime()) -} - -export function removeFromCache(id: string): void { - cachedMeasurements = cachedMeasurements.filter(m => m.id !== id) -} - -export async function refreshCache(): Promise { - await loadFromDb() - return cachedMeasurements -} - -export function isInitialized(): boolean { - return initialized +export function getVersion(): number { + return version } diff --git a/src/sync/syncService.ts b/src/sync/syncService.ts index bf33318..8b2def8 100644 --- a/src/sync/syncService.ts +++ b/src/sync/syncService.ts @@ -3,33 +3,57 @@ import { initDatabase, getAllMeasurementsForSync, upsertMeasurement, - getMeasurementsUpdatedSince, + getPendingRecords, + updateSyncStatus, removeMeasurement, } from '../database/db' -import { createApi } from './api' -import { ApiError } from './api' +import { createApi, ApiError } from './api' import * as SecureStore from 'expo-secure-store' const LAST_SYNC_KEY = 'weegschaal_last_sync' -export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { - const map = new Map() +type SyncResult = { + pushed: number + deleted: number + pulled: number + errors: string[] +} - for (const m of local) { - map.set(m.id, m) - } +export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] { + const localMap = new Map(local.map(m => [m.id, m])) + const resultMap = new Map(local.map(m => [m.id, m])) for (const r of remote) { - const existing = map.get(r.id) - if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) { - map.set(r.id, { - ...r, - deletedAt: (r as any).deleted ? r.updatedAt : (r.deletedAt || undefined), - } as Measurement) + const localRec = localMap.get(r.id) + const isDeleted = !!(r as any).deleted + const localPending = + localRec?.syncStatus === 'pending' || + localRec?.syncStatus === 'pending-delete' + + if (isDeleted) { + if (!localRec || localPending) continue + if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { + resultMap.set(r.id, { + ...r, + syncStatus: 'synced', + deletedAt: (r as any).deletedAt || r.updatedAt, + } as Measurement) + } + continue + } + + if (localPending) continue + + if (localRec) { + if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { + resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement) + } + } else { + resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement) } } - return Array.from(map.values()) + return Array.from(resultMap.values()) } export async function uploadMeasurement(m: Measurement): Promise { @@ -38,9 +62,10 @@ export async function uploadMeasurement(m: Measurement): Promise { if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') const api = createApi(config) await api.createMeasurement(m) + await updateSyncStatus(m.id, 'synced') } -export async function syncAll(): Promise<{ pushed: number; deleted: number; pulled: number }> { +export async function syncAll(): Promise { const { getApiConfig } = await import('../screens/SettingsScreen') const config = await getApiConfig() if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') @@ -48,62 +73,91 @@ export async function syncAll(): Promise<{ pushed: number; deleted: number; pull const api = createApi(config) await initDatabase() - const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) - - const remote = await api.getMeasurements(lastSyncAt || undefined) - const remoteUpdatedMap = new Map() - const remoteDeletedIds = new Set() - - for (const r of remote) { - remoteUpdatedMap.set(r.id, r.updatedAt) - if ((r as any).deleted) { - remoteDeletedIds.add(r.id) - } - } - - const local = await getAllMeasurementsForSync() - const merged = mergeMeasurements(local, remote) - - for (const m of merged) { - const remoteUA = remoteUpdatedMap.get(m.id) - if (remoteUA && new Date(remoteUA) >= new Date(m.updatedAt)) { - if (remoteDeletedIds.has(m.id)) { - await removeMeasurement(m.id) - } else { - await upsertMeasurement(m) - } - } else { - await upsertMeasurement(m) - } - } - - const cutoff = lastSyncAt || '1970-01-01T00:00:00.000Z' - const updated = await getMeasurementsUpdatedSince(cutoff) - + const errors: string[] = [] let pushed = 0 let deleted = 0 + let pulled = 0 - for (const m of updated) { - const remoteUA = remoteUpdatedMap.get(m.id) - if (remoteUA && new Date(remoteUA) >= new Date(m.updatedAt)) { - continue - } + // === PUSH phase — outbox first === + const pending = await getPendingRecords() + for (const m of pending) { if (m.deletedAt) { try { await api.deleteMeasurement(m.id) await removeMeasurement(m.id) deleted++ - } catch {} + } catch (e: any) { + if (e instanceof ApiError && e.status === 404) { + await removeMeasurement(m.id) + deleted++ + } else { + errors.push(e?.message ?? 'Delete failed') + } + } } else { try { await api.createMeasurement(m) + await updateSyncStatus(m.id, 'synced') pushed++ - } catch {} + } catch (e: any) { + errors.push(e?.message ?? 'Create failed') + } + } + } + + // === PULL phase — fetch remote changes === + const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) + const remote = await api.getMeasurements(lastSyncAt || undefined) + + if (remote.length > 0) { + const local = await getAllMeasurementsForSync() + const localMap = new Map(local.map(m => [m.id, m])) + + for (const r of remote) { + const localRec = localMap.get(r.id) + const isDeleted = !!(r as any).deleted + + if (isDeleted) { + if (localRec) { + if ( + localRec.syncStatus === 'pending' || + localRec.syncStatus === 'pending-delete' + ) { + continue + } + await removeMeasurement(r.id) + deleted++ + } + continue + } + + const localIsPending = + localRec?.syncStatus === 'pending' || + localRec?.syncStatus === 'pending-delete' + + if (localIsPending) { + continue + } + + if (localRec) { + if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { + const record = { ...r, deletedAt: (r as any).deletedAt || undefined } as Measurement + await upsertMeasurement(record) + await updateSyncStatus(r.id, 'synced') + pulled++ + } + continue + } + + const record = { ...r, deletedAt: (r as any).deletedAt || undefined } as Measurement + await upsertMeasurement(record) + await updateSyncStatus(r.id, 'synced') + pulled++ } } await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) - return { pushed, deleted, pulled: remote.length } + return { pushed, deleted, pulled, errors } } diff --git a/src/types.ts b/src/types.ts index ea4373d..904cd84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,7 @@ export type Measurement = { measuredAt: string createdAt: string updatedAt: string + syncStatus?: string deletedAt?: string } diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts index e07b21a..e33cc27 100644 --- a/tests/syncService.test.ts +++ b/tests/syncService.test.ts @@ -7,7 +7,7 @@ const make = (overrides: Partial = {}): 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', ...overrides, + updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides, }) describe('mergeMeasurements', () => { @@ -40,37 +40,47 @@ describe('mergeMeasurements', () => { expect(r[0].weight).toBe(77) }) - it('remote deleted wins over older local', () => { + it('remote deleted wins over synced local', () => { const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) - const remote = make({ updatedAt: '2024-06-03T08:00:00.000Z', deletedAt: '2024-06-03T08:00:00.000Z' }) + const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } const r = mergeMeasurements([local], [remote]) expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') }) - it('local wins over older remote deleted', () => { + it('local synced wins over older remote deleted', () => { const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' }) - const remote = make({ updatedAt: '2024-06-01T08:00:00.000Z', deletedAt: '2024-06-01T08:00:00.000Z' }) + const remote: any = { ...make({ updatedAt: '2024-06-01T08:00:00.000Z' }), deleted: true } const r = mergeMeasurements([local], [remote]) expect(r[0].deletedAt).toBeUndefined() }) - it('server deleted flag mapped to deletedAt when remote wins', () => { - const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) + it('local pending-delete wins over remote deleted', () => { + const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' }) const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } const r = mergeMeasurements([local], [remote]) - expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') + expect(r[0].syncStatus).toBe('pending-delete') + expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z') }) - it('server deleted flag ignored when local wins', () => { - const local = make({ updatedAt: '2024-06-05T08:00:00.000Z' }) - const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } + it('local pending wins over remote newer', () => { + const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' }) + const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) } const r = mergeMeasurements([local], [remote]) - expect(r[0].deletedAt).toBeUndefined() + expect(r[0].weight).toBe(75) + expect(r[0].syncStatus).toBe('pending') }) - it('server deleted flag works with no local record', () => { + it('remote deleted with no local record is skipped', () => { const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } const r = mergeMeasurements([], [remote]) - expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') + expect(r).toHaveLength(0) + }) + + it('local without syncStatus treated as synced', () => { + const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' }) + delete local.syncStatus + const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].weight).toBe(76) }) })