diff --git a/.gitignore b/.gitignore index d03787f..f4c927b 100644 --- a/.gitignore +++ b/.gitignore @@ -111,12 +111,6 @@ 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 bccac9b..00a9ac1 100644 --- a/App.tsx +++ b/App.tsx @@ -5,18 +5,8 @@ import DashboardScreen from './src/screens/DashboardScreen' import NewMeasurementScreen from './src/screens/NewMeasurementScreen' import HistoryScreen from './src/screens/HistoryScreen' import SettingsScreen from './src/screens/SettingsScreen' -import MeasurementDetailScreen from './src/screens/MeasurementDetailScreen' -import { Measurement } from './src/types' -type RootStackParamList = { - Dashboard: undefined - NewMeasurement: { measurement?: Measurement } - History: undefined - MeasurementDetail: { measurementId: string } - Settings: undefined -} - -const Stack = createNativeStackNavigator() +const Stack = createNativeStackNavigator() export default function App() { return ( @@ -37,11 +27,6 @@ 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`. +- **Dashboard** — laatste meting (groene kaart) + drie losse grafieken onder elkaar (gewicht, vetmassa, spiermassa), elk met eigen y-as-schaal en 30-dagen filter gerekend vanaf de nieuwste meting +- **Nieuwe meting** — datumkiezer bovenaan (standaard vandaag), formulier vooraf ingevuld met waarden van de laatste meting. Opslaan-knop altijd in beeld (buiten ScrollView). Bij opslaan wordt automatisch gesynchroniseerd met Nextcloud +- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh +- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog ## Ontwikkeling @@ -122,25 +29,15 @@ npx expo start Scannen met Expo Go op Android of iOS. -## Build & deploy +## Synchronisatie -```bash -# APK bouwen (lokaal via EAS) -npx eas build --local --profile apk --platform android +Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in: -# Draadloos sideloaden via ADB -adb-wireless-connect -adb install -r build-*.apk -``` +- **URL**: `https:///remote.php/dav/files//` +- **Gebruikersnaam**: je Nextcloud gebruikersnaam +- **Wachtwoord**: je Nextcloud app-wachtwoord -Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14. - -## Database migratie - -Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`: - -- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat -- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'` +Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basis van `updatedAt`). De map `weegschaal/` wordt automatisch aangemaakt via MKCOL indien deze nog niet bestaat. ## Testen @@ -148,8 +45,6 @@ Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`: npm test ``` -Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties. - ## Licentie Public domain (Unlicense) diff --git a/app.json b/app.json index 53944c6..17faf47 100644 --- a/app.json +++ b/app.json @@ -13,9 +13,9 @@ "package": "nl.eddydevink.weegschaal", "adaptiveIcon": { "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/icon.png", + "foregroundImage": "./assets/android-icon-foreground.png", "backgroundImage": "./assets/android-icon-background.png", - "monochromeImage": "./assets/icon.png" + "monochromeImage": "./assets/android-icon-monochrome.png" }, "predictiveBackGestureEnabled": false }, @@ -26,17 +26,6 @@ "expo-sqlite", "expo-secure-store", "@react-native-community/datetimepicker" - ], - "extra": { - "eas": { - "projectId": "9707c719-d5aa-45a4-a6cd-b95ac3d32996" - } - }, - "runtimeVersion": { - "policy": "appVersion" - }, - "updates": { - "url": "https://u.expo.dev/9707c719-d5aa-45a4-a6cd-b95ac3d32996" - } + ] } } diff --git a/assets/icon.png b/assets/icon.png index ee2e6e7..7165a53 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 deleted file mode 100644 index 9e0e2de..0000000 --- a/docs/blog-sync-upgrade.md +++ /dev/null @@ -1,70 +0,0 @@ -# Van WebDAV naar REST API: een volwassen sync-architectuur voor de weegschaal-app - -*20 juli 2026* - -De afgelopen dagen heb ik de synchronisatie van mijn weegschaal-app volledig herzien. De app slaat metingen lokaal op in SQLite en synchroniseert ze met een centrale server. Tot nu toe gebeurde dat via WebDAV naar Nextcloud — een simpel JSON-bestand dat telkens in zijn geheel werd gedownload, samengevoegd en teruggeüpload. Functioneel, maar niet ideaal. - -## Waarom WebDAV niet voldeed - -Het oude sync-mechanisme was primitief: bij elke sync werd het volledige `measurements.json` bestand van de WebDAV-server gehaald, lokaal gemerged op basis van `updatedAt`, en daarna in zijn geheel teruggeschreven. Met 1300+ metingen werd dat bestand steeds groter, en elke sync — ook als er maar één meting was veranderd — verstuurde het hele bestand over de lijn. Bovendien leverde Basic Auth via WebDAV geen fijnmazige controle over wie toegang had, en was er geen logging van foutieve inlogpogingen. - -## De nieuwe architectuur - -De oplossing is een eigen REST API, gehost op een subdomein van mijn domein, met een MariaDB-backend en Bearer token-authenticatie. De stack: - -- **Server**: Fedora Linux, nginx, PHP 8.5 (Slim Framework 4), MariaDB -- **Client**: React Native (Expo), expo-sqlite, expo-secure-store -- **Authenticatie**: Bearer token, handmatig aangemaakt via SQL -- **Sync**: incrementeel via `?since=` timestamps — last-writer-wins - -### Endpoints - -De API kent drie endpoints, allemaal beschermd met Bearer token-validatie: - -| Methode | Pad | Werking | -|---------|-----|---------| -| `GET` | `/api/v1/measurements?since=ISO` | Delta-sync: alleen metingen met `updated_at > since` | -| `POST` | `/api/v1/measurements` | Upsert op client-ID — de server accepteert het client-gegenereerde UUID | -| `DELETE` | `/api/v1/measurements/:id` | Soft delete: zet `deleted=1` en update `updated_at` | - -Geen PUT, geen register/token-endpoints. Minimaal en doelgericht. - -### Incrementele sync - -De sync-flow is nu gebaseerd op timestamps, zonder `syncStatus` enum. Het mechanisme is hetzelfde als wat CouchDB, PouchDB en tal van productie-apps gebruiken: - -1. **Delta ophalen**: `GET ?since=lastSyncAt` → alleen gewijzigde records van de server -2. **Mergen**: last-writer-wins op `updatedAt`. Remote records met `deleted=true` overschrijven lokaal -3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` worden naar de server gestuurd -4. **Verwijderde records**: lokaal een `deletedAt` timestamp gezet, bij volgende sync via `DELETE` naar de server -5. **High-water mark**: `lastSyncAt` wordt bijgewerkt naar de huidige tijd - -Bij een netwerkfout blijft een meting gewoon lokaal staan — de volgende sync pakt hem automatisch op omdat `updatedAt > lastSyncAt`. - -## Fail2ban-vriendelijke logging - -Een API die op internet staat trekt vroeg of laat ongenode gasten aan. Om hierop voorbereid te zijn logt de API elke mislukte authenticatiepoging naar `/var/log/weegschaal-api/auth.log` in een formaat dat fail2ban direct kan verwerken: - -``` -[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token -[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route -``` - -De fail2ban jail grijpt in bij 5 foutieve pogingen binnen 5 minuten en bant het IP een uur lang. Ook 404's op niet-bestaande routes worden gelogd — een klassiek signaal van fuzzing-tools. - -De logrotering (12 weken, wekelijks) en SELinux-context (`httpd_log_t`) zijn uiteraard geregeld. - -## Resultaten delen - -Een onverwacht leuke toevoeging: de share-knop op het dashboard. Rechtsboven in de kaart met je laatste en voorlaatste meting zit een deel-icoon. Tik erop, en de hele kaart wordt als PNG-afbeelding geëxporteerd. Je kunt hem direct delen via WhatsApp, Signal, e-mail — wat je maar wilt. De techniek erachter: `react-native-view-shot` vangt de view, `expo-sharing` opent het native deelmenu. - -## Wat nog meer is veranderd - -- **App-icoon**: vernieuwd met een eigen afbeelding -- **Dashboard**: toont naast de laatste meting nu ook de voorlaatste en het gewichtsverschil -- **Laad-indicator**: geen verwarrende "Nog geen metingen" meer tijdens het laden — het dashboard toont nu "Laden..." -- **Database-migratie**: een `ALTER TABLE` zorgt dat bestaande SQLite-databases de nieuwe `deletedAt`-kolom krijgen zonder dataverlies - -## Conclusie - -Wat begon als een simpel sync-scriptje via WebDAV is uitgegroeid tot een volwaardige REST API met productie-grade logging, fail2ban-integratie, en een UX die het delen van meetresultaten net zo makkelijk maakt als een screenshot. De app doet nu wat hij moet doen: metingen opslaan, synchroniseren, en — sinds kort — met één tik delen met wie je maar wilt. diff --git a/docs/build-toolchain.md b/docs/build-toolchain.md deleted file mode 100644 index 338fc00..0000000 --- a/docs/build-toolchain.md +++ /dev/null @@ -1,106 +0,0 @@ -# Lokale APK build toolchain - -## Overzicht - -De Weegschaal app kan lokaal gebouwd worden tot een zelfstandige APK -zonder EAS cloud. Dit is handig voor snelle iteraties of als je geen -internetverbinding hebt voor de cloud build. - -## Benodigdheden - -| Component | Versie | Installatie | -|---|---|---| -| Java | OpenJDK 21+ | `brew install --cask temurin` | -| Android Command Line Tools | laatste | `brew install --cask android-commandlinetools` | -| Android Platform Tools | laatste | `brew install --cask android-platform-tools` | -| Node.js | 22+ | `brew install node` | -| EAS CLI | laatste | via npm (`npx eas`) | - -## Android SDK instellen - -`ANDROID_HOME` moet gezet zijn in je shell config (`~/.zshrc`): - -```bash -export ANDROID_HOME="/home/linuxbrew/.linuxbrew/share/android-commandlinetools" -export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" -``` - -Licenties accepteren: - -```bash -yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses -``` - -Best practices: -- Gebruik `--sdk_root=$ANDROID_HOME` bij sdkmanager commando's, anders - installeert hij naar de verkeerde plek -- De eerste build downloadt automatisch de juiste platform-, build-tools-, - NDK- en CMake-versies. Je hoeft ze niet handmatig voor te installeren. -- Wil je ze toch vastzetten: `sdkmanager "platforms;android-36" "build-tools;36.0.0"` - -## APK bouwen - -```bash -cd /home/eddy/bin/weegschaal-app -npx eas build --local --profile apk --platform android -``` - -Het build profiel staat in `eas.json`: - -```json -"apk": { - "android": { - "buildType": "apk" - } -} -``` - -De build: -1. Draait `npx expo prebuild` (maakt `android/` directory aan) -2. Bundelt de JS bundle via Metro -3. Voert `./gradlew :app:assembleRelease` uit -4. Produceert `android/app/build/outputs/apk/release/app-release.apk` -5. Kopieert het resultaat naar `build-.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 deleted file mode 100644 index 7e4460c..0000000 --- a/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md +++ /dev/null @@ -1,1285 +0,0 @@ -# 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 deleted file mode 100644 index 2abee40..0000000 --- a/docs/superpowers/plans/2026-07-25-sync-refactor-plan.md +++ /dev/null @@ -1,139 +0,0 @@ -# Sync refactor — Implementation Plan ✅ (completed 2026-07-25) - -> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync. -> -> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd. - -## Bug Overzicht - -| # | Bug | Oorzaak | Fix | -|---|-----|---------|-----| -| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` | -| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE | -| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt | -| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes | -| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact | -| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` | - -## Sync Protocol (vastgelegd) - -``` -lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY) - -── PULL ── -remote[] = GET /api/v1/measurements?since=lastSyncAt -local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted) -remoteUpdatedMap: Map // 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 deleted file mode 100644 index 164cf71..0000000 --- a/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md +++ /dev/null @@ -1,266 +0,0 @@ -# REST API Sync — Design Doc - -Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`, -met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand. - -## Fase 1 — Server-side: PHP API - -### Infrastructuur - -- **DNS**: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` (nextcloud) -- **nginx**: server block in `/etc/nginx/conf.d/weegschaal-api.conf`, proxy naar PHP-FPM socket -- **SSL**: `certbot --nginx -d weegschaal.eddydevink.nl` -- **Document root**: `/usr/share/nginx/html/weegschaal-api/` - -### MariaDB - -Database `weegschaal` met gebruiker `weegschaal@localhost` (random wachtwoord). - -```sql -CREATE TABLE users ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE auth_tokens ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL REFERENCES users(id), - token VARCHAR(64) NOT NULL UNIQUE, - description VARCHAR(255), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE measurements ( - id VARCHAR(36) NOT NULL, - user_id INT NOT NULL, - weight DECIMAL(5,2) NOT NULL, - bmi DECIMAL(4,1) NOT NULL, - body_fat_percentage DECIMAL(4,1) NOT NULL, - fat_mass DECIMAL(5,2) NOT NULL, - muscle_percentage DECIMAL(4,1) NOT NULL, - muscle_mass DECIMAL(5,2) NOT NULL, - visceral_fat TINYINT NOT NULL, - bmr DECIMAL(6,1) NOT NULL, - measured_at DATE NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted TINYINT NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, id), - INDEX idx_updated_at (user_id, updated_at) -); -``` - -`deleted` flag voor soft deletes op server. De client stuurt harde DELETEs; -de server zet `deleted=1` en update `updated_at` zodat de volgende `?since=` -query deze doorgeeft zodat andere clients de delete kunnen verwerken. - -### Endpoints (Slim Framework) - -| Methode | Pad | Beschrijving | -|---------|-----|-------------| -| `GET` | `/api/v1/measurements` | Alle metingen (`?since=ISO` voor delta) | -| `POST` | `/api/v1/measurements` | Nieuwe meting of upsert op ID | -| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen (soft delete) | - -**Geen** auth endpoints — eerste token wordt handmatig via SQL INSERT aangemaakt. - -### Auth middleware - -- `Authorization: Bearer ` → 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 ec0c56b..5d2510d 100644 --- a/eas.json +++ b/eas.json @@ -1,7 +1,6 @@ { "cli": { - "version": ">= 5.9.1", - "appVersionSource": "local" + "version": ">= 5.9.1" }, "build": { "development": { diff --git a/package-lock.json b/package-lock.json index 4264be1..3310c9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,19 +13,14 @@ "@react-navigation/native-stack": "^7.17.6", "expo": "~54.0.0", "expo-secure-store": "~15.0.8", - "expo-sharing": "~14.0.8", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", - "expo-updates": "~29.0.18", "react": "19.1.0", - "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-chart-kit": "^7.0.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-svg": "15.12.1", - "react-native-view-shot": "4.0.3", - "react-native-web": "^0.21.0" + "react-native-svg": "15.12.1" }, "devDependencies": { "@types/jest": "29.5.14", @@ -1739,6 +1734,25 @@ "semver": "^7.6.0" } }, + "node_modules/@expo/image-utils/node_modules/@expo/require-utils": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", + "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@expo/json-file": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", @@ -2147,25 +2161,6 @@ "xmlbuilder": "^15.1.1" } }, - "node_modules/@expo/require-utils": { - "version": "55.0.5", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", - "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@expo/schema-utils": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", @@ -3730,15 +3725,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4340,57 +4326,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, - "node_modules/cross-fetch/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/cross-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/cross-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/cross-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4405,24 +4340,6 @@ "node": ">= 8" } }, - "node_modules/css-in-js-utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", - "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", - "license": "MIT", - "dependencies": { - "hyphenate-style-name": "^1.0.3" - } - }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -5160,31 +5077,6 @@ } } }, - "node_modules/expo-eas-client": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz", - "integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==", - "license": "MIT" - }, - "node_modules/expo-json-utils": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", - "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", - "license": "MIT" - }, - "node_modules/expo-manifests": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz", - "integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==", - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "expo-json-utils": "~0.15.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-modules-autolinking": { "version": "3.0.26", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", @@ -5232,15 +5124,6 @@ "node": ">=20.16.0" } }, - "node_modules/expo-sharing": { - "version": "14.0.8", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", - "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-sqlite": { "version": "16.0.10", "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz", @@ -5268,57 +5151,6 @@ "react-native": "*" } }, - "node_modules/expo-structured-headers": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz", - "integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==", - "license": "MIT" - }, - "node_modules/expo-updates": { - "version": "29.0.18", - "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.18.tgz", - "integrity": "sha512-qn0YDM7wVwghQAaxb9NYzzwrmj+v8Djz5H+Zft4/myG9SPg4ICAfDy52IVF0K+/GH/oNgsFfzmmLl6hIkDu42g==", - "license": "MIT", - "dependencies": { - "@expo/code-signing-certificates": "^0.0.6", - "@expo/plist": "^0.4.9", - "@expo/spawn-async": "^1.7.2", - "arg": "4.1.0", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "expo-eas-client": "~1.0.8", - "expo-manifests": "~1.0.11", - "expo-structured-headers": "~5.0.0", - "expo-updates-interface": "~2.0.0", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "ignore": "^5.3.1", - "resolve-from": "^5.0.0" - }, - "bin": { - "expo-updates": "bin/cli.js" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-updates-interface": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", - "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-updates/node_modules/arg": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", - "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", - "license": "MIT" - }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.25", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz", @@ -5748,36 +5580,6 @@ "bser": "2.1.1" } }, - "node_modules/fbjs": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", - "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.5", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^1.0.35" - } - }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", - "license": "MIT" - }, - "node_modules/fbjs/node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "license": "MIT", - "dependencies": { - "asap": "~2.0.3" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6173,19 +5975,6 @@ "license": "MIT", "peer": true }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6267,12 +6056,6 @@ "node": ">=10.17.0" } }, - "node_modules/hyphenate-style-name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", - "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", - "license": "BSD-3-Clause" - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6383,15 +6166,6 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/inline-style-prefixer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", - "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", - "license": "MIT", - "dependencies": { - "css-in-js-utils": "^3.1.0" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -9277,12 +9051,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -9495,18 +9263,6 @@ "ws": "^7" } }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, "node_modules/react-freeze": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", @@ -9653,51 +9409,6 @@ "react-native": "*" } }, - "node_modules/react-native-view-shot": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", - "integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==", - "license": "MIT", - "dependencies": { - "html2canvas": "^1.4.1" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/react-native-web": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", - "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.6", - "@react-native/normalize-colors": "^0.74.1", - "fbjs": "^3.0.4", - "inline-style-prefixer": "^7.0.1", - "memoize-one": "^6.0.0", - "nullthrows": "^1.1.1", - "postcss-value-parser": "^4.2.0", - "styleq": "^0.1.3" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { - "version": "0.74.89", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", - "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", - "license": "MIT" - }, - "node_modules/react-native-web/node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, "node_modules/react-native/node_modules/@react-native/js-polyfills": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", @@ -10246,12 +9957,6 @@ "dev": true, "license": "MIT" }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10599,12 +10304,6 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, - "node_modules/styleq": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", - "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", - "license": "MIT" - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -10808,15 +10507,6 @@ "node": "*" } }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -10983,32 +10673,6 @@ "node": ">=14.17" } }, - "node_modules/ua-parser-js": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", - "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -11151,15 +10815,6 @@ "node": ">= 0.4.0" } }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, "node_modules/uuid": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", diff --git a/package.json b/package.json index 51aeed7..b87035e 100644 --- a/package.json +++ b/package.json @@ -8,19 +8,14 @@ "@react-navigation/native-stack": "^7.17.6", "expo": "~54.0.0", "expo-secure-store": "~15.0.8", - "expo-sharing": "~14.0.8", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", - "expo-updates": "~29.0.18", "react": "19.1.0", - "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-chart-kit": "^7.0.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-svg": "15.12.1", - "react-native-view-shot": "4.0.3", - "react-native-web": "^0.21.0" + "react-native-svg": "15.12.1" }, "devDependencies": { "@types/jest": "29.5.14", @@ -30,8 +25,8 @@ }, "scripts": { "start": "expo start", - "android": "expo run:android", - "ios": "expo run:ios", + "android": "expo start --android", + "ios": "expo start --ios", "web": "expo start --web", "test": "jest" }, diff --git a/src/components/LatestMeasurementCard.tsx b/src/components/LatestMeasurementCard.tsx index ba999ba..295fbb5 100644 --- a/src/components/LatestMeasurementCard.tsx +++ b/src/components/LatestMeasurementCard.tsx @@ -1,11 +1,8 @@ -import React, { useRef } from 'react' -import { View, Text, StyleSheet, TouchableOpacity } from 'react-native' -import ViewShot from 'react-native-view-shot' -import * as Sharing from 'expo-sharing' -import { MaterialIcons } from '@expo/vector-icons' +import React from 'react' +import { View, Text, StyleSheet } from 'react-native' import { Measurement } from '../types' -type Props = { measurements: Measurement[] } +type Props = { measurement: Measurement | null } const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ { label: 'Gewicht', key: 'weight', suffix: 'kg' }, @@ -18,10 +15,8 @@ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ { label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' }, ] -export default function LatestMeasurementCard({ measurements }: Props) { - const viewShotRef = useRef(null) - - if (measurements.length === 0) { +export default function LatestMeasurementCard({ measurement }: Props) { + if (!measurement) { return ( Nog geen metingen @@ -29,89 +24,31 @@ export default function LatestMeasurementCard({ measurements }: Props) { ) } - const latest = measurements[0] - const previous = measurements[1] - const diff = previous ? (latest.weight - previous.weight) : null - const fatMassDiff = previous ? (latest.fatMass - previous.fatMass) : null - const muscleMassDiff = previous ? (latest.muscleMass - previous.muscleMass) : null - - let positiveComment: string | null = null - if (previous && diff !== null && diff >= 0) { - const parts: string[] = [] - if (fatMassDiff !== null && fatMassDiff < 0) parts.push('Vetje naar beneden') - if (muscleMassDiff !== null && muscleMassDiff > 0) parts.push('💪') - if (parts.length > 0) positiveComment = parts.join(' ') - } - - const handleShare = async () => { - try { - const uri = await viewShotRef.current?.capture?.() - if (uri) { - await Sharing.shareAsync(uri, { mimeType: 'image/png' }) - } - } catch {} - } - return ( - - - - - - - - - - {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} - - - ))} + 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} + - + ))} ) } const styles = StyleSheet.create({ card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 }, - header: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, - shotContent: { backgroundColor: '#fff', padding: 16 }, title: { fontSize: 18, fontWeight: '700', marginBottom: 4 }, date: { fontSize: 14, color: '#666', marginBottom: 12 }, - row: { flexDirection: 'row', paddingVertical: 4 }, - rowLabel: { fontSize: 14, color: '#555', flex: 1 }, + row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 }, + rowLabel: { fontSize: 14, color: '#555' }, rowValue: { fontSize: 14, fontWeight: '600' }, emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 }, - previous: { marginBottom: 4 }, - divider: { height: 1, backgroundColor: '#eee', marginVertical: 12 }, - previousTitle: { fontSize: 18, fontWeight: '700', marginBottom: 4 }, - previousDate: { fontSize: 14, color: '#666', marginBottom: 4 }, - diffLabel: { fontSize: 16, fontWeight: '700', color: '#2196F3' }, - positiveComment: { fontSize: 14, color: '#4CAF50', marginTop: 4 }, }) diff --git a/src/components/MeasurementForm.tsx b/src/components/MeasurementForm.tsx index 9b4b9ae..f79293e 100644 --- a/src/components/MeasurementForm.tsx +++ b/src/components/MeasurementForm.tsx @@ -8,7 +8,6 @@ import { generateId } from '../utils/uuid' type Props = { onSubmit: (m: Measurement) => void initialValues?: Partial - isEdit?: boolean } type FieldDef = { @@ -30,10 +29,13 @@ const fields: FieldDef[] = [ { key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' }, ] -export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) { +export default function MeasurementForm({ onSubmit, initialValues }: Props) { const insets = useSafeAreaInsets() const [pickerOpen, setPickerOpen] = useState(false) - const [measurementDate, setMeasurementDate] = useState(new Date()) + const [measurementDate, setMeasurementDate] = useState(() => { + if (initialValues?.measuredAt) return new Date(initialValues.measuredAt) + return new Date() + }) const [values, setValues] = useState>(() => { const init: Record = {} for (const f of fields) { @@ -45,18 +47,9 @@ export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Pro -function toFloat(s: string): number { - return parseFloat(s.replace(',', '.')) || 0 -} - -function toInt(s: string): number { - return parseInt(s.replace(',', '.'), 10) || 0 -} - - - const weight = toFloat(values.weight) - const bodyFatPct = toFloat(values.bodyFatPercentage) - const musclePct = toFloat(values.musclePercentage) + const weight = parseFloat(values.weight) || 0 + const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0 + const musclePct = parseFloat(values.musclePercentage) || 0 const computed = useMemo(() => ({ fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '', @@ -66,18 +59,20 @@ function toInt(s: string): number { const handleSubmit = () => { const now = new Date().toISOString() const m: Measurement = { - id: isEdit && initialValues?.id ? initialValues.id : generateId(), + id: generateId(), weight, - bmi: toFloat(values.bmi), + bmi: parseFloat(values.bmi) || 0, bodyFatPercentage: bodyFatPct, - fatMass: toFloat(computed.fatMass), + fatMass: parseFloat(computed.fatMass) || 0, musclePercentage: musclePct, - muscleMass: toFloat(computed.muscleMass), - visceralFat: toInt(values.visceralFat), - bmr: toFloat(values.bmr), + muscleMass: parseFloat(computed.muscleMass) || 0, + visceralFat: parseInt(values.visceralFat, 10) || 0, + bmr: parseFloat(values.bmr) || 0, measuredAt: measurementDate.toISOString(), - createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now, + createdAt: now, updatedAt: now, + syncStatus: 'local', + deleted: 0, } onSubmit(m) } @@ -146,7 +141,7 @@ function toInt(s: string): number { ) })} - + Opslaan @@ -155,7 +150,7 @@ function toInt(s: string): number { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5' }, - scroll: { padding: 16 }, + scroll: { padding: 16, paddingBottom: 0 }, field: { marginBottom: 12 }, label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 }, input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' }, diff --git a/src/components/WeightChart.tsx b/src/components/WeightChart.tsx index 8ddebff..14ebf19 100644 --- a/src/components/WeightChart.tsx +++ b/src/components/WeightChart.tsx @@ -1,5 +1,5 @@ -import React, { useRef, useState } from 'react' -import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native' +import React from 'react' +import { View, Text, StyleSheet, useWindowDimensions } from 'react-native' import { LineChart } from 'react-native-chart-kit' import { Measurement } from '../types' @@ -11,9 +11,6 @@ const COLORS = { muscleMass: '#2196F3', } -const screenWidth = Dimensions.get('window').width -const chartWidth = screenWidth - 32 - function SingleChart({ title, data, @@ -27,12 +24,13 @@ function SingleChart({ color: string unit: string }) { + const { width } = useWindowDimensions() return ( {title} color, strokeWidth: 2 }] }} - width={chartWidth} + width={width - 32} height={180} yAxisSuffix={unit} chartConfig={{ @@ -60,63 +58,7 @@ function hexToRgb(hex: string): string { return `${r}, ${g}, ${b}` } -function filterData(sorted: Measurement[], days: number, lastDate: Date) { - const cutoff = new Date(lastDate) - cutoff.setDate(cutoff.getDate() - days) - const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff) - return filtered.length >= 2 ? filtered : sorted -} - -function prepareLabels(dataPoints: Measurement[]) { - const labels = dataPoints.map(m => { - const d = new Date(m.measuredAt) - return `${d.getDate()}/${d.getMonth() + 1}` - }) - const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1 - return labels.map((l, i) => (i % labelStep === 0 ? l : '')) -} - -function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) { - const labels = prepareLabels(dataPoints) - const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0) - const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0) - - return ( - - {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 } @@ -125,38 +67,52 @@ export default function WeightChart({ measurements }: Props) { (a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime() ) - const lastDate = new Date(sorted[sorted.length - 1].measuredAt) + const cutoff = new Date(sorted[sorted.length - 1].measuredAt) + cutoff.setDate(cutoff.getDate() - 30) + const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff) + const dataPoints = filtered.length >= 2 ? filtered : sorted - const data30 = filterData(sorted, 30, lastDate) - const data365 = filterData(sorted, 365, lastDate) + const labels = dataPoints.map(m => { + const d = new Date(m.measuredAt) + return `${d.getDate()}/${d.getMonth() + 1}` + }) - const onScroll = (e: NativeSyntheticEvent) => { - const offset = e.nativeEvent.contentOffset.x - const newPage = Math.round(offset / screenWidth) - if (newPage !== page) { - setPage(newPage) - } - } + const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1 + const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : '')) return ( - - - - + 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: { marginBottom: 16 }, - page: { width: screenWidth, paddingHorizontal: 16 }, + container: { marginHorizontal: 16, marginBottom: 16 }, title: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, chartBlock: { marginBottom: 12 }, chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 }, diff --git a/src/database/db.ts b/src/database/db.ts index 7b1b42b..c2e59e8 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -1,8 +1,7 @@ import * as SQLite from 'expo-sqlite' -import { Measurement } from '../types' +import { Measurement, SyncStatus } from '../types' let db: SQLite.SQLiteDatabase | null = null -let dbReady = false export async function initDatabase(): Promise { if (db) return @@ -21,112 +20,81 @@ export async function initDatabase(): Promise { measuredAt TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, - syncStatus TEXT NOT NULL DEFAULT 'pending', - deletedAt TEXT + syncStatus TEXT NOT NULL DEFAULT 'local', + deleted INTEGER NOT NULL DEFAULT 0 ); `) - try { - await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT') - } catch {} - - // Migrate existing records to crossbreed syncStatus model - if (!dbReady) { - try { - await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'") - } catch {} - dbReady = true - } + await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`) + .catch(() => {}) } export async function insertMeasurement(m: Measurement): Promise { await db!.runAsync( - `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null] + `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, 0] ) } +function activeOnly(): string { + return 'deleted = 0' +} + export async function getAllMeasurements(): Promise { const rows = await db!.getAllAsync( - 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC' - ) - return rows as Measurement[] -} - -export async function getAllMeasurementsForSync(): Promise { - const rows = await db!.getAllAsync( - 'SELECT * FROM measurements ORDER BY measuredAt DESC' + `SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC` ) return rows as Measurement[] } export async function getLatestMeasurement(): Promise { const rows = await db!.getAllAsync( - 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1' + `SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC LIMIT 1` ) return rows.length > 0 ? (rows[0] as Measurement) : null } -export async function getLatestMeasurements(count: number): Promise { - const rows = await db!.getAllAsync( - 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?', - [count] - ) - return rows as Measurement[] -} - -export async function getMeasurementsSince(date: string): Promise { - const rows = await db!.getAllAsync( - 'SELECT * FROM measurements WHERE measuredAt >= ? AND deletedAt IS NULL ORDER BY measuredAt DESC', - [date] - ) - return rows as Measurement[] -} - export async function getMeasurementByDate(date: string): Promise { const day = date.slice(0, 10) const rows = await db!.getAllAsync( - "SELECT * FROM measurements WHERE measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1", + `SELECT * FROM measurements WHERE ${activeOnly()} AND measuredAt LIKE ? LIMIT 1`, [`${day}%`] ) return rows.length > 0 ? (rows[0] as Measurement) : null } +export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise { + 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, deletedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null] + `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deleted] ) } -export async function getMeasurementsUpdatedSince(since: string): Promise { +export async function getUnsyncedMeasurements(): Promise { const rows = await db!.getAllAsync( - 'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC', - [since] + "SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC" ) return rows as Measurement[] } -export async function setDeletedAt(id: string): Promise { - const now = new Date().toISOString() +export async function deleteMeasurement(id: string): Promise { await db!.runAsync( - 'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?', - [now, now, id] + "UPDATE measurements SET deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?", + [new Date().toISOString(), id] ) } -export async function removeMeasurement(id: string): Promise { - 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 { +export async function getAllMeasurementsIncludingDeleted(): Promise { const rows = await db!.getAllAsync( - "SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC" + 'SELECT * FROM measurements ORDER BY measuredAt DESC' ) return rows as Measurement[] } diff --git a/src/screens/DashboardScreen.tsx b/src/screens/DashboardScreen.tsx index 741440f..dc5c8c7 100644 --- a/src/screens/DashboardScreen.tsx +++ b/src/screens/DashboardScreen.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useState } from 'react' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { getCached, getVersion, refreshCache } from '../store/measurementStore' -import { initDatabase, getLatestMeasurements } from '../database/db' +import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db' import { Measurement } from '../types' import LatestMeasurementCard from '../components/LatestMeasurementCard' import WeightChart from '../components/WeightChart' @@ -12,76 +11,22 @@ type Props = { navigation: any } export default function DashboardScreen({ navigation }: Props) { const insets = useSafeAreaInsets() - const [latestTwo, setLatestTwo] = useState([]) + const [latest, setLatest] = useState(null) const [all, setAll] = useState([]) - const [chartKey, setChartKey] = useState(0) - const seenVersion = useRef(0) useFocusEffect( useCallback(() => { - let active = true const load = async () => { await initDatabase() - - const two = await getLatestMeasurements(2) - if (!active) return - setLatestTwo(two) - - await refreshCache() - if (!active) return - const v = getVersion() - if (v !== seenVersion.current) { - setAll(getCached()) - setChartKey(v) - seenVersion.current = v - } - - try { - const { syncAll } = await import('../sync/syncService') - const result = await syncAll() - if (!active) return - if (result.deleted > 0 || result.pulled > 0) { - await refreshCache() - const v2 = getVersion() - setAll(getCached()) - setChartKey(v2) - seenVersion.current = v2 - } - } catch { - console.warn('Sync failed') - } + const allM = await getAllMeasurements() + setAll(allM) + const latestM = await getLatestMeasurement() + setLatest(latestM) } load() - return () => { active = false } }, []) ) - if (all.length === 0 && latestTwo.length === 0) { - return ( - - - navigation.navigate('History')}> - Historie - - navigation.navigate('Settings')}> - Instellingen - - - - Laden... - - navigation.navigate('NewMeasurement')} - > - + Nieuwe meting - - - ) - } - - const display = all.length > 0 ? all : latestTwo - return ( @@ -93,8 +38,8 @@ export default function DashboardScreen({ navigation }: Props) { - - {all.length >= 2 && } + + { await initDatabase() - await setDeletedAt(m.id) - await updateSyncStatus(m.id, 'pending-delete') - await refreshCache() + await deleteMeasurement(m.id) + try { + const { syncAll } = await import('../sync/syncService') + await syncAll() + } catch (e) { + console.warn('Sync failed after delete:', e) + } load() }, }, @@ -79,10 +82,7 @@ export default function HistoryScreen({ navigation }: Props) { data={measurements} keyExtractor={item => item.id} renderItem={({ item }) => ( - navigation.navigate('MeasurementDetail', { measurementId: item.id })} - /> + handlePress(item)} /> )} contentContainerStyle={measurements.length === 0 ? styles.empty : undefined} ListEmptyComponent={Nog geen metingen} diff --git a/src/screens/MeasurementDetailScreen.tsx b/src/screens/MeasurementDetailScreen.tsx deleted file mode 100644 index 8b3236f..0000000 --- a/src/screens/MeasurementDetailScreen.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { useCallback, useState } from 'react' -import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native' -import { useFocusEffect } from '@react-navigation/native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db' -import { refreshCache } from '../store/measurementStore' -import { Measurement } from '../types' - -type Props = { - navigation: any - route: { - params: { - measurementId: string - } - } -} - -const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ - { label: 'Gewicht', key: 'weight', suffix: 'kg' }, - { label: 'BMI', key: 'bmi', suffix: '' }, - { label: 'Vetpercentage', key: 'bodyFatPercentage', suffix: '%' }, - { label: 'Vetmassa', key: 'fatMass', suffix: 'kg' }, - { label: 'Spierpercentage', key: 'musclePercentage', suffix: '%' }, - { label: 'Spiermassa', key: 'muscleMass', suffix: 'kg' }, - { label: 'Buikvet', key: 'visceralFat', suffix: '' }, - { label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' }, -] - -export default function MeasurementDetailScreen({ navigation, route }: Props) { - const insets = useSafeAreaInsets() - const { measurementId } = route.params - const [measurement, setMeasurement] = useState(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 1f249fc..12cb0d2 100644 --- a/src/screens/NewMeasurementScreen.tsx +++ b/src/screens/NewMeasurementScreen.tsx @@ -1,90 +1,101 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { View, Text, Alert } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import MeasurementForm from '../components/MeasurementForm' import { Measurement } from '../types' -import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db' -import { refreshCache } from '../store/measurementStore' -import { showSuccess, showError } from '../utils/toast' +import { + initDatabase, + insertMeasurement, + getLatestMeasurement, + getMeasurementByDate, + upsertMeasurement, +} from '../database/db' type Props = { navigation: any - route: { - params: { - measurement?: Measurement - } - } + route: any } export default function NewMeasurementScreen({ navigation, route }: Props) { - const editMeasurement = route.params?.measurement - const isEdit = !!editMeasurement + const editing = route.params?.measurement as Measurement | undefined const [latest, setLatest] = useState | undefined>(undefined) const [loaded, setLoaded] = useState(false) - React.useLayoutEffect(() => { - navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' }) - }, [navigation, isEdit]) - useFocusEffect( useCallback(() => { - if (isEdit) { - setLatest(editMeasurement) - setLoaded(true) - return - } let cancelled = false const load = async () => { await initDatabase() - const m = await getLatestMeasurement() - if (!cancelled) { - setLatest(m || {}) - setLoaded(true) + if (editing) { + if (!cancelled) { + setLatest(editing) + setLoaded(true) + } + } else { + const m = await getLatestMeasurement() + if (!cancelled) { + setLatest(m || {}) + setLoaded(true) + } } } load() return () => { cancelled = true } - }, [isEdit, editMeasurement]) + }, [editing]) ) + useEffect(() => { + if (editing) { + navigation.setOptions({ title: 'Meting wijzigen' }) + } + }, [editing, navigation]) + const handleSubmit = async (m: Measurement) => { await initDatabase() - if (isEdit) { + if (editing) { + m.id = editing.id + m.createdAt = editing.createdAt + await upsertMeasurement(m) + doSync() + return + } + const existing = await getMeasurementByDate(m.measuredAt) + if (existing) { + Alert.alert( + 'Dubbele datum', + `Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`, + [ + { text: 'Annuleren', style: 'cancel' }, + { text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing) }, + ] + ) + return + } + doSave(m) + } + + const doSave = async (m: Measurement, existing?: Measurement) => { + if (existing) { + m.id = existing.id + m.createdAt = existing.createdAt await upsertMeasurement(m) } else { - const existing = await getMeasurementByDate(m.measuredAt) - if (existing) { - Alert.alert( - 'Dubbele datum', - `Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`, - [ - { text: 'Annuleren', style: 'cancel' }, - { text: 'Overschrijven', style: 'destructive', onPress: () => { - m.id = existing.id - upsertMeasurement(m).then(() => doSave(m)) - }}, - ] - ) - return - } await insertMeasurement(m) } - await doSave(m) + doSync() } - const doSave = async (m: Measurement) => { - await refreshCache() - navigation.goBack() - uploadInBackground(m) - } - - const uploadInBackground = async (m: Measurement) => { + const doSync = async () => { try { - const { uploadMeasurement } = await import('../sync/syncService') - await uploadMeasurement(m) - showSuccess('Opgeslagen & gesynct') + const { syncAll } = await import('../sync/syncService') + await syncAll() + Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]) } catch { - showError('Opgeslagen (sync mislukt)') + Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]) } } @@ -100,7 +111,6 @@ export default function NewMeasurementScreen({ navigation, route }: Props) { | undefined} - isEdit={isEdit} /> ) } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index a3ca7a4..f434065 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 { ApiConfig } from '../types' +import { WebDAVConfig } from '../types' -const CONFIG_KEY = 'weegschaal_api_config' +const CONFIG_KEY = 'weegschaal_webdav_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 { } } -export async function saveConfig(config: ApiConfig): Promise { +async function saveConfig(config: WebDAVConfig): Promise { await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) } @@ -22,43 +22,50 @@ async function deleteConfig(): Promise { await SecureStore.deleteItemAsync(CONFIG_KEY) } -export async function getApiConfig(): Promise { +export async function getWebDAVConfig(): 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 [url, setUrl] = useState('https://nextcloud.eddydevink.nl/remote.php/dav/files/eddy') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) const [syncing, setSyncing] = useState(false) useEffect(() => { loadConfig().then(c => { if (c) { setUrl(c.url) - setToken(c.token) + setUsername(c.username) + setPassword(c.password) } }) }, []) const handleSave = async () => { - await saveConfig({ url, token }) - Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen') + await saveConfig({ url, username, password }) + Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen') } const handleClear = () => { - Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [ - { text: 'Annuleren', style: 'cancel' }, - { - text: 'Ja, wissen', - style: 'destructive', - onPress: async () => { - await deleteConfig() - setUrl('') - setToken('') + Alert.alert( + 'Instellingen wissen?', + 'WebDAV configuratie definitief verwijderen? Je kunt deze later opnieuw invoeren.', + [ + { text: 'Annuleren', style: 'cancel' }, + { + text: 'Ja, wissen', + style: 'destructive', + onPress: async () => { + await deleteConfig() + setUrl('') + setUsername('') + setPassword('') + }, }, - }, - ]) + ] + ) } const handleSync = async () => { @@ -66,11 +73,7 @@ export default function SettingsScreen() { try { const { syncAll } = await import('../sync/syncService') const result = await syncAll() - let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}` - if (result.errors.length > 0) { - msg += `\n\nFouten: ${result.errors.join(', ')}` - } - Alert.alert('Sync voltooid', msg) + Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`) } catch (e: any) { Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') } finally { @@ -80,30 +83,38 @@ export default function SettingsScreen() { return ( - REST API + WebDAV (Nextcloud) - API URL + URL - Token + Gebruikersnaam + + + Wachtwoord - setShowToken(!showToken)}> - {showToken ? '👁' : '🔒'} + setShowPassword(!showPassword)}> + {showPassword ? '👁' : '👁‍🗨'} diff --git a/src/store/measurementStore.ts b/src/store/measurementStore.ts deleted file mode 100644 index c50fd30..0000000 --- a/src/store/measurementStore.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Measurement } from '../types' -import { initDatabase, getAllMeasurements } from '../database/db' - -let cache: Measurement[] = [] -let version = 0 - -export async function refreshCache(): Promise { - 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 deleted file mode 100644 index 143ec7b..0000000 --- a/src/sync/api.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Measurement } from '../types' -import { ApiConfig } from '../types' - -export class ApiError extends Error { - status: number - constructor(message: string, status: number) { - super(message) - this.name = 'ApiError' - this.status = status - } -} - -function authHeaders(token: string): Record { - 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 1ca483a..b232fca 100644 --- a/src/sync/syncService.ts +++ b/src/sync/syncService.ts @@ -1,146 +1,139 @@ import { Measurement } from '../types' -import { - initDatabase, - getAllMeasurementsForSync, - upsertMeasurement, - getPendingRecords, - updateSyncStatus, - removeMeasurement, -} from '../database/db' -import { createApi, ApiError } from './api' -import * as SecureStore from 'expo-secure-store' +import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db' +import { getWebDAVConfig } from '../screens/SettingsScreen' +import { encodeBase64 } from '../utils/base64' -const LAST_SYNC_KEY = 'weegschaal_last_sync' +const WEBDAV_PATH = 'weegschaal/measurements.json' +const SYNC_TIMEOUT_MS = 15_000 -type SyncResult = { - pushed: number - deleted: number - pulled: number - errors: string[] -} +type SyncResult = { local: number; remote: number; merged: number } -export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] { - const localMap = new Map(local.map(m => [m.id, m])) - const resultMap = new Map(local.map(m => [m.id, m])) +export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { + const map = new Map() - for (const r of remote) { - const localRec = localMap.get(r.id) - const isDeleted = !!(r as any).deleted - const localPending = - localRec?.syncStatus === 'pending' || - localRec?.syncStatus === 'pending-delete' + for (const m of local) { + map.set(m.id, m) + } - if (isDeleted) { - if (!localRec || localPending) continue - if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { - resultMap.set(r.id, { - ...r, - syncStatus: 'synced', - deletedAt: (r as any).deletedAt || r.updatedAt, - } as Measurement) - } - continue - } - - if (localPending) continue - - if (localRec) { - if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) { - resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement) - } + for (const m of remote) { + const existing = map.get(m.id) + if (!existing) { + map.set(m.id, { ...m, syncStatus: 'synced' }) } else { - resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement) + const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt) + if (remoteNewer) { + map.set(m.id, { ...m, syncStatus: 'synced' }) + } } } - return Array.from(resultMap.values()) + return Array.from(map.values()).filter(m => !m.deleted) } -export async function uploadMeasurement(m: Measurement): Promise { - const { getApiConfig } = await import('../screens/SettingsScreen') - const config = await getApiConfig() - if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') - const api = createApi(config) - await api.createMeasurement(m) - await updateSyncStatus(m.id, 'synced') +async function fetchRemote(config: { url: string; username: string; password: string }): Promise { + const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` + const auth = encodeBase64(`${config.username}:${config.password}`) + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS) + + try { + const response = await fetch(url, { + headers: { Authorization: `Basic ${auth}` }, + signal: controller.signal, + }) + + if (response.status === 404) { + return [] + } + + if (!response.ok) { + throw new Error(`WebDAV fetch failed: ${response.status}`) + } + + return response.json() + } finally { + clearTimeout(timer) + } +} + +async function createDir(config: { url: string; username: string; password: string }): Promise { + 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 { getApiConfig } = await import('../screens/SettingsScreen') - const config = await getApiConfig() - if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') + const config = await getWebDAVConfig() + if (!config) { + throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.') + } - const api = createApi(config) await initDatabase() - const errors: string[] = [] - let pushed = 0 - let deleted = 0 - let pulled = 0 + const local = await getAllMeasurementsIncludingDeleted() + const remote = await fetchRemote(config) - // === PUSH phase — outbox first === - const pending = await getPendingRecords() + const merged = mergeMeasurements(local, remote) - for (const m of pending) { - if (m.deletedAt) { - try { - await api.deleteMeasurement(m.id) - await removeMeasurement(m.id) - deleted++ - } catch (e: any) { - if (e instanceof ApiError && e.status === 404) { - await removeMeasurement(m.id) - deleted++ - } else { - errors.push(e?.message ?? 'Delete failed') - } - } - } else { - try { - await api.createMeasurement(m) - await updateSyncStatus(m.id, 'synced') - pushed++ - } catch (e: any) { - errors.push(e?.message ?? 'Create failed') - } - } + for (const m of merged) { + await upsertMeasurement(m) } - // === PULL phase — fetch remote changes === - const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) - const remote = await api.getMeasurements(lastSyncAt || undefined) + await uploadRemote(config, merged) - if (remote.length > 0) { - const local = await getAllMeasurementsForSync() - const merged = mergeMeasurements(local, remote) - - const localMap = new Map(local.map(m => [m.id, m])) - - for (const m of merged) { - if (m.deletedAt) { - await removeMeasurement(m.id) - const localRec = localMap.get(m.id) - if (localRec && !localRec.deletedAt) { - deleted++ - } - } else { - const localRec = localMap.get(m.id) - const isNew = !localRec - const isUpdated = - localRec && - new Date(m.updatedAt).getTime() !== - new Date(localRec.updatedAt).getTime() - - if (isNew || isUpdated) { - await upsertMeasurement(m) - await updateSyncStatus(m.id, 'synced') - pulled++ - } - } - } + return { + local: local.filter(m => !m.deleted).length, + remote: remote.filter(m => !m.deleted).length, + merged: merged.length, } - - await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) - - return { pushed, deleted, pulled, errors } } diff --git a/src/types.ts b/src/types.ts index 904cd84..3006423 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +export type SyncStatus = 'local' | 'synced' + export type Measurement = { id: string weight: number @@ -11,11 +13,12 @@ export type Measurement = { measuredAt: string createdAt: string updatedAt: string - syncStatus?: string - deletedAt?: string + syncStatus: SyncStatus + deleted: number } -export type ApiConfig = { +export type WebDAVConfig = { url: string - token: string + username: string + password: string } diff --git a/src/utils/base64.ts b/src/utils/base64.ts new file mode 100644 index 0000000..ec8d079 --- /dev/null +++ b/src/utils/base64.ts @@ -0,0 +1,17 @@ +const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' + +export function encodeBase64(str: string): string { + const bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match, p1) => String.fromCharCode(parseInt(p1, 16))) + let result = '' + let i = 0 + while (i < bytes.length) { + const a = bytes.charCodeAt(i++) || 0 + const b = bytes.charCodeAt(i++) || 0 + const c = bytes.charCodeAt(i++) || 0 + result += CHARS.charAt(a >> 2) + result += CHARS.charAt(((a & 3) << 4) | (b >> 4)) + result += CHARS.charAt(isNaN(b) ? CHARS.length - 1 : ((b & 15) << 2) | (c >> 6)) + result += CHARS.charAt(isNaN(c) ? CHARS.length - 1 : c & 63) + } + return result +} diff --git a/src/utils/toast.ts b/src/utils/toast.ts deleted file mode 100644 index c849d4a..0000000 --- a/src/utils/toast.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Platform, ToastAndroid } from 'react-native' - -let _toastQueue: Array<{message: string; duration: number}> = [] -let _showFn: ((message: string, duration: number) => void) | null = null - -export function setToastShowFn(fn: (message: string, duration: number) => void) { - _showFn = fn -} - -export function showToast(message: string, duration = 3000) { - if (_showFn) { - _showFn(message, duration) - return - } - if (Platform.OS === 'android') { - ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT) - } -} - -export function showSuccess(message: string) { - showToast(message, 2500) -} - -export function showError(message: string) { - showToast(message, 4000) -} diff --git a/tests/database.test.ts b/tests/database.test.ts index 606736b..becdf28 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -18,6 +18,8 @@ const mockMeasurement: Measurement = { measuredAt: '2024-01-01T08:00:00.000Z', createdAt: '2024-01-01T08:00:00.000Z', updatedAt: '2024-01-01T08:00:00.000Z', + syncStatus: 'local', + deleted: 0, } describe('database', () => { @@ -26,7 +28,9 @@ describe('database', () => { let insertMeasurement: any let getAllMeasurements: any let getLatestMeasurement: any - let setDeletedAt: any + let updateMeasurement: any + let deleteMeasurement: any + let getAllMeasurementsIncludingDeleted: any beforeEach(async () => { jest.resetModules() @@ -34,8 +38,8 @@ describe('database', () => { openDatabaseAsync: jest.fn(), })) db = { - execAsync: jest.fn(), - runAsync: jest.fn(), + execAsync: jest.fn(() => Promise.resolve()), + runAsync: jest.fn(() => Promise.resolve()), getAllAsync: jest.fn(), } const sqlite = require('expo-sqlite') @@ -45,7 +49,9 @@ describe('database', () => { insertMeasurement = mod.insertMeasurement getAllMeasurements = mod.getAllMeasurements getLatestMeasurement = mod.getLatestMeasurement - setDeletedAt = mod.setDeletedAt + updateMeasurement = mod.updateMeasurement + deleteMeasurement = mod.deleteMeasurement + getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted }) it('initDatabase creates the measurements table', async () => { @@ -81,15 +87,42 @@ describe('database', () => { expect(result!.id).toBe(mockMeasurement.id) }) - it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => { + it('updateMeasurement updates syncStatus', async () => { db.runAsync.mockResolvedValue(undefined) await initDatabase() - await setDeletedAt(mockMeasurement.id) + await updateMeasurement(mockMeasurement.id, 'synced') expect(db.runAsync).toHaveBeenCalledWith( - expect.stringContaining('UPDATE measurements SET deletedAt'), + expect.stringContaining('UPDATE measurements'), + expect.arrayContaining(['synced', mockMeasurement.id]) + ) + }) + + it('deleteMeasurement marks measurement as deleted', async () => { + db.runAsync.mockResolvedValue(undefined) + await initDatabase() + await deleteMeasurement(mockMeasurement.id) + expect(db.runAsync).toHaveBeenCalledWith( + expect.stringContaining('UPDATE measurements SET deleted = 1'), expect.arrayContaining([mockMeasurement.id]) ) - const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0] - expect(args[1][0]).toBe(args[1][1]) + }) + + it('getAllMeasurements filters out deleted records', async () => { + await initDatabase() + await getAllMeasurements() + expect(db.getAllAsync).toHaveBeenCalledWith( + expect.stringContaining('deleted = 0') + ) + }) + + it('getAllMeasurementsIncludingDeleted includes deleted records', async () => { + await initDatabase() + await getAllMeasurementsIncludingDeleted() + expect(db.getAllAsync).toHaveBeenCalledWith( + expect.stringContaining('SELECT * FROM measurements') + ) + expect(db.getAllAsync).not.toHaveBeenCalledWith( + expect.stringContaining('deleted = 0') + ) }) }) diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts index e33cc27..dd2ff6e 100644 --- a/tests/syncService.test.ts +++ b/tests/syncService.test.ts @@ -1,86 +1,102 @@ import { mergeMeasurements } from '../src/sync/syncService' import { Measurement } from '../src/types' -jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() })) +jest.mock('expo-sqlite', () => ({ + openDatabaseAsync: jest.fn(), +})) -const make = (overrides: Partial = {}): Measurement => ({ - id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10, - musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600, - measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z', - updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides, -}) +const base: Measurement = { + id: 'id-1', + weight: 75, + bmi: 22, + bodyFatPercentage: 15, + fatMass: 10, + musclePercentage: 40, + muscleMass: 30, + visceralFat: 8, + bmr: 1600, + measuredAt: '2024-06-01T08:00:00.000Z', + createdAt: '2024-06-01T08:00:00.000Z', + updatedAt: '2024-06-01T08:00:00.000Z', + syncStatus: 'local', + deleted: 0, +} describe('mergeMeasurements', () => { - it('both empty returns empty', () => { + it('returns empty array when both are empty', () => { expect(mergeMeasurements([], [])).toEqual([]) }) - it('local only kept', () => { - const r = mergeMeasurements([make()], []) - expect(r).toHaveLength(1) + it('keeps local items not on remote', () => { + const result = mergeMeasurements([base], []) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('id-1') }) - it('remote only kept', () => { - const r = mergeMeasurements([], [make({ id: 'r1' })]) - expect(r).toHaveLength(1) - expect(r[0].id).toBe('r1') + it('keeps remote items not on local', () => { + const remote = { ...base, syncStatus: 'synced' as const } + const result = mergeMeasurements([], [remote]) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('id-1') }) - it('last-writer-wins: remote newer', () => { - const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }) - const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) - const r = mergeMeasurements([local], [remote]) - expect(r[0].weight).toBe(76) + it('picks the newer version when both have same id', () => { + const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' } + const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(1) + expect(result[0].weight).toBe(76) }) - it('last-writer-wins: local newer', () => { - const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }) - const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) - const r = mergeMeasurements([local], [remote]) - expect(r[0].weight).toBe(77) + it('picks local when local is newer', () => { + const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' } + const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(1) + expect(result[0].weight).toBe(77) }) - it('remote deleted wins over synced local', () => { - const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) - const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } - const r = mergeMeasurements([local], [remote]) - expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') + it('filters out locally deleted items even if present on remote', () => { + const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } + const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(0) }) - it('local synced wins over older remote deleted', () => { - const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' }) - const remote: any = { ...make({ updatedAt: '2024-06-01T08:00:00.000Z' }), deleted: true } - const r = mergeMeasurements([local], [remote]) - expect(r[0].deletedAt).toBeUndefined() + it('filters out remotely deleted items when remote is newer', () => { + const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' } + const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(0) }) - it('local pending-delete wins over remote deleted', () => { - const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' }) - const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } - const r = mergeMeasurements([local], [remote]) - expect(r[0].syncStatus).toBe('pending-delete') - expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z') + it('keeps local active item when local is newer than remote deletion', () => { + const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' } + const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(1) + expect(result[0].weight).toBe(78) }) - it('local pending wins over remote newer', () => { - const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' }) - const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) } - const r = mergeMeasurements([local], [remote]) - expect(r[0].weight).toBe(75) - expect(r[0].syncStatus).toBe('pending') + it('keeps active item when local deleted is older than remote active', () => { + const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' } + const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const } + const result = mergeMeasurements([local], [remote]) + expect(result).toHaveLength(1) + expect(result[0].weight).toBe(75) }) - it('remote deleted with no local record is skipped', () => { - const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true } - const r = mergeMeasurements([], [remote]) - expect(r).toHaveLength(0) - }) + it('merges multiple active and deleted items correctly', () => { + const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' } + const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } + const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const } + const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } - it('local without syncStatus treated as synced', () => { - const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' }) - delete local.syncStatus - const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) - const r = mergeMeasurements([local], [remote]) - expect(r[0].weight).toBe(76) + const result = mergeMeasurements( + [localActive, localDeleted], + [remoteActive, remoteDeleted] + ) + expect(result).toHaveLength(2) + expect(result.map(m => m.id).sort()).toEqual(['a', 'c']) }) }) diff --git a/upgrade.md b/upgrade.md deleted file mode 100644 index f51a5e1..0000000 --- a/upgrade.md +++ /dev/null @@ -1,140 +0,0 @@ -# Upgrade: REST API sync i.p.v. WebDAV - -Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`, -met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand. - -## Fase 1 — Server-side: PHP API - -### Subdomein & nginx - -- DNS: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` -- nginx server block in `/etc/nginx/conf.d/weegschaal-api.conf` -- SSL via Let's Encrypt (`sudo certbot`) -- Document root: `/usr/share/nginx/html/weegschaal-api/` - -### MariaDB - -```sql -CREATE DATABASE weegschaal; -CREATE USER 'weegschaal'@'localhost' IDENTIFIED BY ''; -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 deleted file mode 100644 index 297f00b..0000000 Binary files a/weegschaal-app.jpeg and /dev/null differ