Compare commits

..
Author SHA1 Message Date
Eddy de Vink
0513db46ac fix: sync deletion propagation, add base64 util, timeout, and merge improvements
Critical:
- Add deleted column: deleteMeasurement now soft-deletes, merge filters
  out deleted items based on updatedAt (was: deletions were resurrected)
- Replace btoa with UTF-8-safe encodeBase64 utility for Hermes compat

Important:
- Preserve createdAt when overwriting duplicate-date measurements
- Add 15s AbortController timeout to all WebDAV fetch calls
- Revert broken date prefilling regression (MeasurementForm)
- getAllMeasurements/latest/byDate now filter deleted records
- Replace Dimensions with useWindowDimensions for rotation awareness
- Replace silent catch with console.warn in HistoryScreen

Tests: 8 new tests for deletion merge cases and soft-delete queries
2026-08-05 20:55:14 +02:00
32 changed files with 542 additions and 3404 deletions

6
.gitignore vendored
View file

@ -111,12 +111,6 @@ test/fixtures/
test/sample-pdfs/
test/output/
# Expo
.expo/
# APK builds
*.apk
# Temporary files
temp/
tmp/

17
App.tsx
View file

@ -5,18 +5,8 @@ import DashboardScreen from './src/screens/DashboardScreen'
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
import HistoryScreen from './src/screens/HistoryScreen'
import SettingsScreen from './src/screens/SettingsScreen'
import MeasurementDetailScreen from './src/screens/MeasurementDetailScreen'
import { Measurement } from './src/types'
type RootStackParamList = {
Dashboard: undefined
NewMeasurement: { measurement?: Measurement }
History: undefined
MeasurementDetail: { measurementId: string }
Settings: undefined
}
const Stack = createNativeStackNavigator<RootStackParamList>()
const Stack = createNativeStackNavigator()
export default function App() {
return (
@ -37,11 +27,6 @@ export default function App() {
component={HistoryScreen}
options={{ title: 'Historie' }}
/>
<Stack.Screen
name="MeasurementDetail"
component={MeasurementDetailScreen}
options={{ title: 'Meting' }}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}

145
README.md
View file

@ -1,117 +1,24 @@
# Weegschaal App
_*Gemaakt met vibecoding — DeepSeek V4 Pro*_
React Native (Expo) app voor het handmatig invoeren van meetgegevens van een slimme weegschaal. Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd met Nextcloud via WebDAV.
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.
## Meetwaarden
## Inhoud
- [Functionaliteit](#functionaliteit)
- [Schermen](#schermen)
- [Synchronisatie](#synchronisatie)
- [Server-side API](#server-side-api)
- [Beveiliging](#beveiliging)
- [Offline resilience](#offline-resilience)
- [Ontwikkeling](#ontwikkeling)
- [Build & deploy](#build--deploy)
- [Testen](#testen)
- [Licentie](#licentie)
## Functionaliteit
- **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling
- **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
- **Instellingen** voor API-configuratie en handmatige sync
- Gewicht (kg)
- BMI
- Vetpercentage (%)
- Vetmassa (kg) — automatisch berekend
- Spierpercentage (%)
- Spiermassa (kg) — automatisch berekend
- Buikvet (index)
- Stofwisseling in rust (kcal)
## Schermen
| Scherm | Beschrijving |
|--------|-------------|
| 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 |
| Instellingen | API URL + token, opslaan/wissen, handmatige sync |
## Synchronisatie
De app synchroniseert via een zelfgebouwde REST API. Geen WebDAV, geen bestandssync — pure delta-sync.
### Hoe het werkt
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 outbox pattern?
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 met `syncStatus='pending'`. De outbox (`getPendingRecords()`) pikt hem automatisch op bij de volgende sync of dashboard-focus.
## Server-side API
**Stack**: nginx, PHP 8.5 (Slim Framework 4), MariaDB, Let's Encrypt SSL, SELinux
**Endpoints**:
| Methode | Pad | Auth | Beschrijving |
|---------|-----|------|-------------|
| `GET` | `/api/v1/measurements?since=ISO` | Bearer | Delta-sync |
| `POST` | `/api/v1/measurements` | Bearer | Upsert (INSERT ON DUPLICATE KEY) |
| `DELETE` | `/api/v1/measurements/:id` | Bearer | Soft delete (`deleted=1`, update `updated_at`) |
**Database**: MariaDB met drie tabellen — `users`, `auth_tokens`, `measurements`. De `measurements` tabel gebruikt `ON DUPLICATE KEY UPDATE` voor conflictvrije upserts.
**Bestandsstructuur**:
```
/usr/share/nginx/html/weegschaal-api/
├── composer.json
├── config.php
├── public/
│ └── index.php
└── src/
├── Auth.php ← Bearer token validatie middleware
├── Database.php ← PDO singleton
├── Measurements.php ← CRUD + sync queries
└── helpers.php ← JSON response, datetime conversie, logging
```
## Beveiliging
### Authenticatie
Bearer token-validatie via `auth_tokens` tabel. Tokens worden handmatig aangemaakt via SQL.
### Logging
Elke mislukte authenticatie en 404 wordt gelogd naar `/var/log/weegschaal-api/auth.log`:
```
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
```
### Fail2ban
Jail `weegschaal-api`: 5 foutieve pogingen binnen 5 minuten = 1 uur IP-ban. Filter regex:
```
^\[.*\] 401 <HOST> .* - .*$
```
Logrotering wekelijks, 12 weken retentie.
### SELinux
Logmap heeft context `httpd_log_t` gezet via `semanage fcontext`.
- **Dashboard** — laatste meting (groene kaart) + drie losse grafieken onder elkaar (gewicht, vetmassa, spiermassa), elk met eigen y-as-schaal en 30-dagen filter gerekend vanaf de nieuwste meting
- **Nieuwe meting** — datumkiezer bovenaan (standaard vandaag), formulier vooraf ingevuld met waarden van de laatste meting. Opslaan-knop altijd in beeld (buiten ScrollView). Bij opslaan wordt automatisch gesynchroniseerd met Nextcloud
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog
## Ontwikkeling
@ -122,25 +29,15 @@ npx expo start
Scannen met Expo Go op Android of iOS.
## Build & deploy
## Synchronisatie
```bash
# APK bouwen (lokaal via EAS)
npx eas build --local --profile apk --platform android
Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in:
# Draadloos sideloaden via ADB
adb-wireless-connect
adb install -r build-*.apk
```
- **URL**: `https://<jouw-nextcloud>/remote.php/dav/files/<gebruikersnaam>/`
- **Gebruikersnaam**: je Nextcloud gebruikersnaam
- **Wachtwoord**: je Nextcloud app-wachtwoord
Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
## Database migratie
Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
- `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'`
Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basis van `updatedAt`). De map `weegschaal/` wordt automatisch aangemaakt via MKCOL indien deze nog niet bestaat.
## Testen
@ -148,8 +45,6 @@ Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
npm test
```
Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties.
## Licentie
Public domain (Unlicense)

View file

@ -13,9 +13,9 @@
"package": "nl.eddydevink.weegschaal",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/icon.png",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/icon.png"
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
@ -26,17 +26,6 @@
"expo-sqlite",
"expo-secure-store",
"@react-native-community/datetimepicker"
],
"extra": {
"eas": {
"projectId": "9707c719-d5aa-45a4-a6cd-b95ac3d32996"
}
},
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/9707c719-d5aa-45a4-a6cd-b95ac3d32996"
}
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Before After
Before After

View file

@ -1,70 +0,0 @@
# Van WebDAV naar REST API: een volwassen sync-architectuur voor de weegschaal-app
*20 juli 2026*
De afgelopen dagen heb ik de synchronisatie van mijn weegschaal-app volledig herzien. De app slaat metingen lokaal op in SQLite en synchroniseert ze met een centrale server. Tot nu toe gebeurde dat via WebDAV naar Nextcloud — een simpel JSON-bestand dat telkens in zijn geheel werd gedownload, samengevoegd en teruggeüpload. Functioneel, maar niet ideaal.
## Waarom WebDAV niet voldeed
Het oude sync-mechanisme was primitief: bij elke sync werd het volledige `measurements.json` bestand van de WebDAV-server gehaald, lokaal gemerged op basis van `updatedAt`, en daarna in zijn geheel teruggeschreven. Met 1300+ metingen werd dat bestand steeds groter, en elke sync — ook als er maar één meting was veranderd — verstuurde het hele bestand over de lijn. Bovendien leverde Basic Auth via WebDAV geen fijnmazige controle over wie toegang had, en was er geen logging van foutieve inlogpogingen.
## De nieuwe architectuur
De oplossing is een eigen REST API, gehost op een subdomein van mijn domein, met een MariaDB-backend en Bearer token-authenticatie. De stack:
- **Server**: Fedora Linux, nginx, PHP 8.5 (Slim Framework 4), MariaDB
- **Client**: React Native (Expo), expo-sqlite, expo-secure-store
- **Authenticatie**: Bearer token, handmatig aangemaakt via SQL
- **Sync**: incrementeel via `?since=` timestamps — last-writer-wins
### Endpoints
De API kent drie endpoints, allemaal beschermd met Bearer token-validatie:
| Methode | Pad | Werking |
|---------|-----|---------|
| `GET` | `/api/v1/measurements?since=ISO` | Delta-sync: alleen metingen met `updated_at > since` |
| `POST` | `/api/v1/measurements` | Upsert op client-ID — de server accepteert het client-gegenereerde UUID |
| `DELETE` | `/api/v1/measurements/:id` | Soft delete: zet `deleted=1` en update `updated_at` |
Geen PUT, geen register/token-endpoints. Minimaal en doelgericht.
### Incrementele sync
De sync-flow is nu gebaseerd op timestamps, zonder `syncStatus` enum. Het mechanisme is hetzelfde als wat CouchDB, PouchDB en tal van productie-apps gebruiken:
1. **Delta ophalen**: `GET ?since=lastSyncAt` → alleen gewijzigde records van de server
2. **Mergen**: last-writer-wins op `updatedAt`. Remote records met `deleted=true` overschrijven lokaal
3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` worden naar de server gestuurd
4. **Verwijderde records**: lokaal een `deletedAt` timestamp gezet, bij volgende sync via `DELETE` naar de server
5. **High-water mark**: `lastSyncAt` wordt bijgewerkt naar de huidige tijd
Bij een netwerkfout blijft een meting gewoon lokaal staan — de volgende sync pakt hem automatisch op omdat `updatedAt > lastSyncAt`.
## Fail2ban-vriendelijke logging
Een API die op internet staat trekt vroeg of laat ongenode gasten aan. Om hierop voorbereid te zijn logt de API elke mislukte authenticatiepoging naar `/var/log/weegschaal-api/auth.log` in een formaat dat fail2ban direct kan verwerken:
```
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
```
De fail2ban jail grijpt in bij 5 foutieve pogingen binnen 5 minuten en bant het IP een uur lang. Ook 404's op niet-bestaande routes worden gelogd — een klassiek signaal van fuzzing-tools.
De logrotering (12 weken, wekelijks) en SELinux-context (`httpd_log_t`) zijn uiteraard geregeld.
## Resultaten delen
Een onverwacht leuke toevoeging: de share-knop op het dashboard. Rechtsboven in de kaart met je laatste en voorlaatste meting zit een deel-icoon. Tik erop, en de hele kaart wordt als PNG-afbeelding geëxporteerd. Je kunt hem direct delen via WhatsApp, Signal, e-mail — wat je maar wilt. De techniek erachter: `react-native-view-shot` vangt de view, `expo-sharing` opent het native deelmenu.
## Wat nog meer is veranderd
- **App-icoon**: vernieuwd met een eigen afbeelding
- **Dashboard**: toont naast de laatste meting nu ook de voorlaatste en het gewichtsverschil
- **Laad-indicator**: geen verwarrende "Nog geen metingen" meer tijdens het laden — het dashboard toont nu "Laden..."
- **Database-migratie**: een `ALTER TABLE` zorgt dat bestaande SQLite-databases de nieuwe `deletedAt`-kolom krijgen zonder dataverlies
## Conclusie
Wat begon als een simpel sync-scriptje via WebDAV is uitgegroeid tot een volwaardige REST API met productie-grade logging, fail2ban-integratie, en een UX die het delen van meetresultaten net zo makkelijk maakt als een screenshot. De app doet nu wat hij moet doen: metingen opslaan, synchroniseren, en — sinds kort — met één tik delen met wie je maar wilt.

View file

@ -1,106 +0,0 @@
# Lokale APK build toolchain
## Overzicht
De Weegschaal app kan lokaal gebouwd worden tot een zelfstandige APK
zonder EAS cloud. Dit is handig voor snelle iteraties of als je geen
internetverbinding hebt voor de cloud build.
## Benodigdheden
| Component | Versie | Installatie |
|---|---|---|
| Java | OpenJDK 21+ | `brew install --cask temurin` |
| Android Command Line Tools | laatste | `brew install --cask android-commandlinetools` |
| Android Platform Tools | laatste | `brew install --cask android-platform-tools` |
| Node.js | 22+ | `brew install node` |
| EAS CLI | laatste | via npm (`npx eas`) |
## Android SDK instellen
`ANDROID_HOME` moet gezet zijn in je shell config (`~/.zshrc`):
```bash
export ANDROID_HOME="/home/linuxbrew/.linuxbrew/share/android-commandlinetools"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
```
Licenties accepteren:
```bash
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses
```
Best practices:
- Gebruik `--sdk_root=$ANDROID_HOME` bij sdkmanager commando's, anders
installeert hij naar de verkeerde plek
- De eerste build downloadt automatisch de juiste platform-, build-tools-,
NDK- en CMake-versies. Je hoeft ze niet handmatig voor te installeren.
- Wil je ze toch vastzetten: `sdkmanager "platforms;android-36" "build-tools;36.0.0"`
## APK bouwen
```bash
cd /home/eddy/bin/weegschaal-app
npx eas build --local --profile apk --platform android
```
Het build profiel staat in `eas.json`:
```json
"apk": {
"android": {
"buildType": "apk"
}
}
```
De build:
1. Draait `npx expo prebuild` (maakt `android/` directory aan)
2. Bundelt de JS bundle via Metro
3. Voert `./gradlew :app:assembleRelease` uit
4. Produceert `android/app/build/outputs/apk/release/app-release.apk`
5. Kopieert het resultaat naar `build-<timestamp>.apk` in de projectroot
## Duur
- **Eerste build**: ~13 minuten (Gradle download, NDK/CMake compilatie)
- **Latere builds**: ~2-3 minuten (Gradle daemon werkt door)
## Veelvoorkomende problemen
**`ANDROID_HOME` niet gezet**
```
Error: ANDROID_HOME is not set
```
→ Controleer of de export in `~/.zshrc` staat en herlaad met `source ~/.zshrc`
**Gradle fault / OutOfMemoryError**
```
The daemon will expire after running out of JVM Metaspace
```
→ Zet in `android/gradle.properties`:
```
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
```
**Licenties niet geaccepteerd**
→ Voer `yes | sdkmanager --licenses` uit
## Testen
Voor het bouwen kun je de tests draaien:
```bash
cd /home/eddy/bin/weegschaal-app
npm test
```
## Installatie op Android
```bash
# Via ADB (als je toestel aan de USB hangt met debugging aan)
adb install -r weegschaal.apk
# Of handmatig: kopieer APK naar toestel, open bestand, kies 'installeren'
```

File diff suppressed because it is too large Load diff

View file

@ -1,139 +0,0 @@
# 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,266 +0,0 @@
# REST API Sync — Design Doc
Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`,
met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand.
## Fase 1 — Server-side: PHP API
### Infrastructuur
- **DNS**: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` (nextcloud)
- **nginx**: server block in `/etc/nginx/conf.d/weegschaal-api.conf`, proxy naar PHP-FPM socket
- **SSL**: `certbot --nginx -d weegschaal.eddydevink.nl`
- **Document root**: `/usr/share/nginx/html/weegschaal-api/`
### MariaDB
Database `weegschaal` met gebruiker `weegschaal@localhost` (random wachtwoord).
```sql
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE auth_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
token VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE measurements (
id VARCHAR(36) NOT NULL,
user_id INT NOT NULL,
weight DECIMAL(5,2) NOT NULL,
bmi DECIMAL(4,1) NOT NULL,
body_fat_percentage DECIMAL(4,1) NOT NULL,
fat_mass DECIMAL(5,2) NOT NULL,
muscle_percentage DECIMAL(4,1) NOT NULL,
muscle_mass DECIMAL(5,2) NOT NULL,
visceral_fat TINYINT NOT NULL,
bmr DECIMAL(6,1) NOT NULL,
measured_at DATE NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, id),
INDEX idx_updated_at (user_id, updated_at)
);
```
`deleted` flag voor soft deletes op server. De client stuurt harde DELETEs;
de server zet `deleted=1` en update `updated_at` zodat de volgende `?since=`
query deze doorgeeft zodat andere clients de delete kunnen verwerken.
### Endpoints (Slim Framework)
| Methode | Pad | Beschrijving |
|---------|-----|-------------|
| `GET` | `/api/v1/measurements` | Alle metingen (`?since=ISO` voor delta) |
| `POST` | `/api/v1/measurements` | Nieuwe meting of upsert op ID |
| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen (soft delete) |
**Geen** auth endpoints — eerste token wordt handmatig via SQL INSERT aangemaakt.
### Auth middleware
- `Authorization: Bearer <token>` → valideert tegen `auth_tokens` tabel
- Zet `user_id` in request context
- 401 bij ongeldige/missing token
### Bestandsstructuur
```
/usr/share/nginx/html/weegschaal-api/
├── composer.json
├── public/
│ └── index.php ← Entry point, routing
├── src/
│ ├── Database.php ← PDO singleton
│ ├── Auth.php ← Token validation middleware
│ ├── Measurements.php ← CRUD + sync queries
│ └── helpers.php
└── config.php ← DB credentials
```
### Sync-logica server
- `GET ?since=ISO` → `WHERE user_id = ? AND updated_at > ? ORDER BY updated_at ASC`
- `POST` → `INSERT ... ON DUPLICATE KEY UPDATE` — upsert op client-ID
- `DELETE` → set `deleted=1`, update `updated_at=NOW()` (soft delete, zodat delta-sync werkt voor andere clients)
- Geretourneerde records bevatten `deleted: true` veld zodat de client weet dat dit een soft-deleted record is
---
## Fase 2 — Client-side: App aanpassen
### Types (`src/types.ts`)
`WebDAVConfig` en `SyncStatus` vervallen. Toegevoegd: `ApiConfig`, `deletedAt`:
```typescript
export type Measurement = {
id: string
weight: number
bmi: number
bodyFatPercentage: number
fatMass: number
musclePercentage: number
muscleMass: number
visceralFat: number
bmr: number
measuredAt: string
createdAt: string
updatedAt: string
deletedAt?: string
}
export type ApiConfig = {
url: string
token: string
}
```
### SettingsScreen (`src/screens/SettingsScreen.tsx`)
- Vervang URL/username/password door API-URL + token (met oogje)
- SecureStore key: `weegschaal_api_config`
- `getApiConfig()` (exported) i.p.v. `getWebDAVConfig()`
- Sync knop blijft
### Nieuwe module `src/sync/api.ts`
```typescript
const createApi = (config: ApiConfig) => ({
getMeasurements: (since?: string) => GET /api/v1/measurements?since=...
createMeasurement: (m: Measurement) => POST /api/v1/measurements
deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id
})
```
Alle calls met `Authorization: Bearer <token>` header.
### syncService.ts — volledig herschreven
#### `mergeMeasurements(local, remote)`
Simpele last-writer-wins op `updatedAt`:
1. Bouw map van alle IDs (lokaal + remote)
2. Voor elke ID: als beide bestaan → behoud de met de hoogste `updatedAt`
3. Remote metingen die winnen krijgen `deletedAt` overgenomen uit server indien de server record `deleted: true` had
#### `syncAll()`
1. Lees `lastSyncAt` uit SecureStore (`weegschaal_last_sync`)
2. `GET /api/v1/measurements?since=lastSyncAt` → remote delta
3. `getAllMeasurements()` → lokale metingen
4. Merge remote delta in lokale DB (last-writer-wins)
5. Verwijder lokale records waar remote `deleted: true` en remote `updatedAt > local updatedAt`
6. Verzamel lokale metingen met `updatedAt > lastSyncAt` (alleen niet-verwijderde)
7. Voor `deletedAt` metingen met `deletedAt > lastSyncAt` → DELETE naar API → bij succes lokaal verwijderen
8. Voor overige metingen met `updatedAt > lastSyncAt` → POST naar API
9. `lastSyncAt = now()`
#### `uploadMeasurement(m)`
Losstaande functie voor directe push na opslaan:
1. POST naar API
2. Bij succes → return
3. Bij netwerkfout → throw; meting blijft lokaal, `syncAll` pakt hem later op (want `updatedAt > lastSyncAt`)
### NewMeasurementScreen
1. Lokaal opslaan (insert of upsert, met `updatedAt = now()`)
2. Direct `uploadMeasurement(m)` proberen
3. Bij succes → navigeer terug met "Opgeslagen & gesynct"
4. Bij falen → navigeer terug met "Opgeslagen (sync mislukt)"
### MeasurementDetailScreen
Bij delete:
1. Zet `deletedAt = now()` lokaal
2. Fire-and-forget: DELETE API call
3. Bij succes → record lokaal verwijderen
4. Bij falen → record blijft met `deletedAt`, syncAll pakt het op
### HistoryScreen
Bij delete (vanuit alert in HistoryScreen):
1. Zet `deletedAt = now()` lokaal
2. Fire-and-forget: DELETE API call
3. Bij succes → record lokaal verwijderen
4. Bij falen → record blijft met `deletedAt`
Filter bij laden: `!m.deletedAt`.
### DashboardScreen
Laatste meting en grafiek: alleen metingen zonder `deletedAt`.
Laatste meting = meest recente `measuredAt` zonder `deletedAt`.
### Database (`db.ts`)
Nieuw kolom in schema: `deletedAt TEXT`.
Nieuwe functies:
- `getMeasurementsUpdatedSince(since: string)` — metingen met `updatedAt > since`
- `setDeletedAt(id: string)` — zet `deletedAt = now()`
- `removeMeasurement(id: string)` — harde delete
Verwijderd/onbruikbaar:
- `getUnsyncedMeasurements()` — niet meer nodig
- `getPendingMeasurements()` — niet meer nodig
- `markAsDeleted()` — vervangen door `setDeletedAt`
- `syncStatus` kolom — blijft in DB maar wordt niet meer gebruikt in code
---
## Fase 3 — Migratie (eerste sync)
Eerste keer dat de app de nieuwe API gebruikt (`lastSyncAt` is `null`):
- `GET /api/v1/measurements` (zonder `?since`) → alle remote metingen
- Merge met lokale DB: last-writer-wins, remote deleted overschrijft lokaal
- Alle lokale metingen (die niet deleted zijn) worden gepusht (`updatedAt > null` = alles)
- `lastSyncAt = now()`
- Vanaf dan: delta-sync
---
## Foutafhandeling
| Situatie | Gedrag |
|----------|--------|
| Geen netwerk bij opslaan | Lokaal opgeslagen, `updatedAt > lastSyncAt` dus next sync pakt op |
| Geen netwerk bij sync | Throw error, scherm toont melding |
| Geen API-config | Throw `'Geen API configuratie. Stel deze eerst in in Instellingen.'` |
| HTTP 401 (ongeldige token) | Throw `'Authenticatie mislukt. Controleer je token in Instellingen.'` |
| HTTP 5xx | Throw met statuscode, scherm toont melding |
---
## Testen
### Client-side (Jest)
- `mergeMeasurements()` unit tests: local wins, remote wins, deleted remote vs local
- `api.ts` calls mocken met fetch-mock
- `syncAll()` integratietest met gemockte API
### Server-side
- Handmatig testen met curl na deployment
---
## Implementatievolgorde
1. Server-side: nginx, MariaDB, PHP API → testen met curl
2. Client-side: types, api.ts, db.ts, syncService.ts, SettingsScreen → testen
3. Client-side: DashboardScreen, HistoryScreen, MeasurementDetailScreen, NewMeasurementScreen
4. Client-side: unit tests
5. Build APK, sideloaden, testen op telefoon

View file

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

385
package-lock.json generated
View file

@ -13,19 +13,14 @@
"@react-navigation/native-stack": "^7.17.6",
"expo": "~54.0.0",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.18",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^7.0.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-view-shot": "4.0.3",
"react-native-web": "^0.21.0"
"react-native-svg": "15.12.1"
},
"devDependencies": {
"@types/jest": "29.5.14",
@ -1739,6 +1734,25 @@
"semver": "^7.6.0"
}
},
"node_modules/@expo/image-utils/node_modules/@expo/require-utils": {
"version": "55.0.5",
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"@babel/core": "^7.25.2",
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
},
"peerDependencies": {
"typescript": "^5.0.0 || ^5.0.0-0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@expo/json-file": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
@ -2147,25 +2161,6 @@
"xmlbuilder": "^15.1.1"
}
},
"node_modules/@expo/require-utils": {
"version": "55.0.5",
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"@babel/core": "^7.25.2",
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
},
"peerDependencies": {
"typescript": "^5.0.0 || ^5.0.0-0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@expo/schema-utils": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
@ -3730,15 +3725,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -4340,57 +4326,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/cross-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/cross-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4405,24 +4340,6 @@
"node": ">= 8"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@ -5160,31 +5077,6 @@
}
}
},
"node_modules/expo-eas-client": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
"license": "MIT"
},
"node_modules/expo-json-utils": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
"license": "MIT"
},
"node_modules/expo-manifests": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
"integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
"license": "MIT",
"dependencies": {
"@expo/config": "~12.0.13",
"expo-json-utils": "~0.15.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.26",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
@ -5232,15 +5124,6 @@
"node": ">=20.16.0"
}
},
"node_modules/expo-sharing": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-sqlite": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
@ -5268,57 +5151,6 @@
"react-native": "*"
}
},
"node_modules/expo-structured-headers": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
"license": "MIT"
},
"node_modules/expo-updates": {
"version": "29.0.18",
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.18.tgz",
"integrity": "sha512-qn0YDM7wVwghQAaxb9NYzzwrmj+v8Djz5H+Zft4/myG9SPg4ICAfDy52IVF0K+/GH/oNgsFfzmmLl6hIkDu42g==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/plist": "^0.4.9",
"@expo/spawn-async": "^1.7.2",
"arg": "4.1.0",
"chalk": "^4.1.2",
"debug": "^4.3.4",
"expo-eas-client": "~1.0.8",
"expo-manifests": "~1.0.11",
"expo-structured-headers": "~5.0.0",
"expo-updates-interface": "~2.0.0",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"ignore": "^5.3.1",
"resolve-from": "^5.0.0"
},
"bin": {
"expo-updates": "bin/cli.js"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-updates/node_modules/arg": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
"license": "MIT"
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.25",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
@ -5748,36 +5580,6 @@
"bser": "2.1.1"
}
},
"node_modules/fbjs": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^3.1.5",
"fbjs-css-vars": "^1.0.0",
"loose-envify": "^1.0.0",
"object-assign": "^4.1.0",
"promise": "^7.1.1",
"setimmediate": "^1.0.5",
"ua-parser-js": "^1.0.35"
}
},
"node_modules/fbjs-css-vars": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
"license": "MIT"
},
"node_modules/fbjs/node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"license": "MIT",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -6173,19 +5975,6 @@
"license": "MIT",
"peer": true
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -6267,12 +6056,6 @@
"node": ">=10.17.0"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -6383,15 +6166,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-prefixer": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
"license": "MIT",
"dependencies": {
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -9277,12 +9051,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -9495,18 +9263,6 @@
"ws": "^7"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@ -9653,51 +9409,6 @@
"react-native": "*"
}
},
"node_modules/react-native-view-shot": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
"integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==",
"license": "MIT",
"dependencies": {
"html2canvas": "^1.4.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^7.0.1",
"memoize-one": "^6.0.0",
"nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
"styleq": "^0.1.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
"version": "0.74.89",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
"license": "MIT"
},
"node_modules/react-native-web/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
@ -10246,12 +9957,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10599,12 +10304,6 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"license": "MIT"
},
"node_modules/styleq": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -10808,15 +10507,6 @@
"node": "*"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -10983,32 +10673,6 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
@ -11151,15 +10815,6 @@
"node": ">= 0.4.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",

