1285 lines
37 KiB
Markdown
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
|
|
```
|