Compare commits

...
Author SHA1 Message Date
aafa041840 Merge pull request 'fix: remove hardcoded API token from SettingsScreen' (#16) from fix/issue-2-remove-hardcoded-token into main 2026-08-05 21:32:45 +02:00
eddy
d94628ce86 fix: remove hardcoded API token from SettingsScreen
- Replace hardcoded Bearer token default with empty string
- Remove auto-save in useEffect that wrote defaults to SecureStore
  without user consent on first app launch

Fixes #2
2026-08-05 21:20:34 +02:00
eddy
a77b31f95c feat: outbox-pattern sync + progressive dashboard loading
- Sync herschreven: push-vóór-pull, outbox met syncStatus (pending/pending-delete/synced), local-first merge
- Dashboard: progressieve loading — laatste 2 metingen direct, grafieken volgen daarna
- measurementStore versimpeld naar refreshCache/getCached/getVersion (20 regels)
- db.ts: getPendingRecords(), updateSyncStatus(), migratie local→synced, syncStatus DEFAULT 'pending'
- WeightChart: filtert 0-waarden uit vetmassa/spiermassa, verbergt chart bij <2 datapunten
- HistoryScreen/MeasurementDetailScreen: delete flow naar DB-only (setDeletedAt + updateSyncStatus)
- eas.json: appVersionSource toegevoegd tegen waarschuwing
- README bijgewerkt met outbox-pattern documentatie
- 16/16 tests, TypeScript clean
2026-07-25 17:27:54 +02:00
eddy
a32b9e0a88 fix: code review cleanup — filter deletedAt, deduplicate, use toast util
- getMeasurementByDate: AND deletedAt IS NULL (soft-deleted meting triggerde dubbele-datum alert)
- Dashboard/History: verwijder redundante JS .filter(m => !m.deletedAt) (SQL filterde al)
- HistoryScreen confirmDelete: fix dubbele load() call
- NewMeasurementScreen: gebruik toast utility i.p.v. direct ToastAndroid
2026-07-25 15:54:10 +02:00
eddy
4c34dff629 fix: sync refactor — fix 6 bugs + cache refresh na sync
- setDeletedAt update nu ook updatedAt (bug 2)
- getAllMeasurements filtert deletedAt IS NULL, nieuwe getAllMeasurementsForSync voor sync
- mergeMeasurements detecteert server deleted:true → deletedAt (bug 1)
- syncAll: pull-push split met remoteUpdatedMap, geen double-push (bug 3)
- hard delete alleen na API-succes (bug 4)
- DashboardScreen: refreshCache() na syncAll zodat UI niet op Laden blijft hangen
- deleteMeasurement() dead code verwijderd (bug 5)
- tests: 3 nieuwe testcases voor deleted → deletedAt mapping, 15/15 pass
2026-07-25 15:33:22 +02:00
17 changed files with 527 additions and 146 deletions

View file

@ -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

View file

@ -0,0 +1,139 @@
# Sync refactor — Implementation Plan ✅ (completed 2026-07-25)
> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync.
>
> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd.
## Bug Overzicht
| # | Bug | Oorzaak | Fix |
|---|-----|---------|-----|
| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` |
| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE |
| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt |
| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes |
| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact |
| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` |
## Sync Protocol (vastgelegd)
```
lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY)
── PULL ──
remote[] = GET /api/v1/measurements?since=lastSyncAt
local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted)
remoteUpdatedMap: Map<id, updatedAt> // track welke IDs + timestamps we binnenhaalden
for each r in remote:
remoteUpdatedMap.set(r.id, r.updatedAt)
if (r as any).deleted:
r.deletedAt = r.updatedAt // Bug 1 fix
── MERGE ──
remoteDeletedIds: Set<id> // track welke remote records 'deleted' zijn
for each r in remote:
if (r as any).deleted:
remoteDeletedIds.add(r.id)
merged = mergeMeasurements(local, remote)
for each m in merged:
remoteUA = remoteUpdatedMap.get(m.id)
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
// remote won de merge
if remoteDeletedIds.has(m.id):
removeMeasurement(m.id) // hard-delete lokaal (server heeft 'm al)
else:
upsertMeasurement(m) // remote gewijzigd, niet verwijderd
else:
upsertMeasurement(m) // local won of nieuw → gewoon bewaren
── PUSH ──
cutoff = lastSyncAt ?? '1970-01-01T00:00:00.000Z'
localChanges[] = getMeasurementsUpdatedSince(cutoff)
for each m in localChanges:
remoteUA = remoteUpdatedMap.get(m.id)
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
continue // remote won merge → niet terugpushen (Bug 3 fix)
if m.deletedAt:
DELETE /api/v1/measurements/:id
if success:
removeMeasurement(m.id) // Bug 4 fix: alleen hard-delete bij API-succes
else:
POST /api/v1/measurements
SecureStore.setItem(LAST_SYNC_KEY, now())
```
## Delete Protocol (vastgelegd)
```
Lokaal: setDeletedAt(id) + updatedAt = now() (Bug 2 fix)
Cache: removeFromCache(id)
API: DELETE /api/v1/measurements/:id
if success:
Lokaal: removeMeasurement(id) (Bug 4 fix: alleen bij succes)
// bij falen: blijft met deletedAt in DB → syncAll pakt op
```
## Files om te wijzigen
### 1. `src/sync/syncService.ts` — REWRITE syncAll + mergeMeasurements
**Wijzigingen:**
- `mergeMeasurements`: detecteer `(r as any).deleted` → zet `deletedAt = r.updatedAt`
- `syncAll`:
- Track `remoteUpdatedMap` in pull-fase
- Merge via bestaande `mergeMeasurements`
- Push: skip records waar remote.updatedAt ≥ local.updatedAt (remote won)
- Alleen `removeMeasurement` na succesvolle API DELETE
- Exporteer `cleanupDeletedRecord(id)` voor gebruik door screens (DRY)
### 2. `src/database/db.ts` — setDeletedAt fix + query filters
**Wijzigingen:**
- `setDeletedAt(id)`: voeg `updatedAt = ?` toe aan SQL
- `getAllMeasurements()`: voeg `WHERE deletedAt IS NULL` toe
- `getLatestMeasurement()`: voeg `WHERE deletedAt IS NULL` toe
- Nieuw: `getAllMeasurementsForSync()` — ongefilterd (voor syncService)
- Verwijder: `deleteMeasurement()` (dode code, duplicate van `removeMeasurement`)
### 3. `src/screens/HistoryScreen.tsx` — delete fix
**Wijzigingen:**
- `confirmDelete`: alleen `removeMeasurement()` na API-succes
- `load`: gebruik `getMeasurementsSince('1970-01-01')` i.p.v. `getAllMeasurements()` (of de nieuwe `getAllMeasurements` die filtert)
### 4. `src/screens/MeasurementDetailScreen.tsx` — delete fix
**Wijzigingen:**
- `handleDelete`: alleen `removeMeasurement()` na API-succes
### 5. `src/store/measurementStore.ts` — geen wijzigingen nodig
De store gebruikt `getLatestMeasurements(count)` en `getMeasurementsSince(date)` die al filteren. `/loadFromDb` gebruikt `getAllMeasurements()` → die gaat nu ook filteren. ✓
### 6. `tests/syncService.test.ts` — nieuwe test cases
**Wijzigingen:**
- Test: `deleted` van server wordt correct naar `deletedAt` gemapt
- Test: syncAll push skips remote-won records
- Test: syncAll push stuurt local-won records wel
### 7. `tests/database.test.ts` — update voor nieuwe functies
**Wijzigingen:**
- Test `setDeletedAt` update ook `updatedAt`
- Test `getAllMeasurementsForSync` returned deleted records
## Uitvoeringsvolgorde
1. `db.ts` — setDeletedAt fix + query filters
2. `syncService.ts` — merge fix + syncAll refactor
3. `HistoryScreen.tsx` + `MeasurementDetailScreen.tsx` — delete fix
4. Tests updaten + TypeScript check + `npm test`
5. Build APK, testen

View file

@ -1,6 +1,7 @@
{
"cli": {
"version": ">= 5.9.1"
"version": ">= 5.9.1",
"appVersionSource": "local"
},
"build": {
"development": {

View file

@ -30,8 +30,8 @@
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest"
},

View file

@ -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 (
<View style={styles.page}>
<Text style={styles.title}>{title}</Text>
@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str
color={COLORS.weight}
unit=" kg"
/>
{fatMassPoints.length >= 2 && (
<SingleChart
title="Vetmassa"
data={dataPoints.map(m => m.fatMass)}
labels={labels}
data={fatMassPoints.map(m => m.fatMass)}
labels={prepareLabels(fatMassPoints)}
color={COLORS.fatMass}
unit=" kg"
/>
)}
{muscleMassPoints.length >= 2 && (
<SingleChart
title="Spiermassa"
data={dataPoints.map(m => m.muscleMass)}
labels={labels}
data={muscleMassPoints.map(m => m.muscleMass)}
labels={prepareLabels(muscleMassPoints)}
color={COLORS.muscleMass}
unit=" kg"
/>
)}
</View>
)
}

View file

@ -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<void> {
if (db) return
@ -20,13 +21,21 @@ 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 '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<void> {
@ -38,6 +47,13 @@ export async function insertMeasurement(m: Measurement): Promise<void> {
}
export async function getAllMeasurements(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC'
)
return rows as Measurement[]
}
export async function getAllMeasurementsForSync(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC'
)
@ -46,15 +62,31 @@ export async function getAllMeasurements(): Promise<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 deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
)
return rows.length > 0 ? (rows[0] as Measurement) : null
}
export async function getLatestMeasurements(count: number): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?',
[count]
)
return rows as Measurement[]
}
export async function getMeasurementsSince(date: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE measuredAt >= ? AND deletedAt IS NULL ORDER BY measuredAt DESC',
[date]
)
return rows as Measurement[]
}
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 measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1",
[`${day}%`]
)
return rows.length > 0 ? (rows[0] as Measurement) : null
@ -68,10 +100,6 @@ export async function upsertMeasurement(m: Measurement): Promise<void> {
)
}
export async function deleteMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
}
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
@ -81,12 +109,24 @@ export async function getMeasurementsUpdatedSince(since: string): Promise<Measur
}
export async function setDeletedAt(id: string): Promise<void> {
const now = new Date().toISOString()
await db!.runAsync(
'UPDATE measurements SET deletedAt = ? WHERE id = ?',
[new Date().toISOString(), id]
'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
[now, now, id]
)
}
export async function removeMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
}
export async function updateSyncStatus(id: string, status: string): Promise<void> {
await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id])
}
export async function getPendingRecords(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
)
return rows as Measurement[]
}

View file

@ -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 { initDatabase, getAllMeasurements } from '../database/db'
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,23 +12,76 @@ type Props = { navigation: any }
export default function DashboardScreen({ navigation }: Props) {
const insets = useSafeAreaInsets()
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
const [all, setAll] = useState<Measurement[]>([])
const [loading, setLoading] = useState(true)
const [chartKey, setChartKey] = useState(0)
const seenVersion = useRef(0)
useFocusEffect(
useCallback(() => {
let active = true
const load = async () => {
setLoading(true)
await initDatabase()
const allM = await getAllMeasurements()
const visible = allM.filter(m => !m.deletedAt)
setAll(visible)
setLoading(false)
const two = await getLatestMeasurements(2)
if (!active) return
setLatestTwo(two)
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')
}
}
load()
return () => { active = false }
}, [])
)
if (all.length === 0 && latestTwo.length === 0) {
return (
<View style={styles.outer}>
<View style={styles.navBar}>
<TouchableOpacity onPress={() => navigation.navigate('History')}>
<Text style={styles.navLink}>Historie</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
<Text style={styles.navLink}>Instellingen</Text>
</TouchableOpacity>
</View>
<View style={styles.loadingContainer}>
<Text style={styles.loading}>Laden...</Text>
</View>
<TouchableOpacity
style={[styles.fab, { marginBottom: insets.bottom + 16 }]}
onPress={() => navigation.navigate('NewMeasurement')}
>
<Text style={styles.fabText}>+ Nieuwe meting</Text>
</TouchableOpacity>
</View>
)
}
const display = all.length > 0 ? all : latestTwo
return (
<View style={styles.outer}>
<View style={styles.navBar}>
@ -39,14 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
</TouchableOpacity>
</View>
<ScrollView style={styles.container}>
{loading ? (
<Text style={styles.loading}>Laden...</Text>
) : (
<>
<LatestMeasurementCard measurements={all} />
<WeightChart measurements={all} />
</>
)}
<LatestMeasurementCard measurements={display} />
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
</ScrollView>
<TouchableOpacity
@ -66,5 +114,6 @@ const styles = StyleSheet.create({
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
loading: { fontSize: 16, color: '#999', textAlign: 'center', padding: 40 },
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loading: { fontSize: 16, color: '#999' },
})

View file

@ -1,10 +1,9 @@
import React, { useCallback, useState } from 'react'
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { initDatabase, getAllMeasurements, setDeletedAt, removeMeasurement } from '../database/db'
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'
import MeasurementRow from '../components/MeasurementRow'
type Props = { navigation: any }
@ -16,7 +15,7 @@ export default function HistoryScreen({ navigation }: Props) {
const load = useCallback(async () => {
await initDatabase()
const all = await getAllMeasurements()
setMeasurements(all.filter(m => !m.deletedAt))
setMeasurements(all)
}, [])
useFocusEffect(
@ -64,13 +63,8 @@ export default function HistoryScreen({ navigation }: Props) {
onPress: async () => {
await initDatabase()
await setDeletedAt(m.id)
try {
const config = await getApiConfig()
if (config) {
await createApi(config).deleteMeasurement(m.id)
await removeMeasurement(m.id)
}
} catch {}
await updateSyncStatus(m.id, 'pending-delete')
await refreshCache()
load()
},
},

View file

@ -2,10 +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 { 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
@ -60,13 +59,8 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) {
onPress: async () => {
await initDatabase()
await setDeletedAt(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()
},
},

View file

@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useState } from 'react'
import { View, Text, Alert } from 'react-native'
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 { uploadMeasurement } from '../sync/syncService'
import { refreshCache } from '../store/measurementStore'
import { showSuccess, showError } from '../utils/toast'
type Props = {
navigation: any
@ -60,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))
}},
]
)
@ -68,19 +69,22 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
}
await insertMeasurement(m)
}
doSave(m)
await doSave(m)
}
const doSave = async (m: Measurement) => {
await refreshCache()
navigation.goBack()
uploadInBackground(m)
}
const uploadInBackground = async (m: Measurement) => {
try {
const { uploadMeasurement } = await import('../sync/syncService')
await uploadMeasurement(m)
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
{ text: 'OK', onPress: () => navigation.goBack() },
])
showSuccess('Opgeslagen & gesynct')
} catch {
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
{ text: 'OK', onPress: () => navigation.goBack() },
])
showError('Opgeslagen (sync mislukt)')
}
}

View file

@ -14,7 +14,7 @@ async function loadConfig(): Promise<ApiConfig | null> {
}
}
async function saveConfig(config: ApiConfig): Promise<void> {
export async function saveConfig(config: ApiConfig): Promise<void> {
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
}
@ -66,7 +66,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 {
@ -99,7 +103,7 @@ export default function SettingsScreen() {
autoCorrect={false}
/>
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
<Text style={styles.eyeIcon}>{showToken ? 'O' : 'O'}</Text>
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
</TouchableOpacity>
</View>

View file

@ -0,0 +1,20 @@
import { Measurement } from '../types'
import { initDatabase, getAllMeasurements } from '../database/db'
let cache: Measurement[] = []
let version = 0
export async function refreshCache(): Promise<Measurement[]> {
await initDatabase()
cache = await getAllMeasurements()
version++
return cache
}
export function getCached(): Measurement[] {
return cache
}
export function getVersion(): number {
return version
}

View file

@ -1,35 +1,59 @@
import { Measurement } from '../types'
import {
initDatabase,
getAllMeasurements,
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<string, Measurement>()
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, {
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,
deletedAt: r.deletedAt || undefined,
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<void> {
@ -38,9 +62,10 @@ export async function uploadMeasurement(m: Measurement): Promise<void> {
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<SyncResult> {
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,41 +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 local = await getAllMeasurements()
const merged = mergeMeasurements(local, remote)
for (const m of merged) {
await upsertMeasurement(m)
}
const since = lastSyncAt || '1970-01-01T00:00:00.000Z'
const updated = await getMeasurementsUpdatedSince(since)
const errors: string[] = []
let pushed = 0
let deleted = 0
let pulled = 0
await Promise.allSettled(
updated.map(async m => {
if (m.deletedAt && m.deletedAt > since) {
// === 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 }
}

View file

@ -11,6 +11,7 @@ export type Measurement = {
measuredAt: string
createdAt: string
updatedAt: string
syncStatus?: string
deletedAt?: string
}

26
src/utils/toast.ts Normal file
View file

@ -0,0 +1,26 @@
import { Platform, ToastAndroid } from 'react-native'
let _toastQueue: Array<{message: string; duration: number}> = []
let _showFn: ((message: string, duration: number) => void) | null = null
export function setToastShowFn(fn: (message: string, duration: number) => void) {
_showFn = fn
}
export function showToast(message: string, duration = 3000) {
if (_showFn) {
_showFn(message, duration)
return
}
if (Platform.OS === 'android') {
ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT)
}
}
export function showSuccess(message: string) {
showToast(message, 2500)
}
export function showError(message: string) {
showToast(message, 4000)
}

View file

@ -81,7 +81,7 @@ describe('database', () => {
expect(result!.id).toBe(mockMeasurement.id)
})
it('setDeletedAt sets the deletedAt timestamp', async () => {
it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await setDeletedAt(mockMeasurement.id)
@ -89,5 +89,7 @@ describe('database', () => {
expect.stringContaining('UPDATE measurements SET deletedAt'),
expect.arrayContaining([mockMeasurement.id])
)
const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
expect(args[1][0]).toBe(args[1][1])
})
})

View file

@ -7,7 +7,7 @@ const make = (overrides: Partial<Measurement> = {}): 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,17 +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('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].syncStatus).toBe('pending-delete')
expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z')
})
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].weight).toBe(75)
expect(r[0].syncStatus).toBe('pending')
})
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).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)
})
})