View file

@ -8,19 +8,14 @@
"@react-navigation/native-stack": "^7.17.6",
"expo": "~54.0.0",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.18",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^7.0.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-view-shot": "4.0.3",
"react-native-web": "^0.21.0"
"react-native-svg": "15.12.1"
},
"devDependencies": {
"@types/jest": "29.5.14",
@ -30,8 +25,8 @@
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"test": "jest"
},

View file

@ -1,11 +1,8 @@
import React, { useRef } from 'react'
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
import ViewShot from 'react-native-view-shot'
import * as Sharing from 'expo-sharing'
import { MaterialIcons } from '@expo/vector-icons'
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { Measurement } from '../types'
type Props = { measurements: Measurement[] }
type Props = { measurement: Measurement | null }
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
@ -18,10 +15,8 @@ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
]
export default function LatestMeasurementCard({ measurements }: Props) {
const viewShotRef = useRef<ViewShot>(null)
if (measurements.length === 0) {
export default function LatestMeasurementCard({ measurement }: Props) {
if (!measurement) {
return (
<View style={styles.card}>
<Text style={styles.emptyText}>Nog geen metingen</Text>
@ -29,89 +24,31 @@ export default function LatestMeasurementCard({ measurements }: Props) {
)
}
const latest = measurements[0]
const previous = measurements[1]
const diff = previous ? (latest.weight - previous.weight) : null
const fatMassDiff = previous ? (latest.fatMass - previous.fatMass) : null
const muscleMassDiff = previous ? (latest.muscleMass - previous.muscleMass) : null
let positiveComment: string | null = null
if (previous && diff !== null && diff >= 0) {
const parts: string[] = []
if (fatMassDiff !== null && fatMassDiff < 0) parts.push('Vetje naar beneden')
if (muscleMassDiff !== null && muscleMassDiff > 0) parts.push('💪')
if (parts.length > 0) positiveComment = parts.join(' ')
}
const handleShare = async () => {
try {
const uri = await viewShotRef.current?.capture?.()
if (uri) {
await Sharing.shareAsync(uri, { mimeType: 'image/png' })
}
} catch {}
}
return (
<View style={styles.card}>
<View style={styles.header}>
<View style={{ flex: 1 }} />
<TouchableOpacity onPress={handleShare} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<MaterialIcons name="share" size={24} color="#2196F3" />
</TouchableOpacity>
</View>
<ViewShot ref={viewShotRef} options={{ format: 'png', quality: 1 }}>
<View style={styles.shotContent}>
{previous && (
<View style={styles.previous}>
<Text style={styles.previousTitle}>Voorlaatste meting</Text>
<Text style={styles.previousDate}>
{new Date(previous.measuredAt).toLocaleDateString('nl-NL')} — {previous.weight.toFixed(1)} kg
</Text>
<Text style={styles.diffLabel}>
Verschil {diff !== null && (diff > 0 ? '+' : '')}{diff?.toFixed(1)} kg
</Text>
{positiveComment && (
<Text style={styles.positiveComment}>{positiveComment}</Text>
)}
<View style={styles.divider} />
</View>
)}
<Text style={styles.title}>Laatste meting</Text>
<Text style={styles.date}>{new Date(latest.measuredAt).toLocaleDateString('nl-NL')}</Text>
{rows.map(r => (
<View key={r.key} style={styles.row}>
<Text style={styles.rowLabel}>{r.label}</Text>
<Text style={styles.rowValue}>
{typeof latest[r.key] === 'number'
? (latest[r.key] as number).toFixed(1)
: latest[r.key]}{' '}
{r.suffix}
</Text>
</View>
))}
<Text style={styles.title}>Laatste meting</Text>
<Text style={styles.date}>{new Date(measurement.measuredAt).toLocaleDateString('nl-NL')}</Text>
{rows.map(r => (
<View key={r.key} style={styles.row}>
<Text style={styles.rowLabel}>{r.label}</Text>
<Text style={styles.rowValue}>
{typeof measurement[r.key] === 'number'
? (measurement[r.key] as number).toFixed(1)
: measurement[r.key]}{' '}
{r.suffix}
</Text>
</View>
</ViewShot>
))}
</View>
)
}
const styles = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 },
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
shotContent: { backgroundColor: '#fff', padding: 16 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
date: { fontSize: 14, color: '#666', marginBottom: 12 },
row: { flexDirection: 'row', paddingVertical: 4 },
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
rowLabel: { fontSize: 14, color: '#555' },
rowValue: { fontSize: 14, fontWeight: '600' },
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
previous: { marginBottom: 4 },
divider: { height: 1, backgroundColor: '#eee', marginVertical: 12 },
previousTitle: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
previousDate: { fontSize: 14, color: '#666', marginBottom: 4 },
diffLabel: { fontSize: 16, fontWeight: '700', color: '#2196F3' },
positiveComment: { fontSize: 14, color: '#4CAF50', marginTop: 4 },
})

View file

@ -8,7 +8,6 @@ import { generateId } from '../utils/uuid'
type Props = {
onSubmit: (m: Measurement) => void
initialValues?: Partial<Measurement>
isEdit?: boolean
}
type FieldDef = {
@ -30,10 +29,13 @@ const fields: FieldDef[] = [
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
]
export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) {
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
const insets = useSafeAreaInsets()
const [pickerOpen, setPickerOpen] = useState(false)
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
const [measurementDate, setMeasurementDate] = useState<Date>(() => {
if (initialValues?.measuredAt) return new Date(initialValues.measuredAt)
return new Date()
})
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
for (const f of fields) {
@ -45,18 +47,9 @@ export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Pro
function toFloat(s: string): number {
return parseFloat(s.replace(',', '.')) || 0
}
function toInt(s: string): number {
return parseInt(s.replace(',', '.'), 10) || 0
}
const weight = toFloat(values.weight)
const bodyFatPct = toFloat(values.bodyFatPercentage)
const musclePct = toFloat(values.musclePercentage)
const weight = parseFloat(values.weight) || 0
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0
const musclePct = parseFloat(values.musclePercentage) || 0
const computed = useMemo(() => ({
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
@ -66,18 +59,20 @@ function toInt(s: string): number {
const handleSubmit = () => {
const now = new Date().toISOString()
const m: Measurement = {
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
id: generateId(),
weight,
bmi: toFloat(values.bmi),
bmi: parseFloat(values.bmi) || 0,
bodyFatPercentage: bodyFatPct,
fatMass: toFloat(computed.fatMass),
fatMass: parseFloat(computed.fatMass) || 0,
musclePercentage: musclePct,
muscleMass: toFloat(computed.muscleMass),
visceralFat: toInt(values.visceralFat),
bmr: toFloat(values.bmr),
muscleMass: parseFloat(computed.muscleMass) || 0,
visceralFat: parseInt(values.visceralFat, 10) || 0,
bmr: parseFloat(values.bmr) || 0,
measuredAt: measurementDate.toISOString(),
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
createdAt: now,
updatedAt: now,
syncStatus: 'local',
deleted: 0,
}
onSubmit(m)
}
@ -146,7 +141,7 @@ function toInt(s: string): number {
)
})}
</ScrollView>
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 48 }]} onPress={handleSubmit}>
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 16 }]} onPress={handleSubmit}>
<Text style={styles.buttonText}>Opslaan</Text>
</TouchableOpacity>
</KeyboardAvoidingView>
@ -155,7 +150,7 @@ function toInt(s: string): number {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
scroll: { padding: 16 },
scroll: { padding: 16, paddingBottom: 0 },
field: { marginBottom: 12 },
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },

