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>
<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 latest[r.key] === 'number'
? (latest[r.key] as number).toFixed(1)
: latest[r.key]}{' '}
{typeof measurement[r.key] === 'number'
? (measurement[r.key] as number).toFixed(1)
: measurement[r.key]}{' '}
{r.suffix}
</Text>
</View>
))}
</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' },
})

Some files were not shown because too many files have changed in this diff Show more