diff --git a/.gitignore b/.gitignore index f4c927b..d03787f 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,12 @@ test/fixtures/ test/sample-pdfs/ test/output/ +# Expo +.expo/ + +# APK builds +*.apk + # Temporary files temp/ tmp/ diff --git a/App.tsx b/App.tsx index 00a9ac1..bccac9b 100644 --- a/App.tsx +++ b/App.tsx @@ -5,8 +5,18 @@ 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' -const Stack = createNativeStackNavigator() +type RootStackParamList = { + Dashboard: undefined + NewMeasurement: { measurement?: Measurement } + History: undefined + MeasurementDetail: { measurementId: string } + Settings: undefined +} + +const Stack = createNativeStackNavigator() export default function App() { return ( @@ -27,6 +37,11 @@ export default function App() { component={HistoryScreen} options={{ title: 'Historie' }} /> + .* - .*$ +``` + +Logrotering wekelijks, 12 weken retentie. + +### SELinux + +Logmap heeft context `httpd_log_t` gezet via `semanage fcontext`. ## Ontwikkeling @@ -29,15 +122,25 @@ npx expo start Scannen met Expo Go op Android of iOS. -## Synchronisatie +## Build & deploy -Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in: +```bash +# APK bouwen (lokaal via EAS) +npx eas build --local --profile apk --platform android -- **URL**: `https:///remote.php/dav/files//` -- **Gebruikersnaam**: je Nextcloud gebruikersnaam -- **Wachtwoord**: je Nextcloud app-wachtwoord +# Draadloos sideloaden via ADB +adb-wireless-connect +adb install -r build-*.apk +``` -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. +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'` ## Testen @@ -45,6 +148,8 @@ Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basi npm test ``` +Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties. + ## Licentie Public domain (Unlicense) diff --git a/app.json b/app.json index 17faf47..53944c6 100644 --- a/app.json +++ b/app.json @@ -13,9 +13,9 @@ "package": "nl.eddydevink.weegschaal", "adaptiveIcon": { "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/android-icon-foreground.png", + "foregroundImage": "./assets/icon.png", "backgroundImage": "./assets/android-icon-background.png", - "monochromeImage": "./assets/android-icon-monochrome.png" + "monochromeImage": "./assets/icon.png" }, "predictiveBackGestureEnabled": false }, @@ -26,6 +26,17 @@ "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" + } } } diff --git a/assets/icon.png b/assets/icon.png index 7165a53..ee2e6e7 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/docs/blog-sync-upgrade.md b/docs/blog-sync-upgrade.md new file mode 100644 index 0000000..9e0e2de --- /dev/null +++ b/docs/blog-sync-upgrade.md @@ -0,0 +1,70 @@ +# 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. diff --git a/docs/build-toolchain.md b/docs/build-toolchain.md new file mode 100644 index 0000000..338fc00 --- /dev/null +++ b/docs/build-toolchain.md @@ -0,0 +1,106 @@ +# 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-.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' +``` diff --git a/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md b/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md new file mode 100644 index 0000000..7e4460c --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md @@ -0,0 +1,1285 @@ +# REST API Sync — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl` met MariaDB en Bearer token auth. Incrementele delta-sync op basis van `updatedAt`/`deletedAt` timestamps — standaard sync patroon zonder syncStatus veld. + +**Architecture:** Server: PHP API (Slim 4) op nextcloud met nginx + MariaDB, soft deletes via `deleted` flag. Client: fetch() met Bearer token, delta-sync via `?since=lastSyncAt`, last-writer-wins merge, push van alle lokale records met `updatedAt > lastSyncAt`. `deletedAt` veld voor offline delete resilience. + +**Tech Stack:** PHP 8 + Slim 4 + PDO/MySQL, nginx, certbot, expo-sqlite, expo-secure-store, TypeScript, Jest + +--- + +## File Structure + +``` +nextcloud server: + /etc/nginx/conf.d/weegschaal-api.conf ← NEW + /usr/share/nginx/html/weegschaal-api/ ← NEW directory + composer.json + public/index.php + src/Database.php + src/Auth.php + src/Measurements.php + src/helpers.php + config.php + +app (weegschaal-app): + src/types.ts ← MODIFY (WebDAVConfig→ApiConfig, SyncStatus weg, +deletedAt) + src/sync/api.ts ← NEW + src/sync/syncService.ts ← REWRITE + src/screens/SettingsScreen.tsx ← MODIFY (WebDAV→API form) + src/screens/NewMeasurementScreen.tsx ← MODIFY (direct push) + src/screens/MeasurementDetailScreen.tsx ← MODIFY (soft delete via deletedAt) + src/screens/HistoryScreen.tsx ← MODIFY (filter deletedAt, soft delete) + src/screens/DashboardScreen.tsx ← MODIFY (filter deletedAt) + src/database/db.ts ← MODIFY (+deletedAt column, +setDeletedAt, +getUpdatedSince) + tests/syncService.test.ts ← REWRITE +``` + +--- + +### Task 1: SSH naar nextcloud — nginx + certbot + +**Files:** +- Create: `/etc/nginx/conf.d/weegschaal-api.conf` (remote) + +- [ ] **Step 1: SSH naar nextcloud, check PHP-FPM socket** + +```bash +ssh aiaiaiaiagent@nextcloud "ls /run/php-fpm/ && php -v && which composer" +``` + +Noteer de socket naam (bv. `www.sock`). Als composer ontbreekt: `sudo -A dnf install -y composer`. + +- [ ] **Step 2: Schrijf nginx config** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A tee /etc/nginx/conf.d/weegschaal-api.conf' <<'EOF' +server { + listen 80; + server_name weegschaal.eddydevink.nl; + root /usr/share/nginx/html/weegschaal-api/public; + + index index.php; + + location / { + try_files $uri /index.php$is_args$args; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm/www.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } +} +EOF +``` + +Pas `fastcgi_pass` aan naar stap 1 resultaat indien nodig. + +- [ ] **Step 3: Maak document root, zet permissies** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A mkdir -p /usr/share/nginx/html/weegschaal-api/public /usr/share/nginx/html/weegschaal-api/src && sudo -A chown -R aiaiaiaiagent:aiaiaiaiagent /usr/share/nginx/html/weegschaal-api' +``` + +- [ ] **Step 4: SELinux context** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A restorecon -R /usr/share/nginx/html/weegschaal-api' +``` + +- [ ] **Step 5: Test nginx config en herlaad** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A nginx -t && sudo -A systemctl reload nginx' +``` + +- [ ] **Step 6: Certbot SSL** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A certbot --nginx -d weegschaal.eddydevink.nl' +``` + +--- + +### Task 2: MariaDB setup + +- [ ] **Step 1: Bepaal MySQL auth op nextcloud** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A mysql -e "SELECT 1"' +``` + +- [ ] **Step 2: Genereer credentials lokaal** + +```bash +DB_PASS=$(openssl rand -hex 16) && echo "DB_PASS=$DB_PASS" +APP_TOKEN=$(openssl rand -hex 32) && echo "APP_TOKEN=$APP_TOKEN" +``` + +Sla beide op in `/tmp/weegschaal-credentials.txt` (tijdelijk, wordt later in de app gezet): + +```bash +echo "URL=https://weegschaal.eddydevink.nl" > /tmp/weegschaal-credentials.txt +echo "TOKEN=$APP_TOKEN" >> /tmp/weegschaal-credentials.txt +``` + +- [ ] **Step 3: Database, gebruiker, tabellen** + +```bash +ssh aiaiaiaiagent@nextcloud "sudo -A mysql" < /usr/share/nginx/html/weegschaal-api/composer.json' <<'EOF' +{ + "name": "eddy/weegschaal-api", + "type": "project", + "require": { + "php": ">=8.0", + "slim/slim": "^4.0", + "slim/psr7": "^1.0" + }, + "autoload": { + "psr-4": { + "Weegschaal\\": "src/" + } + } +} +EOF +``` + +- [ ] **Step 2: composer install** + +```bash +ssh aiaiaiaiagent@nextcloud 'cd /usr/share/nginx/html/weegschaal-api && composer install' +``` + +- [ ] **Step 3: config.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/config.php' < [ + 'host' => 'localhost', + 'port' => 3306, + 'name' => 'weegschaal', + 'user' => 'weegschaal', + 'pass' => '$DB_PASS', + ], +]; +EOF +``` + +- [ ] **Step 4: src/Database.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Database.php' <<'EOF' + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return self::$instance; + } +} +EOF +``` + +- [ ] **Step 5: src/helpers.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/helpers.php' <<'EOF' +getBody()->write($body); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); +} + +function errorResponse(Response $response, string $message, int $status): Response +{ + return jsonResponse($response, ['error' => $message], $status); +} + +function rowToMeasurement(array $row): array +{ + return [ + 'id' => $row['id'], + 'weight' => (float)$row['weight'], + 'bmi' => (float)$row['bmi'], + 'bodyFatPercentage' => (float)$row['body_fat_percentage'], + 'fatMass' => (float)$row['fat_mass'], + 'musclePercentage' => (float)$row['muscle_percentage'], + 'muscleMass' => (float)$row['muscle_mass'], + 'visceralFat' => (int)$row['visceral_fat'], + 'bmr' => (float)$row['bmr'], + 'measuredAt' => $row['measured_at'], + 'createdAt' => $row['created_at'], + 'updatedAt' => $row['updated_at'], + 'deleted' => (bool)$row['deleted'], + ]; +} +EOF +``` + +--- + +### Task 4: PHP Auth middleware + +**Files:** +- Create: `src/Auth.php` (remote) + +- [ ] **Step 1: src/Auth.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Auth.php' <<'EOF' +getHeaderLine('Authorization'); + if (!preg_match('/^Bearer\s+(.+)$/', $header, $matches)) { + return errorResponse(new SlimResponse(), 'Authenticatie vereist', 401); + } + $stmt = Database::get()->prepare('SELECT user_id FROM auth_tokens WHERE token = ?'); + $stmt->execute([$matches[1]]); + $row = $stmt->fetch(); + if (!$row) { + return errorResponse(new SlimResponse(), 'Ongeldige token', 401); + } + return $handler->handle($request->withAttribute('user_id', (int)$row['user_id'])); + } +} +EOF +``` + +--- + +### Task 5: PHP Measurements controller + +**Files:** +- Create: `src/Measurements.php` (remote) + +- [ ] **Step 1: src/Measurements.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Measurements.php' <<'EOF' +getAttribute('user_id'); + $params = $request->getQueryParams(); + $db = Database::get(); + + if (!empty($params['since'])) { + $stmt = $db->prepare( + 'SELECT * FROM measurements WHERE user_id = ? AND updated_at > ? ORDER BY updated_at ASC' + ); + $stmt->execute([$userId, $params['since']]); + } else { + $stmt = $db->prepare( + 'SELECT * FROM measurements WHERE user_id = ? ORDER BY updated_at ASC' + ); + $stmt->execute([$userId]); + } + + $rows = $stmt->fetchAll(); + $data = array_map('\Weegschaal\rowToMeasurement', $rows); + return jsonResponse($response, $data); + } + + public function create(Request $request, Response $response): Response + { + $userId = $request->getAttribute('user_id'); + $body = $request->getParsedBody(); + if (!is_array($body)) { + return errorResponse($response, 'Ongeldige JSON', 400); + } + + $db = Database::get(); + $stmt = $db->prepare( + 'INSERT INTO measurements + (id, user_id, weight, bmi, body_fat_percentage, fat_mass, + muscle_percentage, muscle_mass, visceral_fat, bmr, + measured_at, created_at, updated_at) + VALUES + (:id, :user_id, :weight, :bmi, :body_fat_percentage, :fat_mass, + :muscle_percentage, :muscle_mass, :visceral_fat, :bmr, + :measured_at, :created_at, :updated_at) + ON DUPLICATE KEY UPDATE + weight = VALUES(weight), bmi = VALUES(bmi), + body_fat_percentage = VALUES(body_fat_percentage), + fat_mass = VALUES(fat_mass), + muscle_percentage = VALUES(muscle_percentage), + muscle_mass = VALUES(muscle_mass), + visceral_fat = VALUES(visceral_fat), + bmr = VALUES(bmr), + measured_at = VALUES(measured_at), + updated_at = VALUES(updated_at), + deleted = 0' + ); + + $stmt->execute([ + ':id' => $body['id'], + ':user_id' => $userId, + ':weight' => $body['weight'], + ':bmi' => $body['bmi'], + ':body_fat_percentage' => $body['bodyFatPercentage'], + ':fat_mass' => $body['fatMass'], + ':muscle_percentage' => $body['musclePercentage'], + ':muscle_mass' => $body['muscleMass'], + ':visceral_fat' => $body['visceralFat'], + ':bmr' => $body['bmr'], + ':measured_at' => substr($body['measuredAt'], 0, 10), + ':created_at' => $body['createdAt'], + ':updated_at' => $body['updatedAt'], + ]); + + return jsonResponse($response, $body, 201); + } + + public function delete(Request $request, Response $response, array $args): Response + { + $userId = $request->getAttribute('user_id'); + $id = $args['id']; + $db = Database::get(); + $stmt = $db->prepare( + 'UPDATE measurements SET deleted = 1, updated_at = NOW() WHERE user_id = ? AND id = ?' + ); + $stmt->execute([$userId, $id]); + return jsonResponse($response, ['deleted' => $id]); + } +} +EOF +``` + +--- + +### Task 6: PHP Entry point + +**Files:** +- Create: `public/index.php` (remote) + +- [ ] **Step 1: public/index.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/public/index.php' <<'EOF' +addErrorMiddleware(false, true, true); + +$app->get('/api/v1/measurements', [Measurements::class, 'list'])->add(new Auth()); +$app->post('/api/v1/measurements', [Measurements::class, 'create'])->add(new Auth()); +$app->delete('/api/v1/measurements/{id}', [Measurements::class, 'delete'])->add(new Auth()); + +$app->run(); +EOF +``` + +--- + +### Task 7: Test API met curl + +- [ ] **Step 1: GET measurements (lege lijst)** + +```bash +curl -s https://weegschaal.eddydevink.nl/api/v1/measurements \ + -H "Authorization: Bearer $APP_TOKEN" +``` +Expected: `200` met `[]` + +- [ ] **Step 2: POST nieuwe meting** + +```bash +curl -s -X POST https://weegschaal.eddydevink.nl/api/v1/measurements \ + -H "Authorization: Bearer $APP_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"test-$(date +%s)\", + \"weight\": 75.5, + \"bmi\": 22.3, + \"bodyFatPercentage\": 15.2, + \"fatMass\": 11.5, + \"musclePercentage\": 40.1, + \"muscleMass\": 30.3, + \"visceralFat\": 8, + \"bmr\": 1650, + \"measuredAt\": \"2026-07-20T08:00:00.000Z\", + \"createdAt\": \"2026-07-20T08:00:00.000Z\", + \"updatedAt\": \"2026-07-20T08:00:00.000Z\" + }" +``` +Expected: `201` + +- [ ] **Step 3: GET met since** + +```bash +curl -s "https://weegschaal.eddydevink.nl/api/v1/measurements?since=2020-01-01T00:00:00Z" \ + -H "Authorization: Bearer $APP_TOKEN" | python3 -m json.tool +``` +Expected: array met de test meting + +- [ ] **Step 4: Test 401** + +```bash +curl -s -o /dev/null -w "%{http_code}" https://weegschaal.eddydevink.nl/api/v1/measurements +``` +Expected: `401` + +--- + +### Task 8: Client — Update types.ts + +**Files:** +- Modify: `src/types.ts` + +- [ ] **Step 1: Vervang types** + +Vervang volledige inhoud van `src/types.ts`: + +```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 +} +``` + +- [ ] **Step 2: Check TypeScript** + +```bash +cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit +``` +Expected: fouten vanwege verwijzingen naar `WebDAVConfig` en `syncStatus` in andere files — normaal, wordt in volgende tasks opgelost. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "types: vervang WebDAVConfig door ApiConfig, verwijder SyncStatus, voeg deletedAt toe" +``` + +--- + +### Task 9: Client — Create src/sync/api.ts + +**Files:** +- Create: `src/sync/api.ts` + +- [ ] **Step 1: Schrijf api.ts** + +```typescript +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 { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + } +} + +export type Api = ReturnType + +export function createApi(config: ApiConfig) { + const base = config.url.replace(/\/+$/, '') + + return { + async getMeasurements(since?: string): Promise { + 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 { + 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 { + 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) + } + }, + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/sync/api.ts +git commit -m "feat: REST API client module" +``` + +--- + +### Task 10: Client — Rewrite src/sync/syncService.ts + +**Files:** +- Modify: `src/sync/syncService.ts` + +- [ ] **Step 1: Volledige herschrijving** + +```typescript +import { Measurement } from '../types' +import { + initDatabase, + getAllMeasurements, + upsertMeasurement, + getMeasurementsUpdatedSince, + removeMeasurement, +} from '../database/db' +import { createApi, ApiError } from './api' +import * as SecureStore from 'expo-secure-store' + +const LAST_SYNC_KEY = 'weegschaal_last_sync' + +export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { + const map = new Map() + + for (const m of local) { + map.set(m.id, m) + } + + for (const r of remote) { + const existing = map.get(r.id) + if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) { + map.set(r.id, { + ...r, + deletedAt: r.deletedAt || undefined, + } as Measurement) + } + } + + return Array.from(map.values()) +} + +export async function uploadMeasurement(m: Measurement): Promise { + const { getApiConfig } = await import('../screens/SettingsScreen') + const config = await getApiConfig() + if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') + const api = createApi(config) + await api.createMeasurement(m) +} + +export async function syncAll(): Promise<{ pushed: number; deleted: number; pulled: number }> { + 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 initDatabase() + + const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) + + const remote = await api.getMeasurements(lastSyncAt || undefined) + const local = await getAllMeasurements() + + const merged = mergeMeasurements(local, remote) + + for (const m of merged) { + await upsertMeasurement(m) + } + + const since = lastSyncAt || '1970-01-01T00:00:00.000Z' + const updated = await getMeasurementsUpdatedSince(since) + + let pushed = 0 + let deleted = 0 + + await Promise.allSettled( + updated.map(async m => { + if (m.deletedAt && m.deletedAt > since) { + try { + await api.deleteMeasurement(m.id) + await removeMeasurement(m.id) + deleted++ + } catch {} + } else { + try { + await api.createMeasurement(m) + pushed++ + } catch {} + } + }) + ) + + await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) + + return { pushed, deleted, pulled: remote.length } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/sync/syncService.ts +git commit -m "sync: herschrijf syncService voor timestamp-based REST sync" +``` + +--- + +### Task 11: Client — Update src/database/db.ts + +**Files:** +- Modify: `src/database/db.ts` + +- [ ] **Step 1: Voeg deletedAt kolom toe, nieuwe functies** + +Huidige `db.ts` lezen en alle wijzigingen toepassen: + +**Schema wijziging** — voeg `deletedAt TEXT` toe na `syncStatus TEXT` in CREATE TABLE: + +```typescript +await db.execAsync(` + CREATE TABLE IF NOT EXISTS measurements ( + id TEXT PRIMARY KEY, + weight REAL NOT NULL, + bmi REAL NOT NULL, + bodyFatPercentage REAL NOT NULL, + fatMass REAL NOT NULL, + musclePercentage REAL NOT NULL, + muscleMass REAL NOT NULL, + visceralFat INTEGER NOT NULL, + bmr REAL NOT NULL, + measuredAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + deletedAt TEXT, + syncStatus TEXT NOT NULL DEFAULT 'local' + ); +`) +``` + +**Verwijder** `updateMeasurement`, `getUnsyncedMeasurements`. Houd `deleteMeasurement` voor backward compat (wordt niet meer geëxporteerd). + +**Voeg toe** onderaan: + +```typescript +export async function getMeasurementsUpdatedSince(since: string): Promise { + const rows = await db!.getAllAsync( + 'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC', + [since] + ) + return rows as Measurement[] +} + +export async function setDeletedAt(id: string): Promise { + await db!.runAsync( + 'UPDATE measurements SET deletedAt = ? WHERE id = ?', + [new Date().toISOString(), id] + ) +} + +export async function removeMeasurement(id: string): Promise { + await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) +} +``` + +- [ ] **Step 2: Controleer dat insertMeasurement het deletedAt veld ook invult** + +`insertMeasurement` moet `deletedAt` opnemen in INSERT (NULL voor nieuwe metingen): + +```typescript +export async function insertMeasurement(m: Measurement): Promise { + 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] + ) +} +``` + +En `upsertMeasurement`: + +```typescript +export async function upsertMeasurement(m: Measurement): Promise { + 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] + ) +} +``` + +- [ ] **Step 3: Check TypeScript** + +```bash +cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/database/db.ts +git commit -m "db: voeg deletedAt kolom toe, nieuwe sync-gerelateerde functies" +``` + +--- + +### Task 12: Client — Update SettingsScreen + +**Files:** +- Modify: `src/screens/SettingsScreen.tsx` + +- [ ] **Step 1: Vervang WebDAV form door API form** + +```tsx +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' + +const CONFIG_KEY = 'weegschaal_api_config' + +async function loadConfig(): Promise { + try { + const json = await SecureStore.getItemAsync(CONFIG_KEY) + return json ? JSON.parse(json) : null + } catch { + return null + } +} + +async function saveConfig(config: ApiConfig): Promise { + await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) +} + +async function deleteConfig(): Promise { + await SecureStore.deleteItemAsync(CONFIG_KEY) +} + +export async function getApiConfig(): Promise { + return loadConfig() +} + +export default function SettingsScreen() { + const [url, setUrl] = useState('https://weegschaal.eddydevink.nl') + const [token, setToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [syncing, setSyncing] = useState(false) + + useEffect(() => { + loadConfig().then(c => { + if (c) { setUrl(c.url); setToken(c.token) } + }) + }, []) + + const handleSave = async () => { + await saveConfig({ url, token }) + Alert.alert('Opgeslagen', 'API 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('') + }}, + ]) + } + + const handleSync = async () => { + setSyncing(true) + try { + const { syncAll } = await import('../sync/syncService') + const result = await syncAll() + Alert.alert('Sync voltooid', `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`) + } catch (e: any) { + Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') + } finally { + setSyncing(false) + } + } + + return ( + + REST API + API URL + + Token + + + setShowToken(!showToken)}> + {showToken ? 'O' : 'O'} + + + + Instellingen opslaan + + + Instellingen wissen + + + {syncing ? 'Bezig met sync...' : 'Nu synchroniseren'} + + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#f5f5f5' }, + content: { padding: 16 }, + sectionTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 }, + label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4, marginTop: 12 }, + input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' }, + passwordRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', borderRadius: 8, borderWidth: 1, borderColor: '#ddd' }, + passwordInput: { flex: 1, padding: 12, fontSize: 16 }, + eyeButton: { padding: 12 }, + eyeIcon: { fontSize: 20 }, + saveButton: { backgroundColor: '#4CAF50', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 }, + clearButton: { padding: 14, borderRadius: 8, alignItems: 'center', alignSelf: 'stretch', marginTop: 8 }, + clearButtonText: { color: '#e53935', fontSize: 14 }, + syncButton: { backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 }, + syncButtonDisabled: { opacity: 0.6 }, + buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' }, +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/screens/SettingsScreen.tsx +git commit -m "settings: vervang WebDAV configuratie door REST API configuratie" +``` + +--- + +### Task 13: Client — Update NewMeasurementScreen + +**Files:** +- Modify: `src/screens/NewMeasurementScreen.tsx` + +- [ ] **Step 1: Pas doSave aan voor directe push** + +In `doSave`, gebruik `uploadMeasurement` i.p.v. `syncAll`: + +Huidige file lezen en de `doSave` functie vervangen door: + +```typescript +const doSave = async (m: Measurement) => { + try { + await uploadMeasurement(m) + Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]) + } catch { + Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]) + } +} +``` + +En de import aanpassen: `import { uploadMeasurement } from '../sync/syncService'` toevoegen, `syncAll` import verwijderen. + +In `handleSubmit`: na insert/upsert (die `syncStatus: 'local'` niet meer zet — moet `syncStatus` überhaupt nog? Nee, het veld bestaat niet meer in het type). Dus: + +Het MeasurementForm zet nu geen `syncStatus` meer omdat het type dat veld niet meer heeft. We moeten `MeasurementForm.tsx` aanpassen. + +**MeasurementForm.tsx wijziging** — verwijder `syncStatus: 'local'` uit de `submit` functie. Lees het bestand en verwijder die regel. + +- [ ] **Step 2: Pas MeasurementForm aan** + +Huidig: `syncStatus: 'local'` in de submit. Verwijder die regel. Form submit returned nu een Measurement zonder syncStatus. + +- [ ] **Step 3: Check TypeScript** + +```bash +cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/screens/NewMeasurementScreen.tsx src/components/MeasurementForm.tsx +git commit -m "save: directe uploadMeasurement push, verwijder syncStatus uit MeasurementForm" +``` + +--- + +### Task 14: Client — Update MeasurementDetailScreen + +**Files:** +- Modify: `src/screens/MeasurementDetailScreen.tsx` + +- [ ] **Step 1: Soft delete via deletedAt** + +In `MeasurementDetailScreen.tsx`: + +Imports aanpassen: +- Verwijder `deleteMeasurement`, voeg `setDeletedAt`, `removeMeasurement` toe van `../database/db` +- Voeg `createApi` toe van `../sync/api` +- Import `getApiConfig` van `./SettingsScreen` (of `../screens/SettingsScreen`) + +Vervang `handleDelete`: + +```typescript +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) + try { + const config = await getApiConfig() + if (config) { + await createApi(config).deleteMeasurement(measurementId) + await removeMeasurement(measurementId) + } + } catch {} + navigation.goBack() + }, + }, + ]) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/screens/MeasurementDetailScreen.tsx +git commit -m "detail: soft delete via deletedAt i.p.v. syncStatus" +``` + +--- + +### Task 15: Client — Update HistoryScreen + DashboardScreen + +**Files:** +- Modify: `src/screens/HistoryScreen.tsx` +- Modify: `src/screens/DashboardScreen.tsx` + +- [ ] **Step 1: HistoryScreen — filter + soft delete** + +In `HistoryScreen.tsx`, `load` functie: + +```typescript +const load = useCallback(async () => { + await initDatabase() + const all = await getAllMeasurements() + setMeasurements(all.filter(m => !m.deletedAt)) +}, []) +``` + +`confirmDelete` aanpassen: + +```typescript +const confirmDelete = (m: Measurement) => { + const date = new Date(m.measuredAt).toLocaleDateString('nl-NL') + Alert.alert('Verwijderen?', `Meting van ${date} definitief verwijderen?`, [ + { text: 'Annuleren', style: 'cancel' }, + { + text: 'Ja, verwijderen', + style: 'destructive', + onPress: async () => { + await initDatabase() + await setDeletedAt(m.id) + try { + const config = await getApiConfig() + if (config) { + await createApi(config).deleteMeasurement(m.id) + await removeMeasurement(m.id) + } + } catch {} + load() + }, + }, + ]) +} +``` + +Imports updaten: +- Verwijder `deleteMeasurement`, voeg `setDeletedAt`, `removeMeasurement` toe van `../database/db` +- Voeg `createApi` toe van `../sync/api` +- Import `getApiConfig` van `../screens/SettingsScreen` + +- [ ] **Step 2: DashboardScreen — filter deleted** + +In `DashboardScreen.tsx`, `load` functie: + +```typescript +const load = async () => { + await initDatabase() + const allM = await getAllMeasurements() + const visible = allM.filter(m => !m.deletedAt) + setAll(visible) + setLatest(visible.length > 0 ? visible[0] : null) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/screens/HistoryScreen.tsx src/screens/DashboardScreen.tsx +git commit -m "ui: filter deletedAt in History en Dashboard, soft delete in History" +``` + +--- + +### Task 16: Client — Update tests + +**Files:** +- Modify: `tests/syncService.test.ts` + +- [ ] **Step 1: Herschrijf tests** + +```typescript +import { mergeMeasurements } from '../src/sync/syncService' +import { Measurement } from '../src/types' + +jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() })) + +const make = (overrides: Partial = {}): Measurement => ({ + id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10, + musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600, + measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z', + updatedAt: '2024-06-01T08:00:00.000Z', ...overrides, +}) + +describe('mergeMeasurements', () => { + it('both empty returns empty', () => { + expect(mergeMeasurements([], [])).toEqual([]) + }) + + it('local only kept', () => { + const r = mergeMeasurements([make()], []) + expect(r).toHaveLength(1) + }) + + it('remote only kept', () => { + const r = mergeMeasurements([], [make({ id: 'r1' })]) + expect(r).toHaveLength(1) + expect(r[0].id).toBe('r1') + }) + + 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('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('remote deleted wins over older local', () => { + const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) + const remote = make({ updatedAt: '2024-06-03T08:00:00.000Z', deletedAt: '2024-06-03T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') + }) + + it('local wins over older remote deleted', () => { + const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' }) + const remote = make({ updatedAt: '2024-06-01T08:00:00.000Z', deletedAt: '2024-06-01T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].deletedAt).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run tests** + +```bash +cd /home/eddy/bin/weegschaal-app && npm test +``` +Expected: 7 PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/syncService.test.ts +git commit -m "test: herschrijf merge tests voor timestamp-based sync" +``` + +--- + +### Task 17: Build APK + sideload + test + +- [ ] **Step 1: Bouw APK** + +```bash +cd /home/eddy/bin/weegschaal-app && npx eas build --local --profile apk --platform android +``` + +- [ ] **Step 2: Verbind telefoon** + +```bash +adb-wireless-connect +``` + +- [ ] **Step 3: Installeer APK** + +```bash +adb install -r /home/eddy/bin/weegschaal-app/build-*.apk +``` + +- [ ] **Step 4: Stel API configuratie in op de telefoon** + +Open de app → Instellingen → vul `https://weegschaal.eddydevink.nl` en token (uit `/tmp/weegschaal-credentials.txt`) in → Opslaan → Nu synchroniseren. + +- [ ] **Step 5: Commit** + +```bash +# Done +``` diff --git a/docs/superpowers/plans/2026-07-25-sync-refactor-plan.md b/docs/superpowers/plans/2026-07-25-sync-refactor-plan.md new file mode 100644 index 0000000..2abee40 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-sync-refactor-plan.md @@ -0,0 +1,139 @@ +# Sync refactor — Implementation Plan ✅ (completed 2026-07-25) + +> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync. +> +> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd. + +## Bug Overzicht + +| # | Bug | Oorzaak | Fix | +|---|-----|---------|-----| +| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` | +| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE | +| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt | +| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes | +| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact | +| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` | + +## Sync Protocol (vastgelegd) + +``` +lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY) + +── PULL ── +remote[] = GET /api/v1/measurements?since=lastSyncAt +local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted) +remoteUpdatedMap: Map // 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 // 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 diff --git a/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md b/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md new file mode 100644 index 0000000..164cf71 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md @@ -0,0 +1,266 @@ +# 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 ` → 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 ` 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 diff --git a/eas.json b/eas.json index 5d2510d..ec0c56b 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 5.9.1" + "version": ">= 5.9.1", + "appVersionSource": "local" }, "build": { "development": { diff --git a/package-lock.json b/package-lock.json index 3310c9d..4264be1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,14 +13,19 @@ "@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-svg": "15.12.1", + "react-native-view-shot": "4.0.3", + "react-native-web": "^0.21.0" }, "devDependencies": { "@types/jest": "29.5.14", @@ -1734,25 +1739,6 @@ "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", @@ -2161,6 +2147,25 @@ "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", @@ -3725,6 +3730,15 @@ "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", @@ -4326,6 +4340,57 @@ "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", @@ -4340,6 +4405,24 @@ "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", @@ -5077,6 +5160,31 @@ } } }, + "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", @@ -5124,6 +5232,15 @@ "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", @@ -5151,6 +5268,57 @@ "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", @@ -5580,6 +5748,36 @@ "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", @@ -5975,6 +6173,19 @@ "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", @@ -6056,6 +6267,12 @@ "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", @@ -6166,6 +6383,15 @@ "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", @@ -9051,6 +9277,12 @@ "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", @@ -9263,6 +9495,18 @@ "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", @@ -9409,6 +9653,51 @@ "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", @@ -9957,6 +10246,12 @@ "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", @@ -10304,6 +10599,12 @@ "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", @@ -10507,6 +10808,15 @@ "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", @@ -10673,6 +10983,32 @@ "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", @@ -10815,6 +11151,15 @@ "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", diff --git a/package.json b/package.json index b87035e..51aeed7 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,19 @@ "@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-svg": "15.12.1", + "react-native-view-shot": "4.0.3", + "react-native-web": "^0.21.0" }, "devDependencies": { "@types/jest": "29.5.14", @@ -25,8 +30,8 @@ }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "test": "jest" }, diff --git a/src/components/LatestMeasurementCard.tsx b/src/components/LatestMeasurementCard.tsx index 295fbb5..ba999ba 100644 --- a/src/components/LatestMeasurementCard.tsx +++ b/src/components/LatestMeasurementCard.tsx @@ -1,8 +1,11 @@ -import React from 'react' -import { View, Text, StyleSheet } from 'react-native' +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 { Measurement } from '../types' -type Props = { measurement: Measurement | null } +type Props = { measurements: Measurement[] } const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ { label: 'Gewicht', key: 'weight', suffix: 'kg' }, @@ -15,8 +18,10 @@ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ { label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' }, ] -export default function LatestMeasurementCard({ measurement }: Props) { - if (!measurement) { +export default function LatestMeasurementCard({ measurements }: Props) { + const viewShotRef = useRef(null) + + if (measurements.length === 0) { return ( Nog geen metingen @@ -24,31 +29,89 @@ export default function LatestMeasurementCard({ measurement }: 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 ( - Laatste meting - {new Date(measurement.measuredAt).toLocaleDateString('nl-NL')} - {rows.map(r => ( - - {r.label} - - {typeof measurement[r.key] === 'number' - ? (measurement[r.key] as number).toFixed(1) - : measurement[r.key]}{' '} - {r.suffix} - + + + + + + + + + + {previous && ( + + Voorlaatste meting + + {new Date(previous.measuredAt).toLocaleDateString('nl-NL')} — {previous.weight.toFixed(1)} kg + + + Verschil {diff !== null && (diff > 0 ? '+' : '')}{diff?.toFixed(1)} kg + + {positiveComment && ( + {positiveComment} + )} + + + )} + + Laatste meting + {new Date(latest.measuredAt).toLocaleDateString('nl-NL')} + {rows.map(r => ( + + {r.label} + + {typeof latest[r.key] === 'number' + ? (latest[r.key] as number).toFixed(1) + : latest[r.key]}{' '} + {r.suffix} + + + ))} - ))} + ) } 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', justifyContent: 'space-between', paddingVertical: 4 }, - rowLabel: { fontSize: 14, color: '#555' }, + row: { flexDirection: 'row', paddingVertical: 4 }, + rowLabel: { fontSize: 14, color: '#555', flex: 1 }, 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 }, }) diff --git a/src/components/MeasurementForm.tsx b/src/components/MeasurementForm.tsx index 9888c37..9b4b9ae 100644 --- a/src/components/MeasurementForm.tsx +++ b/src/components/MeasurementForm.tsx @@ -8,6 +8,7 @@ import { generateId } from '../utils/uuid' type Props = { onSubmit: (m: Measurement) => void initialValues?: Partial + isEdit?: boolean } type FieldDef = { @@ -29,13 +30,10 @@ const fields: FieldDef[] = [ { key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' }, ] -export default function MeasurementForm({ onSubmit, initialValues }: Props) { +export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) { const insets = useSafeAreaInsets() const [pickerOpen, setPickerOpen] = useState(false) - const [measurementDate, setMeasurementDate] = useState(() => { - if (initialValues?.measuredAt) return new Date(initialValues.measuredAt) - return new Date() - }) + const [measurementDate, setMeasurementDate] = useState(new Date()) const [values, setValues] = useState>(() => { const init: Record = {} for (const f of fields) { @@ -47,9 +45,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) { - const weight = parseFloat(values.weight) || 0 - const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0 - const musclePct = parseFloat(values.musclePercentage) || 0 +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 computed = useMemo(() => ({ fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '', @@ -59,19 +66,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) { const handleSubmit = () => { const now = new Date().toISOString() const m: Measurement = { - id: generateId(), + id: isEdit && initialValues?.id ? initialValues.id : generateId(), weight, - bmi: parseFloat(values.bmi) || 0, + bmi: toFloat(values.bmi), bodyFatPercentage: bodyFatPct, - fatMass: parseFloat(computed.fatMass) || 0, + fatMass: toFloat(computed.fatMass), musclePercentage: musclePct, - muscleMass: parseFloat(computed.muscleMass) || 0, - visceralFat: parseInt(values.visceralFat, 10) || 0, - bmr: parseFloat(values.bmr) || 0, + muscleMass: toFloat(computed.muscleMass), + visceralFat: toInt(values.visceralFat), + bmr: toFloat(values.bmr), measuredAt: measurementDate.toISOString(), - createdAt: now, + createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now, updatedAt: now, - syncStatus: 'local', } onSubmit(m) } @@ -140,7 +146,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) { ) })} - + Opslaan @@ -149,7 +155,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5' }, - scroll: { padding: 16, paddingBottom: 0 }, + scroll: { padding: 16 }, 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' }, diff --git a/src/components/WeightChart.tsx b/src/components/WeightChart.tsx index 7ea663f..8ddebff 100644 --- a/src/components/WeightChart.tsx +++ b/src/components/WeightChart.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { View, Text, StyleSheet, Dimensions } from 'react-native' +import React, { useRef, useState } from 'react' +import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native' import { LineChart } from 'react-native-chart-kit' import { Measurement } from '../types' @@ -11,6 +11,9 @@ const COLORS = { muscleMass: '#2196F3', } +const screenWidth = Dimensions.get('window').width +const chartWidth = screenWidth - 32 + function SingleChart({ title, data, @@ -29,7 +32,7 @@ function SingleChart({ {title} color, strokeWidth: 2 }] }} - width={Dimensions.get('window').width - 32} + width={chartWidth} height={180} yAxisSuffix={unit} chartConfig={{ @@ -57,7 +60,63 @@ 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 ( + + {title} + m.weight)} + labels={labels} + color={COLORS.weight} + unit=" kg" + /> + {fatMassPoints.length >= 2 && ( + m.fatMass)} + labels={prepareLabels(fatMassPoints)} + color={COLORS.fatMass} + unit=" kg" + /> + )} + {muscleMassPoints.length >= 2 && ( + m.muscleMass)} + labels={prepareLabels(muscleMassPoints)} + color={COLORS.muscleMass} + unit=" kg" + /> + )} + + ) +} + export default function WeightChart({ measurements }: Props) { + const scrollRef = useRef(null) + const [page, setPage] = useState(0) + if (measurements.length < 2) { return null } @@ -66,52 +125,38 @@ export default function WeightChart({ measurements }: Props) { (a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime() ) - 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 lastDate = new Date(sorted[sorted.length - 1].measuredAt) - const labels = dataPoints.map(m => { - const d = new Date(m.measuredAt) - return `${d.getDate()}/${d.getMonth() + 1}` - }) + const data30 = filterData(sorted, 30, lastDate) + const data365 = filterData(sorted, 365, lastDate) - const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1 - const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : '')) + const onScroll = (e: NativeSyntheticEvent) => { + const offset = e.nativeEvent.contentOffset.x + const newPage = Math.round(offset / screenWidth) + if (newPage !== page) { + setPage(newPage) + } + } return ( - Voortgang (30 dagen) - - m.weight)} - labels={sparseLabels} - color={COLORS.weight} - unit=" kg" - /> - - m.fatMass)} - labels={sparseLabels} - color={COLORS.fatMass} - unit=" kg" - /> - - m.muscleMass)} - labels={sparseLabels} - color={COLORS.muscleMass} - unit=" kg" - /> + + + + ) } const styles = StyleSheet.create({ - container: { marginHorizontal: 16, marginBottom: 16 }, + container: { marginBottom: 16 }, + page: { width: screenWidth, paddingHorizontal: 16 }, title: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, chartBlock: { marginBottom: 12 }, chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 }, diff --git a/src/database/db.ts b/src/database/db.ts index 77b8fe9..7b1b42b 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -1,7 +1,8 @@ import * as SQLite from 'expo-sqlite' -import { Measurement, SyncStatus } from '../types' +import { Measurement } from '../types' let db: SQLite.SQLiteDatabase | null = null +let dbReady = false export async function initDatabase(): Promise { if (db) return @@ -20,20 +21,39 @@ export async function initDatabase(): Promise { measuredAt TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, - syncStatus TEXT NOT NULL DEFAULT 'local' + syncStatus TEXT NOT NULL DEFAULT 'pending', + deletedAt TEXT ); `) + try { + await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT') + } catch {} + + // Migrate existing records to crossbreed syncStatus model + if (!dbReady) { + try { + await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'") + } catch {} + dbReady = true + } } export async function insertMeasurement(m: Measurement): Promise { await db!.runAsync( - `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus) + `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.syncStatus] + [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] ) } export async function getAllMeasurements(): Promise { + const rows = await db!.getAllAsync( + 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC' + ) + return rows as Measurement[] +} + +export async function getAllMeasurementsForSync(): Promise { const rows = await db!.getAllAsync( 'SELECT * FROM measurements ORDER BY measuredAt DESC' ) @@ -42,42 +62,71 @@ export async function getAllMeasurements(): Promise { export async function getLatestMeasurement(): Promise { const rows = await db!.getAllAsync( - 'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1' + 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1' ) return rows.length > 0 ? (rows[0] as Measurement) : null } +export async function getLatestMeasurements(count: number): Promise { + const rows = await db!.getAllAsync( + 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?', + [count] + ) + return rows as Measurement[] +} + +export async function getMeasurementsSince(date: string): Promise { + const rows = await db!.getAllAsync( + '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 { const day = date.slice(0, 10) const rows = await db!.getAllAsync( - "SELECT * FROM measurements WHERE measuredAt LIKE ? LIMIT 1", + "SELECT * FROM measurements WHERE measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1", [`${day}%`] ) return rows.length > 0 ? (rows[0] as Measurement) : null } -export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise { - await db!.runAsync( - 'UPDATE measurements SET syncStatus = ?, updatedAt = ? WHERE id = ?', - [syncStatus, new Date().toISOString(), id] - ) -} - export async function upsertMeasurement(m: Measurement): Promise { await db!.runAsync( - `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus) + `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.syncStatus] + [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] ) } -export async function getUnsyncedMeasurements(): Promise { +export async function getMeasurementsUpdatedSince(since: string): Promise { const rows = await db!.getAllAsync( - "SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC" + 'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC', + [since] ) return rows as Measurement[] } -export async function deleteMeasurement(id: string): Promise { +export async function setDeletedAt(id: string): Promise { + const now = new Date().toISOString() + await db!.runAsync( + 'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?', + [now, now, id] + ) +} + +export async function removeMeasurement(id: string): Promise { await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) } + +export async function updateSyncStatus(id: string, status: string): Promise { + await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id]) +} + +export async function getPendingRecords(): Promise { + const rows = await db!.getAllAsync( + "SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC" + ) + return rows as Measurement[] +} diff --git a/src/screens/DashboardScreen.tsx b/src/screens/DashboardScreen.tsx index dc5c8c7..741440f 100644 --- a/src/screens/DashboardScreen.tsx +++ b/src/screens/DashboardScreen.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db' +import { getCached, getVersion, refreshCache } from '../store/measurementStore' +import { initDatabase, getLatestMeasurements } from '../database/db' import { Measurement } from '../types' import LatestMeasurementCard from '../components/LatestMeasurementCard' import WeightChart from '../components/WeightChart' @@ -11,22 +12,76 @@ type Props = { navigation: any } export default function DashboardScreen({ navigation }: Props) { const insets = useSafeAreaInsets() - const [latest, setLatest] = useState(null) + const [latestTwo, setLatestTwo] = useState([]) const [all, setAll] = useState([]) + const [chartKey, setChartKey] = useState(0) + const seenVersion = useRef(0) useFocusEffect( useCallback(() => { + let active = true const load = async () => { await initDatabase() - const allM = await getAllMeasurements() - setAll(allM) - const latestM = await getLatestMeasurement() - setLatest(latestM) + + const two = await getLatestMeasurements(2) + if (!active) return + setLatestTwo(two) + + await refreshCache() + if (!active) return + const v = getVersion() + if (v !== seenVersion.current) { + setAll(getCached()) + setChartKey(v) + seenVersion.current = v + } + + try { + const { syncAll } = await import('../sync/syncService') + const result = await syncAll() + if (!active) return + if (result.deleted > 0 || result.pulled > 0) { + await refreshCache() + const v2 = getVersion() + setAll(getCached()) + setChartKey(v2) + seenVersion.current = v2 + } + } catch { + console.warn('Sync failed') + } } load() + return () => { active = false } }, []) ) + if (all.length === 0 && latestTwo.length === 0) { + return ( + + + navigation.navigate('History')}> + Historie + + navigation.navigate('Settings')}> + Instellingen + + + + Laden... + + navigation.navigate('NewMeasurement')} + > + + Nieuwe meting + + + ) + } + + const display = all.length > 0 ? all : latestTwo + return ( @@ -38,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) { - - + + {all.length >= 2 && } { await initDatabase() - await deleteMeasurement(m.id) - try { - const { syncAll } = await import('../sync/syncService') - await syncAll() - } catch {} + await setDeletedAt(m.id) + await updateSyncStatus(m.id, 'pending-delete') + await refreshCache() load() }, }, @@ -80,7 +79,10 @@ export default function HistoryScreen({ navigation }: Props) { data={measurements} keyExtractor={item => item.id} renderItem={({ item }) => ( - handlePress(item)} /> + navigation.navigate('MeasurementDetail', { measurementId: item.id })} + /> )} contentContainerStyle={measurements.length === 0 ? styles.empty : undefined} ListEmptyComponent={Nog geen metingen} diff --git a/src/screens/MeasurementDetailScreen.tsx b/src/screens/MeasurementDetailScreen.tsx new file mode 100644 index 0000000..8b3236f --- /dev/null +++ b/src/screens/MeasurementDetailScreen.tsx @@ -0,0 +1,134 @@ +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(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 ( + + Laden... + + ) + } + + return ( + + + + {new Date(measurement.measuredAt).toLocaleDateString('nl-NL', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + + {rows.map(r => ( + + {r.label} + + {typeof measurement[r.key] === 'number' + ? (measurement[r.key] as number).toFixed(1) + : measurement[r.key]}{' '} + {r.suffix} + + + ))} + + + + + Wijzigen + + + Verwijderen + + + + ) +} + +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' }, +}) diff --git a/src/screens/NewMeasurementScreen.tsx b/src/screens/NewMeasurementScreen.tsx index 82eb6f3..1f249fc 100644 --- a/src/screens/NewMeasurementScreen.tsx +++ b/src/screens/NewMeasurementScreen.tsx @@ -1,100 +1,90 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useState } from 'react' import { View, Text, Alert } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import MeasurementForm from '../components/MeasurementForm' import { Measurement } from '../types' -import { - initDatabase, - insertMeasurement, - getLatestMeasurement, - getMeasurementByDate, - upsertMeasurement, -} from '../database/db' +import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db' +import { refreshCache } from '../store/measurementStore' +import { showSuccess, showError } from '../utils/toast' type Props = { navigation: any - route: any + route: { + params: { + measurement?: Measurement + } + } } export default function NewMeasurementScreen({ navigation, route }: Props) { - const editing = route.params?.measurement as Measurement | undefined + const editMeasurement = route.params?.measurement + const isEdit = !!editMeasurement const [latest, setLatest] = useState | 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() - if (editing) { - if (!cancelled) { - setLatest(editing) - setLoaded(true) - } - } else { - const m = await getLatestMeasurement() - if (!cancelled) { - setLatest(m || {}) - setLoaded(true) - } + const m = await getLatestMeasurement() + if (!cancelled) { + setLatest(m || {}) + setLoaded(true) } } load() return () => { cancelled = true } - }, [editing]) + }, [isEdit, editMeasurement]) ) - useEffect(() => { - if (editing) { - navigation.setOptions({ title: 'Meting wijzigen' }) - } - }, [editing, navigation]) - const handleSubmit = async (m: Measurement) => { await initDatabase() - 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.id) }, - ] - ) - return - } - doSave(m) - } - - const doSave = async (m: Measurement, existingId?: string) => { - if (existingId) { - m.id = existingId + if (isEdit) { 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) } - doSync() + await doSave(m) } - const doSync = async () => { + const doSave = async (m: Measurement) => { + await refreshCache() + navigation.goBack() + uploadInBackground(m) + } + + const uploadInBackground = async (m: Measurement) => { try { - const { syncAll } = await import('../sync/syncService') - await syncAll() - Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [ - { text: 'OK', onPress: () => navigation.goBack() }, - ]) + const { uploadMeasurement } = await import('../sync/syncService') + await uploadMeasurement(m) + showSuccess('Opgeslagen & gesynct') } catch { - Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [ - { text: 'OK', onPress: () => navigation.goBack() }, - ]) + showError('Opgeslagen (sync mislukt)') } } @@ -110,6 +100,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) { | undefined} + isEdit={isEdit} /> ) } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index f434065..a3ca7a4 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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 { WebDAVConfig } from '../types' +import { ApiConfig } from '../types' -const CONFIG_KEY = 'weegschaal_webdav_config' +const CONFIG_KEY = 'weegschaal_api_config' -async function loadConfig(): Promise { +async function loadConfig(): Promise { try { const json = await SecureStore.getItemAsync(CONFIG_KEY) return json ? JSON.parse(json) : null @@ -14,7 +14,7 @@ async function loadConfig(): Promise { } } -async function saveConfig(config: WebDAVConfig): Promise { +export async function saveConfig(config: ApiConfig): Promise { await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) } @@ -22,50 +22,43 @@ async function deleteConfig(): Promise { await SecureStore.deleteItemAsync(CONFIG_KEY) } -export async function getWebDAVConfig(): Promise { +export async function getApiConfig(): Promise { return loadConfig() } export default function SettingsScreen() { - 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 [url, setUrl] = useState('https://weegschaal.eddydevink.nl') + const [token, setToken] = useState('') + const [showToken, setShowToken] = useState(false) const [syncing, setSyncing] = useState(false) useEffect(() => { loadConfig().then(c => { if (c) { setUrl(c.url) - setUsername(c.username) - setPassword(c.password) + setToken(c.token) } }) }, []) const handleSave = async () => { - await saveConfig({ url, username, password }) - Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen') + await saveConfig({ url, token }) + Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen') } const handleClear = () => { - 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('') - }, + Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [ + { text: 'Annuleren', style: 'cancel' }, + { + text: 'Ja, wissen', + style: 'destructive', + onPress: async () => { + await deleteConfig() + setUrl('') + setToken('') }, - ] - ) + }, + ]) } const handleSync = async () => { @@ -73,7 +66,11 @@ export default function SettingsScreen() { try { const { syncAll } = await import('../sync/syncService') const result = await syncAll() - Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`) + let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}` + if (result.errors.length > 0) { + msg += `\n\nFouten: ${result.errors.join(', ')}` + } + Alert.alert('Sync voltooid', msg) } catch (e: any) { Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') } finally { @@ -83,38 +80,30 @@ export default function SettingsScreen() { return ( - WebDAV (Nextcloud) + REST API - URL + API URL - Gebruikersnaam - - - Wachtwoord + Token - setShowPassword(!showPassword)}> - {showPassword ? '👁' : '👁‍🗨'} + setShowToken(!showToken)}> + {showToken ? '👁' : '🔒'} diff --git a/src/store/measurementStore.ts b/src/store/measurementStore.ts new file mode 100644 index 0000000..c50fd30 --- /dev/null +++ b/src/store/measurementStore.ts @@ -0,0 +1,20 @@ +import { Measurement } from '../types' +import { initDatabase, getAllMeasurements } from '../database/db' + +let cache: Measurement[] = [] +let version = 0 + +export async function refreshCache(): Promise { + await initDatabase() + cache = await getAllMeasurements() + version++ + return cache +} + +export function getCached(): Measurement[] { + return cache +} + +export function getVersion(): number { + return version +} diff --git a/src/sync/api.ts b/src/sync/api.ts new file mode 100644 index 0000000..143ec7b --- /dev/null +++ b/src/sync/api.ts @@ -0,0 +1,70 @@ +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 { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + } +} + +export type Api = ReturnType + +export function createApi(config: ApiConfig) { + const base = config.url.replace(/\/+$/, '') + + return { + async getMeasurements(since?: string): Promise { + 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 { + 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 { + 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) + } + }, + } +} diff --git a/src/sync/syncService.ts b/src/sync/syncService.ts index 1bc0991..1ca483a 100644 --- a/src/sync/syncService.ts +++ b/src/sync/syncService.ts @@ -1,109 +1,146 @@ import { Measurement } from '../types' -import { initDatabase, getAllMeasurements, upsertMeasurement } from '../database/db' -import { getWebDAVConfig } from '../screens/SettingsScreen' +import { + initDatabase, + getAllMeasurementsForSync, + upsertMeasurement, + getPendingRecords, + updateSyncStatus, + removeMeasurement, +} from '../database/db' +import { createApi, ApiError } from './api' +import * as SecureStore from 'expo-secure-store' -const WEBDAV_PATH = 'weegschaal/measurements.json' +const LAST_SYNC_KEY = 'weegschaal_last_sync' -type SyncResult = { local: number; remote: number; merged: number } +type SyncResult = { + pushed: number + deleted: number + pulled: number + errors: string[] +} -export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { - const map = new Map() +export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] { + const localMap = new Map(local.map(m => [m.id, m])) + const resultMap = new Map(local.map(m => [m.id, m])) - for (const m of local) { - map.set(m.id, m) - } + 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 remote) { - const existing = map.get(m.id) - if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) { - map.set(m.id, { ...m, syncStatus: 'synced' }) + if (isDeleted) { + if (!localRec || localPending) continue + if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { + resultMap.set(r.id, { + ...r, + syncStatus: 'synced', + deletedAt: (r as any).deletedAt || r.updatedAt, + } as Measurement) + } + continue + } + + if (localPending) continue + + if (localRec) { + if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { + resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement) + } + } else { + resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement) } } - return Array.from(map.values()) + return Array.from(resultMap.values()) } -async function fetchRemote(config: { url: string; username: string; password: string }): Promise { - const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` - const response = await fetch(url, { - headers: { - Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), - }, - }) - - if (response.status === 404) { - return [] - } - - if (!response.ok) { - throw new Error(`WebDAV fetch failed: ${response.status}`) - } - - return response.json() -} - -async function createDir(config: { url: string; username: string; password: string }): Promise { - const base = config.url.replace(/\/+$/, '') - const dirUrl = `${base}/weegschaal` - - const response = await fetch(dirUrl, { - method: 'MKCOL', - headers: { - Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), - }, - }) - - if (response.status !== 201 && response.status !== 405) { - throw new Error(`WebDAV directory create failed: ${response.status}`) - } -} - -async function uploadRemote( - config: { url: string; username: string; password: string }, - measurements: Measurement[] -): Promise { - const base = config.url.replace(/\/+$/, '') - const url = `${base}/${WEBDAV_PATH}` - const body = JSON.stringify(measurements) - - await createDir(config) - - const response = await fetch(url, { - method: 'PUT', - headers: { - Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), - 'Content-Type': 'application/json', - }, - body, - }) - - if (!response.ok) { - throw new Error(`WebDAV upload failed: ${response.status}`) - } +export async function uploadMeasurement(m: Measurement): Promise { + const { getApiConfig } = await import('../screens/SettingsScreen') + const config = await getApiConfig() + if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') + const api = createApi(config) + await api.createMeasurement(m) + await updateSyncStatus(m.id, 'synced') } export async function syncAll(): Promise { - const config = await getWebDAVConfig() - if (!config) { - throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.') - } + 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 initDatabase() - const local = await getAllMeasurements() - const remote = await fetchRemote(config) + const errors: string[] = [] + let pushed = 0 + let deleted = 0 + let pulled = 0 - const merged = mergeMeasurements(local, remote) + // === PUSH phase — outbox first === + const pending = await getPendingRecords() - for (const m of merged) { - await upsertMeasurement(m) + 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') + } + } } - await uploadRemote(config, merged) + // === PULL phase — fetch remote changes === + const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) + const remote = await api.getMeasurements(lastSyncAt || undefined) - return { - local: local.length, - remote: remote.length, - merged: merged.length, + 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++ + } + } + } } + + await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) + + return { pushed, deleted, pulled, errors } } diff --git a/src/types.ts b/src/types.ts index 1d759d0..904cd84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -export type SyncStatus = 'local' | 'synced' - export type Measurement = { id: string weight: number @@ -13,11 +11,11 @@ export type Measurement = { measuredAt: string createdAt: string updatedAt: string - syncStatus: SyncStatus + syncStatus?: string + deletedAt?: string } -export type WebDAVConfig = { +export type ApiConfig = { url: string - username: string - password: string + token: string } diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 0000000..c849d4a --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,26 @@ +import { Platform, ToastAndroid } from 'react-native' + +let _toastQueue: Array<{message: string; duration: number}> = [] +let _showFn: ((message: string, duration: number) => void) | null = null + +export function setToastShowFn(fn: (message: string, duration: number) => void) { + _showFn = fn +} + +export function showToast(message: string, duration = 3000) { + if (_showFn) { + _showFn(message, duration) + return + } + if (Platform.OS === 'android') { + ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT) + } +} + +export function showSuccess(message: string) { + showToast(message, 2500) +} + +export function showError(message: string) { + showToast(message, 4000) +} diff --git a/tests/database.test.ts b/tests/database.test.ts index 48a7f22..606736b 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -18,7 +18,6 @@ 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', } describe('database', () => { @@ -27,7 +26,7 @@ describe('database', () => { let insertMeasurement: any let getAllMeasurements: any let getLatestMeasurement: any - let updateMeasurement: any + let setDeletedAt: any beforeEach(async () => { jest.resetModules() @@ -46,7 +45,7 @@ describe('database', () => { insertMeasurement = mod.insertMeasurement getAllMeasurements = mod.getAllMeasurements getLatestMeasurement = mod.getLatestMeasurement - updateMeasurement = mod.updateMeasurement + setDeletedAt = mod.setDeletedAt }) it('initDatabase creates the measurements table', async () => { @@ -82,13 +81,15 @@ describe('database', () => { expect(result!.id).toBe(mockMeasurement.id) }) - it('updateMeasurement updates syncStatus', async () => { + it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => { db.runAsync.mockResolvedValue(undefined) await initDatabase() - await updateMeasurement(mockMeasurement.id, 'synced') + await setDeletedAt(mockMeasurement.id) expect(db.runAsync).toHaveBeenCalledWith( - expect.stringContaining('UPDATE measurements'), - expect.arrayContaining(['synced', mockMeasurement.id]) + expect.stringContaining('UPDATE measurements SET deletedAt'), + expect.arrayContaining([mockMeasurement.id]) ) + const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0] + expect(args[1][0]).toBe(args[1][1]) }) }) diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts index fae08ca..e33cc27 100644 --- a/tests/syncService.test.ts +++ b/tests/syncService.test.ts @@ -1,57 +1,86 @@ 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 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', -} +const make = (overrides: Partial = {}): Measurement => ({ + id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10, + musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600, + measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z', + updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides, +}) describe('mergeMeasurements', () => { - it('returns empty array when both are empty', () => { + it('both empty returns empty', () => { expect(mergeMeasurements([], [])).toEqual([]) }) - it('keeps local items not on remote', () => { - const result = mergeMeasurements([base], []) - expect(result).toHaveLength(1) - expect(result[0].id).toBe('id-1') + it('local only kept', () => { + const r = mergeMeasurements([make()], []) + expect(r).toHaveLength(1) }) - 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('remote only kept', () => { + const r = mergeMeasurements([], [make({ id: 'r1' })]) + expect(r).toHaveLength(1) + expect(r[0].id).toBe('r1') }) - 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: 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 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('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('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('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('local pending-delete wins over remote deleted', () => { + const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' }) + const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } + const r = mergeMeasurements([local], [remote]) + expect(r[0].syncStatus).toBe('pending-delete') + expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z') + }) + + it('local pending wins over remote newer', () => { + const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' }) + const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) } + const r = mergeMeasurements([local], [remote]) + expect(r[0].weight).toBe(75) + expect(r[0].syncStatus).toBe('pending') + }) + + it('remote deleted with no local record is skipped', () => { + const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } + const r = mergeMeasurements([], [remote]) + expect(r).toHaveLength(0) + }) + + it('local without syncStatus treated as synced', () => { + const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' }) + delete local.syncStatus + const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].weight).toBe(76) }) }) diff --git a/upgrade.md b/upgrade.md new file mode 100644 index 0000000..f51a5e1 --- /dev/null +++ b/upgrade.md @@ -0,0 +1,140 @@ +# 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 ''; +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 `, 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 diff --git a/weegschaal-app.jpeg b/weegschaal-app.jpeg new file mode 100644 index 0000000..297f00b Binary files /dev/null and b/weegschaal-app.jpeg differ