View file

@ -1,5 +1,5 @@
import React, { useRef, useState } from 'react'
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
import React from 'react'
import { View, Text, StyleSheet, useWindowDimensions } from 'react-native'
import { LineChart } from 'react-native-chart-kit'
import { Measurement } from '../types'
@ -11,9 +11,6 @@ const COLORS = {
muscleMass: '#2196F3',
}
const screenWidth = Dimensions.get('window').width
const chartWidth = screenWidth - 32
function SingleChart({
title,
data,
@ -27,12 +24,13 @@ function SingleChart({
color: string
unit: string
}) {
const { width } = useWindowDimensions()
return (
<View style={styles.chartBlock}>
<Text style={styles.chartTitle}>{title}</Text>
<LineChart
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
width={chartWidth}
width={width - 32}
height={180}
yAxisSuffix={unit}
chartConfig={{
@ -60,63 +58,7 @@ function hexToRgb(hex: string): string {
return `${r}, ${g}, ${b}`
}
function filterData(sorted: Measurement[], days: number, lastDate: Date) {
const cutoff = new Date(lastDate)
cutoff.setDate(cutoff.getDate() - days)
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
return filtered.length >= 2 ? filtered : sorted
}
function prepareLabels(dataPoints: Measurement[]) {
const labels = dataPoints.map(m => {
const d = new Date(m.measuredAt)
return `${d.getDate()}/${d.getMonth() + 1}`
})
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
return labels.map((l, i) => (i % labelStep === 0 ? l : ''))
}
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>
<SingleChart
title="Gewicht"
data={dataPoints.map(m => m.weight)}
labels={labels}
color={COLORS.weight}
unit=" kg"
/>
{fatMassPoints.length >= 2 && (
<SingleChart
title="Vetmassa"
data={fatMassPoints.map(m => m.fatMass)}
labels={prepareLabels(fatMassPoints)}
color={COLORS.fatMass}
unit=" kg"
/>
)}
{muscleMassPoints.length >= 2 && (
<SingleChart
title="Spiermassa"
data={muscleMassPoints.map(m => m.muscleMass)}
labels={prepareLabels(muscleMassPoints)}
color={COLORS.muscleMass}
unit=" kg"
/>
)}
</View>
)
}
export default function WeightChart({ measurements }: Props) {
const scrollRef = useRef<ScrollView>(null)
const [page, setPage] = useState(0)
if (measurements.length < 2) {
return null
}
@ -125,38 +67,52 @@ export default function WeightChart({ measurements }: Props) {
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
)
const lastDate = new Date(sorted[sorted.length - 1].measuredAt)
const cutoff = new Date(sorted[sorted.length - 1].measuredAt)
cutoff.setDate(cutoff.getDate() - 30)
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
const dataPoints = filtered.length >= 2 ? filtered : sorted
const data30 = filterData(sorted, 30, lastDate)
const data365 = filterData(sorted, 365, lastDate)
const labels = dataPoints.map(m => {
const d = new Date(m.measuredAt)
return `${d.getDate()}/${d.getMonth() + 1}`
})
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = e.nativeEvent.contentOffset.x
const newPage = Math.round(offset / screenWidth)
if (newPage !== page) {
setPage(newPage)
}
}
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
return (
<View style={styles.container}>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onScroll}
>
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
<ChartSet dataPoints={data365} title="Voortgang (365 dagen)" />
</ScrollView>
<Text style={styles.title}>Voortgang (30 dagen)</Text>
<SingleChart
title="Gewicht"
data={dataPoints.map(m => m.weight)}
labels={sparseLabels}
color={COLORS.weight}
unit=" kg"
/>
<SingleChart
title="Vetmassa"
data={dataPoints.map(m => m.fatMass)}
labels={sparseLabels}
color={COLORS.fatMass}
unit=" kg"
/>
<SingleChart
title="Spiermassa"
data={dataPoints.map(m => m.muscleMass)}
labels={sparseLabels}
color={COLORS.muscleMass}
unit=" kg"
/>
</View>
)
}
const styles = StyleSheet.create({
container: { marginBottom: 16 },
page: { width: screenWidth, paddingHorizontal: 16 },
container: { marginHorizontal: 16, marginBottom: 16 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
chartBlock: { marginBottom: 12 },
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },

View file

@ -1,8 +1,7 @@
import * as SQLite from 'expo-sqlite'
import { Measurement } from '../types'
import { Measurement, SyncStatus } from '../types'
let db: SQLite.SQLiteDatabase | null = null
let dbReady = false
export async function initDatabase(): Promise<void> {
if (db) return
@ -21,112 +20,81 @@ export async function initDatabase(): Promise<void> {
measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
deletedAt TEXT
syncStatus TEXT NOT NULL DEFAULT 'local',
deleted INTEGER NOT NULL DEFAULT 0
);
`)
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
}
await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`)
.catch(() => {})
}
export async function insertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync(
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, 0]
)
}
function activeOnly(): string {
return 'deleted = 0'
}
export async function getAllMeasurements(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements 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'
`SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC`
)
return rows as Measurement[]
}
export async function getLatestMeasurement(): Promise<Measurement | null> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
`SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC LIMIT 1`
)
return rows.length > 0 ? (rows[0] as Measurement) : null
}
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 ? AND deletedAt IS NULL LIMIT 1",
`SELECT * FROM measurements WHERE ${activeOnly()} AND measuredAt LIKE ? LIMIT 1`,
[`${day}%`]
)
return rows.length > 0 ? (rows[0] as Measurement) : null
}
export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise<void> {
await db!.runAsync(
'UPDATE measurements SET syncStatus = ?, updatedAt = ? WHERE id = ?',
[syncStatus, new Date().toISOString(), id]
)
}
export async function upsertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync(
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deleted]
)
}
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
[since]
"SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC"
)
return rows as Measurement[]
}
export async function setDeletedAt(id: string): Promise<void> {
const now = new Date().toISOString()
export async function deleteMeasurement(id: string): Promise<void> {
await db!.runAsync(
'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
[now, now, id]
"UPDATE measurements SET deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?",
[new Date().toISOString(), 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[]> {
export async function getAllMeasurementsIncludingDeleted(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
'SELECT * FROM measurements ORDER BY measuredAt DESC'
)
return rows as Measurement[]
}

View file

@ -1,9 +1,8 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, 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, getVersion, refreshCache } from '../store/measurementStore'
import { initDatabase, getLatestMeasurements } from '../database/db'
import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db'
import { Measurement } from '../types'
import LatestMeasurementCard from '../components/LatestMeasurementCard'
import WeightChart from '../components/WeightChart'
@ -12,76 +11,22 @@ type Props = { navigation: any }
export default function DashboardScreen({ navigation }: Props) {
const insets = useSafeAreaInsets()
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
const [latest, setLatest] = useState<Measurement | null>(null)
const [all, setAll] = useState<Measurement[]>([])
const [chartKey, setChartKey] = useState(0)
const seenVersion = useRef(0)
useFocusEffect(
useCallback(() => {
let active = true
const load = async () => {
await initDatabase()
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')
}
const allM = await getAllMeasurements()
setAll(allM)
const latestM = await getLatestMeasurement()
setLatest(latestM)
}
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}>
@ -93,8 +38,8 @@ export default function DashboardScreen({ navigation }: Props) {
</TouchableOpacity>
</View>
<ScrollView style={styles.container}>
<LatestMeasurementCard measurements={display} />
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
<LatestMeasurementCard measurement={latest} />
<WeightChart measurements={all} />
</ScrollView>
<TouchableOpacity
@ -114,6 +59,4 @@ 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' },
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loading: { fontSize: 16, color: '#999' },
})

View file

@ -1,8 +1,7 @@
import React, { useCallback, useState } from 'react'
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
import { View, FlatList, StyleSheet, Text, RefreshControl, Alert } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
import { refreshCache } from '../store/measurementStore'
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
import { Measurement } from '../types'
import MeasurementRow from '../components/MeasurementRow'
@ -62,9 +61,13 @@ export default function HistoryScreen({ navigation }: Props) {
style: 'destructive',
onPress: async () => {
await initDatabase()
await setDeletedAt(m.id)
await updateSyncStatus(m.id, 'pending-delete')
await refreshCache()
await deleteMeasurement(m.id)
try {
const { syncAll } = await import('../sync/syncService')
await syncAll()
} catch (e) {
console.warn('Sync failed after delete:', e)
}
load()
},
},
@ -79,10 +82,7 @@ export default function HistoryScreen({ navigation }: Props) {
data={measurements}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<MeasurementRow
measurement={item}
onPress={() => navigation.navigate('MeasurementDetail', { measurementId: item.id })}
/>
<MeasurementRow measurement={item} onPress={() => handlePress(item)} />
)}
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}

View file

@ -1,134 +0,0 @@
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, updateSyncStatus } from '../database/db'
import { refreshCache } from '../store/measurementStore'
import { Measurement } from '../types'
type Props = {
navigation: any
route: {
params: {
measurementId: string
}
}
}
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
{ label: 'BMI', key: 'bmi', suffix: '' },
{ label: 'Vetpercentage', key: 'bodyFatPercentage', suffix: '%' },
{ label: 'Vetmassa', key: 'fatMass', suffix: 'kg' },
{ label: 'Spierpercentage', key: 'musclePercentage', suffix: '%' },
{ label: 'Spiermassa', key: 'muscleMass', suffix: 'kg' },
{ label: 'Buikvet', key: 'visceralFat', suffix: '' },
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
]
export default function MeasurementDetailScreen({ navigation, route }: Props) {
const insets = useSafeAreaInsets()
const { measurementId } = route.params
const [measurement, setMeasurement] = useState<Measurement | null>(null)
useFocusEffect(
useCallback(() => {
const load = async () => {
await initDatabase()
const all = await getAllMeasurements()
const m = all.find(x => x.id === measurementId)
setMeasurement(m ?? null)
}
load()
}, [measurementId])
)
const handleEdit = () => {
navigation.navigate('NewMeasurement', { measurement })
}
const handleDelete = () => {
Alert.alert(
'Meting verwijderen?',
'Dit kan niet ongedaan worden gemaakt.',
[
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Verwijderen',
style: 'destructive',
onPress: async () => {
await initDatabase()
await setDeletedAt(measurementId)
await updateSyncStatus(measurementId, 'pending-delete')
await refreshCache()
navigation.goBack()
},
},
]
)
}
if (!measurement) {
return (
<View style={styles.centered}>
<Text style={{ color: '#999' }}>Laden...</Text>
</View>
)
}
return (
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.date}>
{new Date(measurement.measuredAt).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
{rows.map(r => (
<View key={r.key} style={styles.row}>
<Text style={styles.label}>{r.label}</Text>
<Text style={styles.value}>
{typeof measurement[r.key] === 'number'
? (measurement[r.key] as number).toFixed(1)
: measurement[r.key]}{' '}
{r.suffix}
</Text>
</View>
))}
</ScrollView>
<View style={[styles.buttons, { paddingBottom: insets.bottom + 16 }]}>
<TouchableOpacity style={styles.editButton} onPress={handleEdit}>
<Text style={styles.buttonText}>Wijzigen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
<Text style={styles.buttonText}>Verwijderen</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
content: { padding: 16 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f5f5f5' },
date: { fontSize: 18, fontWeight: '700', marginBottom: 16, textAlign: 'center' },
row: {
flexDirection: 'row',
backgroundColor: '#fff',
padding: 14,
marginBottom: 8,
borderRadius: 8,
},
label: { fontSize: 15, color: '#555', flex: 1 },
value: { fontSize: 15, fontWeight: '600' },
buttons: { flexDirection: 'row', gap: 12, padding: 16 },
editButton: { flex: 1, backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center' },
deleteButton: { flex: 1, backgroundColor: '#e53935', padding: 14, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
})

View file

@ -1,90 +1,101 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, 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 { refreshCache } from '../store/measurementStore'
import { showSuccess, showError } from '../utils/toast'
import {
initDatabase,
insertMeasurement,
getLatestMeasurement,
getMeasurementByDate,
upsertMeasurement,
} from '../database/db'
type Props = {
navigation: any
route: {
params: {
measurement?: Measurement
}
}
route: any
}
export default function NewMeasurementScreen({ navigation, route }: Props) {
const editMeasurement = route.params?.measurement
const isEdit = !!editMeasurement
const editing = route.params?.measurement as Measurement | undefined
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
const [loaded, setLoaded] = useState(false)
React.useLayoutEffect(() => {
navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' })
}, [navigation, isEdit])
useFocusEffect(
useCallback(() => {
if (isEdit) {
setLatest(editMeasurement)
setLoaded(true)
return
}
let cancelled = false
const load = async () => {
await initDatabase()
const m = await getLatestMeasurement()
if (!cancelled) {
setLatest(m || {})
setLoaded(true)
if (editing) {
if (!cancelled) {
setLatest(editing)
setLoaded(true)
}
} else {
const m = await getLatestMeasurement()
if (!cancelled) {
setLatest(m || {})
setLoaded(true)
}
}
}
load()
return () => { cancelled = true }
}, [isEdit, editMeasurement])
}, [editing])
)
useEffect(() => {
if (editing) {
navigation.setOptions({ title: 'Meting wijzigen' })
}
}, [editing, navigation])
const handleSubmit = async (m: Measurement) => {
await initDatabase()
if (isEdit) {
if (editing) {
m.id = editing.id
m.createdAt = editing.createdAt
await upsertMeasurement(m)
doSync()
return
}
const existing = await getMeasurementByDate(m.measuredAt)
if (existing) {
Alert.alert(
'Dubbele datum',
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
[
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing) },
]
)
return
}
doSave(m)
}
const doSave = async (m: Measurement, existing?: Measurement) => {
if (existing) {
m.id = existing.id
m.createdAt = existing.createdAt
await upsertMeasurement(m)
} else {
const existing = await getMeasurementByDate(m.measuredAt)
if (existing) {
Alert.alert(
'Dubbele datum',
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
[
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
m.id = existing.id
upsertMeasurement(m).then(() => doSave(m))
}},
]
)
return
}
await insertMeasurement(m)
}
await doSave(m)
doSync()
}
const doSave = async (m: Measurement) => {
await refreshCache()
navigation.goBack()
uploadInBackground(m)
}
const uploadInBackground = async (m: Measurement) => {
const doSync = async () => {
try {
const { uploadMeasurement } = await import('../sync/syncService')
await uploadMeasurement(m)
showSuccess('Opgeslagen & gesynct')
const { syncAll } = await import('../sync/syncService')
await syncAll()
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
{ text: 'OK', onPress: () => navigation.goBack() },
])
} catch {
showError('Opgeslagen (sync mislukt)')
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
{ text: 'OK', onPress: () => navigation.goBack() },
])
}
}
@ -100,7 +111,6 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
<MeasurementForm
onSubmit={handleSubmit}
initialValues={latest as Partial<Measurement> | undefined}
isEdit={isEdit}
/>
)
}

