Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a37259688a | |||
|
|
a27a96957d | ||
|
|
aca968bbda | ||
|
|
d09951c493 | ||
|
|
df05fb75dd |
17 changed files with 527 additions and 146 deletions
33
README.md
33
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
_*Gemaakt met vibecoding — DeepSeek V4 Pro*_
|
_*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
|
## Inhoud
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen
|
||||||
## Functionaliteit
|
## Functionaliteit
|
||||||
|
|
||||||
- **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling
|
- **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.
|
- **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc.
|
||||||
- **Historie** van alle metingen, gesorteerd op datum
|
- **Historie** van alle metingen, gesorteerd op datum
|
||||||
- **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd
|
- **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 |
|
| 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 |
|
| Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting |
|
||||||
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
|
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
|
||||||
| Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail |
|
| 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
|
### Hoe het werkt
|
||||||
|
|
||||||
1. **Delta ophalen**: `GET /api/v1/measurements?since=lastSyncAt` — alleen gewijzigde records van de server
|
1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server)
|
||||||
2. **Mergen**: last-writer-wins op `updatedAt`
|
2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald
|
||||||
3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` → POST naar API
|
3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd
|
||||||
4. **Verwijderen**: soft delete via `deletedAt` timestamp → DELETE naar API bij volgende sync
|
4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase
|
||||||
5. **High-water mark**: `lastSyncAt` bijgewerkt na elke succesvolle sync
|
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:
|
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.
|
||||||
- `updatedAt` op elke wijziging (client-tijd)
|
|
||||||
- `deletedAt` voor soft deletes
|
|
||||||
- `lastSyncAt` (opgeslagen in SecureStore) voor delta-tracking
|
|
||||||
- Merge: last-writer-wins, zonder uitzonderingen
|
|
||||||
|
|
||||||
### Offline resilience
|
### 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
|
## Server-side API
|
||||||
|
|
||||||
|
|
@ -141,11 +137,10 @@ Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
|
||||||
|
|
||||||
## Database migratie
|
## 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
|
- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat
|
||||||
ALTER TABLE measurements ADD COLUMN deletedAt TEXT
|
- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'`
|
||||||
```
|
|
||||||
|
|
||||||
## Testen
|
## Testen
|
||||||
|
|
||||||
|
|
|
||||||
139
docs/superpowers/plans/2026-07-25-sync-refactor-plan.md
Normal file
139
docs/superpowers/plans/2026-07-25-sync-refactor-plan.md
Normal 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
|
||||||
3
eas.json
3
eas.json
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 5.9.1"
|
"version": ">= 5.9.1",
|
||||||
|
"appVersionSource": "local"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ function prepareLabels(dataPoints: Measurement[]) {
|
||||||
|
|
||||||
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
|
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
|
||||||
const labels = prepareLabels(dataPoints)
|
const labels = prepareLabels(dataPoints)
|
||||||
|
const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0)
|
||||||
|
const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.page}>
|
<View style={styles.page}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
|
@ -88,20 +91,24 @@ function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: str
|
||||||
color={COLORS.weight}
|
color={COLORS.weight}
|
||||||
unit=" kg"
|
unit=" kg"
|
||||||
/>
|
/>
|
||||||
<SingleChart
|
{fatMassPoints.length >= 2 && (
|
||||||
title="Vetmassa"
|
<SingleChart
|
||||||
data={dataPoints.map(m => m.fatMass)}
|
title="Vetmassa"
|
||||||
labels={labels}
|
data={fatMassPoints.map(m => m.fatMass)}
|
||||||
color={COLORS.fatMass}
|
labels={prepareLabels(fatMassPoints)}
|
||||||
unit=" kg"
|
color={COLORS.fatMass}
|
||||||
/>
|
unit=" kg"
|
||||||
<SingleChart
|
/>
|
||||||
title="Spiermassa"
|
)}
|
||||||
data={dataPoints.map(m => m.muscleMass)}
|
{muscleMassPoints.length >= 2 && (
|
||||||
labels={labels}
|
<SingleChart
|
||||||
color={COLORS.muscleMass}
|
title="Spiermassa"
|
||||||
unit=" kg"
|
data={muscleMassPoints.map(m => m.muscleMass)}
|
||||||
/>
|
labels={prepareLabels(muscleMassPoints)}
|
||||||
|
color={COLORS.muscleMass}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
let db: SQLite.SQLiteDatabase | null = null
|
let db: SQLite.SQLiteDatabase | null = null
|
||||||
|
let dbReady = false
|
||||||
|
|
||||||
export async function initDatabase(): Promise<void> {
|
export async function initDatabase(): Promise<void> {
|
||||||
if (db) return
|
if (db) return
|
||||||
|
|
@ -20,13 +21,21 @@ export async function initDatabase(): Promise<void> {
|
||||||
measuredAt TEXT NOT NULL,
|
measuredAt TEXT NOT NULL,
|
||||||
createdAt TEXT NOT NULL,
|
createdAt TEXT NOT NULL,
|
||||||
updatedAt TEXT NOT NULL,
|
updatedAt TEXT NOT NULL,
|
||||||
syncStatus TEXT NOT NULL DEFAULT 'local',
|
syncStatus TEXT NOT NULL DEFAULT 'pending',
|
||||||
deletedAt TEXT
|
deletedAt TEXT
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
try {
|
try {
|
||||||
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
|
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
|
||||||
} catch {}
|
} 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> {
|
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[]> {
|
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>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
||||||
)
|
)
|
||||||
|
|
@ -46,15 +62,31 @@ export async function getAllMeasurements(): Promise<Measurement[]> {
|
||||||
|
|
||||||
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
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
|
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> {
|
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
|
||||||
const day = date.slice(0, 10)
|
const day = date.slice(0, 10)
|
||||||
const rows = await db!.getAllAsync<any>(
|
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}%`]
|
[`${day}%`]
|
||||||
)
|
)
|
||||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
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[]> {
|
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
|
'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> {
|
export async function setDeletedAt(id: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
await db!.runAsync(
|
await db!.runAsync(
|
||||||
'UPDATE measurements SET deletedAt = ? WHERE id = ?',
|
'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
|
||||||
[new Date().toISOString(), id]
|
[now, now, id]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeMeasurement(id: string): Promise<void> {
|
export async function removeMeasurement(id: string): Promise<void> {
|
||||||
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
|
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[]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
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 { Measurement } from '../types'
|
||||||
import LatestMeasurementCard from '../components/LatestMeasurementCard'
|
import LatestMeasurementCard from '../components/LatestMeasurementCard'
|
||||||
import WeightChart from '../components/WeightChart'
|
import WeightChart from '../components/WeightChart'
|
||||||
|
|
@ -11,23 +12,76 @@ type Props = { navigation: any }
|
||||||
|
|
||||||
export default function DashboardScreen({ navigation }: Props) {
|
export default function DashboardScreen({ navigation }: Props) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
|
||||||
const [all, setAll] = useState<Measurement[]>([])
|
const [all, setAll] = useState<Measurement[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [chartKey, setChartKey] = useState(0)
|
||||||
|
const seenVersion = useRef(0)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
let active = true
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
const allM = await getAllMeasurements()
|
|
||||||
const visible = allM.filter(m => !m.deletedAt)
|
const two = await getLatestMeasurements(2)
|
||||||
setAll(visible)
|
if (!active) return
|
||||||
setLoading(false)
|
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()
|
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 (
|
return (
|
||||||
<View style={styles.outer}>
|
<View style={styles.outer}>
|
||||||
<View style={styles.navBar}>
|
<View style={styles.navBar}>
|
||||||
|
|
@ -39,14 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView style={styles.container}>
|
<ScrollView style={styles.container}>
|
||||||
{loading ? (
|
<LatestMeasurementCard measurements={display} />
|
||||||
<Text style={styles.loading}>Laden...</Text>
|
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LatestMeasurementCard measurements={all} />
|
|
||||||
<WeightChart measurements={all} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -66,5 +114,6 @@ const styles = StyleSheet.create({
|
||||||
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
|
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
|
||||||
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
|
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
|
||||||
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
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' },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
|
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/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 { Measurement } from '../types'
|
||||||
import { createApi } from '../sync/api'
|
|
||||||
import { getApiConfig } from './SettingsScreen'
|
|
||||||
import MeasurementRow from '../components/MeasurementRow'
|
import MeasurementRow from '../components/MeasurementRow'
|
||||||
|
|
||||||
type Props = { navigation: any }
|
type Props = { navigation: any }
|
||||||
|
|
@ -16,7 +15,7 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
const all = await getAllMeasurements()
|
const all = await getAllMeasurements()
|
||||||
setMeasurements(all.filter(m => !m.deletedAt))
|
setMeasurements(all)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
|
|
@ -64,13 +63,8 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await setDeletedAt(m.id)
|
await setDeletedAt(m.id)
|
||||||
try {
|
await updateSyncStatus(m.id, 'pending-delete')
|
||||||
const config = await getApiConfig()
|
await refreshCache()
|
||||||
if (config) {
|
|
||||||
await createApi(config).deleteMeasurement(m.id)
|
|
||||||
await removeMeasurement(m.id)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
load()
|
load()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ import React, { useCallback, useState } from 'react'
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
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 { Measurement } from '../types'
|
||||||
import { createApi } from '../sync/api'
|
|
||||||
import { getApiConfig } from './SettingsScreen'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
navigation: any
|
navigation: any
|
||||||
|
|
@ -60,13 +59,8 @@ export default function MeasurementDetailScreen({ navigation, route }: Props) {
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await setDeletedAt(measurementId)
|
await setDeletedAt(measurementId)
|
||||||
try {
|
await updateSyncStatus(measurementId, 'pending-delete')
|
||||||
const config = await getApiConfig()
|
await refreshCache()
|
||||||
if (config) {
|
|
||||||
await createApi(config).deleteMeasurement(measurementId)
|
|
||||||
await removeMeasurement(measurementId)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 { View, Text, Alert } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import MeasurementForm from '../components/MeasurementForm'
|
import MeasurementForm from '../components/MeasurementForm'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
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 = {
|
type Props = {
|
||||||
navigation: any
|
navigation: any
|
||||||
|
|
@ -60,7 +61,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
|
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
|
||||||
m.id = existing.id
|
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)
|
await insertMeasurement(m)
|
||||||
}
|
}
|
||||||
doSave(m)
|
await doSave(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSave = async (m: Measurement) => {
|
const doSave = async (m: Measurement) => {
|
||||||
|
await refreshCache()
|
||||||
|
navigation.goBack()
|
||||||
|
uploadInBackground(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadInBackground = async (m: Measurement) => {
|
||||||
try {
|
try {
|
||||||
|
const { uploadMeasurement } = await import('../sync/syncService')
|
||||||
await uploadMeasurement(m)
|
await uploadMeasurement(m)
|
||||||
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
|
showSuccess('Opgeslagen & gesynct')
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
|
||||||
])
|
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
|
showError('Opgeslagen (sync mislukt)')
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +66,11 @@ export default function SettingsScreen() {
|
||||||
try {
|
try {
|
||||||
const { syncAll } = await import('../sync/syncService')
|
const { syncAll } = await import('../sync/syncService')
|
||||||
const result = await syncAll()
|
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) {
|
} catch (e: any) {
|
||||||
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
|
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -99,7 +103,7 @@ export default function SettingsScreen() {
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
|
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
|
||||||
<Text style={styles.eyeIcon}>{showToken ? 'O' : 'O'}</Text>
|
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
20
src/store/measurementStore.ts
Normal file
20
src/store/measurementStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,35 +1,59 @@
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import {
|
import {
|
||||||
initDatabase,
|
initDatabase,
|
||||||
getAllMeasurements,
|
getAllMeasurementsForSync,
|
||||||
upsertMeasurement,
|
upsertMeasurement,
|
||||||
getMeasurementsUpdatedSince,
|
getPendingRecords,
|
||||||
|
updateSyncStatus,
|
||||||
removeMeasurement,
|
removeMeasurement,
|
||||||
} from '../database/db'
|
} from '../database/db'
|
||||||
import { createApi } from './api'
|
import { createApi, ApiError } from './api'
|
||||||
import { ApiError } from './api'
|
|
||||||
import * as SecureStore from 'expo-secure-store'
|
import * as SecureStore from 'expo-secure-store'
|
||||||
|
|
||||||
const LAST_SYNC_KEY = 'weegschaal_last_sync'
|
const LAST_SYNC_KEY = 'weegschaal_last_sync'
|
||||||
|
|
||||||
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
|
type SyncResult = {
|
||||||
const map = new Map<string, Measurement>()
|
pushed: number
|
||||||
|
deleted: number
|
||||||
|
pulled: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
for (const m of local) {
|
export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
|
||||||
map.set(m.id, m)
|
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) {
|
for (const r of remote) {
|
||||||
const existing = map.get(r.id)
|
const localRec = localMap.get(r.id)
|
||||||
if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) {
|
const isDeleted = !!(r as any).deleted
|
||||||
map.set(r.id, {
|
const localPending =
|
||||||
...r,
|
localRec?.syncStatus === 'pending' ||
|
||||||
deletedAt: r.deletedAt || undefined,
|
localRec?.syncStatus === 'pending-delete'
|
||||||
} as Measurement)
|
|
||||||
|
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<void> {
|
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.')
|
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
||||||
const api = createApi(config)
|
const api = createApi(config)
|
||||||
await api.createMeasurement(m)
|
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 { getApiConfig } = await import('../screens/SettingsScreen')
|
||||||
const config = await getApiConfig()
|
const config = await getApiConfig()
|
||||||
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
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)
|
const api = createApi(config)
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
|
|
||||||
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
|
const errors: string[] = []
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
let pushed = 0
|
let pushed = 0
|
||||||
let deleted = 0
|
let deleted = 0
|
||||||
|
let pulled = 0
|
||||||
|
|
||||||
await Promise.allSettled(
|
// === PUSH phase — outbox first ===
|
||||||
updated.map(async m => {
|
const pending = await getPendingRecords()
|
||||||
if (m.deletedAt && m.deletedAt > since) {
|
|
||||||
try {
|
for (const m of pending) {
|
||||||
await api.deleteMeasurement(m.id)
|
if (m.deletedAt) {
|
||||||
|
try {
|
||||||
|
await api.deleteMeasurement(m.id)
|
||||||
|
await removeMeasurement(m.id)
|
||||||
|
deleted++
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
await removeMeasurement(m.id)
|
await removeMeasurement(m.id)
|
||||||
deleted++
|
deleted++
|
||||||
} catch {}
|
} else {
|
||||||
} else {
|
errors.push(e?.message ?? 'Delete failed')
|
||||||
try {
|
}
|
||||||
await api.createMeasurement(m)
|
|
||||||
pushed++
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
})
|
} else {
|
||||||
)
|
try {
|
||||||
|
await api.createMeasurement(m)
|
||||||
|
await updateSyncStatus(m.id, 'synced')
|
||||||
|
pushed++
|
||||||
|
} 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())
|
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
|
||||||
|
|
||||||
return { pushed, deleted, pulled: remote.length }
|
return { pushed, deleted, pulled, errors }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export type Measurement = {
|
||||||
measuredAt: string
|
measuredAt: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
syncStatus?: string
|
||||||
deletedAt?: string
|
deletedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
26
src/utils/toast.ts
Normal file
26
src/utils/toast.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -81,7 +81,7 @@ describe('database', () => {
|
||||||
expect(result!.id).toBe(mockMeasurement.id)
|
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)
|
db.runAsync.mockResolvedValue(undefined)
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await setDeletedAt(mockMeasurement.id)
|
await setDeletedAt(mockMeasurement.id)
|
||||||
|
|
@ -89,5 +89,7 @@ describe('database', () => {
|
||||||
expect.stringContaining('UPDATE measurements SET deletedAt'),
|
expect.stringContaining('UPDATE measurements SET deletedAt'),
|
||||||
expect.arrayContaining([mockMeasurement.id])
|
expect.arrayContaining([mockMeasurement.id])
|
||||||
)
|
)
|
||||||
|
const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
|
||||||
|
expect(args[1][0]).toBe(args[1][1])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const make = (overrides: Partial<Measurement> = {}): Measurement => ({
|
||||||
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
|
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
|
||||||
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
|
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
|
||||||
measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
|
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', () => {
|
describe('mergeMeasurements', () => {
|
||||||
|
|
@ -40,17 +40,47 @@ describe('mergeMeasurements', () => {
|
||||||
expect(r[0].weight).toBe(77)
|
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 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])
|
const r = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
|
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 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])
|
const r = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].deletedAt).toBeUndefined()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue