fix: Sync-Icon nur während aktiver Synchronisation animieren

Die Drehung hing an der Queue-Länge statt am laufenden Sync. Veraltete
Queue-Einträge werden nach Pull bereinigt; parallele syncAll-Läufe
werden im Sync-State korrekt gezählt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 12:23:50 +02:00
co-authored by Cursor
parent b86e5a15d6
commit 282e7ba8ba
6 changed files with 117 additions and 13 deletions
+20 -6
View File
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks' import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx' import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
@@ -32,8 +31,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [online, setOnline] = useState(navigator.onLine) const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper') const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
// Reactive sync queue count const { pendingCount, showSpinner, showPendingWarning } = useSyncIndicator()
const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
// Listen to connectivity changes // Listen to connectivity changes
useEffect(() => { useEffect(() => {
@@ -272,11 +270,27 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<div className="header-actions"> <div className="header-actions">
{/* Connection Indicator */} {/* Connection Indicator */}
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}> <div
className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`}
title={
online
? showSpinner
? 'Syncing'
: pendingCount > 0
? 'Pending Sync'
: 'Synced'
: 'Offline'
}
>
{online ? ( {online ? (
pendingCount > 0 ? ( showSpinner ? (
<> <>
<RefreshCw size={18} className="spin" /> <RefreshCw size={18} className="spin" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={18} />
<span>{t('sync.status_unsynced')} ({pendingCount})</span> <span>{t('sync.status_unsynced')} ({pendingCount})</span>
</> </>
) : ( ) : (
+8 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { import {
User, User,
ChevronLeft, ChevronLeft,
@@ -128,7 +129,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null) const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
const [recoveryCopied, setRecoveryCopied] = useState(false) const [recoveryCopied, setRecoveryCopied] = useState(false)
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0 const { pendingCount: pendingSyncCount, showSpinner, showPendingWarning } = useSyncIndicator()
const sharedLogbookCount = useLiveQuery( const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(), () => db.logbooks.filter((lb) => lb.isShared === 1).count(),
@@ -529,9 +530,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<p className="profile-section-desc">{t('profile.device_desc')}</p> <p className="profile-section-desc">{t('profile.device_desc')}</p>
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}> <div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
{online ? ( {online ? (
pendingSyncCount > 0 ? ( showSpinner ? (
<> <>
<RefreshCw size={16} className="spin" aria-hidden="true" /> <RefreshCw size={16} className="spin" aria-hidden="true" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={16} aria-hidden="true" />
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span> <span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
</> </>
) : ( ) : (
+28
View File
@@ -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
}
}
+1
View File
@@ -84,6 +84,7 @@
}, },
"sync": { "sync": {
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache", "status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen" "status_unsynced": "Unsynchronisierte Änderungen"
}, },
+1
View File
@@ -84,6 +84,7 @@
}, },
"sync": { "sync": {
"status_synced": "Synced", "status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache", "status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes" "status_unsynced": "Unsynced changes"
}, },
+59 -5
View File
@@ -6,6 +6,7 @@ import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
const pendingResync = new Set<string>() const pendingResync = new Set<string>()
let syncAllInFlight = 0
let isSyncing = false let isSyncing = false
const listeners = new Set<(syncing: boolean) => void>() 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) { if (isSyncing !== syncing) {
isSyncing = syncing isSyncing = syncing
listeners.forEach((l) => l(isSyncing)) listeners.forEach((l) => l(isSyncing))
@@ -205,6 +207,54 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
return ok 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<void> {
const pending = await db.syncQueue.where({ logbookId }).toArray()
if (pending.length === 0) return
const serverTimes = new Map<string, string>()
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 // Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> { async function pullChanges(logbookId: string): Promise<boolean> {
if (!localStorage.getItem('active_userid')) return false if (!localStorage.getItem('active_userid')) return false
@@ -220,6 +270,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
} }
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json() const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
// 1. Sync Yacht Payload // 1. Sync Yacht Payload
if (yacht) { if (yacht) {
@@ -380,6 +431,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
} }
} }
await pruneAcknowledgedQueueItems(logbookId, serverSnapshot)
return true return true
} catch (error) { } catch (error) {
console.error('Error during sync pull:', error) console.error('Error during sync pull:', error)
@@ -400,7 +452,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} }
syncingLogbooks.add(logbookId) syncingLogbooks.add(logbookId)
setSyncing(true) recomputeSyncingState()
try { try {
const pushed = await flushPushQueue(logbookId) const pushed = await flushPushQueue(logbookId)
@@ -410,7 +462,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
return pushed && pulled && pushedAfterPull return pushed && pulled && pushedAfterPull
} finally { } finally {
syncingLogbooks.delete(logbookId) syncingLogbooks.delete(logbookId)
setSyncing(syncingLogbooks.size > 0) recomputeSyncingState()
} }
} }
@@ -421,8 +473,9 @@ export async function syncAllLogbooks(): Promise<void> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) return if (!masterKey) return
syncAllInFlight++
recomputeSyncingState()
try { try {
setSyncing(true)
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index) // 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray() const logbooks = await db.logbooks.toArray()
@@ -446,7 +499,8 @@ export async function syncAllLogbooks(): Promise<void> {
} catch (error) { } catch (error) {
console.error('Error synchronizing all logbooks:', error) console.error('Error synchronizing all logbooks:', error)
} finally { } finally {
setSyncing(syncingLogbooks.size > 0) syncAllInFlight = Math.max(0, syncAllInFlight - 1)
recomputeSyncingState()
} }
} }