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:
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user