feat: enhance offline support with improved network management and sync handling

This commit is contained in:
Mo Tarbin
2026-06-06 00:58:18 -04:00
parent 24118e0dc3
commit 4e0b5d79df
9 changed files with 219 additions and 29 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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'])

View File

@@ -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
}

View File

@@ -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({

View File

@@ -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}`,

View File

@@ -14,6 +14,7 @@ import {
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
Accordion,
AccordionDetails,
@@ -1604,6 +1605,7 @@ const MyChores = () => {
/>
</IconButton>
<IconButton
data-testid='open-add-task-modal'
color='primary'
variant='soft'
sx={{

View File

@@ -6,17 +6,47 @@ import { networkManager } from '../../hooks/NetworkManager'
const NetworkBanner = () => {
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 (
<Box sx={{}}>
{!isOnline && (
{!isOnline && isBannerVisible && (
<Alert
variant='soft'
color='warning'
@@ -36,7 +66,7 @@ const NetworkBanner = () => {
}}
startDecorator={<WifiOff />}
>
You are currently offline. Some features may not be available.
{message}
</Alert>
)}
</Box>

View File

@@ -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() {
</Typography>
</Box>
)}
{!isOnline && (
{!isOnline && offlineReason === 'server' && (
<Box sx={{ px: 1, pb: 0.5 }}>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
{syncState.syncing
? 'Checking server...'
: `Retrying in ${retryIn}s`}
</Typography>
</Box>
)}
{!isOnline && offlineReason !== 'server' && (
<Box sx={{ px: 1, pb: 0.5 }}>
<Typography
level='body-xs'