View file

@ -1,11 +1,11 @@
import React, { useEffect, useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
import * as SecureStore from 'expo-secure-store'
import { ApiConfig } from '../types'
import { WebDAVConfig } from '../types'
const CONFIG_KEY = 'weegschaal_api_config'
const CONFIG_KEY = 'weegschaal_webdav_config'
async function loadConfig(): Promise<ApiConfig | null> {
async function loadConfig(): Promise<WebDAVConfig | null> {
try {
const json = await SecureStore.getItemAsync(CONFIG_KEY)
return json ? JSON.parse(json) : null
@ -14,7 +14,7 @@ async function loadConfig(): Promise<ApiConfig | null> {
}
}
export async function saveConfig(config: ApiConfig): Promise<void> {
async function saveConfig(config: WebDAVConfig): Promise<void> {
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
}
@ -22,43 +22,50 @@ async function deleteConfig(): Promise<void> {
await SecureStore.deleteItemAsync(CONFIG_KEY)
}
export async function getApiConfig(): Promise<ApiConfig | null> {
export async function getWebDAVConfig(): Promise<WebDAVConfig | null> {
return loadConfig()
}
export default function SettingsScreen() {
const [url, setUrl] = useState('https://weegschaal.eddydevink.nl')
const [token, setToken] = useState('')
const [showToken, setShowToken] = useState(false)
const [url, setUrl] = useState('https://nextcloud.eddydevink.nl/remote.php/dav/files/eddy')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [syncing, setSyncing] = useState(false)
useEffect(() => {
loadConfig().then(c => {
if (c) {
setUrl(c.url)
setToken(c.token)
setUsername(c.username)
setPassword(c.password)
}
})
}, [])
const handleSave = async () => {
await saveConfig({ url, token })
Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen')
await saveConfig({ url, username, password })
Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen')
}
const handleClear = () => {
Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Ja, wissen',
style: 'destructive',
onPress: async () => {
await deleteConfig()
setUrl('')
setToken('')
Alert.alert(
'Instellingen wissen?',
'WebDAV configuratie definitief verwijderen? Je kunt deze later opnieuw invoeren.',
[
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Ja, wissen',
style: 'destructive',
onPress: async () => {
await deleteConfig()
setUrl('')
setUsername('')
setPassword('')
},
},
},
])
]
)
}
const handleSync = async () => {
@ -66,11 +73,7 @@ export default function SettingsScreen() {
try {
const { syncAll } = await import('../sync/syncService')
const result = await syncAll()
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)
Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`)
} catch (e: any) {
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
} finally {
@ -80,30 +83,38 @@ export default function SettingsScreen() {
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>REST API</Text>
<Text style={styles.sectionTitle}>WebDAV (Nextcloud)</Text>
<Text style={styles.label}>API URL</Text>
<Text style={styles.label}>URL</Text>
<TextInput
style={styles.input}
value={url}
onChangeText={setUrl}
placeholder="https://weegschaal.eddydevink.nl"
placeholder="https://nextcloud.eddydevink.nl/remote.php/dav/files/gebruiker/"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Token</Text>
<Text style={styles.label}>Gebruikersnaam</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Wachtwoord</Text>
<View style={styles.passwordRow}>
<TextInput
style={styles.passwordInput}
value={token}
onChangeText={setToken}
secureTextEntry={!showToken}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowPassword(!showPassword)}>
<Text style={styles.eyeIcon}>{showPassword ? '👁' : '👁‍🗨'}</Text>
</TouchableOpacity>
</View>

View file

@ -1,20 +0,0 @@
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,70 +0,0 @@
import { Measurement } from '../types'
import { ApiConfig } from '../types'
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
function authHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
}
}
export type Api = ReturnType<typeof createApi>
export function createApi(config: ApiConfig) {
const base = config.url.replace(/\/+$/, '')
return {
async getMeasurements(since?: string): Promise<Measurement[]> {
let url = `${base}/api/v1/measurements`
if (since) {
url += `?since=${encodeURIComponent(since)}`
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${config.token}` },
})
if (response.status === 401) {
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
}
if (!response.ok) {
throw new ApiError(`Serverfout bij ophalen metingen: ${response.status}`, response.status)
}
return response.json()
},
async createMeasurement(m: Measurement): Promise<void> {
const response = await fetch(`${base}/api/v1/measurements`, {
method: 'POST',
headers: authHeaders(config.token),
body: JSON.stringify(m),
})
if (response.status === 401) {
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
}
if (!response.ok) {
throw new ApiError(`Serverfout bij opslaan meting: ${response.status}`, response.status)
}
},
async deleteMeasurement(id: string): Promise<void> {
const response = await fetch(`${base}/api/v1/measurements/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${config.token}` },
})
if (response.status === 401) {
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
}
if (!response.ok) {
throw new ApiError(`Serverfout bij verwijderen meting: ${response.status}`, response.status)
}
},
}
}

