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

1285 lines
37 KiB
Markdown

# 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" <<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**
```bash
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**
```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' <<EOF
<?php
return [
'db' => [
'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'
<?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**
```bash
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**
```bash
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**
```bash
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**
```bash
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)**
```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<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**
```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<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**
```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<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):
```typescript
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`:
```typescript
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**
```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<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**
```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> = {}): 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
```