# 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 ```