# Weegschaal App — 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:** Build a React Native (Expo) app to manually record weight scale measurements and sync them to Nextcloud via WebDAV. **Architecture:** Expo managed workflow, local SQLite via expo-sqlite, React Navigation for screens, custom sync layer fetching a single JSON file over WebDAV. **Tech Stack:** Expo SDK 52+, expo-sqlite, @react-navigation/native-stack, react-native-chart-kit, expo-secure-store, Jest --- ### Task 1: Project scaffold **Files:** - Create: `App.tsx` - Create: `app.json` - Create: `package.json` - Create: `tsconfig.json` - Create: `babel.config.js` - Create: `src/types.ts` - Create: `src/utils/uuid.ts` - [ ] **Step 1: Initialize Expo project** ```bash npx create-expo-app@latest weegschaal-app --template blank-typescript ``` - [ ] **Step 2: Install core dependencies** ```bash npx expo install expo-sqlite @react-navigation/native @react-navigation/native-stack react-native-screens react-native-safe-area-context expo-secure-store npx expo install react-native-chart-kit react-native-svg npm install --save-dev jest @types/jest ``` - [ ] **Step 3: Create folder structure and types file** ```bash mkdir -p src/database src/sync src/screens src/components src/utils tests ``` ```typescript // src/types.ts export type SyncStatus = 'local' | 'synced' 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 syncStatus: SyncStatus } export type WebDAVConfig = { url: string username: string password: string } ``` - [ ] **Step 4: Create UUID utility** ```typescript // src/utils/uuid.ts export function generateId(): string { const chars = '0123456789abcdef' const segments = [8, 4, 4, 4, 12] return segments .map(len => { let s = '' for (let i = 0; i < len; i++) { s += chars[Math.floor(Math.random() * 16)] } return s }) .join('-') } ``` - [ ] **Step 5: Add Jest config to package.json** ```json { "jest": { "preset": "jest-expo" } } ``` Run: `npm install --save-dev jest-expo` - [ ] **Step 6: Commit** ```bash git init git add -A git commit -m "chore: scaffold Expo project with types and utils" ``` --- ### Task 2: Database layer — types & schema **Files:** - Create: `src/database/db.ts` - Create: `tests/database.test.ts` - [ ] **Step 1: Write the database tests** ```typescript // tests/database.test.ts import { initDatabase, insertMeasurement, getAllMeasurements, getLatestMeasurement, updateMeasurement } from '../src/database/db' import { Measurement } from '../src/types' import * as SQLite from 'expo-sqlite' jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn(), })) const mockMeasurement: Measurement = { id: '550e8400-e29b-41d4-a716-446655440000', weight: 75.5, bmi: 22.3, bodyFatPercentage: 15.2, fatMass: 11.5, musclePercentage: 40.1, muscleMass: 30.3, visceralFat: 8, bmr: 1650, measuredAt: '2024-01-01T08:00:00.000Z', createdAt: '2024-01-01T08:00:00.000Z', updatedAt: '2024-01-01T08:00:00.000Z', syncStatus: 'local', } describe('database', () => { let db: any beforeEach(() => { db = { execAsync: jest.fn(), runAsync: jest.fn(), getAllAsync: jest.fn(), } ;(SQLite.openDatabaseAsync as jest.Mock).mockResolvedValue(db) }) it('initDatabase creates the measurements table', async () => { await initDatabase() expect(db.execAsync).toHaveBeenCalledWith( expect.stringContaining('CREATE TABLE IF NOT EXISTS measurements') ) }) it('insertMeasurement inserts a row', async () => { db.runAsync.mockResolvedValue(undefined) await initDatabase() await insertMeasurement(mockMeasurement) expect(db.runAsync).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO measurements'), expect.arrayContaining([mockMeasurement.id]) ) }) it('getAllMetrics returns all measurements ordered by measuredAt desc', async () => { db.getAllAsync.mockResolvedValue([{ ...mockMeasurement }]) await initDatabase() const result = await getAllMeasurements() expect(result).toHaveLength(1) expect(result[0].id).toBe(mockMeasurement.id) }) it('getLatestMeasurement returns the most recent', async () => { db.getAllAsync.mockResolvedValue([{ ...mockMeasurement }]) await initDatabase() const result = await getLatestMeasurement() expect(result).not.toBeNull() expect(result!.id).toBe(mockMeasurement.id) }) it('updateMeasurement updates syncStatus', async () => { db.runAsync.mockResolvedValue(undefined) await initDatabase() await updateMeasurement(mockMeasurement.id, 'synced') expect(db.runAsync).toHaveBeenCalledWith( expect.stringContaining('UPDATE measurements'), expect.arrayContaining(['synced', mockMeasurement.id]) ) }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx jest tests/database.test.ts --no-cache` Expected: FAIL — cannot find module `../src/database/db` - [ ] **Step 3: Write minimal implementation** ```typescript // src/database/db.ts import * as SQLite from 'expo-sqlite' import { Measurement, SyncStatus } from '../types' let db: SQLite.SQLiteDatabase export async function initDatabase(): Promise { db = await SQLite.openDatabaseAsync('weegschaal.db') 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, syncStatus TEXT NOT NULL DEFAULT 'local' ); `) } export async function insertMeasurement(m: Measurement): Promise { await db.runAsync( `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus) 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.syncStatus] ) } export async function getAllMeasurements(): Promise { const rows = await db.getAllAsync( 'SELECT * FROM measurements ORDER BY measuredAt DESC' ) return rows as Measurement[] } export async function getLatestMeasurement(): Promise { const rows = await db.getAllAsync( 'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1' ) return rows.length > 0 ? (rows[0] as Measurement) : null } export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise { await db.runAsync( 'UPDATE measurements SET syncStatus = ?, updatedAt = ? WHERE id = ?', [syncStatus, new Date().toISOString(), id] ) } export async function upsertMeasurement(m: Measurement): Promise { await db.runAsync( `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus) 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.syncStatus] ) } export async function getUnsyncedMeasurements(): Promise { const rows = await db.getAllAsync( "SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC" ) return rows as Measurement[] } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx jest tests/database.test.ts --no-cache` Expected: PASS - [ ] **Step 5: Commit** ```bash git add -A git commit -m "feat: add database layer with SQLite CRUD" ``` --- ### Task 3: MeasurementForm component & NewMeasurement screen **Files:** - Create: `src/components/MeasurementForm.tsx` - Create: `src/screens/NewMeasurementScreen.tsx` - [ ] **Step 1: Write MeasurementForm component** ```typescript // src/components/MeasurementForm.tsx import React, { useState } from 'react' import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native' import { Measurement } from '../types' import { generateId } from '../utils/uuid' type Props = { onSubmit: (m: Measurement) => void initialValues?: Partial } const fields: { key: keyof Measurement; label: string; placeholder: string; keyboardType: 'decimal-pad' | 'number-pad' }[] = [ { key: 'weight', label: 'Gewicht (kg)', placeholder: '75.5', keyboardType: 'decimal-pad' }, { key: 'bmi', label: 'BMI', placeholder: '22.3', keyboardType: 'decimal-pad' }, { key: 'bodyFatPercentage', label: 'Vetpercentage (%)', placeholder: '15.2', keyboardType: 'decimal-pad' }, { key: 'fatMass', label: 'Vetmassa (kg)', placeholder: '11.5', keyboardType: 'decimal-pad' }, { key: 'musclePercentage', label: 'Spierpercentage (%)', placeholder: '40.1', keyboardType: 'decimal-pad' }, { key: 'muscleMass', label: 'Spiermassa (kg)', placeholder: '30.3', keyboardType: 'decimal-pad' }, { key: 'visceralFat', label: 'Buikvet (index)', placeholder: '8', keyboardType: 'number-pad' }, { key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' }, ] export default function MeasurementForm({ onSubmit, initialValues }: Props) { const [values, setValues] = useState>(() => { const init: Record = {} for (const f of fields) { init[f.key] = initialValues?.[f.key]?.toString() ?? '' } return init }) const handleSubmit = () => { const now = new Date().toISOString() const m: Measurement = { id: generateId(), weight: parseFloat(values.weight) || 0, bmi: parseFloat(values.bmi) || 0, bodyFatPercentage: parseFloat(values.bodyFatPercentage) || 0, fatMass: parseFloat(values.fatMass) || 0, musclePercentage: parseFloat(values.musclePercentage) || 0, muscleMass: parseFloat(values.muscleMass) || 0, visceralFat: parseInt(values.visceralFat, 10) || 0, bmr: parseFloat(values.bmr) || 0, measuredAt: now, createdAt: now, updatedAt: now, syncStatus: 'local', } onSubmit(m) } const setField = (key: string, value: string) => { setValues(prev => ({ ...prev, [key]: value })) } return ( {fields.map(f => ( {f.label} setField(f.key, v)} placeholderTextColor="#999" /> ))} Opslaan ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5' }, scroll: { padding: 16 }, field: { marginBottom: 12 }, label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 }, input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' }, button: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, alignItems: 'center', marginTop: 8 }, buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' }, }) ``` - [ ] **Step 2: Write NewMeasurementScreen** ```typescript // src/screens/NewMeasurementScreen.tsx import React from 'react' import { Alert } from 'react-native' import MeasurementForm from '../components/MeasurementForm' import { Measurement } from '../types' import { initDatabase, insertMeasurement } from '../database/db' type Props = { navigation: any } export default function NewMeasurementScreen({ navigation }: Props) { const handleSubmit = async (m: Measurement) => { await initDatabase() await insertMeasurement(m) Alert.alert('Opgeslagen', 'Meting is opgeslagen', [ { text: 'OK', onPress: () => navigation.goBack() }, ]) } return } ``` - [ ] **Step 3: Commit** ```bash git add -A git commit -m "feat: add MeasurementForm and NewMeasurementScreen" ``` --- ### Task 4: Dashboard screen (card + chart) **Files:** - Create: `src/components/LatestMeasurementCard.tsx` - Create: `src/components/WeightChart.tsx` - Create: `src/screens/DashboardScreen.tsx` - [ ] **Step 1: Write LatestMeasurementCard** ```typescript // src/components/LatestMeasurementCard.tsx import React from 'react' import { View, Text, StyleSheet } from 'react-native' import { Measurement } from '../types' type Props = { measurement: Measurement | null } const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ { label: 'Gewicht', key: 'weight', suffix: 'kg' }, { label: 'BMI', key: 'bmi', suffix: '' }, { label: 'Vetpercentage', key: 'bodyFatPercentage', suffix: '%' }, { label: 'Vetmassa', key: 'fatMass', suffix: 'kg' }, { label: 'Spierpercentage', key: 'musclePercentage', suffix: '%' }, { label: 'Spiermassa', key: 'muscleMass', suffix: 'kg' }, { label: 'Buikvet', key: 'visceralFat', suffix: '' }, { label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' }, ] export default function LatestMeasurementCard({ measurement }: Props) { if (!measurement) { return ( Nog geen metingen ) } return ( Laatste meting {new Date(measurement.measuredAt).toLocaleDateString('nl-NL')} {rows.map(r => ( {r.label} {typeof measurement[r.key] === 'number' ? (measurement[r.key] as number).toFixed(1) : measurement[r.key]}{' '} {r.suffix} ))} ) } const styles = StyleSheet.create({ card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 }, title: { fontSize: 18, fontWeight: '700', marginBottom: 4 }, date: { fontSize: 14, color: '#666', marginBottom: 12 }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 }, rowLabel: { fontSize: 14, color: '#555' }, rowValue: { fontSize: 14, fontWeight: '600' }, emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 }, }) ``` - [ ] **Step 2: Write WeightChart** ```typescript // src/components/WeightChart.tsx import React from 'react' import { View, Text, StyleSheet, Dimensions } from 'react-native' import { LineChart } from 'react-native-chart-kit' import { Measurement } from '../types' type Props = { measurements: Measurement[] } export default function WeightChart({ measurements }: Props) { if (measurements.length < 2) { return null } const sorted = [...measurements].sort( (a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime() ) const labels = sorted.map(m => { const d = new Date(m.measuredAt) return `${d.getDate()}/${d.getMonth() + 1}` }) const data = sorted.map(m => m.weight) return ( Gewichtsgrafiek 10 ? labels.filter((_, i) => i % Math.ceil(labels.length / 10) === 0) : labels, datasets: [{ data }], }} width={Dimensions.get('window').width - 32} height={220} yAxisSuffix=" kg" chartConfig={{ backgroundColor: '#ffffff', backgroundGradientFrom: '#ffffff', backgroundGradientTo: '#ffffff', decimalCount: 1, color: (opacity = 1) => `rgba(76, 175, 80, ${opacity})`, labelColor: () => '#666', }} bezier style={styles.chart} /> ) } const styles = StyleSheet.create({ container: { marginHorizontal: 16, marginBottom: 16 }, title: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, chart: { borderRadius: 12 }, }) ``` - [ ] **Step 3: Write DashboardScreen** ```typescript // src/screens/DashboardScreen.tsx import React, { useCallback, useState } from 'react' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db' import { Measurement } from '../types' import LatestMeasurementCard from '../components/LatestMeasurementCard' import WeightChart from '../components/WeightChart' type Props = { navigation: any } export default function DashboardScreen({ navigation }: Props) { const [latest, setLatest] = useState(null) const [all, setAll] = useState([]) useFocusEffect( useCallback(() => { const load = async () => { await initDatabase() const allM = await getAllMeasurements() setAll(allM) const latestM = await getLatestMeasurement() setLatest(latestM) } load() }, []) ) return ( navigation.navigate('NewMeasurement')}> + Nieuwe meting ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5' }, fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' }, fabText: { color: '#fff', fontSize: 16, fontWeight: '700' }, }) ``` - [ ] **Step 4: Commit** ```bash git add -A git commit -m "feat: add Dashboard with latest card and weight chart" ``` --- ### Task 5: History screen **Files:** - Create: `src/components/MeasurementRow.tsx` - Create: `src/screens/HistoryScreen.tsx` - [ ] **Step 1: Write MeasurementRow** ```typescript // src/components/MeasurementRow.tsx import React from 'react' import { View, Text, StyleSheet } from 'react-native' import { Measurement } from '../types' type Props = { measurement: Measurement } export default function MeasurementRow({ measurement }: Props) { return ( {new Date(measurement.measuredAt).toLocaleDateString('nl-NL', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric', })} {new Date(measurement.measuredAt).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', })} {measurement.weight.toFixed(1)} kg BMI {measurement.bmi.toFixed(1)} ) } const styles = StyleSheet.create({ row: { flexDirection: 'row', justifyContent: 'space-between', backgroundColor: '#fff', padding: 16, marginHorizontal: 16, marginVertical: 4, borderRadius: 8, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 4, elevation: 2, }, left: {}, date: { fontSize: 14, fontWeight: '600' }, time: { fontSize: 12, color: '#666', marginTop: 2 }, right: { alignItems: 'flex-end' }, weight: { fontSize: 18, fontWeight: '700' }, detail: { fontSize: 12, color: '#666' }, }) ``` - [ ] **Step 2: Write HistoryScreen** ```typescript // src/screens/HistoryScreen.tsx import React, { useCallback, useState } from 'react' import { View, FlatList, StyleSheet, Text } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { initDatabase, getAllMeasurements } from '../database/db' import { Measurement } from '../types' import MeasurementRow from '../components/MeasurementRow' export default function HistoryScreen() { const [measurements, setMeasurements] = useState([]) useFocusEffect( useCallback(() => { const load = async () => { await initDatabase() const all = await getAllMeasurements() setMeasurements(all) } load() }, []) ) return ( {measurements.length} metingen item.id} renderItem={({ item }) => } contentContainerStyle={measurements.length === 0 ? styles.empty : undefined} ListEmptyComponent={Nog geen metingen} /> ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5' }, count: { fontSize: 14, color: '#666', padding: 16, paddingBottom: 8 }, empty: { flex: 1, justifyContent: 'center', alignItems: 'center' }, emptyText: { fontSize: 16, color: '#999' }, }) ``` - [ ] **Step 3: Commit** ```bash git add -A git commit -m "feat: add history screen with measurement list" ``` --- ### Task 6: Settings screen (WebDAV config) **Files:** - Create: `src/screens/SettingsScreen.tsx` - [ ] **Step 1: Write SettingsScreen** ```typescript // src/screens/SettingsScreen.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 { WebDAVConfig } from '../types' const CONFIG_KEY = 'weegschaal_webdav_config' async function loadConfig(): Promise { try { const json = await SecureStore.getItemAsync(CONFIG_KEY) return json ? JSON.parse(json) : null } catch { return null } } async function saveConfig(config: WebDAVConfig): Promise { await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) } async function deleteConfig(): Promise { await SecureStore.deleteItemAsync(CONFIG_KEY) } export async function getWebDAVConfig(): Promise { return loadConfig() } export default function SettingsScreen() { const [url, setUrl] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [syncing, setSyncing] = useState(false) useEffect(() => { loadConfig().then(c => { if (c) { setUrl(c.url) setUsername(c.username) setPassword(c.password) } }) }, []) const handleSave = async () => { await saveConfig({ url, username, password }) Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen') } const handleClear = async () => { await deleteConfig() setUrl('') setUsername('') setPassword('') Alert.alert('Verwijderd', 'WebDAV instellingen zijn gewist') } const handleSync = async () => { setSyncing(true) try { const { syncAll } = await import('../sync/syncService') const result = await syncAll() Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`) } catch (e: any) { Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') } finally { setSyncing(false) } } return ( WebDAV (Nextcloud) URL Gebruikersnaam Wachtwoord Instellingen opslaan Instellingen wissen {syncing ? 'Bezig met sync...' : 'Nu synchroniseren'} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5' }, content: { padding: 16 }, sectionTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 }, label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4, marginTop: 12 }, input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' }, saveButton: { backgroundColor: '#4CAF50', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 }, clearButton: { padding: 14, alignItems: 'center', 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 -A git commit -m "feat: add settings screen with WebDAV config" ``` --- ### Task 7: Sync service **Files:** - Create: `src/sync/syncService.ts` - Create: `tests/syncService.test.ts` - [ ] **Step 1: Write the sync service tests** ```typescript // tests/syncService.test.ts import { mergeMeasurements } from '../src/sync/syncService' import { Measurement } from '../src/types' const base: 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', syncStatus: 'local', } describe('mergeMeasurements', () => { it('returns empty array when both are empty', () => { expect(mergeMeasurements([], [])).toEqual([]) }) it('keeps local items not on remote', () => { const result = mergeMeasurements([base], []) expect(result).toHaveLength(1) expect(result[0].id).toBe('id-1') }) it('keeps remote items not on local', () => { const remote = { ...base, syncStatus: 'synced' as const } const result = mergeMeasurements([], [remote]) expect(result).toHaveLength(1) expect(result[0].id).toBe('id-1') }) it('picks the newer version when both have same id', () => { const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' } const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } const result = mergeMeasurements([local], [remote]) expect(result).toHaveLength(1) expect(result[0].weight).toBe(76) }) it('picks local when local is newer', () => { const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' } const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } const result = mergeMeasurements([local], [remote]) expect(result).toHaveLength(1) expect(result[0].weight).toBe(77) }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx jest tests/syncService.test.ts --no-cache` Expected: FAIL — cannot find module `../src/sync/syncService` - [ ] **Step 3: Write minimal implementation** ```typescript // src/sync/syncService.ts import { Measurement, WebDAVConfig } from '../types' import { initDatabase, getAllMeasurements, upsertMeasurement, updateMeasurement } from '../database/db' import { getWebDAVConfig } from '../screens/SettingsScreen' const WEBDAV_PATH = 'weegschaal/measurements.json' type SyncResult = { local: number; remote: number; merged: number } export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { const map = new Map() for (const m of local) { map.set(m.id, m) } for (const m of remote) { const existing = map.get(m.id) if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) { map.set(m.id, { ...m, syncStatus: 'synced' }) } } return Array.from(map.values()) } async function fetchRemote(config: WebDAVConfig): Promise { const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` const response = await fetch(url, { headers: { Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), }, }) if (response.status === 404) { return [] } if (!response.ok) { throw new Error(`WebDAV fetch failed: ${response.status}`) } return response.json() } async function uploadRemote(config: WebDAVConfig, measurements: Measurement[]): Promise { const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` const body = JSON.stringify(measurements) const response = await fetch(url, { method: 'PUT', headers: { Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`), 'Content-Type': 'application/json', }, body, }) if (!response.ok) { throw new Error(`WebDAV upload failed: ${response.status}`) } } export async function syncAll(): Promise { const config = await getWebDAVConfig() if (!config) { throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.') } await initDatabase() const local = await getAllMeasurements() const remote = await fetchRemote(config) const merged = mergeMeasurements(local, remote) for (const m of merged) { await upsertMeasurement(m) } await uploadRemote(config, merged) return { local: local.length, remote: remote.length, merged: merged.length, } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx jest tests/syncService.test.ts --no-cache` Expected: PASS - [ ] **Step 5: Commit** ```bash git add -A git commit -m "feat: add sync service with WebDAV merge logic" ``` --- ### Task 8: Wire up App.tsx with navigation **Files:** - Modify: `App.tsx` - [ ] **Step 1: Rewrite App.tsx with navigation** ```typescript // App.tsx import React from 'react' import { NavigationContainer } from '@react-navigation/native' import { createNativeStackNavigator } from '@react-navigation/native-stack' import DashboardScreen from './src/screens/DashboardScreen' import NewMeasurementScreen from './src/screens/NewMeasurementScreen' import HistoryScreen from './src/screens/HistoryScreen' import SettingsScreen from './src/screens/SettingsScreen' const Stack = createNativeStackNavigator() export default function App() { return ( ) } ``` - [ ] **Step 2: Add navigation buttons to DashboardScreen** Update the navigation header or add buttons. Modify `DashboardScreen.tsx` to add a header bar with nav links: Edit `src/screens/DashboardScreen.tsx` — replace the return block with: ```typescript return ( navigation.navigate('History')}> Historie navigation.navigate('Settings')}> Instellingen navigation.navigate('NewMeasurement')}> + Nieuwe meting ) ``` Add to the StyleSheet: ```typescript navBar: { flexDirection: 'row', justifyContent: 'space-around', padding: 12, backgroundColor: '#fff' }, navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' }, ``` And update the import to include `View`: ```typescript import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' ``` - [ ] **Step 3: Run typecheck** Run: `npx tsc --noEmit` Expected: No type errors - [ ] **Step 4: Commit** ```bash git add -A git commit -m "feat: wire up App.tsx with navigation" ``` --- ### Task 9: Final verification - [ ] **Step 1: Run all tests** Run: `npx jest --no-cache` Expected: All tests pass - [ ] **Step 2: Run typecheck** Run: `npx tsc --noEmit` Expected: No errors - [ ] **Step 3: Start Expo dev server** Run: `npx expo start` Expected: Dev server starts successfully (verify on Android/iOS simulator)