Merge pull request #117 from donetick/offline-support-v2.3
Offline support v2.3
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -191,13 +192,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 +253,7 @@ export const useUpdateChore = () => {
|
||||
),
|
||||
}
|
||||
})
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
return updatedChoreRes?.res || updatedChore
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineUpdate()
|
||||
@@ -577,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'])
|
||||
@@ -608,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'])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user