weegschaal-app/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md

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, voeg setDeletedAt, removeMeasurement toe van ../database/db
  • Voeg createApi toe van ../sync/api
  • Import getApiConfig van ./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, 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:

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