0 ? 'unsynced' : 'online') : 'offline'}`}
+ title={
+ online
+ ? showSpinner
+ ? 'Syncing'
+ : pendingCount > 0
+ ? 'Pending Sync'
+ : 'Synced'
+ : 'Offline'
+ }
+ >
{online ? (
- pendingCount > 0 ? (
+ showSpinner ? (
<>
+ >
+ ) : showPendingWarning ? (
+ <>
+
>
) : (
diff --git a/client/src/components/UserProfilePage.tsx b/client/src/components/UserProfilePage.tsx
index c5beb1a..3cc7317 100644
--- a/client/src/components/UserProfilePage.tsx
+++ b/client/src/components/UserProfilePage.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
+import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import {
User,
ChevronLeft,
@@ -128,7 +129,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState
(null)
const [recoveryCopied, setRecoveryCopied] = useState(false)
- const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
+ const { pendingCount: pendingSyncCount, showSpinner, showPendingWarning } = useSyncIndicator()
const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
@@ -529,9 +530,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
{t('profile.device_desc')}
0 ? 'warning' : 'online') : 'offline'}`}>
{online ? (
- pendingSyncCount > 0 ? (
+ showSpinner ? (
<>
+ {t('sync.status_syncing')}
+ >
+ ) : showPendingWarning ? (
+ <>
+
{t('profile.device_sync_pending', { count: pendingSyncCount })}
>
) : (
diff --git a/client/src/hooks/useSyncIndicator.ts b/client/src/hooks/useSyncIndicator.ts
new file mode 100644
index 0000000..0618a1b
--- /dev/null
+++ b/client/src/hooks/useSyncIndicator.ts
@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react'
+import { useLiveQuery } from 'dexie-react-hooks'
+import { db } from '../services/db.js'
+import { subscribeToSyncState } from '../services/sync.js'
+
+/** Sync queue depth and whether a sync pass is running (for header indicators). */
+export function useSyncIndicator(logbookId?: string | null) {
+ const [isSyncing, setIsSyncing] = useState(false)
+
+ const pendingCount =
+ useLiveQuery(
+ () =>
+ logbookId
+ ? db.syncQueue.where({ logbookId }).count()
+ : db.syncQueue.count(),
+ [logbookId]
+ ) ?? 0
+
+ useEffect(() => subscribeToSyncState(setIsSyncing), [])
+
+ return {
+ isSyncing,
+ pendingCount,
+ /** Spin only while a sync pass is active — not for stale queue counts. */
+ showSpinner: isSyncing,
+ showPendingWarning: pendingCount > 0 && !isSyncing
+ }
+}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index b6dcf61..9e51909 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -84,6 +84,7 @@
},
"sync": {
"status_synced": "Synchronisiert",
+ "status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen"
},
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 2562704..4aa2648 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -84,6 +84,7 @@
},
"sync": {
"status_synced": "Synced",
+ "status_syncing": "Syncing…",
"status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes"
},
diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts
index 9d3f14b..8449aa7 100644
--- a/client/src/services/sync.ts
+++ b/client/src/services/sync.ts
@@ -6,6 +6,7 @@ import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set()
const pendingResync = new Set()
+let syncAllInFlight = 0
let isSyncing = false
const listeners = new Set<(syncing: boolean) => void>()
@@ -18,7 +19,8 @@ export function subscribeToSyncState(listener: (syncing: boolean) => void) {
}
}
-function setSyncing(syncing: boolean) {
+function recomputeSyncingState() {
+ const syncing = syncingLogbooks.size > 0 || syncAllInFlight > 0
if (isSyncing !== syncing) {
isSyncing = syncing
listeners.forEach((l) => l(isSyncing))
@@ -205,6 +207,54 @@ async function flushPushQueue(logbookId: string): Promise {
return ok
}
+type PulledServerPayload = {
+ yacht?: { updatedAt: string } | null
+ deviation?: { updatedAt: string } | null
+ crews?: Array<{ payloadId: string; updatedAt: string }>
+ entries?: Array<{ payloadId: string; updatedAt: string }>
+ photos?: Array<{ payloadId: string; updatedAt: string }>
+ gpsTracks?: Array<{ entryId: string; updatedAt: string }>
+}
+
+/** Drop queue rows already reflected on the server (e.g. after direct API save). */
+async function pruneAcknowledgedQueueItems(
+ logbookId: string,
+ server: PulledServerPayload
+): Promise {
+ const pending = await db.syncQueue.where({ logbookId }).toArray()
+ if (pending.length === 0) return
+
+ const serverTimes = new Map()
+ if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
+ if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
+ for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
+ for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
+ for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
+ for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
+
+ const localLogbook = await db.logbooks.get(logbookId)
+ const staleIds: number[] = []
+
+ for (const item of pending) {
+ if (item.type === 'logbook') {
+ if (localLogbook?.isSynced === 1) {
+ if (item.id !== undefined) staleIds.push(item.id)
+ }
+ continue
+ }
+
+ const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
+ const serverUpdatedAt = serverTimes.get(key)
+ if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
+ if (item.id !== undefined) staleIds.push(item.id)
+ }
+ }
+
+ if (staleIds.length > 0) {
+ await db.syncQueue.bulkDelete(staleIds)
+ }
+}
+
// Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise {
if (!localStorage.getItem('active_userid')) return false
@@ -220,6 +270,7 @@ async function pullChanges(logbookId: string): Promise {
}
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
+ const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
// 1. Sync Yacht Payload
if (yacht) {
@@ -380,6 +431,7 @@ async function pullChanges(logbookId: string): Promise {
}
}
+ await pruneAcknowledgedQueueItems(logbookId, serverSnapshot)
return true
} catch (error) {
console.error('Error during sync pull:', error)
@@ -400,7 +452,7 @@ export async function syncLogbook(logbookId: string): Promise {
}
syncingLogbooks.add(logbookId)
- setSyncing(true)
+ recomputeSyncingState()
try {
const pushed = await flushPushQueue(logbookId)
@@ -410,7 +462,7 @@ export async function syncLogbook(logbookId: string): Promise {
return pushed && pulled && pushedAfterPull
} finally {
syncingLogbooks.delete(logbookId)
- setSyncing(syncingLogbooks.size > 0)
+ recomputeSyncingState()
}
}
@@ -421,8 +473,9 @@ export async function syncAllLogbooks(): Promise {
const masterKey = getActiveMasterKey()
if (!masterKey) return
+ syncAllInFlight++
+ recomputeSyncingState()
try {
- setSyncing(true)
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray()
@@ -446,7 +499,8 @@ export async function syncAllLogbooks(): Promise {
} catch (error) {
console.error('Error synchronizing all logbooks:', error)
} finally {
- setSyncing(syncingLogbooks.size > 0)
+ syncAllInFlight = Math.max(0, syncAllInFlight - 1)
+ recomputeSyncingState()
}
}