From 24118e0dc36f26412c61dc9f71818b925868bc3d Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 31 May 2026 19:04:04 -0400 Subject: [PATCH 1/2] Chore create and update doesn't return useful other than id when we area creating. so we dismiss the value being returned --- src/queries/ChoreQueries.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 85f7daf..6f9953f 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -191,13 +191,7 @@ export const useCreateChore = () => { if (!createdChore) { throw new Error('Failed to get created chore data') } - // Successfully created the chore on the server, return the created chore - // update the local chores cache with the new chore: - queryClient.setQueryData(['chores', false], oldData => { - if (!oldData) return { res: [createdChore.res] } - return { res: [...oldData.res, createdChore.res] } - }) - return createdChore.res + return { ...newTask, id: createdChore.res } } catch (error) { if (isNetworkError(error)) { return queueOfflineCreate(newTask) @@ -258,7 +252,7 @@ export const useUpdateChore = () => { ), } }) - return updatedChoreRes?.res || updatedChoreRes + return updatedChoreRes?.res || updatedChore } catch (error) { if (isNetworkError(error)) { return queueOfflineUpdate() From 4e0b5d79dfacbe1ce34cd62feed3d5f53972846e Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 6 Jun 2026 00:58:18 -0400 Subject: [PATCH 2/2] feat: enhance offline support with improved network management and sync handling --- src/hooks/NetworkManager.jsx | 65 +++++++++++++++----- src/hooks/useSyncOnReconnect.js | 20 ++++++ src/queries/ChoreQueries.jsx | 64 ++++++++++++++++++- src/utils/ApiClient.js | 11 ++++ src/utils/OfflineDB.js | 25 ++++++-- src/utils/SyncEngine.js | 4 +- src/views/Chores/MyChores.jsx | 2 + src/views/components/NetworkBanner.jsx | 34 +++++++++- src/views/components/SyncStatusIndicator.jsx | 23 ++++++- 9 files changed, 219 insertions(+), 29 deletions(-) diff --git a/src/hooks/NetworkManager.jsx b/src/hooks/NetworkManager.jsx index 65aafa8..3a5894c 100644 --- a/src/hooks/NetworkManager.jsx +++ b/src/hooks/NetworkManager.jsx @@ -1,47 +1,83 @@ import { Network } from '@capacitor/network' +import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle' class NetworkManager { constructor() { - this.isOnline = true - this.isNetworkOn = null - this.init() + this.deviceOnline = true + this.serverReachable = true + this.offlineReason = null // 'device' | 'server' | null this.connectionStatusListeners = [] this.queueSyncListeners = [] this.lastChecked = null this.offlineSince = null + this.init() } + + // Effective online status: both device network AND server must be reachable + get isOnline() { + return this.deviceOnline && this.serverReachable + } + + // Alias for backward compatibility (DeveloperSettings uses this) + get isNetworkOn() { + return this.deviceOnline + } + async init() { const status = await Network.getStatus() - this.isNetworkOn = status.connected + this.deviceOnline = status.connected this.lastChecked = Date.now() + if (!status.connected) { + this.offlineReason = 'device' + this.offlineSince = Date.now() + } Network.addListener('networkStatusChange', status => { - if (this.isNetworkOn !== status.connected) { - this.isNetworkOn = status.connected + if (this.deviceOnline !== status.connected) { + this.deviceOnline = status.connected this.lastChecked = Date.now() - this.isOnline = status.connected + if (!status.connected) { + this.offlineReason = 'device' this.offlineSince = Date.now() + } else { + // Device came back online — update reason based on server state + this.offlineReason = this.serverReachable ? null : 'server' } this.notifyConnectionStatus() } }) } - setOffline() { - if (this.isOnline === true) { - this.isOnline = false + // Called when a fetch() response is received (any HTTP status = server is up) + setServerReachable() { + if (!this.serverReachable) { + this.serverReachable = true + this.offlineReason = this.deviceOnline ? null : 'device' this.notifyConnectionStatus() - this.offlineSince = Date.now() // Record the time when we went offline } } - setOnline() { - if (this.isOnline === false) { - this.isOnline = true + + // Called when fetch() throws a network error (server unreachable) + // Only takes effect when offline mode is enabled + setServerUnreachable() { + if (!isOfflineFeatureEnabled()) return + if (this.serverReachable) { + this.serverReachable = false + this.offlineReason = 'server' + this.offlineSince = Date.now() this.notifyConnectionStatus() } } + // Legacy methods kept for compatibility + setOffline() { + this.setServerUnreachable() + } + setOnline() { + this.setServerReachable() + } + notifyConnectionStatus() { this.connectionStatusListeners.forEach(callback => { callback(this.isOnline) @@ -63,7 +99,6 @@ class NetworkManager { ) } registerBackendSyncListener(callback) { - // if callback is not in the list already, add it if (!this.queueSyncListeners.includes(callback)) { this.queueSyncListeners.push(callback) } diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js index 65272ef..f60151c 100644 --- a/src/hooks/useSyncOnReconnect.js +++ b/src/hooks/useSyncOnReconnect.js @@ -10,6 +10,7 @@ import { networkManager } from './NetworkManager' const PENDING_POLL_MS = 30_000 // retry pending commands every 30s const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online +const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network export function useSyncOnReconnect() { const queryClient = useQueryClient() @@ -18,6 +19,7 @@ export function useSyncOnReconnect() { useEffect(() => { let pendingPollInterval let cacheRefreshInterval + let serverProbeInterval let resumeListener let networkListener const handleVisibilityChange = () => { @@ -77,13 +79,27 @@ export function useSyncOnReconnect() { cacheRefreshInterval = setInterval(() => { runSync() }, CACHE_REFRESH_MS) + + // 6. Probe server every 15s when server is unreachable but device has network + serverProbeInterval = setInterval(async () => { + if (!networkManager.isOnline && networkManager.deviceOnline) { + await runSync() + } + }, SERVER_PROBE_MS) } const runSync = async () => { if (!isOfflineFeatureEnabled()) return + const wasOffline = !networkManager.isOnline const didSync = await syncEngine.sync() if (didSync) { queryClient.invalidateQueries() + // After recovery from server-unreachable, run a second pass to flush + // any commands that were skipped while offline + if (wasOffline && networkManager.isOnline) { + const didSync2 = await syncEngine.sync() + if (didSync2) queryClient.invalidateQueries() + } } } @@ -98,6 +114,10 @@ export function useSyncOnReconnect() { clearInterval(cacheRefreshInterval) } + if (serverProbeInterval) { + clearInterval(serverProbeInterval) + } + if (networkListener) { networkManager.unregisterNetworkListener(networkListener) } diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 6f9953f..ec4883d 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -58,7 +58,8 @@ const mergePendingCreates = async chores => { } const isNetworkError = error => - error instanceof TypeError && error.message === 'Failed to fetch' + (error instanceof TypeError && error.message === 'Failed to fetch') || + error?.name === 'AbortError' const buildOfflineChore = task => ({ ...task, @@ -571,7 +572,45 @@ export const useMarkChoreComplete = () => { }) return { res: { _pending: 'complete' } } } - return MarkChoreComplete(choreId, body, completedDate, performer) + + const queueOfflineComplete = async () => { + await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, { + id: choreId, + body, + completedDate, + performer, + }) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: body?.completedBy || 0, + performedAt: completedDate || new Date().toISOString(), + notes: body?.note || null, + status: 1, + points: 0, + pending: true, + }) + queryClient.setQueryData(['chores'], oldData => { + if (!oldData) return oldData + return { + res: oldData.res.map(chore => + chore.id === choreId + ? { ...chore, _pending: 'complete' } + : chore, + ), + } + }) + return { res: { _pending: 'complete' } } + } + + try { + return await MarkChoreComplete(choreId, body, completedDate, performer) + } catch (error) { + if (isNetworkError(error)) { + return queueOfflineComplete() + } + throw error + } }, onSuccess: (_, { choreId }) => { queryClient.invalidateQueries(['chores']) @@ -602,7 +641,26 @@ export const useSkipChore = () => { }) return { res: { _pending: 'skip' } } } - return SkipChore(choreId) + + try { + return await SkipChore(choreId) + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, { + id: choreId, + }) + queryClient.setQueryData(['chores'], oldData => { + if (!oldData) return oldData + return { + res: oldData.res.map(chore => + chore.id === choreId ? { ...chore, _pending: 'skip' } : chore, + ), + } + }) + return { res: { _pending: 'skip' } } + } + throw error + } }, onSuccess: (_, choreId) => { queryClient.invalidateQueries(['chores']) diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 3da4e0a..d517c91 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -1,5 +1,6 @@ import { Preferences } from '@capacitor/preferences' import { API_URL } from '../Config' +import { networkManager } from '../hooks/NetworkManager' import { logout, RefreshToken } from './Fetcher' import { clearAllTokens, @@ -146,15 +147,22 @@ class ApiClient { async request(endpoint, options = {}) { await this.init() const url = `${this.customServerURL}${endpoint}` + + // Abort after 10s so a dead/unreachable server doesn't hang the UI + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10_000) + const config = { // credentials: 'include', ...options, headers: this.getHeaders(options.headers), + signal: options.signal ?? controller.signal, } try { // 1. Initial Request let response = await fetch(url, config) + clearTimeout(timeoutId) // 2. Check for 401 (Unauthorized) if (response.status === 401) { @@ -222,6 +230,9 @@ class ApiClient { return response } catch (error) { + clearTimeout(timeoutId) + // fetch() threw = network-level failure or timeout — mark server unreachable + networkManager.setServerUnreachable() console.error('Request failed', error) throw error } diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 00e45ec..24b8b68 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -26,17 +26,32 @@ class SQLiteBackend { constructor() { this.db = null this.initialized = false + this._initPromise = null } async init() { if (this.initialized) return + // Return the in-flight promise if init is already underway (prevents double createConnection) + if (this._initPromise) return this._initPromise - this.db = await CapacitorSQLite.createConnection({ - database: DB_NAME, - version: DB_VERSION, - encrypted: false, - mode: 'no-encryption', + this._initPromise = this._doInit().finally(() => { + this._initPromise = null }) + return this._initPromise + } + + async _doInit() { + try { + this.db = await CapacitorSQLite.createConnection({ + database: DB_NAME, + version: DB_VERSION, + encrypted: false, + mode: 'no-encryption', + }) + } catch (err) { + // Connection already open (e.g. React StrictMode double-mount) — reuse it + if (!err?.message?.includes('already exists')) throw err + } await CapacitorSQLite.open({ database: DB_NAME }) await CapacitorSQLite.execute({ diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 6f1b5ff..2d247a1 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -55,6 +55,8 @@ class SyncEngine { // Step 3: Delta sync from server await this._deltaSync() + // Sync succeeded — server is reachable (only sync success restores online status) + networkManager.setServerReachable() this._notify({ syncing: false, lastSync: Date.now() }) return true } catch (err) { @@ -182,7 +184,7 @@ class SyncEngine { let hasMore = true let currentCursor = cursor - while (hasMore && networkManager.isOnline) { + while (hasMore && networkManager.deviceOnline) { // Use apiClient.get which handles auth and returns a fetch Response const response = await apiClient.get( `/sync/changes?since=${currentCursor}`, diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 4a70442..09b0981 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -14,6 +14,7 @@ import { ViewAgenda, ViewModule, } from '@mui/icons-material' +import Logo from '../../Logo' import { Accordion, AccordionDetails, @@ -1604,6 +1605,7 @@ const MyChores = () => { /> { const [isOnline, setIsOnline] = useState(networkManager.isOnline) + const [offlineReason, setOfflineReason] = useState( + networkManager.offlineReason, + ) + const [isBannerVisible, setIsBannerVisible] = useState( + !networkManager.isOnline, + ) + useEffect(() => { const handleNetworkChange = isOnline => { setIsOnline(isOnline) + setOfflineReason(networkManager.offlineReason) + + if (!isOnline) { + setIsBannerVisible(true) + } } networkManager.registerNetworkListener(handleNetworkChange) + return () => networkManager.unregisterNetworkListener(handleNetworkChange) }, []) + useEffect(() => { + if (isOnline || !isBannerVisible) { + return + } + + const timerId = setTimeout(() => { + setIsBannerVisible(false) + }, 5000) + + return () => clearTimeout(timerId) + }, [isOnline, isBannerVisible]) + + const message = + offlineReason === 'server' + ? 'Server unreachable. Changes will sync when connection is restored.' + : 'No internet connection. Some features may not be available.' + return ( - {!isOnline && ( + {!isOnline && isBannerVisible && ( { }} startDecorator={} > - You are currently offline. Some features may not be available. + {message} )} diff --git a/src/views/components/SyncStatusIndicator.jsx b/src/views/components/SyncStatusIndicator.jsx index a12d250..7900b7d 100644 --- a/src/views/components/SyncStatusIndicator.jsx +++ b/src/views/components/SyncStatusIndicator.jsx @@ -70,6 +70,7 @@ function SyncStatusIndicator() { }) const [isOnline, setIsOnline] = useState(networkManager.isOnline) const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince) + const [offlineReason, setOfflineReason] = useState(networkManager.offlineReason) const [retryIn, setRetryIn] = useState(RETRY_INTERVAL) const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState( isOfflineFeatureEnabled(), @@ -96,17 +97,21 @@ function SyncStatusIndicator() { useEffect(() => { networkManager.registerNetworkListener(online => { setIsOnline(online) + setOfflineReason(networkManager.offlineReason) if (!online) setOfflineSince(networkManager.offlineSince) }) }, []) useEffect(() => { - if (!isOnline || syncState.syncing) return + // Run countdown both when online (pending commands) and when server-unreachable (probe interval) + if (syncState.syncing) return + if (isOnline && pendingCommands.length === 0) return + if (!isOnline && offlineReason === 'device') return const interval = setInterval(() => { setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1)) }, 1000) return () => clearInterval(interval) - }, [isOnline, syncState.syncing, syncState.lastSync]) + }, [isOnline, offlineReason, syncState.syncing, syncState.lastSync, pendingCommands.length]) useEffect(() => { const update = async () => { @@ -461,7 +466,19 @@ function SyncStatusIndicator() { )} - {!isOnline && ( + {!isOnline && offlineReason === 'server' && ( + + + {syncState.syncing + ? 'Checking server...' + : `Retrying in ${retryIn}s`} + + + )} + {!isOnline && offlineReason !== 'server' && (