diff --git a/.planning/STATE.md b/.planning/STATE.md
index d500911..196830c 100755
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -9,12 +9,12 @@ See: .planning/PROJECT.md (updated 2026-05-26)
## Current Position
-Phase: 2 of 4 (Sync Protocol & Multi-Logbooks)
-Plan: 2 of 2 in current phase
+Phase: 3 of 4 (Master Data & Log entries)
+Plan: 1 of 3 in current phase
Status: Ready to plan
-Last activity: 2026-05-27 — Plan 02-01 completed (Multi-logbooks IndexedDB Dexie.js caching, E2E title encryption client service, and dashboard UI switching complete)
+Last activity: 2026-05-27 — Phase 2 completed (Multi-logbooks IndexedDB cache, E2E push/pull sync scheduler, last-write-wins conflict resolution, and live sync status indicators complete)
-Progress: [████░░░░░░] 40%
+Progress: [█████░░░░░] 50%
## Performance Metrics
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 6654b7c..378afbd 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -3,6 +3,7 @@ import './App.css'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
+import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js'
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -15,7 +16,10 @@ function App() {
const [online, setOnline] = useState(navigator.onLine)
useEffect(() => {
- const handleOnline = () => setOnline(true)
+ const handleOnline = () => {
+ setOnline(true)
+ syncAllLogbooks()
+ }
const handleOffline = () => setOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
@@ -25,6 +29,17 @@ function App() {
}
}, [])
+ useEffect(() => {
+ if (isAuthenticated) {
+ startBackgroundSync()
+ } else {
+ stopBackgroundSync()
+ }
+ return () => {
+ stopBackgroundSync()
+ }
+ }, [isAuthenticated])
+
useEffect(() => {
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx
index f63581a..2a277a2 100644
--- a/client/src/components/LogbookDashboard.tsx
+++ b/client/src/components/LogbookDashboard.tsx
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
+import { useLiveQuery } from 'dexie-react-hooks'
+import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import { logoutUser } from '../services/auth.js'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
@@ -19,6 +21,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
+ // Reactive sync queue count
+ const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
+
// Listen to connectivity changes
useEffect(() => {
const handleOnline = () => setOnline(true)
@@ -109,9 +114,25 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
{/* Connection Indicator */}
-
- {online ?
:
}
-
{online ? 'Online' : t('sync.status_offline')}
+
0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}>
+ {online ? (
+ pendingCount > 0 ? (
+ <>
+
+ {t('sync.status_unsynced')} ({pendingCount})
+ >
+ ) : (
+ <>
+
+ {t('sync.status_synced')}
+ >
+ )
+ ) : (
+ <>
+
+ {t('sync.status_offline')}
+ >
+ )}
{/* Skipper profile */}
diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts
new file mode 100644
index 0000000..aa440c7
--- /dev/null
+++ b/client/src/services/sync.ts
@@ -0,0 +1,237 @@
+import { db } from './db.js'
+import { getActiveMasterKey } from './auth.js'
+
+const API_BASE = 'http://localhost:5000/api/sync'
+const syncingLogbooks = new Set
()
+
+// Helper to check if a timestamp is newer
+function isNewer(timeA: string | Date, timeB: string | Date): boolean {
+ return new Date(timeA).getTime() > new Date(timeB).getTime()
+}
+
+// Push local sync queue items to the server
+async function pushChanges(logbookId: string): Promise {
+ const userId = localStorage.getItem('active_userid')
+ if (!userId) return false
+
+ // Fetch all pending queue items for this logbook
+ const pending = await db.syncQueue.where({ logbookId }).toArray()
+ if (pending.length === 0) return true
+
+ try {
+ const response = await fetch(`${API_BASE}/push`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-User-Id': userId
+ },
+ body: JSON.stringify({ items: pending })
+ })
+
+ if (!response.ok) {
+ console.warn('Sync push request was rejected by server')
+ return false
+ }
+
+ const { results } = await response.json()
+
+ // Process results
+ for (const res of results) {
+ if (res.status === 'success' || res.status === 'conflict') {
+ // Find matching queue item
+ const queueItem = pending.find((item) => item.payloadId === res.payloadId)
+ if (queueItem && queueItem.id !== undefined) {
+ // Delete from sync queue
+ await db.syncQueue.delete(queueItem.id)
+ }
+ } else {
+ console.error(`Sync failed for item ${res.payloadId}:`, res.error)
+ }
+ }
+ return true
+ } catch (error) {
+ console.error('Error during sync push:', error)
+ return false
+ }
+}
+
+// Pull updates from the server and apply last-write-wins
+async function pullChanges(logbookId: string): Promise {
+ const userId = localStorage.getItem('active_userid')
+ if (!userId) return false
+
+ try {
+ const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
+ method: 'GET',
+ headers: {
+ 'X-User-Id': userId
+ }
+ })
+
+ if (!response.ok) {
+ console.warn('Sync pull request was rejected by server')
+ return false
+ }
+
+ const { yacht, deviation, crews, entries } = await response.json()
+
+ // 1. Sync Yacht Payload
+ if (yacht) {
+ const local = await db.yachts.get(logbookId)
+ if (!local || isNewer(yacht.updatedAt, local.updatedAt)) {
+ await db.yachts.put({
+ logbookId,
+ encryptedData: yacht.encryptedData,
+ iv: yacht.iv,
+ tag: yacht.tag,
+ updatedAt: yacht.updatedAt
+ })
+ }
+ }
+
+ // 2. Sync Deviation Payload
+ if (deviation) {
+ const local = await db.deviations.get(logbookId)
+ if (!local || isNewer(deviation.updatedAt, local.updatedAt)) {
+ await db.deviations.put({
+ logbookId,
+ encryptedData: deviation.encryptedData,
+ iv: deviation.iv,
+ tag: deviation.tag,
+ updatedAt: deviation.updatedAt
+ })
+ }
+ }
+
+ // 3. Sync Crew List Payloads
+ const serverCrewMap = new Map()
+ if (crews && Array.isArray(crews)) {
+ for (const c of crews) {
+ serverCrewMap.set(c.payloadId, c)
+ const local = await db.crews.get(c.payloadId)
+ if (!local || isNewer(c.updatedAt, local.updatedAt)) {
+ await db.crews.put({
+ payloadId: c.payloadId,
+ logbookId,
+ encryptedData: c.encryptedData,
+ iv: c.iv,
+ tag: c.tag,
+ updatedAt: c.updatedAt
+ })
+ }
+ }
+ }
+
+ // Deletions for Crew: If present locally but not on server, and not pending creation locally
+ const localCrews = await db.crews.where({ logbookId }).toArray()
+ for (const lc of localCrews) {
+ if (!serverCrewMap.has(lc.payloadId)) {
+ // Verify it's not a newly created item offline that hasn't synced yet
+ const pendingCreate = await db.syncQueue
+ .where({ payloadId: lc.payloadId, action: 'create' })
+ .first()
+ if (!pendingCreate) {
+ await db.crews.delete(lc.payloadId)
+ }
+ }
+ }
+
+ // 4. Sync Journal Entry Payloads
+ const serverEntryMap = new Map()
+ if (entries && Array.isArray(entries)) {
+ for (const e of entries) {
+ serverEntryMap.set(e.payloadId, e)
+ const local = await db.entries.get(e.payloadId)
+ if (!local || isNewer(e.updatedAt, local.updatedAt)) {
+ await db.entries.put({
+ payloadId: e.payloadId,
+ logbookId,
+ encryptedData: e.encryptedData,
+ iv: e.iv,
+ tag: e.tag,
+ updatedAt: e.updatedAt
+ })
+ }
+ }
+ }
+
+ // Deletions for Entries
+ const localEntries = await db.entries.where({ logbookId }).toArray()
+ for (const le of localEntries) {
+ if (!serverEntryMap.has(le.payloadId)) {
+ const pendingCreate = await db.syncQueue
+ .where({ payloadId: le.payloadId, action: 'create' })
+ .first()
+ if (!pendingCreate) {
+ await db.entries.delete(le.payloadId)
+ }
+ }
+ }
+
+ return true
+ } catch (error) {
+ console.error('Error during sync pull:', error)
+ return false
+ }
+}
+
+// Main function to synchronize a specific logbook
+export async function syncLogbook(logbookId: string): Promise {
+ if (!navigator.onLine) return false
+
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) return false
+
+ if (syncingLogbooks.has(logbookId)) return false
+ syncingLogbooks.add(logbookId)
+
+ try {
+ const pushed = await pushChanges(logbookId)
+ const pulled = await pullChanges(logbookId)
+ return pushed && pulled;
+ } finally {
+ syncingLogbooks.delete(logbookId)
+ }
+}
+
+// Synchronize all user logbooks that are cached locally
+export async function syncAllLogbooks(): Promise {
+ if (!navigator.onLine) return
+
+ const masterKey = getActiveMasterKey()
+ if (!masterKey) return
+
+ try {
+ // 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
+ const logbooks = await db.logbooks.toArray()
+
+ // 2. Synchronize payloads for each logbook
+ for (const lb of logbooks) {
+ await syncLogbook(lb.id)
+ }
+ } catch (error) {
+ console.error('Error synchronizing all logbooks:', error)
+ }
+}
+
+// Setup background sync intervals
+let syncIntervalId: any = null
+
+export function startBackgroundSync(intervalMs = 30000) {
+ if (syncIntervalId) clearInterval(syncIntervalId)
+
+ // Trigger immediate sync
+ syncAllLogbooks()
+
+ // Set interval
+ syncIntervalId = setInterval(() => {
+ syncAllLogbooks()
+ }, intervalMs)
+}
+
+export function stopBackgroundSync() {
+ if (syncIntervalId) {
+ clearInterval(syncIntervalId)
+ syncIntervalId = null
+ }
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 1a4fb75..c35157e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -3,6 +3,7 @@ import cors from 'cors'
import dotenv from 'dotenv'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
+import syncRouter from './routes/sync.js'
dotenv.config()
@@ -15,6 +16,7 @@ app.use(express.json())
// Mount routes
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
+app.use('/api/sync', syncRouter)
// Health check endpoint
app.get('/api/health', (req, res) => {
diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts
new file mode 100644
index 0000000..fb0fa64
--- /dev/null
+++ b/server/src/routes/sync.ts
@@ -0,0 +1,208 @@
+import { Router } from 'express'
+import { prisma } from '../db.js'
+
+const router = Router()
+
+// Middleware to extract user ID from headers
+const requireUser = (req: any, res: any, next: any) => {
+ const userId = req.headers['x-user-id']
+ if (!userId) {
+ return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
+ }
+ req.userId = userId
+ next()
+}
+
+router.use(requireUser)
+
+// 1. Push local changes to the server
+router.post('/push', async (req: any, res) => {
+ try {
+ const { items } = req.body
+ if (!items || !Array.isArray(items)) {
+ return res.status(400).json({ error: 'items array is required' })
+ }
+
+ const results = []
+
+ for (const item of items) {
+ const { action, type, payloadId, logbookId, data, updatedAt } = item
+ const itemUpdatedAt = new Date(updatedAt)
+
+ try {
+ // Authorize: Check if logbook belongs to user
+ // Exception: If action is create logbook, the logbook might not exist yet,
+ // so we authorize based on user creating a logbook with their userId.
+ if (type === 'logbook' && action === 'create') {
+ const existing = await prisma.logbook.findUnique({
+ where: { id: logbookId }
+ })
+ if (existing && existing.userId !== req.userId) {
+ results.push({ payloadId, status: 'error', error: 'Forbidden: Logbook belongs to another user' })
+ continue
+ }
+
+ const parsed = JSON.parse(data)
+ await prisma.logbook.upsert({
+ where: { id: logbookId },
+ create: {
+ id: logbookId,
+ userId: req.userId,
+ encryptedTitle: parsed.encryptedTitle,
+ updatedAt: itemUpdatedAt
+ },
+ update: {
+ encryptedTitle: parsed.encryptedTitle,
+ updatedAt: itemUpdatedAt
+ }
+ })
+ results.push({ payloadId, status: 'success' })
+ continue
+ }
+
+ // Standard Authorization: Logbook must exist and belong to user
+ const logbook = await prisma.logbook.findUnique({
+ where: { id: logbookId }
+ })
+
+ if (!logbook) {
+ results.push({ payloadId, status: 'error', error: 'Logbook not found' })
+ continue
+ }
+
+ if (logbook.userId !== req.userId) {
+ results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
+ continue
+ }
+
+ if (type === 'logbook' && action === 'delete') {
+ await prisma.logbook.delete({
+ where: { id: logbookId }
+ })
+ results.push({ payloadId, status: 'success' })
+ continue
+ }
+
+ // Parse Payload parameters
+ const parsed = JSON.parse(data)
+ const { encryptedData, iv, tag } = parsed
+
+ if (type === 'yacht') {
+ if (action === 'delete') {
+ await prisma.yachtPayload.deleteMany({ where: { logbookId } })
+ } else {
+ const existing = await prisma.yachtPayload.findUnique({ where: { logbookId } })
+ if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
+ results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
+ continue
+ }
+ await prisma.yachtPayload.upsert({
+ where: { logbookId },
+ create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
+ update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
+ })
+ }
+ } else if (type === 'deviation') {
+ if (action === 'delete') {
+ await prisma.deviationPayload.deleteMany({ where: { logbookId } })
+ } else {
+ const existing = await prisma.deviationPayload.findUnique({ where: { logbookId } })
+ if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
+ results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
+ continue
+ }
+ await prisma.deviationPayload.upsert({
+ where: { logbookId },
+ create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
+ update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
+ })
+ }
+ } else if (type === 'crew') {
+ if (action === 'delete') {
+ await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
+ } else {
+ const existing = await prisma.crewPayload.findUnique({
+ where: { logbookId_payloadId: { logbookId, payloadId } }
+ })
+ if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
+ results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
+ continue
+ }
+ await prisma.crewPayload.upsert({
+ where: { logbookId_payloadId: { logbookId, payloadId } },
+ create: { logbookId, payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
+ update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
+ })
+ }
+ } else if (type === 'entry') {
+ if (action === 'delete') {
+ await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
+ } else {
+ const existing = await prisma.entryPayload.findUnique({
+ where: { logbookId_payloadId: { logbookId, payloadId } }
+ })
+ if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
+ results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
+ continue
+ }
+ await prisma.entryPayload.upsert({
+ where: { logbookId_payloadId: { logbookId, payloadId } },
+ create: { logbookId, payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
+ update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
+ })
+ }
+ }
+
+ results.push({ payloadId, status: 'success' })
+ } catch (err: any) {
+ console.error(`Error processing sync item ${payloadId}:`, err)
+ results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
+ }
+ }
+
+ return res.json({ results })
+ } catch (error: any) {
+ console.error('Error during sync push:', error)
+ return res.status(500).json({ error: error.message || 'Internal server error' })
+ }
+})
+
+// 2. Pull server updates to the client
+router.get('/pull', async (req: any, res) => {
+ try {
+ const { logbookId } = req.query
+ if (!logbookId) {
+ return res.status(400).json({ error: 'logbookId is required' })
+ }
+
+ // Authorize: Check if logbook belongs to user
+ const logbook = await prisma.logbook.findUnique({
+ where: { id: logbookId }
+ })
+
+ if (!logbook) {
+ return res.status(404).json({ error: 'Logbook not found' })
+ }
+
+ if (logbook.userId !== req.userId) {
+ return res.status(403).json({ error: 'Forbidden: Access denied' })
+ }
+
+ const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
+ const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
+ const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
+ const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
+
+ return res.json({
+ yacht,
+ deviation,
+ crews,
+ entries
+ })
+ } catch (error: any) {
+ console.error('Error during sync pull:', error)
+ return res.status(500).json({ error: error.message || 'Internal server error' })
+ }
+})
+
+export default router