Add network management features and sync functionality; introduce NetworkBanner component

This commit is contained in:
Mo Tarbin
2025-04-12 01:03:00 -04:00
parent 06288af18d
commit 33d46209ef
15 changed files with 1484 additions and 381 deletions

216
src/utils/LocalStore.jsx Normal file
View File

@@ -0,0 +1,216 @@
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { Capacitor } from '@capacitor/core'
const CACHE_TABLE = 'offline_cache'
const QUEUE_TABLE = 'offline_request_queue'
const OFFLINE_TASK = 'offlineTasks' // For storing offline tasks
class LocalStore {
constructor() {
this.db = null
this.useLocalStorage = !Capacitor.isNativePlatform()
}
async initDatabase() {
if (this.useLocalStorage) return null
if (this.db) return this.db
const db = await CapacitorSQLite.createConnection({
database: 'offline_data',
version: 1,
})
await db.open()
// Create tables if they don't exist
await db.execute(`
CREATE TABLE IF NOT EXISTS ${CACHE_TABLE} (
key TEXT PRIMARY KEY,
value TEXT,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS ${QUEUE_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT,
requestBody TEXT
);
`)
this.db = db
return db
}
async saveToCache(key, data) {
const timestamp = Date.now() // Current timestamp in milliseconds
if (this.useLocalStorage) {
localStorage.setItem(key, JSON.stringify({ value: data, timestamp }))
return
}
const db = await this.initDatabase()
await db.run(
`
INSERT OR REPLACE INTO ${CACHE_TABLE} (key, value, timestamp)
VALUES (?, ?, ?);
`,
[key, JSON.stringify(data), timestamp],
)
}
// async saveTemporaryTask(task) {
// const baseURL = apiManager.getApiURL()
// const fullURL = `${baseURL}/chores/${task.tempId}`
// const options = {
// method: 'GET',
// headers: HEADERS(),
// url: fullURL,
// }
// const respond = { res: task }
// const requestId = murmurhash.v3(JSON.stringify({ fullURL, options }))
// if (this.useLocalStorage) {
// this.saveToCache(requestId, respond)
// return
// }
// const db = await this.initDatabase()
// await db.run(
// `
// INSERT INTO ${CACHE_TABLE} (url, requestBody)
// VALUES (?, ?);
// `,
// [
// requestId,
// JSON.stringify({
// url: fullURL,
// options: { method: 'GET', headers: HEADERS() },
// }),
// ],
// )
// console.log('Saved temporary task to queue:', task)
// return
// }
async getFromCache(key, ttl = 0) {
const now = Date.now()
if (this.useLocalStorage) {
const cachedItem = localStorage.getItem(key)
if (!cachedItem) return null
const { value, timestamp } = JSON.parse(cachedItem)
if (ttl > 0 && now - timestamp > ttl) {
localStorage.removeItem(key) // Remove expired item
return null
}
return value
}
const db = await this.initDatabase()
const result = await db.query(
`
SELECT value, timestamp FROM ${CACHE_TABLE} WHERE key = ?;
`,
[key],
)
if (result.values.length === 0) return null
const { value, timestamp } = result.values[0]
if (ttl > 0 && now - timestamp > ttl) {
// Remove expired item
await db.run(`DELETE FROM ${CACHE_TABLE} WHERE key = ?;`, [key])
return null
}
return JSON.parse(value)
}
async cleanExpiredCache(ttl) {
const now = Date.now()
if (this.useLocalStorage) {
const keys = Object.keys(localStorage)
for (const key of keys) {
const cachedItem = localStorage.getItem(key)
if (!cachedItem) continue
const { timestamp } = JSON.parse(cachedItem)
if (now - timestamp > ttl) {
localStorage.removeItem(key) // Remove expired item
}
}
return
}
const db = await this.initDatabase()
await db.run(
`
DELETE FROM ${CACHE_TABLE} WHERE ? - timestamp > ?;
`,
[now, ttl],
)
}
async queueRequest(requestId, requestBody) {
if (this.useLocalStorage) {
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
queue.push({ requestId, requestBody })
localStorage.setItem(QUEUE_TABLE, JSON.stringify(queue))
return
}
const db = await this.initDatabase()
await db.run(
`
INSERT INTO ${QUEUE_TABLE} (url, requestBody)
VALUES (?, ?);
`,
[requestId, JSON.stringify(requestBody)],
)
}
async syncQueuedRequests() {
var queueSize = 0
if (this.useLocalStorage) {
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
console.log('LocalStore: queue: ', queue)
queueSize = queue.length
for (const request of queue) {
try {
await fetch(request.requestBody.url, request.requestBody.options)
console.log('LocalStore: Synced request:', request)
} catch (error) {
console.error('LocalStore: Failed to sync request:', request, error)
}
}
// Clear the queue after syncing
localStorage.removeItem(QUEUE_TABLE)
localStorage.removeItem(OFFLINE_TASK)
return queueSize > 0
}
const db = await this.initDatabase()
const result = await db.query(`SELECT * FROM ${QUEUE_TABLE};`)
queueSize = result.values.length
for (const request of result.values) {
try {
await fetch(
request.requestBody.url,
JSON.parse(request.requestBody.options),
)
console.log('Synced request:', request)
} catch (error) {
console.error('Failed to sync request:', request, error)
}
}
// Clear the queue after syncing
await db.run(`DELETE FROM ${QUEUE_TABLE};`)
return queueSize > 0
}
}
export const localStore = new LocalStore()