View file

@ -1,146 +1,139 @@
import { Measurement } from '../types'
import {
initDatabase,
getAllMeasurementsForSync,
upsertMeasurement,
getPendingRecords,
updateSyncStatus,
removeMeasurement,
} from '../database/db'
import { createApi, ApiError } from './api'
import * as SecureStore from 'expo-secure-store'
import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db'
import { getWebDAVConfig } from '../screens/SettingsScreen'
import { encodeBase64 } from '../utils/base64'
const LAST_SYNC_KEY = 'weegschaal_last_sync'
const WEBDAV_PATH = 'weegschaal/measurements.json'
const SYNC_TIMEOUT_MS = 15_000
type SyncResult = {
pushed: number
deleted: number
pulled: number
errors: string[]
}
type SyncResult = { local: number; remote: number; merged: number }
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]))
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
const map = new Map<string, Measurement>()
for (const r of remote) {
const localRec = localMap.get(r.id)
const isDeleted = !!(r as any).deleted
const localPending =
localRec?.syncStatus === 'pending' ||
localRec?.syncStatus === 'pending-delete'
for (const m of local) {
map.set(m.id, m)
}
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)
}
for (const m of remote) {
const existing = map.get(m.id)
if (!existing) {
map.set(m.id, { ...m, syncStatus: 'synced' })
} else {
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt)
if (remoteNewer) {
map.set(m.id, { ...m, syncStatus: 'synced' })
}
}
}
return Array.from(resultMap.values())
return Array.from(map.values()).filter(m => !m.deleted)
}
export async function uploadMeasurement(m: Measurement): Promise<void> {
const { getApiConfig } = await import('../screens/SettingsScreen')
const config = await getApiConfig()
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')
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> {
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
const auth = encodeBase64(`${config.username}:${config.password}`)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { Authorization: `Basic ${auth}` },
signal: controller.signal,
})
if (response.status === 404) {
return []
}
if (!response.ok) {
throw new Error(`WebDAV fetch failed: ${response.status}`)
}
return response.json()
} finally {
clearTimeout(timer)
}
}
async function createDir(config: { url: string; username: string; password: string }): Promise<void> {
const base = config.url.replace(/\/+$/, '')
const dirUrl = `${base}/weegschaal`
const auth = encodeBase64(`${config.username}:${config.password}`)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try {
const response = await fetch(dirUrl, {
method: 'MKCOL',
headers: { Authorization: `Basic ${auth}` },
signal: controller.signal,
})
if (response.status !== 201 && response.status !== 405) {
throw new Error(`WebDAV directory create failed: ${response.status}`)
}
} finally {
clearTimeout(timer)
}
}
async function uploadRemote(
config: { url: string; username: string; password: string },
measurements: Measurement[]
): Promise<void> {
const base = config.url.replace(/\/+$/, '')
const url = `${base}/${WEBDAV_PATH}`
const body = JSON.stringify(measurements)
const auth = encodeBase64(`${config.username}:${config.password}`)
await createDir(config)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try {
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`WebDAV upload failed: ${response.status}`)
}
} finally {
clearTimeout(timer)
}
}
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.')
const config = await getWebDAVConfig()
if (!config) {
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
}
const api = createApi(config)
await initDatabase()
const errors: string[] = []
let pushed = 0
let deleted = 0
let pulled = 0
const local = await getAllMeasurementsIncludingDeleted()
const remote = await fetchRemote(config)
// === PUSH phase — outbox first ===
const pending = await getPendingRecords()
const merged = mergeMeasurements(local, remote)
for (const m of pending) {
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)
deleted++
} else {
errors.push(e?.message ?? 'Delete failed')
}
}
} else {
try {
await api.createMeasurement(m)
await updateSyncStatus(m.id, 'synced')
pushed++
} catch (e: any) {
errors.push(e?.message ?? 'Create failed')
}
}
for (const m of merged) {
await upsertMeasurement(m)
}
// === PULL phase — fetch remote changes ===
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
const remote = await api.getMeasurements(lastSyncAt || undefined)
await uploadRemote(config, merged)
if (remote.length > 0) {
const local = await getAllMeasurementsForSync()
const merged = mergeMeasurements(local, remote)
const localMap = new Map(local.map(m => [m.id, m]))
for (const m of merged) {
if (m.deletedAt) {
await removeMeasurement(m.id)
const localRec = localMap.get(m.id)
if (localRec && !localRec.deletedAt) {
deleted++
}
} else {
const localRec = localMap.get(m.id)
const isNew = !localRec
const isUpdated =
localRec &&
new Date(m.updatedAt).getTime() !==
new Date(localRec.updatedAt).getTime()
if (isNew || isUpdated) {
await upsertMeasurement(m)
await updateSyncStatus(m.id, 'synced')
pulled++
}
}
}
return {
local: local.filter(m => !m.deleted).length,
remote: remote.filter(m => !m.deleted).length,
merged: merged.length,
}
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
return { pushed, deleted, pulled, errors }
}

