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 f79293e..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,20 +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', - deleted: 0, } onSubmit(m) } @@ -141,7 +146,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) { ) })} - + Opslaan @@ -150,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 14ebf19..8ddebff 100644 --- a/src/components/WeightChart.tsx +++ b/src/components/WeightChart.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { View, Text, StyleSheet, useWindowDimensions } 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, @@ -24,13 +27,12 @@ function SingleChart({ color: string unit: string }) { - const { width } = useWindowDimensions() return ( {title} color, strokeWidth: 2 }] }} - width={width - 32} + width={chartWidth} height={180} yAxisSuffix={unit} chartConfig={{ @@ -58,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 } @@ -67,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 c2e59e8..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,81 +21,112 @@ export async function initDatabase(): Promise { measuredAt TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, - syncStatus TEXT NOT NULL DEFAULT 'local', - deleted INTEGER NOT NULL DEFAULT 0 + syncStatus TEXT NOT NULL DEFAULT 'pending', + deletedAt TEXT ); `) - await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`) - .catch(() => {}) + 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, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, 0] + `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] ) } -function activeOnly(): string { - return 'deleted = 0' -} - export async function getAllMeasurements(): Promise { const rows = await db!.getAllAsync( - `SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC` + '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' ) return rows as Measurement[] } export async function getLatestMeasurement(): Promise { const rows = await db!.getAllAsync( - `SELECT * FROM measurements WHERE ${activeOnly()} 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 ${activeOnly()} AND 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, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deleted] + `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] ) } -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 deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?", - [new Date().toISOString(), id] + 'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?', + [now, now, id] ) } -export async function getAllMeasurementsIncludingDeleted(): Promise { +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 ORDER BY measuredAt DESC' + "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 (e) { - console.warn('Sync failed after delete:', e) - } + await setDeletedAt(m.id) + await updateSyncStatus(m.id, 'pending-delete') + await refreshCache() load() }, }, @@ -82,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 12cb0d2..1f249fc 100644 --- a/src/screens/NewMeasurementScreen.tsx +++ b/src/screens/NewMeasurementScreen.tsx @@ -1,101 +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) }, - ] - ) - return - } - doSave(m) - } - - const doSave = async (m: Measurement, existing?: Measurement) => { - if (existing) { - m.id = existing.id - m.createdAt = existing.createdAt + 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)') } } @@ -111,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 b232fca..1ca483a 100644 --- a/src/sync/syncService.ts +++ b/src/sync/syncService.ts @@ -1,139 +1,146 @@ import { Measurement } from '../types' -import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db' -import { getWebDAVConfig } from '../screens/SettingsScreen' -import { encodeBase64 } from '../utils/base64' +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 SYNC_TIMEOUT_MS = 15_000 +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' + + 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) + } } - for (const m of remote) { - const existing = map.get(m.id) - if (!existing) { - map.set(m.id, { ...m, syncStatus: 'synced' }) + return Array.from(resultMap.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) + await updateSyncStatus(m.id, 'synced') +} + +export async function syncAll(): 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 initDatabase() + + const errors: string[] = [] + let pushed = 0 + let deleted = 0 + let pulled = 0 + + // === PUSH phase — outbox first === + const pending = await getPendingRecords() + + for (const m of pending) { + if (m.deletedAt) { + try { + await api.deleteMeasurement(m.id) + await removeMeasurement(m.id) + deleted++ + } catch (e: any) { + if (e instanceof ApiError && e.status === 404) { + await removeMeasurement(m.id) + deleted++ + } else { + errors.push(e?.message ?? 'Delete failed') + } + } } else { - const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt) - if (remoteNewer) { - map.set(m.id, { ...m, syncStatus: 'synced' }) + try { + await api.createMeasurement(m) + await updateSyncStatus(m.id, 'synced') + pushed++ + } catch (e: any) { + errors.push(e?.message ?? 'Create failed') } } } - return Array.from(map.values()).filter(m => !m.deleted) -} + // === PULL phase — fetch remote changes === + const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) + const remote = await api.getMeasurements(lastSyncAt || undefined) -async function fetchRemote(config: { url: string; username: string; password: string }): Promise { - const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` - const auth = encodeBase64(`${config.username}:${config.password}`) + if (remote.length > 0) { + const local = await getAllMeasurementsForSync() + const merged = mergeMeasurements(local, remote) - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS) + const localMap = new Map(local.map(m => [m.id, m])) - try { - const response = await fetch(url, { - headers: { Authorization: `Basic ${auth}` }, - signal: controller.signal, - }) + 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 (response.status === 404) { - return [] + if (isNew || isUpdated) { + await upsertMeasurement(m) + await updateSyncStatus(m.id, 'synced') + pulled++ + } + } } - - if (!response.ok) { - throw new Error(`WebDAV fetch failed: ${response.status}`) - } - - return response.json() - } finally { - clearTimeout(timer) - } -} - -async function createDir(config: { url: string; username: string; password: string }): Promise { - const base = config.url.replace(/\/+$/, '') - const dirUrl = `${base}/weegschaal` - const auth = encodeBase64(`${config.username}:${config.password}`) - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS) - - try { - const response = await fetch(dirUrl, { - method: 'MKCOL', - headers: { Authorization: `Basic ${auth}` }, - signal: controller.signal, - }) - - if (response.status !== 201 && response.status !== 405) { - throw new Error(`WebDAV directory create failed: ${response.status}`) - } - } finally { - clearTimeout(timer) - } -} - -async function uploadRemote( - config: { url: string; username: string; password: string }, - measurements: Measurement[] -): Promise { - const base = config.url.replace(/\/+$/, '') - const url = `${base}/${WEBDAV_PATH}` - const body = JSON.stringify(measurements) - const auth = encodeBase64(`${config.username}:${config.password}`) - - await createDir(config) - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS) - - try { - const response = await fetch(url, { - method: 'PUT', - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json', - }, - signal: controller.signal, - }) - - if (!response.ok) { - throw new Error(`WebDAV upload failed: ${response.status}`) - } - } finally { - clearTimeout(timer) - } -} - -export async function syncAll(): Promise { - const config = await getWebDAVConfig() - if (!config) { - throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.') - } - - await initDatabase() - - const local = await getAllMeasurementsIncludingDeleted() - const remote = await fetchRemote(config) - - const merged = mergeMeasurements(local, remote) - - for (const m of merged) { - await upsertMeasurement(m) - } - - await uploadRemote(config, merged) - - return { - local: local.filter(m => !m.deleted).length, - remote: remote.filter(m => !m.deleted).length, - merged: merged.length, } + + await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) + + return { pushed, deleted, pulled, errors } } diff --git a/src/types.ts b/src/types.ts index 3006423..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,12 +11,11 @@ export type Measurement = { measuredAt: string createdAt: string updatedAt: string - syncStatus: SyncStatus - deleted: number + syncStatus?: string + deletedAt?: string } -export type WebDAVConfig = { +export type ApiConfig = { url: string - username: string - password: string + token: string } diff --git a/src/utils/base64.ts b/src/utils/base64.ts deleted file mode 100644 index ec8d079..0000000 --- a/src/utils/base64.ts +++ /dev/null @@ -1,17 +0,0 @@ -const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' - -export function encodeBase64(str: string): string { - const bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match, p1) => String.fromCharCode(parseInt(p1, 16))) - let result = '' - let i = 0 - while (i < bytes.length) { - const a = bytes.charCodeAt(i++) || 0 - const b = bytes.charCodeAt(i++) || 0 - const c = bytes.charCodeAt(i++) || 0 - result += CHARS.charAt(a >> 2) - result += CHARS.charAt(((a & 3) << 4) | (b >> 4)) - result += CHARS.charAt(isNaN(b) ? CHARS.length - 1 : ((b & 15) << 2) | (c >> 6)) - result += CHARS.charAt(isNaN(c) ? CHARS.length - 1 : c & 63) - } - return result -} 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 becdf28..606736b 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -18,8 +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', - deleted: 0, } describe('database', () => { @@ -28,9 +26,7 @@ describe('database', () => { let insertMeasurement: any let getAllMeasurements: any let getLatestMeasurement: any - let updateMeasurement: any - let deleteMeasurement: any - let getAllMeasurementsIncludingDeleted: any + let setDeletedAt: any beforeEach(async () => { jest.resetModules() @@ -38,8 +34,8 @@ describe('database', () => { openDatabaseAsync: jest.fn(), })) db = { - execAsync: jest.fn(() => Promise.resolve()), - runAsync: jest.fn(() => Promise.resolve()), + execAsync: jest.fn(), + runAsync: jest.fn(), getAllAsync: jest.fn(), } const sqlite = require('expo-sqlite') @@ -49,9 +45,7 @@ describe('database', () => { insertMeasurement = mod.insertMeasurement getAllMeasurements = mod.getAllMeasurements getLatestMeasurement = mod.getLatestMeasurement - updateMeasurement = mod.updateMeasurement - deleteMeasurement = mod.deleteMeasurement - getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted + setDeletedAt = mod.setDeletedAt }) it('initDatabase creates the measurements table', async () => { @@ -87,42 +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]) - ) - }) - - it('deleteMeasurement marks measurement as deleted', async () => { - db.runAsync.mockResolvedValue(undefined) - await initDatabase() - await deleteMeasurement(mockMeasurement.id) - expect(db.runAsync).toHaveBeenCalledWith( - expect.stringContaining('UPDATE measurements SET deleted = 1'), + expect.stringContaining('UPDATE measurements SET deletedAt'), expect.arrayContaining([mockMeasurement.id]) ) - }) - - it('getAllMeasurements filters out deleted records', async () => { - await initDatabase() - await getAllMeasurements() - expect(db.getAllAsync).toHaveBeenCalledWith( - expect.stringContaining('deleted = 0') - ) - }) - - it('getAllMeasurementsIncludingDeleted includes deleted records', async () => { - await initDatabase() - await getAllMeasurementsIncludingDeleted() - expect(db.getAllAsync).toHaveBeenCalledWith( - expect.stringContaining('SELECT * FROM measurements') - ) - expect(db.getAllAsync).not.toHaveBeenCalledWith( - expect.stringContaining('deleted = 0') - ) + 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 dd2ff6e..e33cc27 100644 --- a/tests/syncService.test.ts +++ b/tests/syncService.test.ts @@ -1,102 +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', - deleted: 0, -} +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('filters out locally deleted items even if present on remote', () => { - const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } - const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' } - const result = mergeMeasurements([local], [remote]) - expect(result).toHaveLength(0) + it('remote deleted wins over synced local', () => { + const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) + const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } + const r = mergeMeasurements([local], [remote]) + expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') }) - it('filters out remotely deleted items when remote is newer', () => { - const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' } - const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } - const result = mergeMeasurements([local], [remote]) - expect(result).toHaveLength(0) + it('local 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('keeps local active item when local is newer than remote deletion', () => { - const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' } - const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } - const result = mergeMeasurements([local], [remote]) - expect(result).toHaveLength(1) - expect(result[0].weight).toBe(78) + it('local pending-delete wins over remote deleted', () => { + const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' }) + const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } + const r = mergeMeasurements([local], [remote]) + expect(r[0].syncStatus).toBe('pending-delete') + expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z') }) - it('keeps active item when local deleted is older than remote active', () => { - const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' } - const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const } - const result = mergeMeasurements([local], [remote]) - expect(result).toHaveLength(1) - expect(result[0].weight).toBe(75) + it('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('merges multiple active and deleted items correctly', () => { - const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' } - const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } - const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const } - const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } + it('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) + }) - const result = mergeMeasurements( - [localActive, localDeleted], - [remoteActive, remoteDeleted] - ) - expect(result).toHaveLength(2) - expect(result.map(m => m.id).sort()).toEqual(['a', 'c']) + 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