37 KiB
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
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
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
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
ssh aiaiaiaiagent@nextcloud 'sudo -A restorecon -R /usr/share/nginx/html/weegschaal-api'
- Step 5: Test nginx config en herlaad
ssh aiaiaiaiagent@nextcloud 'sudo -A nginx -t && sudo -A systemctl reload nginx'
- Step 6: Certbot SSL
ssh aiaiaiaiagent@nextcloud 'sudo -A certbot --nginx -d weegschaal.eddydevink.nl'
Task 2: MariaDB setup
- Step 1: Bepaal MySQL auth op nextcloud
ssh aiaiaiaiagent@nextcloud 'sudo -A mysql -e "SELECT 1"'
- Step 2: Genereer credentials lokaal
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):
echo "URL=https://weegschaal.eddydevink.nl" > /tmp/weegschaal-credentials.txt
echo "TOKEN=$APP_TOKEN" >> /tmp/weegschaal-credentials.txt
- Step 3: Database, gebruiker, tabellen
ssh aiaiaiaiagent@nextcloud "sudo -A mysql" <<EOF
CREATE DATABASE IF NOT EXISTS weegschaal CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'weegschaal'@'localhost' IDENTIFIED BY '$DB_PASS';
GRANT ALL ON weegschaal.* TO 'weegschaal'@'localhost';
FLUSH PRIVILEGES;
USE weegschaal;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (name) VALUES ('Eddy');
CREATE TABLE IF NOT EXISTS auth_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
INSERT INTO auth_tokens (user_id, token, description) VALUES (1, '$APP_TOKEN', 'weegschaal-app');
CREATE TABLE IF NOT EXISTS measurements (
id VARCHAR(36) NOT NULL,
user_id INT NOT NULL DEFAULT 1,
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)
);
EOF
Task 3: PHP project skeleton
Files:
-
Create:
composer.json,config.php,src/Database.php,src/helpers.php(remote) -
Step 1: composer.json
ssh aiaiaiaiagent@nextcloud 'cat > /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
ssh aiaiaiaiagent@nextcloud 'cd /usr/share/nginx/html/weegschaal-api && composer install'
- Step 3: config.php
ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/config.php' <<EOF
<?php
return [
'db' => [
'host' => 'localhost',
'port' => 3306,
'name' => 'weegschaal',
'user' => 'weegschaal',
'pass' => '$DB_PASS',
],
];
EOF
- Step 4: src/Database.php
ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Database.php' <<'EOF'
<?php
namespace Weegschaal;
use PDO;
class Database
{
private static ?PDO $instance = null;
public static function get(): PDO
{
if (self::$instance === null) {
$cfg = require __DIR__ . '/../config.php';
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$cfg['db']['host'], $cfg['db']['port'], $cfg['db']['name']
);
self::$instance = new PDO($dsn, $cfg['db']['user'], $cfg['db']['pass'], [
PDO::ATTR_ERRMODE => 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
ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/helpers.php' <<'EOF'
<?php
namespace Weegschaal;
use Psr\Http\Message\ResponseInterface as Response;
function jsonResponse(Response $response, array $data, int $status = 200): Response
{
$body = json_encode($data, JSON_UNESCAPED_UNICODE);
$response->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
ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Auth.php' <<'EOF'
<?php
namespace Weegschaal;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response as SlimResponse;
class Auth implements MiddlewareInterface
{
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$header = $request->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
ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Measurements.php' <<'EOF'
<?php
namespace Weegschaal;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Psr7\Response as SlimResponse;
class Measurements
{
public function list(Request $request, Response $response): Response
{
$userId = $request->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
ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/public/index.php' <<'EOF'
<?php
require __DIR__ . '/../vendor/autoload.php';
use Slim\Factory\AppFactory;
use Weegschaal\Auth;
use Weegschaal\Measurements;
$app = AppFactory::create();
$app->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)
curl -s https://weegschaal.eddydevink.nl/api/v1/measurements \
-H "Authorization: Bearer $APP_TOKEN"
Expected: 200 met []
- Step 2: POST nieuwe meting
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
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
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:
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
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
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
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<string, string> {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
}
}
export type Api = ReturnType<typeof createApi>
export function createApi(config: ApiConfig) {
const base = config.url.replace(/\/+$/, '')
return {
async getMeasurements(since?: string): Promise<Measurement[]> {
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<void> {
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<void> {
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
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
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<string, Measurement>()
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<void> {
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
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:
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:
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
[since]
)
return rows as Measurement[]
}
export async function setDeletedAt(id: string): Promise<void> {
await db!.runAsync(
'UPDATE measurements SET deletedAt = ? WHERE id = ?',
[new Date().toISOString(), id]
)
}
export async function removeMeasurement(id: string): Promise<void> {
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):
export async function insertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync(
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
)
}
En upsertMeasurement:
export async function upsertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync(
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
)
}
- Step 3: Check TypeScript
cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit
- Step 4: Commit
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
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<ApiConfig | null> {
try {
const json = await SecureStore.getItemAsync(CONFIG_KEY)
return json ? JSON.parse(json) : null
} catch {
return null
}
}
async function saveConfig(config: ApiConfig): Promise<void> {
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
}
async function deleteConfig(): Promise<void> {
await SecureStore.deleteItemAsync(CONFIG_KEY)
}
export async function getApiConfig(): Promise<ApiConfig | null> {
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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>REST API</Text>
<Text style={styles.label}>API URL</Text>
<TextInput style={styles.input} value={url} onChangeText={setUrl} placeholder="https://weegschaal.eddydevink.nl" autoCapitalize="none" autoCorrect={false} />
<Text style={styles.label}>Token</Text>
<View style={styles.passwordRow}>
<TextInput style={styles.passwordInput} value={token} onChangeText={setToken} secureTextEntry={!showToken} autoCapitalize="none" autoCorrect={false} />
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
<Text style={styles.eyeIcon}>{showToken ? 'O' : 'O'}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.saveButton} onPress={handleSave}>
<Text style={styles.buttonText}>Instellingen opslaan</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.clearButton} onPress={handleClear}>
<Text style={styles.clearButtonText} numberOfLines={1}>Instellingen wissen</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.syncButton, syncing && styles.syncButtonDisabled]} onPress={handleSync} disabled={syncing}>
<Text style={styles.buttonText}>{syncing ? 'Bezig met sync...' : 'Nu synchroniseren'}</Text>
</TouchableOpacity>
</ScrollView>
)
}
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
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:
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
cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit
- Step 4: Commit
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, voegsetDeletedAt,removeMeasurementtoe van../database/db - Voeg
createApitoe van../sync/api - Import
getApiConfigvan./SettingsScreen(of../screens/SettingsScreen)
Vervang handleDelete:
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
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:
const load = useCallback(async () => {
await initDatabase()
const all = await getAllMeasurements()
setMeasurements(all.filter(m => !m.deletedAt))
}, [])
confirmDelete aanpassen:
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, voegsetDeletedAt,removeMeasurementtoe van../database/db -
Voeg
createApitoe van../sync/api -
Import
getApiConfigvan../screens/SettingsScreen -
Step 2: DashboardScreen — filter deleted
In DashboardScreen.tsx, load functie:
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
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
import { mergeMeasurements } from '../src/sync/syncService'
import { Measurement } from '../src/types'
jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() }))
const make = (overrides: Partial<Measurement> = {}): 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
cd /home/eddy/bin/weegschaal-app && npm test
Expected: 7 PASS
- Step 3: Commit
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
cd /home/eddy/bin/weegschaal-app && npx eas build --local --profile apk --platform android
- Step 2: Verbind telefoon
adb-wireless-connect
- Step 3: Installeer APK
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
# Done