33
src/utils/SyncManager.jsx Normal file
View File

@@ -0,0 +1,33 @@
import { CreateChore, SaveChore } from './Fetcher'
import { localStore } from './LocalStore'
class SyncManager {
async syncTasks() {
console.log('SYNCMANAGER: Starting sync process for offline tasks.')
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
for (const task of offlineTasks) {
// if task.needSync then it's need to be created:
var resp
if (task.needSync) {
resp = await CreateChore(task)
} else {
resp = await SaveChore(task)
}
if (!resp.ok) {
console.log(
`SYNCMANAGER: Failed to sync task with id: ${task.id}. Error: ${resp.statusText}`,
)
} else {
console.log(
`SYNCMANAGER: Successfully synced task with id: ${task.id}.`,
)
console.log(`SYNCMANAGER: Response:`, resp)
}
}
// Clear the offline tasks cache after syncing
await localStore.saveToCache('offlineTasks', [])
return true
}
}
export const syncManager = new SyncManager()

View File

@@ -1,28 +1,36 @@
import Cookies from 'js-cookie'
import { API_URL } from '../Config'
import { RefreshToken } from './Fetcher'
import { Network } from '@capacitor/network'
import { Preferences } from '@capacitor/preferences'
import Cookies from 'js-cookie'
import murmurhash from 'murmurhash'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { RefreshToken } from './Fetcher'
import { localStore } from './LocalStore'
class ApiManager {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.initialized = false
}
async init() {
if (this.initialized) {
return
}
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
await localStore.initDatabase()
this.initialized = true
}
getApiURL() {
return this.customServerURL
}
updateApiURL(url) {
this.customServerURL = url
this.init()
@@ -31,23 +39,51 @@ class ApiManager {
export const apiManager = new ApiManager()
export function Fetch(url, options) {
export async function Fetch(url, options) {
if (!isTokenValid()) {
// store current location in cookie
Cookies.set('ca_redirect', window.location.pathname)
// Assuming you have a function isTokenValid() that checks token validity
window.location.href = '/login' // Redirect to login page
// return Promise.reject("Token is not valid");
window.location.href = '/login'
}
if (!options) {
options = {}
}
options.headers = { ...options.headers, ...HEADERS() }
const baseURL = apiManager.getApiURL()
const fullURL = `${baseURL}${url}`
return fetch(fullURL, options)
const networkStatus = await Network.getStatus()
if (!networkStatus.connected) {
return handleOfflineRequest(fullURL, options)
}
// Online: Perform the fetch
try {
const response = await fetch(fullURL, options)
if (response.ok) {
const data = await response.clone().json()
const optionsHash = murmurhash.v3(JSON.stringify(options))
await localStore.saveToCache(fullURL + optionsHash, data)
networkManager.setOnline()
} else if (
response.status === 503 ||
response.type === 'opaque' ||
response.status === 0
) {
networkManager.setOffline()
return handleOfflineRequest(fullURL, options)
}
// return promise that resolves to response object:
return Promise.resolve(response)
} catch (error) {
networkManager.setOffline()
console.error('Fetch error:', error)
// throw error
return handleOfflineRequest(fullURL, options)
}
}
export const HEADERS = () => {
@@ -61,14 +97,13 @@ export const isTokenValid = () => {
const expiration = localStorage.getItem('ca_expiration')
const token = localStorage.getItem('ca_token')
if (localStorage.getItem('ca_token')) {
if (token) {
const now = new Date()
const expire = new Date(expiration)
if (now < expire) {
if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) {
refreshAccessToken()
}
return true
} else {
localStorage.removeItem('ca_token')
@@ -92,3 +127,38 @@ export const refreshAccessToken = () => {
}
})
}
async function handleOfflineRequest(url, options) {
// if get request then attempt to fetch from cache otherewise queue it :
if (options.method === 'GET') {
return attemptFetchFromCache(url, options)
} else {
// Queue the request for later processing
const requestId = murmurhash.v3(JSON.stringify({ url, options }))
await localStore.queueRequest(requestId, { url, options })
console.log('Request queued for later processing:', requestId)
return Promise.reject(new Error('Offline and request queued: ' + requestId))
}
}
async function attemptFetchFromCache(url, options) {
const optionsHash = murmurhash.v3(JSON.stringify(options))
const cachedData = await localStore.getFromCache(url + optionsHash)
networkManager.setOffline()
if (cachedData) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => cachedData,
})
} else {
return Promise.reject(
new Error(
'No cached data found for URL: ' +
url +
' with options hash: ' +
optionsHash,
),
)
}
}