View file

@ -1,3 +1,5 @@
export type SyncStatus = 'local' | 'synced'
export type Measurement = {
id: string
weight: number
@ -11,11 +13,12 @@ export type Measurement = {
measuredAt: string
createdAt: string
updatedAt: string
syncStatus?: string
deletedAt?: string
syncStatus: SyncStatus
deleted: number
}
export type ApiConfig = {
export type WebDAVConfig = {
url: string
token: string
username: string
password: string
}

17
src/utils/base64.ts Normal file
View file

@ -0,0 +1,17 @@
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
export function encodeBase64(str: string): string {
const bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match, p1) => String.fromCharCode(parseInt(p1, 16)))
let result = ''
let i = 0
while (i < bytes.length) {
const a = bytes.charCodeAt(i++) || 0
const b = bytes.charCodeAt(i++) || 0
const c = bytes.charCodeAt(i++) || 0
result += CHARS.charAt(a >> 2)
result += CHARS.charAt(((a & 3) << 4) | (b >> 4))
result += CHARS.charAt(isNaN(b) ? CHARS.length - 1 : ((b & 15) << 2) | (c >> 6))
result += CHARS.charAt(isNaN(c) ? CHARS.length - 1 : c & 63)
}
return result
}

View file

@ -1,26 +0,0 @@
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

@ -18,6 +18,8 @@ const mockMeasurement: Measurement = {
measuredAt: '2024-01-01T08:00:00.000Z',
createdAt: '2024-01-01T08:00:00.000Z',
updatedAt: '2024-01-01T08:00:00.000Z',
syncStatus: 'local',
deleted: 0,
}
describe('database', () => {
@ -26,7 +28,9 @@ describe('database', () => {
let insertMeasurement: any
let getAllMeasurements: any
let getLatestMeasurement: any
let setDeletedAt: any
let updateMeasurement: any
let deleteMeasurement: any
let getAllMeasurementsIncludingDeleted: any
beforeEach(async () => {
jest.resetModules()
@ -34,8 +38,8 @@ describe('database', () => {
openDatabaseAsync: jest.fn(),
}))
db = {
execAsync: jest.fn(),
runAsync: jest.fn(),
execAsync: jest.fn(() => Promise.resolve()),
runAsync: jest.fn(() => Promise.resolve()),
getAllAsync: jest.fn(),
}
const sqlite = require('expo-sqlite')
@ -45,7 +49,9 @@ describe('database', () => {
insertMeasurement = mod.insertMeasurement
getAllMeasurements = mod.getAllMeasurements
getLatestMeasurement = mod.getLatestMeasurement
setDeletedAt = mod.setDeletedAt
updateMeasurement = mod.updateMeasurement
deleteMeasurement = mod.deleteMeasurement
getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted
})
it('initDatabase creates the measurements table', async () => {
@ -81,15 +87,42 @@ describe('database', () => {
expect(result!.id).toBe(mockMeasurement.id)
})
it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
it('updateMeasurement updates syncStatus', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await setDeletedAt(mockMeasurement.id)
await updateMeasurement(mockMeasurement.id, 'synced')
expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('UPDATE measurements SET deletedAt'),
expect.stringContaining('UPDATE measurements'),
expect.arrayContaining(['synced', mockMeasurement.id])
)
})
it('deleteMeasurement marks measurement as deleted', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await deleteMeasurement(mockMeasurement.id)
expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('UPDATE measurements SET deleted = 1'),
expect.arrayContaining([mockMeasurement.id])
)
const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
expect(args[1][0]).toBe(args[1][1])
})
it('getAllMeasurements filters out deleted records', async () => {
await initDatabase()
await getAllMeasurements()
expect(db.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining('deleted = 0')
)
})
it('getAllMeasurementsIncludingDeleted includes deleted records', async () => {
await initDatabase()
await getAllMeasurementsIncludingDeleted()
expect(db.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM measurements')
)
expect(db.getAllAsync).not.toHaveBeenCalledWith(
expect.stringContaining('deleted = 0')
)
})
})

View file

@ -1,86 +1,102 @@
import { mergeMeasurements } from '../src/sync/syncService'
import { Measurement } from '../src/types'
jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() }))
jest.mock('expo-sqlite', () => ({
openDatabaseAsync: jest.fn(),
}))
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', syncStatus: 'synced', ...overrides,
})
const base: 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',
syncStatus: 'local',
deleted: 0,
}
describe('mergeMeasurements', () => {
it('both empty returns empty', () => {
it('returns empty array when both are empty', () => {
expect(mergeMeasurements([], [])).toEqual([])
})
it('local only kept', () => {
const r = mergeMeasurements([make()], [])
expect(r).toHaveLength(1)
it('keeps local items not on remote', () => {
const result = mergeMeasurements([base], [])
expect(result).toHaveLength(1)
expect(result[0].id).toBe('id-1')
})
it('remote only kept', () => {
const r = mergeMeasurements([], [make({ id: 'r1' })])
expect(r).toHaveLength(1)
expect(r[0].id).toBe('r1')
it('keeps remote items not on local', () => {
const remote = { ...base, syncStatus: 'synced' as const }
const result = mergeMeasurements([], [remote])
expect(result).toHaveLength(1)
expect(result[0].id).toBe('id-1')
})
it('last-writer-wins: remote newer', () => {
const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' })
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
const r = mergeMeasurements([local], [remote])
expect(r[0].weight).toBe(76)
it('picks the newer version when both have same id', () => {
const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(76)
})
it('last-writer-wins: local newer', () => {
const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' })
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
const r = mergeMeasurements([local], [remote])
expect(r[0].weight).toBe(77)
it('picks local when local is newer', () => {
const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(77)
})
it('remote deleted wins over synced local', () => {
const local = make({ updatedAt: '2024-06-01T08: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('filters out locally deleted items even if present on remote', () => {
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(0)
})
it('local synced wins over older remote deleted', () => {
const local = make({ updatedAt: '2024-06-03T08: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('filters out remotely deleted items when remote is newer', () => {
const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' }
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(0)
})
it('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('keeps local active item when local is newer than remote deletion', () => {
const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' }
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(78)
})
it('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('keeps active item when local deleted is older than remote active', () => {
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' }
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(75)
})
it('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('merges multiple active and deleted items correctly', () => {
const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' }
const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const }
const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
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)
const result = mergeMeasurements(
[localActive, localDeleted],
[remoteActive, remoteDeleted]
)
expect(result).toHaveLength(2)
expect(result.map(m => m.id).sort()).toEqual(['a', 'c'])
})
})

View file

@ -1,140 +0,0 @@
# Upgrade: REST API sync i.p.v. WebDAV
Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`,
met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand.
## Fase 1 — Server-side: PHP API
### Subdomein & nginx
- DNS: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl`
- nginx server block in `/etc/nginx/conf.d/weegschaal-api.conf`
- SSL via Let's Encrypt (`sudo certbot`)
- Document root: `/usr/share/nginx/html/weegschaal-api/`
### MariaDB
```sql
CREATE DATABASE weegschaal;
CREATE USER 'weegschaal'@'localhost' IDENTIFIED BY '<random>';
GRANT ALL ON weegschaal.* TO 'weegschaal'@'localhost';
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE auth_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
token VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE measurements (
id VARCHAR(36) NOT NULL,
user_id INT NOT NULL,
weight DECIMAL(5,2) NOT NULL,
bmi DECIMAL(4,1) NOT NULL,
body_fat_percentage DECIMAL(4,1) NOT NULL,
fat_mass DECIMAL(5,2) NOT NULL,
muscle_percentage DECIMAL(4,1) NOT NULL,
muscle_mass DECIMAL(5,2) NOT NULL,
visceral_fat TINYINT NOT NULL,
bmr DECIMAL(6,1) NOT NULL,
measured_at DATE NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (user_id, id),
INDEX idx_updated_at (user_id, updated_at)
);
```
### PHP API endpoints (Slim Framework via Composer)
| Methode | Pad | Beschrijving |
|---------|-----|-------------|
| `POST` | `/api/v1/auth/register` | (later) Nieuwe gebruiker |
| `POST` | `/api/v1/auth/tokens` | (later) Nieuwe token aanmaken |
| `GET` | `/api/v1/measurements` | Alle metingen (of `?since=ISO` voor delta) |
| `POST` | `/api/v1/measurements` | Nieuwe meting aanmaken |
| `PUT` | `/api/v1/measurements/:id` | Meting bijwerken |
| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen |
Authenticatie: `Authorization: Bearer <token>`, gevalideerd tegen `auth_tokens`-tabel.
Sync-logica server:
- `GET /api/v1/measurements?since=2025-01-01T12:00:00Z` → alleen metingen met `updated_at > since`
- Bij PUT/DELETE: controleer `updated_at` client, bij conflict → HTTP 409 met server-versie
### Bestandsstructuur API
```
/usr/share/nginx/html/weegschaal-api/
├── composer.json
├── public/
│ └── index.php ← Entry point, routing
├── src/
│ ├── Database.php ← PDO singleton
│ ├── Auth.php ← Token validation middleware
│ ├── Measurements.php ← CRUD + sync queries
│ └── helpers.php
└── config.php ← DB credentials (uit .env of env vars)
```
## Fase 2 — Client-side: App aanpassen
### Types (`src/types.ts`)
- Vervang `WebDAVConfig` door `ApiConfig { url: string; token: string }`
- Verwijder `SyncStatus` (niet meer nodig — sync status volgt uit `updatedAt`)
### SettingsScreen (`src/screens/SettingsScreen.tsx`)
- Vervang URL/username/password door API-URL + token (met oogje)
- Opslaan → SecureStore
- Wissen → SecureStore verwijderen
- 'Nu synchroniseren' knop blijft
### Nieuwe module `src/sync/api.ts`
```typescript
const api = (config: ApiConfig) => ({
getMeasurements: (since?: string) => GET /api/v1/measurements?since=...
createMeasurement: (m: Measurement) => POST /api/v1/measurements
updateMeasurement: (m: Measurement) => PUT /api/v1/measurements/:id
deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id
})
```
### syncService.ts — volledig herschreven
Nieuwe sync-flow:
| Stap | Actie |
|------|-------|
| 1 | Lees lokale `lastSyncAt` (uit SecureStore) |
| 2 | `GET ?since=lastSyncAt` → alleen wijzigingen ophalen |
| 3 | Upsert remote wijzigingen lokaal (last-writer-wins) |
| 4 | Voor elke lokale meting met `updatedAt > lastSyncAt`: push naar API |
| 5 | Update `lastSyncAt` naar server-tijd |
Bij opslaan in `NewMeasurementScreen`:
1. Direct naar lokale SQLite (offline-first)
2. Direct erna: push naar API (async, niet-blokkerend via `Promise.allSettled`)
## Fase 3 — Migratie
Eerste keer na API-setup:
1. App stuurt alle lokale metingen naar de API (1-voor-1 via POST)
2. Alle metingen nu in MariaDB
3. Vanaf dan: alleen delta-sync
## Benodigde server-toegang
- SSH naar nextcloud (`aiaiaiaiagent@nextcloud`) — key werkte niet
- `sudo -A mysql` voor DB + user aanmaken
- nginx config aanpassen (`/etc/nginx/conf.d/`)
- DNS controleren voor subdomein

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB