Merge pull request #99 from donetick/offline-support-v2
Offline support v2
This commit is contained in:
@@ -11,6 +11,7 @@ import SSEProvider from './contexts/SSEContext'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
@@ -34,6 +35,7 @@ const startOpenReplay = () => {
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
useSyncOnReconnect()
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
import { syncManager } from '../utils/SyncManager.jsx' // Ensure you import syncManager if needed for syncing
|
||||
|
||||
class NetworkManager {
|
||||
constructor() {
|
||||
this.isOnline = true
|
||||
@@ -21,35 +20,12 @@ class NetworkManager {
|
||||
this.isNetworkOn = status.connected
|
||||
this.lastChecked = Date.now()
|
||||
this.isOnline = status.connected
|
||||
if (!status.connected) {
|
||||
this.offlineSince = Date.now()
|
||||
}
|
||||
this.notifyConnectionStatus()
|
||||
}
|
||||
})
|
||||
const syncQueue = () => {
|
||||
localStore
|
||||
.syncQueuedRequests()
|
||||
.then(hasMessages => {
|
||||
console.log(
|
||||
'Queued requests synced successfully. Queue has messaage is: ',
|
||||
hasMessages,
|
||||
)
|
||||
if (hasMessages) {
|
||||
this.notifyBackendSync()
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error syncing queued requests:', error)
|
||||
})
|
||||
}
|
||||
this.registerNetworkListener(async isOnline => {
|
||||
if (isOnline && this.isNetworkOn) {
|
||||
// TODO: Delete when Sync manager Implemented
|
||||
syncQueue()
|
||||
console.log('NetworkManager: Network is back online. with SYNCMANAGER')
|
||||
|
||||
await syncManager.syncTasks()
|
||||
console.log('Finished syncing queued requests.')
|
||||
}
|
||||
})
|
||||
syncQueue()
|
||||
}
|
||||
|
||||
setOffline() {
|
||||
@@ -81,6 +57,11 @@ class NetworkManager {
|
||||
registerNetworkListener(callback) {
|
||||
this.connectionStatusListeners.push(callback)
|
||||
}
|
||||
unregisterNetworkListener(callback) {
|
||||
this.connectionStatusListeners = this.connectionStatusListeners.filter(
|
||||
cb => cb !== callback,
|
||||
)
|
||||
}
|
||||
registerBackendSyncListener(callback) {
|
||||
// if callback is not in the list already, add it
|
||||
if (!this.queueSyncListeners.includes(callback)) {
|
||||
|
||||
25
src/hooks/usePendingCommands.js
Normal file
25
src/hooks/usePendingCommands.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
|
||||
// Hook to get pending commands for a specific chore (for showing pending badges/undo)
|
||||
export const usePendingCommands = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['pendingCommands', choreId],
|
||||
queryFn: () => commandQueue.getPendingForEntity(String(choreId)),
|
||||
refetchInterval: 2000, // Poll since commands change outside React
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Hook to get all pending command count (for sync indicator)
|
||||
export const usePendingCommandCount = () => {
|
||||
return useQuery({
|
||||
queryKey: ['pendingCommands', 'all'],
|
||||
queryFn: async () => {
|
||||
const cmds = await commandQueue.getPending()
|
||||
return cmds.length
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
109
src/hooks/useSyncOnReconnect.js
Normal file
109
src/hooks/useSyncOnReconnect.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { App as capacitorApp } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
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
|
||||
|
||||
export function useSyncOnReconnect() {
|
||||
const queryClient = useQueryClient()
|
||||
const initialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let pendingPollInterval
|
||||
let cacheRefreshInterval
|
||||
let resumeListener
|
||||
let networkListener
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
runSync()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnline = () => runSync()
|
||||
|
||||
const init = async () => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
|
||||
if (isOfflineFeatureEnabled()) {
|
||||
await offlineDB.init()
|
||||
}
|
||||
|
||||
// 1. Device network change (works on native + real network drops)
|
||||
networkListener = async isOnline => {
|
||||
if (isOnline) {
|
||||
await runSync()
|
||||
}
|
||||
}
|
||||
networkManager.registerNetworkListener(networkListener)
|
||||
|
||||
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// 3. Browser online event (fires when device network is restored)
|
||||
window.addEventListener('online', handleOnline)
|
||||
|
||||
// 3.5 Native app resume (fires when returning to the foreground)
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
resumeListener = await capacitorApp.addListener(
|
||||
'appStateChange',
|
||||
({ isActive }) => {
|
||||
if (isActive) {
|
||||
console.log(
|
||||
'App resumed, checking connectivity and syncing if online...',
|
||||
)
|
||||
runSync()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Retry pending commands every 30s (catches backend restart)
|
||||
pendingPollInterval = setInterval(async () => {
|
||||
const pending = await commandQueue.getPending()
|
||||
if (pending.length > 0) {
|
||||
runSync()
|
||||
}
|
||||
}, PENDING_POLL_MS)
|
||||
|
||||
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
|
||||
cacheRefreshInterval = setInterval(() => {
|
||||
runSync()
|
||||
}, CACHE_REFRESH_MS)
|
||||
}
|
||||
|
||||
const runSync = async () => {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
queryClient.invalidateQueries()
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
if (pendingPollInterval) {
|
||||
clearInterval(pendingPollInterval)
|
||||
}
|
||||
|
||||
if (cacheRefreshInterval) {
|
||||
clearInterval(cacheRefreshInterval)
|
||||
}
|
||||
|
||||
if (networkListener) {
|
||||
networkManager.unregisterNetworkListener(networkListener)
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
resumeListener?.remove()
|
||||
}
|
||||
}, [queryClient])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
|
||||
import { commandQueue, CommandType } from '../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
@@ -20,51 +20,90 @@ import {
|
||||
UnArchiveChore,
|
||||
UpdateChoreHistory,
|
||||
} from '../utils/Fetcher'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
|
||||
export const useChores = includeArchive => {
|
||||
const mergePendingCreates = async chores => {
|
||||
const pending = await commandQueue.getPending()
|
||||
const pendingCreates = pending.filter(
|
||||
cmd => cmd.commandType === CommandType.CREATE_CHORE,
|
||||
)
|
||||
const deletedIds = new Set(
|
||||
pending
|
||||
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
|
||||
.map(cmd => String(cmd.entityId)),
|
||||
)
|
||||
|
||||
if (pendingCreates.length === 0) return chores
|
||||
|
||||
const existingIds = new Set((chores || []).map(chore => String(chore.id)))
|
||||
const createdFromQueue = pendingCreates
|
||||
.filter(
|
||||
cmd =>
|
||||
!existingIds.has(String(cmd.entityId)) &&
|
||||
!deletedIds.has(String(cmd.entityId)),
|
||||
)
|
||||
.map(cmd => {
|
||||
const payload = cmd.payload || {}
|
||||
return {
|
||||
...payload,
|
||||
id: cmd.entityId,
|
||||
nextDueDate: payload.nextDueDate || payload.dueDate || null,
|
||||
_pendingCreate: true,
|
||||
}
|
||||
})
|
||||
|
||||
return [...(chores || []), ...createdFromQueue]
|
||||
}
|
||||
|
||||
const isNetworkError = error =>
|
||||
error instanceof TypeError && error.message === 'Failed to fetch'
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||
nextDueDate: task.nextDueDate || task.dueDate || null,
|
||||
_pendingCreate: true,
|
||||
})
|
||||
|
||||
export const useChores = (includeArchive = false) => {
|
||||
return useQuery({
|
||||
queryKey: ['chores', includeArchive],
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: async () => {
|
||||
const onlineChores = await GetChoresNew(includeArchive)
|
||||
|
||||
// Only handle offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChores
|
||||
if (isOfflineFeatureEnabled()) {
|
||||
// Sync from server first (no-op if already syncing or offline)
|
||||
if (networkManager.isOnline) {
|
||||
await syncEngine.sync()
|
||||
}
|
||||
const cursor = await offlineDB.getSyncCursor()
|
||||
if (cursor > 0) {
|
||||
const cached = await offlineDB.getChores(includeArchive)
|
||||
const merged = await mergePendingCreates(cached || [])
|
||||
return { res: merged }
|
||||
}
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
|
||||
var finalChores = []
|
||||
if (onlineChores && onlineChores.res) {
|
||||
finalChores = onlineChores.res.filter(
|
||||
onlineChore =>
|
||||
!offlineTasks.some(offlineTask => {
|
||||
// Match by id or tempId
|
||||
return (
|
||||
String(onlineChore.id) === String(offlineTask.id) ||
|
||||
(offlineTask.tempId &&
|
||||
String(onlineChore.id) === String(offlineTask.tempId))
|
||||
)
|
||||
}),
|
||||
// Offline feature disabled — fetch from API.
|
||||
try {
|
||||
const data = await GetChoresNew(includeArchive)
|
||||
if (data?.res) {
|
||||
syncEngine.cacheChores(data.res)
|
||||
}
|
||||
const merged = await mergePendingCreates(data?.res || [])
|
||||
return { ...data, res: merged }
|
||||
} catch {
|
||||
// API failed — fall back to whatever is in the cache
|
||||
const cached = await offlineDB.getChores(includeArchive)
|
||||
const merged = await mergePendingCreates(cached || [])
|
||||
if (merged && merged.length > 0) {
|
||||
return { res: merged }
|
||||
}
|
||||
throw new Error(
|
||||
'Unable to communicate with server and no data available',
|
||||
)
|
||||
}
|
||||
// Combine online chores with offline tasks
|
||||
if (offlineTasks.length > 0) {
|
||||
// Merge the offline tasks with the online chores
|
||||
finalChores = [
|
||||
...finalChores,
|
||||
...offlineTasks.map(task => ({
|
||||
...task,
|
||||
id: task.id || task.tempId, // Ensure we have an id for consistency
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
return { res: finalChores }
|
||||
|
||||
// return { res: [...onlineChores.res, ...offlineTasks] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -73,21 +112,28 @@ export const useDeleteChores = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async choreIds => {
|
||||
// If offline mode is enabled and we're offline, handle deletion locally
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
const updatedOfflineTasks = offlineTasks.filter(
|
||||
task =>
|
||||
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
|
||||
if (!networkManager.isOnline) {
|
||||
await offlineDB.deleteChores(choreIds)
|
||||
await Promise.all(
|
||||
choreIds.map(async id => {
|
||||
await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id })
|
||||
}),
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
// Force the chores query to refetch
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
return
|
||||
|
||||
const removeDeletedChores = oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
|
||||
const deletedIds = new Set(choreIds.map(id => String(id)))
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.filter(chore => !deletedIds.has(String(chore.id))),
|
||||
}
|
||||
}
|
||||
|
||||
// If online, proceed with server-side deletion
|
||||
queryClient.setQueryData(['chores', false], removeDeletedChores)
|
||||
queryClient.setQueryData(['chores', true], removeDeletedChores)
|
||||
return
|
||||
}
|
||||
await Promise.all(
|
||||
choreIds.map(async id => {
|
||||
const resp = await DeleteChore(id)
|
||||
@@ -99,14 +145,44 @@ export const useDeleteChores = () => {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
export const useCreateChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queueOfflineCreate = async newTask => {
|
||||
const offlineChore = buildOfflineChore(newTask)
|
||||
await commandQueue.enqueue(
|
||||
CommandType.CREATE_CHORE,
|
||||
offlineChore.id,
|
||||
newTask,
|
||||
)
|
||||
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData?.res) {
|
||||
return { res: [offlineChore] }
|
||||
}
|
||||
|
||||
const alreadyExists = oldData.res.some(
|
||||
chore => String(chore.id) === String(offlineChore.id),
|
||||
)
|
||||
if (alreadyExists) return oldData
|
||||
|
||||
return { ...oldData, res: [...oldData.res, offlineChore] }
|
||||
})
|
||||
|
||||
return offlineChore
|
||||
}
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async newTask => {
|
||||
if (!networkManager.isOnline) {
|
||||
return queueOfflineCreate(newTask)
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
@@ -117,57 +193,21 @@ export const useCreateChore = () => {
|
||||
}
|
||||
// Successfully created the chore on the server, return the created chore
|
||||
// update the local chores cache with the new chore:
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [createdChore.res] }
|
||||
return { res: [...oldData.res, createdChore.res] }
|
||||
})
|
||||
return { res: createdChore }
|
||||
return createdChore.res
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineCreate(newTask)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// onMutate: async newTask => {
|
||||
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
// const tempId = crypto.randomUUID() // Generate temp ID
|
||||
// const offlineTasks =
|
||||
// (await localStore.getFromCache('offlineTasks')) || []
|
||||
// const updateOfflineTasks = [
|
||||
// ...offlineTasks,
|
||||
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
|
||||
// ]
|
||||
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
|
||||
// // force useChores to refetch:
|
||||
// queryClient.invalidateQueries(['chores'])
|
||||
// // Force the chores query to refetch
|
||||
// queryClient.refetchQueries(['chores'])
|
||||
// // Update the chores query cache immediately
|
||||
// // queryClient.setQueryData(['chores'], oldData => {
|
||||
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
|
||||
|
||||
// // if (!oldData)
|
||||
// // return {
|
||||
// // res: [{ ...newTask, id: tempId, tempId }],
|
||||
// // } // If no data, return offline tasks
|
||||
// // return {
|
||||
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// // }
|
||||
// // })
|
||||
// return { tempId }
|
||||
// }
|
||||
// const tempId = crypto.randomUUID() // Generate temp ID
|
||||
// // Update the chores query cache immediately
|
||||
// queryClient.setQueryData(['chores'], oldData => {
|
||||
// if (!oldData)
|
||||
// return {
|
||||
// res: [{ ...newTask, id: tempId, tempId }],
|
||||
// } // If no data, return offline tasks
|
||||
// return {
|
||||
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// }
|
||||
// })
|
||||
// return { tempId: null }
|
||||
// },
|
||||
onSuccess: () => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -177,44 +217,31 @@ export const useUpdateChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async updatedChore => {
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
updatedChore['updatedAt'] = new Date().toISOString()
|
||||
if (!updatedChore['nextDueDate']) {
|
||||
updatedChore['nextDueDate'] = updatedChore['dueDate']
|
||||
}
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
|
||||
for (const task of offlineTasks) {
|
||||
// Find the task with the same id or tempId and update it
|
||||
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
|
||||
// Update the task in local storage
|
||||
const updatedTask = { ...task, ...updatedChore }
|
||||
const updatedOfflineTasks = offlineTasks.map(t =>
|
||||
t.id === task.id ? updatedTask : t,
|
||||
const queueOfflineUpdate = async () => {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.UPDATE_CHORE,
|
||||
updatedChore.id,
|
||||
updatedChore,
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(updatedTask)
|
||||
const pendingChore = { ...updatedChore, _pendingUpdate: true }
|
||||
// Persist to offline DB so cache fallback reads the updated data
|
||||
await offlineDB.saveChores([pendingChore])
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [pendingChore] }
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === updatedChore.id ? pendingChore : chore,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const newTaskId = crypto.randomUUID()
|
||||
const updatedChoreWithNewId = {
|
||||
...updatedChore,
|
||||
tempId: newTaskId,
|
||||
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
|
||||
if (!oldData) return { res: pendingChore }
|
||||
return { ...oldData, res: pendingChore }
|
||||
})
|
||||
return pendingChore
|
||||
}
|
||||
|
||||
await localStore.saveToCache('offlineTasks', [
|
||||
...offlineTasks,
|
||||
updatedChoreWithNewId,
|
||||
])
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve with the updated task
|
||||
resolve(updatedChoreWithNewId)
|
||||
})
|
||||
} else {
|
||||
// Call the API to update the chore
|
||||
try {
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
@@ -223,9 +250,7 @@ export const useUpdateChore = () => {
|
||||
if (!updatedChoreRes) {
|
||||
throw new Error('Failed to get updated chore data')
|
||||
}
|
||||
// Successfully updated the chore on the server, return the updated chore
|
||||
// update the local chores cache with the updated chore:
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [updatedChore] }
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
@@ -234,19 +259,17 @@ export const useUpdateChore = () => {
|
||||
}
|
||||
})
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineUpdate()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
// Invalidate history for the specific chore
|
||||
queryClient.invalidateQueries(['choreHistory', variables.id])
|
||||
},
|
||||
onMutate: async updatedChore => {
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
// Handle offline case here if needed
|
||||
return
|
||||
}
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -257,8 +280,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['choresHistory', limit],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const resp = await GetChoresHistory(limit, includeMembers)
|
||||
return resp?.res || []
|
||||
const entries = resp?.res || []
|
||||
// Cache for offline use — fire-and-forget so a cache failure never
|
||||
// degrades the online experience
|
||||
if (entries.length > 0) {
|
||||
offlineDB
|
||||
.saveHistory(entries)
|
||||
.catch(err => console.error('Failed to cache chores history:', err))
|
||||
}
|
||||
return entries
|
||||
} catch {
|
||||
return offlineDB.getHistoryByDays(limit)
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
@@ -275,30 +310,20 @@ export const useChoreDetails = choreId => {
|
||||
queryKey: ['choreDetails', choreId],
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: async () => {
|
||||
var onlineChore = null
|
||||
|
||||
try {
|
||||
const response = await GetChoreDetailById(choreId)
|
||||
|
||||
if (response && response.ok) {
|
||||
onlineChore = await response.json()
|
||||
return await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching chore detail:', error)
|
||||
throw new Error('Failed to fetch chore detail')
|
||||
} catch {
|
||||
// Fall back to cached chore (without timer details)
|
||||
const cached = await offlineDB.getChore(choreId)
|
||||
if (cached) {
|
||||
return { res: cached }
|
||||
}
|
||||
|
||||
// Only check offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChore
|
||||
throw new Error('Chore detail not available offline')
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
const offline = offlineTasks.find(task => {
|
||||
// Match by tempId or id if it was created offline
|
||||
return task.id === choreId || (task.tempId && task.tempId === choreId)
|
||||
})
|
||||
|
||||
return { res: offline ? { ...offline } : onlineChore.res }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -312,32 +337,21 @@ export const useChore = choreId => {
|
||||
if (!choreId) {
|
||||
throw new Error('Chore ID is required to fetch chore details')
|
||||
}
|
||||
var onlineChore = null
|
||||
|
||||
try {
|
||||
const response = await GetChoreByID(choreId)
|
||||
|
||||
if (response && response.ok) {
|
||||
onlineChore = await response.json()
|
||||
return await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching chore detail:', error)
|
||||
throw new Error('Failed to fetch chore')
|
||||
} catch {
|
||||
// API failed — try offline cache
|
||||
const cached = await offlineDB.getChore(choreId)
|
||||
if (cached) {
|
||||
return { res: cached }
|
||||
}
|
||||
|
||||
// Only check offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChore
|
||||
throw new Error('Chore not available offline')
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
const offline = offlineTasks.find(task => {
|
||||
return (
|
||||
String(task.id) === choreId ||
|
||||
(task.tempId && task.tempId === choreId)
|
||||
)
|
||||
})
|
||||
|
||||
return { res: offline ? { ...offline } : onlineChore.res }
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
@@ -374,11 +388,30 @@ export const useChoreHistory = choreId => {
|
||||
if (!choreId) {
|
||||
throw new Error('Chore ID is required to fetch history')
|
||||
}
|
||||
let json
|
||||
try {
|
||||
const response = await GetChoreHistory(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
json = await response.json()
|
||||
} else {
|
||||
throw new Error('Failed to fetch chore history')
|
||||
}
|
||||
} catch {
|
||||
const cached = await offlineDB.getHistoryByChore(choreId)
|
||||
return { res: cached }
|
||||
}
|
||||
// Cache for offline use — fire-and-forget so a cache failure never
|
||||
// degrades the online view. Inject choreId since the single-chore
|
||||
// endpoint may omit it from each entry.
|
||||
const entries = (json?.res || []).map(e =>
|
||||
e.choreId != null ? e : { ...e, choreId: Number(choreId) },
|
||||
)
|
||||
if (entries.length > 0) {
|
||||
offlineDB
|
||||
.saveHistory(entries)
|
||||
.catch(err => console.error('Failed to cache chore history:', err))
|
||||
}
|
||||
return json
|
||||
},
|
||||
enabled: !!choreId,
|
||||
staleTime: 0, // Always consider data stale
|
||||
@@ -392,10 +425,62 @@ export const useUpdateChoreHistory = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, historyId, historyData }) =>
|
||||
UpdateChoreHistory(choreId, historyId, historyData),
|
||||
mutationFn: async ({ choreId, historyId, historyData }) => {
|
||||
const applyOptimisticUpdate = async () => {
|
||||
queryClient.setQueryData(['choreHistory', choreId], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(entry =>
|
||||
entry.id === historyId
|
||||
? { ...entry, ...historyData, _pendingUpdate: true }
|
||||
: entry,
|
||||
),
|
||||
}
|
||||
})
|
||||
await offlineDB.updateHistoryEntry(choreId, historyId, {
|
||||
...historyData,
|
||||
_pendingUpdate: true,
|
||||
})
|
||||
return { queued: true }
|
||||
}
|
||||
|
||||
if (!networkManager.isOnline) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.UPDATE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId, historyData },
|
||||
)
|
||||
return applyOptimisticUpdate()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await UpdateChoreHistory(
|
||||
choreId,
|
||||
historyId,
|
||||
historyData,
|
||||
)
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to update chore history')
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.UPDATE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId, historyData },
|
||||
)
|
||||
return applyOptimisticUpdate()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (data, { choreId }) => {
|
||||
if (!data?.queued) {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
}
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -404,10 +489,57 @@ export const useDeleteChoreHistory = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, historyId }) =>
|
||||
DeleteChoreHistory(choreId, historyId),
|
||||
mutationFn: async ({ choreId, historyId }) => {
|
||||
const applyOptimisticDelete = async () => {
|
||||
queryClient.setQueryData(['choreHistory', choreId], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(entry =>
|
||||
entry.id === historyId
|
||||
? { ...entry, _pendingDelete: true }
|
||||
: entry,
|
||||
),
|
||||
}
|
||||
})
|
||||
await offlineDB.updateHistoryEntry(choreId, historyId, {
|
||||
_pendingDelete: true,
|
||||
})
|
||||
return { queued: true }
|
||||
}
|
||||
|
||||
if (!networkManager.isOnline) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.DELETE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId },
|
||||
)
|
||||
return applyOptimisticDelete()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await DeleteChoreHistory(choreId, historyId)
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to delete chore history')
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.DELETE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId },
|
||||
)
|
||||
return applyOptimisticDelete()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (data, { choreId }) => {
|
||||
if (!data?.queued) {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
}
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -416,12 +548,42 @@ export const useMarkChoreComplete = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, body, completedDate, performer }) =>
|
||||
MarkChoreComplete(choreId, body, completedDate, performer),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
mutationFn: async ({ choreId, body, completedDate, performer }) => {
|
||||
if (!networkManager.isOnline) {
|
||||
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,
|
||||
})
|
||||
// Optimistically update the cache to show pending state
|
||||
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' } }
|
||||
}
|
||||
return MarkChoreComplete(choreId, body, completedDate, performer)
|
||||
},
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -430,11 +592,29 @@ export const useSkipChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: SkipChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
mutationFn: async choreId => {
|
||||
if (!networkManager.isOnline) {
|
||||
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
|
||||
id: choreId,
|
||||
})
|
||||
// Optimistically update the cache to show pending state
|
||||
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' } }
|
||||
}
|
||||
return SkipChore(choreId)
|
||||
},
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -444,7 +624,7 @@ export const useApproveChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ApproveChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
@@ -457,7 +637,7 @@ export const useRejectChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: RejectChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
|
||||
@@ -1,51 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
|
||||
export const useUpdate = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async updatedChore => {
|
||||
if (!networkManager.isOnline) {
|
||||
updatedChore['updatedAt'] = new Date().toISOString()
|
||||
if (!updatedChore['nextDueDate']) {
|
||||
updatedChore['nextDueDate'] = updatedChore['dueDate']
|
||||
}
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
|
||||
for (const task of offlineTasks) {
|
||||
// Find the task with the same id or tempId and update it
|
||||
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
|
||||
// Update the task in local storage
|
||||
const updatedTask = { ...task, ...updatedChore }
|
||||
const updatedOfflineTasks = offlineTasks.map(t =>
|
||||
t.id === task.id ? updatedTask : t,
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(updatedTask)
|
||||
})
|
||||
}
|
||||
}
|
||||
const newTaskId = crypto.randomUUID()
|
||||
const updatedChoreWithNewId = {
|
||||
...updatedChore,
|
||||
tempId: newTaskId,
|
||||
}
|
||||
|
||||
await localStore.saveToCache('offlineTasks', [
|
||||
...offlineTasks,
|
||||
updatedChoreWithNewId,
|
||||
])
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve with the updated task
|
||||
resolve(updatedChoreWithNewId)
|
||||
})
|
||||
} else {
|
||||
// Call the API to update the chore
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
@@ -56,18 +17,10 @@ export const useUpdate = () => {
|
||||
}
|
||||
// Successfully updated the chore on the server, return the updated chore
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
},
|
||||
onMutate: async updatedChore => {
|
||||
if (!networkManager.isOnline) {
|
||||
// Handle offline case here if needed
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const useStartChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: StartChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -44,7 +44,7 @@ export const usePauseChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: PauseChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -58,7 +58,7 @@ export const useUpdateTimeSession = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
||||
UpdateTimeSession(choreId, sessionId, sessionData),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -72,7 +72,7 @@ export const useDeleteTimeSession = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId }) =>
|
||||
DeleteTimeSession(choreId, sessionId),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -85,7 +85,7 @@ export const useResetChoreTimer = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ResetChoreTimer,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -98,7 +98,7 @@ export const useClearChoreTimer = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ClearChoreTimer,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GetDeviceTokens,
|
||||
GetUserProfile,
|
||||
} from '../utils/Fetcher'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
|
||||
// Helper to check if we have a valid token
|
||||
const isTokenValid = () => {
|
||||
@@ -30,7 +31,20 @@ export const useCircleMembers = () => {
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['allCircleMembers'],
|
||||
queryFn: GetAllCircleMembers,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const result = await GetAllCircleMembers()
|
||||
// Cache for offline use
|
||||
if (result?.res) {
|
||||
offlineDB.saveKV('circle_members', result.res).catch(() => {})
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
const cached = await offlineDB.getKV('circle_members')
|
||||
if (cached) return { res: cached }
|
||||
return { res: [] }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleRefetch = () => {
|
||||
@@ -42,19 +56,31 @@ export const useCircleMembers = () => {
|
||||
|
||||
export const useUserProfile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['userProfile'],
|
||||
queryKey: ['userProfile', token],
|
||||
queryFn: async () => {
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
||||
|
||||
if (result?.res) {
|
||||
await offlineDB.saveKV('user_profile', result.res)
|
||||
}
|
||||
return result.res || null
|
||||
} catch {
|
||||
// API unreachable — only serve cached profile for authenticated sessions
|
||||
return await offlineDB.getKV('user_profile')
|
||||
}
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
enabled: isTokenValid(), // Only run query when we have a valid token
|
||||
staleTime: 30 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
enabled: !!token,
|
||||
})
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -17,7 +17,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async init(force = false) {
|
||||
if (this.initPromise) {
|
||||
if (!force && this.initPromise) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
|
||||
213
src/utils/CommandQueue.js
Normal file
213
src/utils/CommandQueue.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||
|
||||
// Domain command types
|
||||
export const CommandType = {
|
||||
CREATE_CHORE: 'create_chore',
|
||||
UPDATE_CHORE: 'update_chore',
|
||||
UPDATE_CHORE_HISTORY: 'update_chore_history',
|
||||
COMPLETE_CHORE: 'complete_chore',
|
||||
SKIP_CHORE: 'skip_chore',
|
||||
START_CHORE: 'start_chore',
|
||||
PAUSE_CHORE: 'pause_chore',
|
||||
DELETE_CHORE: 'delete_chore',
|
||||
DELETE_CHORE_HISTORY: 'delete_chore_history',
|
||||
RESCHEDULE_CHORE: 'reschedule_chore',
|
||||
ARCHIVE_CHORE: 'archive_chore',
|
||||
UNARCHIVE_CHORE: 'unarchive_chore',
|
||||
}
|
||||
|
||||
class CommandQueue {
|
||||
_sanitizeCreatePayload(payload = {}) {
|
||||
const sanitized = { ...payload }
|
||||
delete sanitized.id
|
||||
delete sanitized._pendingCreate
|
||||
delete sanitized._pendingUpdate
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// Enqueue a domain command
|
||||
async enqueue(type, entityId, payload) {
|
||||
if (!isOfflineFeatureEnabled()) {
|
||||
throw new Error('Offline support is disabled on this device')
|
||||
}
|
||||
|
||||
const command = {
|
||||
commandType: type,
|
||||
entityId: String(entityId),
|
||||
payload: JSON.stringify(payload),
|
||||
createdAt: Date.now(),
|
||||
status: 'pending',
|
||||
error: null,
|
||||
}
|
||||
return offlineDB.enqueueCommand(command)
|
||||
}
|
||||
|
||||
// Get all pending commands in order
|
||||
async getPending() {
|
||||
if (!isOfflineFeatureEnabled()) return []
|
||||
const commands = await offlineDB.getCommands()
|
||||
return commands
|
||||
.filter(c => c.status === 'pending' || c.status === 'syncing')
|
||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||
}
|
||||
|
||||
// Get all failed commands
|
||||
async getFailed() {
|
||||
if (!isOfflineFeatureEnabled()) return []
|
||||
const commands = await offlineDB.getCommands()
|
||||
return commands
|
||||
.filter(c => c.status === 'failed')
|
||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||
}
|
||||
|
||||
// Get pending commands for a specific entity (for undo/UI)
|
||||
async getPendingForEntity(entityId) {
|
||||
if (!isOfflineFeatureEnabled()) return []
|
||||
const allCommands = await offlineDB.getCommands()
|
||||
const key = String(entityId)
|
||||
const commands = allCommands
|
||||
.filter(
|
||||
c =>
|
||||
c.entityId === key ||
|
||||
(typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)),
|
||||
)
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
return commands
|
||||
.filter(c => c.status === 'pending' || c.status === 'syncing')
|
||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||
}
|
||||
|
||||
// Cancel/undo a pending command
|
||||
async cancel(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.removeCommand(commandId)
|
||||
}
|
||||
|
||||
// Mark as syncing
|
||||
async markSyncing(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.updateCommandStatus(commandId, 'syncing', null)
|
||||
}
|
||||
|
||||
// Mark as failed (only for unrecoverable errors like conflicts)
|
||||
async markFailed(commandId, error) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.updateCommandStatus(commandId, 'failed', error)
|
||||
}
|
||||
|
||||
// Reset back to pending (for transient network/server errors so it retries)
|
||||
async resetPending(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.updateCommandStatus(commandId, 'pending', null)
|
||||
}
|
||||
|
||||
// Reset any in-flight commands so they remain retryable after aborted syncs
|
||||
async resetSyncing() {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const commands = await offlineDB.getCommands()
|
||||
const syncingCommands = commands.filter(c => c.status === 'syncing')
|
||||
|
||||
await Promise.all(
|
||||
syncingCommands.map(cmd =>
|
||||
offlineDB.updateCommandStatus(cmd.id, 'pending', null),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove after successful sync
|
||||
async markDone(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.removeCommand(commandId)
|
||||
}
|
||||
|
||||
// Compact: merge consecutive updates to same entity
|
||||
async compact() {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const pending = await this.getPending()
|
||||
const seen = new Map() // entityId -> last command
|
||||
const toRemove = []
|
||||
|
||||
for (const cmd of pending) {
|
||||
const prev = seen.get(cmd.entityId)
|
||||
|
||||
if (prev?.commandType === CommandType.CREATE_CHORE) {
|
||||
if (cmd.commandType === CommandType.UPDATE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
...cmd.payload,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.RESCHEDULE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
dueDate: cmd.payload?.dueDate ?? prev.payload?.dueDate,
|
||||
nextDueDate: cmd.payload?.dueDate ?? prev.payload?.nextDueDate,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.ARCHIVE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
isActive: false,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.UNARCHIVE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
isActive: true,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.DELETE_CHORE) {
|
||||
toRemove.push(prev.id, cmd.id)
|
||||
seen.delete(cmd.entityId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.UPDATE_CHORE) {
|
||||
if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
|
||||
// Merge: keep latest payload, remove older
|
||||
toRemove.push(prev.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.DELETE_CHORE) {
|
||||
if (prev?.commandType === CommandType.UPDATE_CHORE) {
|
||||
toRemove.push(prev.id)
|
||||
}
|
||||
}
|
||||
|
||||
seen.set(cmd.entityId, cmd)
|
||||
}
|
||||
|
||||
for (const id of [...new Set(toRemove)]) {
|
||||
await offlineDB.removeCommand(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const commandQueue = new CommandQueue()
|
||||
@@ -1,70 +1,3 @@
|
||||
export const FEATURES = {
|
||||
OFFLINE_MODE: 'experimental_feature_offline_mode',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of a feature flag from localStorage
|
||||
* @param {string} featureKey - The feature key from FEATURES constant
|
||||
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
|
||||
* @returns {boolean} - Whether the feature is enabled
|
||||
*/
|
||||
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
|
||||
try {
|
||||
const value = localStorage.getItem(featureKey)
|
||||
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
|
||||
if (value === null || value === undefined) return defaultValue
|
||||
|
||||
return Boolean(value)
|
||||
} catch (error) {
|
||||
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of a feature flag in localStorage
|
||||
* @param {string} featureKey - The feature key from FEATURES constant
|
||||
* @param {boolean} enabled - Whether to enable the feature
|
||||
*/
|
||||
export const setFeatureEnabled = (featureKey, enabled) => {
|
||||
try {
|
||||
localStorage.setItem(featureKey, enabled.toString())
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`FeatureToggle: Error setting feature "${featureKey}":`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleFeature = featureKey => {
|
||||
const currentState = isFeatureEnabled(featureKey)
|
||||
const newState = !currentState
|
||||
setFeatureEnabled(featureKey, newState)
|
||||
return newState
|
||||
}
|
||||
|
||||
export const getAllFeatureStates = () => {
|
||||
const states = {}
|
||||
Object.entries(FEATURES).forEach(([name, key]) => {
|
||||
states[name] = isFeatureEnabled(key)
|
||||
})
|
||||
return states
|
||||
}
|
||||
|
||||
export const clearAllFeatures = () => {
|
||||
try {
|
||||
Object.values(FEATURES).forEach(featureKey => {
|
||||
localStorage.removeItem(featureKey)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('FeatureToggle: Error clearing features:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current instance is the official donetick.com service
|
||||
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
|
||||
@@ -102,7 +35,11 @@ export const isOfficialDonetickInstanceSync = () => {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
return import('./ApiClient')
|
||||
.then(({ apiClient }) => {
|
||||
const currentApiUrl = apiClient.baseURL
|
||||
const currentApiUrl =
|
||||
apiClient.baseURL || apiClient.customServerURL || ''
|
||||
if (!currentApiUrl || typeof currentApiUrl !== 'string') {
|
||||
return false
|
||||
}
|
||||
// Check if the API URL contains donetick.com
|
||||
return currentApiUrl.toLowerCase().includes('donetick.com')
|
||||
})
|
||||
@@ -123,12 +60,6 @@ export const isOfficialDonetickInstanceSync = () => {
|
||||
|
||||
// Export default object for easier imports
|
||||
export default {
|
||||
FEATURES,
|
||||
isFeatureEnabled,
|
||||
setFeatureEnabled,
|
||||
toggleFeature,
|
||||
getAllFeatureStates,
|
||||
clearAllFeatures,
|
||||
isOfficialDonetickInstance,
|
||||
isOfficialDonetickInstanceSync,
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||
|
||||
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()
|
||||
this.useLocalStorage = true // default to localStorage for now.
|
||||
}
|
||||
|
||||
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, requestPayload) {
|
||||
if (this.useLocalStorage) {
|
||||
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
|
||||
console.log('requestPayload', requestPayload)
|
||||
|
||||
if (typeof requestPayload?.options?.body['id'] === 'string') {
|
||||
requestPayload['id'] = null
|
||||
}
|
||||
queue.push({ requestId, requestBody: requestPayload })
|
||||
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(requestPayload)],
|
||||
)
|
||||
}
|
||||
|
||||
async syncQueuedRequests() {
|
||||
console.log('Syncing queued requests...')
|
||||
|
||||
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()
|
||||
1075
src/utils/OfflineDB.js
Normal file
1075
src/utils/OfflineDB.js
Normal file
File diff suppressed because it is too large
Load Diff
58
src/utils/OfflineFeatureToggle.js
Normal file
58
src/utils/OfflineFeatureToggle.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const OFFLINE_FEATURE_KEY = 'offline_feature_enabled'
|
||||
const OFFLINE_FEATURE_EVENT = 'donetick:offline-feature-changed'
|
||||
|
||||
const parseBoolean = value => {
|
||||
if (value === null || typeof value === 'undefined') return true
|
||||
try {
|
||||
return JSON.parse(value) !== false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const isOfflineFeatureEnabled = () => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return true
|
||||
return parseBoolean(window.localStorage.getItem(OFFLINE_FEATURE_KEY))
|
||||
}
|
||||
|
||||
export const setOfflineFeatureEnabled = enabled => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return
|
||||
window.localStorage.setItem(OFFLINE_FEATURE_KEY, JSON.stringify(!!enabled))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(OFFLINE_FEATURE_EVENT, {
|
||||
detail: { enabled: !!enabled },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const subscribeToOfflineFeature = callback => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
|
||||
const handleToggle = event => {
|
||||
if (event?.type === 'storage') {
|
||||
if (event.key !== OFFLINE_FEATURE_KEY) return
|
||||
callback(parseBoolean(event.newValue))
|
||||
return
|
||||
}
|
||||
|
||||
callback(!!event?.detail?.enabled)
|
||||
}
|
||||
|
||||
window.addEventListener(OFFLINE_FEATURE_EVENT, handleToggle)
|
||||
window.addEventListener('storage', handleToggle)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(OFFLINE_FEATURE_EVENT, handleToggle)
|
||||
window.removeEventListener('storage', handleToggle)
|
||||
}
|
||||
}
|
||||
|
||||
export const clearBrowserCacheStorage = async () => {
|
||||
if (typeof window === 'undefined' || !('caches' in window)) return
|
||||
try {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
await Promise.all(cacheKeys.map(key => window.caches.delete(key)))
|
||||
} catch {
|
||||
// Ignore cache clear failures and continue with offline cleanup
|
||||
}
|
||||
}
|
||||
246
src/utils/SyncEngine.js
Normal file
246
src/utils/SyncEngine.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { apiClient } from './ApiClient'
|
||||
import { commandQueue, CommandType } from './CommandQueue'
|
||||
import {
|
||||
ArchiveChore,
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
MarkChoreComplete,
|
||||
PauseChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
StartChore,
|
||||
UnArchiveChore,
|
||||
UpdateChoreHistory,
|
||||
UpdateDueDate,
|
||||
} from './Fetcher'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||
|
||||
class SyncEngine {
|
||||
constructor() {
|
||||
this.isSyncing = false
|
||||
this.listeners = []
|
||||
}
|
||||
|
||||
// Register listener for sync state changes
|
||||
onSyncStateChange(callback) {
|
||||
this.listeners.push(callback)
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== callback)
|
||||
}
|
||||
}
|
||||
|
||||
_notify(state) {
|
||||
this.listeners.forEach(cb => cb(state))
|
||||
}
|
||||
|
||||
// Main sync entry point — returns true if sync succeeded, false otherwise
|
||||
async sync() {
|
||||
if (!isOfflineFeatureEnabled()) return false
|
||||
if (this.isSyncing) return false
|
||||
this.isSyncing = true
|
||||
this._notify({ syncing: true, error: null })
|
||||
|
||||
try {
|
||||
await commandQueue.resetSyncing()
|
||||
|
||||
// Step 1: Compact the queue (merge consecutive updates)
|
||||
await commandQueue.compact()
|
||||
|
||||
// Step 2: Replay pending commands
|
||||
await this._replayCommands()
|
||||
|
||||
// Step 3: Delta sync from server
|
||||
await this._deltaSync()
|
||||
|
||||
this._notify({ syncing: false, lastSync: Date.now() })
|
||||
return true
|
||||
} catch (err) {
|
||||
await commandQueue.resetSyncing()
|
||||
console.error('Sync failed:', err)
|
||||
this._notify({ syncing: false, error: err.message })
|
||||
return false
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async _replayCommands() {
|
||||
const commands = await commandQueue.getPending()
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (!networkManager.isOnline) break
|
||||
|
||||
await commandQueue.markSyncing(cmd.id)
|
||||
|
||||
try {
|
||||
await this._executeCommand(cmd)
|
||||
await commandQueue.markDone(cmd.id)
|
||||
} catch (err) {
|
||||
const status = err.status || err.statusCode
|
||||
if (status === 409) {
|
||||
// Conflict - mark for user attention but continue with other commands
|
||||
await commandQueue.markFailed(
|
||||
cmd.id,
|
||||
'Conflict: modified by another user',
|
||||
)
|
||||
} else if (status === 404) {
|
||||
// Entity no longer exists - discard command
|
||||
await commandQueue.markDone(cmd.id)
|
||||
} else {
|
||||
// Transient network/server error - reset to pending so it retries
|
||||
await commandQueue.resetPending(cmd.id)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _executeCommand(cmd) {
|
||||
let response
|
||||
|
||||
switch (cmd.commandType) {
|
||||
case CommandType.CREATE_CHORE:
|
||||
response = await CreateChore(cmd.payload)
|
||||
break
|
||||
|
||||
case CommandType.UPDATE_CHORE:
|
||||
response = await SaveChore(cmd.payload)
|
||||
break
|
||||
|
||||
case CommandType.COMPLETE_CHORE: {
|
||||
const { id, body, completedDate, performer } = cmd.payload
|
||||
response = await MarkChoreComplete(
|
||||
id,
|
||||
body || {},
|
||||
completedDate || null,
|
||||
performer || null,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.SKIP_CHORE:
|
||||
response = await SkipChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.START_CHORE:
|
||||
response = await StartChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.PAUSE_CHORE:
|
||||
response = await PauseChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.DELETE_CHORE:
|
||||
response = await DeleteChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.UPDATE_CHORE_HISTORY: {
|
||||
const { choreId, historyId, historyData } = cmd.payload
|
||||
response = await UpdateChoreHistory(choreId, historyId, historyData)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.DELETE_CHORE_HISTORY: {
|
||||
const { choreId, historyId } = cmd.payload
|
||||
response = await DeleteChoreHistory(choreId, historyId)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.RESCHEDULE_CHORE: {
|
||||
const { id, dueDate } = cmd.payload
|
||||
response = await UpdateDueDate(id, dueDate)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.ARCHIVE_CHORE:
|
||||
response = await ArchiveChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.UNARCHIVE_CHORE:
|
||||
response = await UnArchiveChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('Unknown command type:', cmd.commandType)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the response indicates an error and throw so the caller can handle it
|
||||
if (response && typeof response.ok !== 'undefined' && !response.ok) {
|
||||
const err = new Error(`API error: ${response.status}`)
|
||||
err.status = response.status
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async _deltaSync() {
|
||||
const cursor = (await offlineDB.getSyncCursor()) || -1
|
||||
|
||||
let hasMore = true
|
||||
let currentCursor = cursor
|
||||
|
||||
while (hasMore && networkManager.isOnline) {
|
||||
// Use apiClient.get which handles auth and returns a fetch Response
|
||||
const response = await apiClient.get(
|
||||
`/sync/changes?since=${currentCursor}`,
|
||||
)
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const error = new Error(
|
||||
response
|
||||
? `Delta sync failed: ${response.status}`
|
||||
: 'Delta sync failed: no response from server',
|
||||
)
|
||||
error.status = response?.status
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Upsert changed chores first
|
||||
const changedChores = data.changes?.chores ?? []
|
||||
if (changedChores.length > 0) {
|
||||
await offlineDB.saveChores(changedChores)
|
||||
}
|
||||
|
||||
// Upsert changed history entries (also clears any pending entries for the same chore IDs)
|
||||
const changedHistory = data.changes?.choreHistories ?? []
|
||||
if (changedHistory.length > 0) {
|
||||
await offlineDB.saveHistory(changedHistory)
|
||||
}
|
||||
|
||||
// Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both)
|
||||
const deletedIds = data.deletions?.chores ?? []
|
||||
if (deletedIds.length > 0) {
|
||||
await offlineDB.deleteChores(deletedIds)
|
||||
}
|
||||
|
||||
const deletedHistoryIds = data.deletions?.choreHistories ?? []
|
||||
if (deletedHistoryIds.length > 0) {
|
||||
await offlineDB.deleteHistory(deletedHistoryIds)
|
||||
}
|
||||
|
||||
// Always advance the cursor, even when there are no changes
|
||||
if (data.cursor) {
|
||||
currentCursor = data.cursor
|
||||
}
|
||||
|
||||
hasMore = !!data.hasMore
|
||||
}
|
||||
|
||||
await offlineDB.setSyncCursor(currentCursor)
|
||||
await offlineDB.setLastSyncTime(Date.now())
|
||||
}
|
||||
|
||||
// Cache current chores (call after a successful online fetch)
|
||||
async cacheChores(chores) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
if (!chores || chores.length === 0) return
|
||||
await offlineDB.saveChores(chores)
|
||||
}
|
||||
}
|
||||
|
||||
export const syncEngine = new SyncEngine()
|
||||
@@ -1,33 +0,0 @@
|
||||
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()
|
||||
@@ -163,6 +163,7 @@ export const clearAllTokens = async () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
|
||||
localStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY)
|
||||
// Clean up legacy keys
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
|
||||
@@ -336,6 +336,7 @@ const ChoreEdit = () => {
|
||||
description: description,
|
||||
assignees: assignees,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
nextDueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
frequencyType: frequencyType,
|
||||
frequency: Number(frequency),
|
||||
frequencyMetadata: frequencyMetadata,
|
||||
@@ -365,11 +366,22 @@ const ChoreEdit = () => {
|
||||
}
|
||||
|
||||
SaveFunction(chore)
|
||||
.then(() => {
|
||||
.then(result => {
|
||||
if (
|
||||
result?._pendingUpdate ||
|
||||
result?._pendingCreate ||
|
||||
result?.res?._pendingCreate
|
||||
) {
|
||||
showSuccess({
|
||||
title: 'Saved Offline',
|
||||
message: 'Your changes will sync when you are back online.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'Chore Saved',
|
||||
message: 'Your task has been saved successfully!',
|
||||
})
|
||||
}
|
||||
Navigate('/chores')
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -44,7 +44,11 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreDetails,
|
||||
useChoreHistory,
|
||||
} from '../../queries/ChoreQueries.jsx'
|
||||
import {
|
||||
useChoreTimer,
|
||||
useDeleteTimeSession,
|
||||
@@ -54,8 +58,13 @@ import {
|
||||
} from '../../queries/TimeQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import {
|
||||
ChoreHistoryStatus,
|
||||
ChoreStatus,
|
||||
notInCompletionWindow,
|
||||
} from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
GetChoreDetailById,
|
||||
@@ -66,16 +75,34 @@ import {
|
||||
UndoChoreAction,
|
||||
UpdateChorePriority,
|
||||
} from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
|
||||
const decodeHtmlEntities = value => {
|
||||
if (typeof value !== 'string') return ''
|
||||
|
||||
return value
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&')
|
||||
}
|
||||
|
||||
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
|
||||
const ChoreView = () => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { fmt } = useLocalization()
|
||||
@@ -104,6 +131,17 @@ const ChoreView = () => {
|
||||
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
const { data: choreHistoryData } = useChoreHistory(choreId)
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
const historyCompletionCount = choreHistory.filter(historyEntry => {
|
||||
const status = Number(historyEntry?.status)
|
||||
return status === ChoreHistoryStatus.COMPLETED
|
||||
}).length
|
||||
const completionCount = choreHistoryData
|
||||
? historyCompletionCount
|
||||
: chore.totalCompletedCount || 0
|
||||
|
||||
const startChore = useStartChore()
|
||||
const pauseChore = usePauseChore()
|
||||
@@ -128,21 +166,6 @@ const ChoreView = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (chore && performers?.length > 0) {
|
||||
generateInfoCards(chore)
|
||||
}
|
||||
}, [chore, performers])
|
||||
const handleUpdatePriority = priority => {
|
||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(() => {
|
||||
setChorePriority(priority)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
const generateInfoCards = chore => {
|
||||
const cards = [
|
||||
{
|
||||
size: 6,
|
||||
@@ -164,7 +187,9 @@ const ChoreView = () => {
|
||||
icon: <CalendarMonth />,
|
||||
title: t('choreView.schedule'),
|
||||
text: `${t('choreView.due')}: ${
|
||||
chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : t('choreView.na')
|
||||
chore.nextDueDate
|
||||
? moment(chore.nextDueDate).fromNow()
|
||||
: t('choreView.na')
|
||||
}`,
|
||||
subtext: `${t('choreView.last')}: ${
|
||||
chore.lastCompletedDate
|
||||
@@ -181,7 +206,7 @@ const ChoreView = () => {
|
||||
size: 6,
|
||||
icon: <Checklist />,
|
||||
title: t('choreView.statistics'),
|
||||
text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
|
||||
text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
@@ -195,8 +220,20 @@ const ChoreView = () => {
|
||||
]
|
||||
setInfoCards(cards)
|
||||
}
|
||||
const handleTaskCompletion = () => {
|
||||
MarkChoreComplete(
|
||||
}, [chore, performers, completionCount, t])
|
||||
const handleUpdatePriority = priority => {
|
||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(() => {
|
||||
setChorePriority(priority)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleTaskCompletion = async () => {
|
||||
try {
|
||||
const resp = await MarkChoreComplete(
|
||||
choreId,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId, note }
|
||||
@@ -204,30 +241,16 @@ const ChoreView = () => {
|
||||
completedDate,
|
||||
null,
|
||||
)
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(data => {
|
||||
const data = await resp.json()
|
||||
setNote(null)
|
||||
setChore(data.res)
|
||||
})
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
.then(() => {
|
||||
// refetch the chore details
|
||||
GetChoreDetailById(choreId).then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(data => {
|
||||
setChore(data.res)
|
||||
})
|
||||
const detailResp = await GetChoreDetailById(choreId)
|
||||
if (detailResp.ok) {
|
||||
const detailData = await detailResp.json()
|
||||
setChore(detailData.res)
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
// Show undo notification
|
||||
showSuccess({
|
||||
title: t('choreView.taskCompleted'),
|
||||
message: t('choreView.taskCompletedMessage'),
|
||||
@@ -235,7 +258,6 @@ const ChoreView = () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(choreId)
|
||||
if (undoResponse.ok) {
|
||||
// Refetch chore details after undo
|
||||
const detailResponse = await GetChoreDetailById(choreId)
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json()
|
||||
@@ -257,25 +279,61 @@ const ChoreView = () => {
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.COMPLETE_CHORE,
|
||||
choreId,
|
||||
{
|
||||
id: choreId,
|
||||
body: impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId, note }
|
||||
: { note },
|
||||
completedDate: completedDate || null,
|
||||
performer: null,
|
||||
},
|
||||
)
|
||||
await offlineDB.savePendingHistory({
|
||||
id: -Date.now(),
|
||||
choreId: Number(choreId),
|
||||
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
|
||||
performedAt: completedDate || new Date().toISOString(),
|
||||
dueDate: chore.nextDueDate || null,
|
||||
notes: note || null,
|
||||
status: 1,
|
||||
points: chore.points || 0,
|
||||
pending: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — completion will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to complete task',
|
||||
})
|
||||
}
|
||||
const handleSkippingTask = () => {
|
||||
SkipChore(choreId).then(response => {
|
||||
}
|
||||
}
|
||||
const handleSkippingTask = async () => {
|
||||
try {
|
||||
const response = await SkipChore(choreId)
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
setChore(newChore)
|
||||
// Invalidate chores cache to refetch data
|
||||
const data = await response.json()
|
||||
setChore(data.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
|
||||
// Show undo notification
|
||||
showSuccess({
|
||||
message: t('choreView.skipTask'),
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(choreId)
|
||||
if (undoResponse.ok) {
|
||||
// Refetch chore details after undo
|
||||
const detailResponse = await GetChoreDetailById(choreId)
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json()
|
||||
@@ -297,11 +355,32 @@ const ChoreView = () => {
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.SKIP_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — skip will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to skip task',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleChoreStart = () => {
|
||||
const startedChore = { ...chore, status: ChoreStatus.ACTIVE }
|
||||
startChore.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
@@ -310,10 +389,37 @@ const ChoreView = () => {
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.START_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
setChore(startedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
setChore({ ...chore, status: previousStatus })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to start task',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleChorePause = () => {
|
||||
const pausedChore = { ...chore, status: ChoreStatus.PAUSED }
|
||||
pauseChore.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
@@ -322,6 +428,32 @@ const ChoreView = () => {
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.PAUSE_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
setChore(pausedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
setChore({ ...chore, status: previousStatus })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to pause task',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,7 +515,6 @@ const ChoreView = () => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore(data.res)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
@@ -395,23 +526,45 @@ const ChoreView = () => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore(data.res)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUnarchiveChore = () => {
|
||||
UnArchiveChore(choreId).then(response => {
|
||||
const handleUnarchiveChore = async () => {
|
||||
try {
|
||||
const response = await UnArchiveChore(choreId)
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore({ ...chore, isActive: true })
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.UNARCHIVE_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
setChore({ ...chore, isActive: true })
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — restore will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
setChore({ ...chore, isActive: false })
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
message: error.message || 'Unable to restore task',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||
@@ -458,16 +611,19 @@ const ChoreView = () => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='h3'
|
||||
// textAlign={'center'}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
mt: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
<Typography level='h3'>{chore.name}</Typography>
|
||||
<PendingBadge commands={pendingCmds} />
|
||||
</Box>
|
||||
{chore.isActive === false && (
|
||||
<Chip
|
||||
startDecorator={<Archive />}
|
||||
@@ -747,7 +903,30 @@ const ChoreView = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor value={chore.description} isEditable={false} />
|
||||
{(() => {
|
||||
const raw = chore.description || ''
|
||||
const shouldRenderHtml = hasHtmlTags(raw)
|
||||
|
||||
return shouldRenderHtml ? (
|
||||
<Box
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{decodeHtmlEntities(raw)}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Sheet>
|
||||
</>
|
||||
@@ -792,7 +971,30 @@ const ChoreView = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor value={chore.notes} isEditable={false} />
|
||||
{(() => {
|
||||
const raw = chore.notes || ''
|
||||
const shouldRenderHtml = hasHtmlTags(raw)
|
||||
|
||||
return shouldRenderHtml ? (
|
||||
<Box
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{decodeHtmlEntities(raw)}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Sheet>
|
||||
</>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -28,6 +29,7 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
@@ -41,6 +43,7 @@ const ArchivedTasks = () => {
|
||||
useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const queryClient = useQueryClient()
|
||||
const unArchiveChore = useUnArchiveChore()
|
||||
const [archivedChores, setArchivedChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
@@ -319,6 +322,10 @@ const ArchivedTasks = () => {
|
||||
const restoredTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
const queuedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -327,9 +334,15 @@ const ArchivedTasks = () => {
|
||||
restoredTasks.push(chore)
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id })
|
||||
queuedTasks.push(chore)
|
||||
resolve()
|
||||
} else {
|
||||
failedTasks.push(chore)
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -338,22 +351,22 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
// Remove restored tasks from archived list
|
||||
const restoredIds = new Set(restoredTasks.map(c => c.id))
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
const allRestored = [...restoredTasks, ...queuedTasks]
|
||||
if (allRestored.length > 0) {
|
||||
const offlineNote = queuedTasks.length > 0 ? " (queued — will sync when back online)" : ''
|
||||
// Remove from archived view optimistically for both online and queued
|
||||
const restoredIds = new Set(allRestored.map(c => c.id))
|
||||
const newArchivedChores = archivedChores.filter(c => !restoredIds.has(c.id))
|
||||
const newFilteredChores = filteredChores.filter(c => !restoredIds.has(c.id))
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
if (queuedTasks.length > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
}
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Restored ${allRestored.length} task${allRestored.length > 1 ? 's' : ''}${offlineNote}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
Repeat,
|
||||
Schedule,
|
||||
ThumbUp,
|
||||
TimesOneMobiledata,
|
||||
Toll,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import {
|
||||
getDueDateChipColor,
|
||||
@@ -32,6 +34,7 @@ import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
const ChoreCard = ({
|
||||
chore,
|
||||
performers,
|
||||
@@ -47,6 +50,7 @@ const ChoreCard = ({
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
@@ -86,7 +90,11 @@ const ChoreCard = ({
|
||||
return name
|
||||
}
|
||||
return (
|
||||
<Box key={chore.id + '-box'} minWidth={'100%'}>
|
||||
<Box
|
||||
key={chore.id + '-box'}
|
||||
minWidth={'100%'}
|
||||
sx={{ position: 'relative' }}
|
||||
>
|
||||
<Chip
|
||||
variant='soft'
|
||||
sx={{
|
||||
@@ -122,6 +130,9 @@ const ChoreCard = ({
|
||||
</div>
|
||||
</Chip>
|
||||
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||
<PendingBadge commands={pendingCmds} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
@@ -342,9 +353,31 @@ const ChoreCard = ({
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: 0.75,
|
||||
py: 0.5,
|
||||
minHeight: 56,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Schedule sx={{ fontSize: 16 }} />
|
||||
<Typography level='body-xs'>Pending</Typography>
|
||||
</Chip>
|
||||
)}
|
||||
{showActions && (
|
||||
<Box
|
||||
display='flex'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import {
|
||||
getDueDateChipColor,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors.jsx'
|
||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
|
||||
const CompactChoreCard = ({
|
||||
chore,
|
||||
@@ -44,6 +46,7 @@ const CompactChoreCard = ({
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
@@ -383,7 +386,9 @@ const CompactChoreCard = ({
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
|
||||
{(chore._pending || (pendingCmds && pendingCmds.length > 0)) && (
|
||||
<PendingBadge commands={pendingCmds} size='xs' sx={{ mr: -0.5 }} />
|
||||
)}
|
||||
{/* Due Date - Inline with name */}
|
||||
<Chip
|
||||
variant='soft'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
|
||||
const IconButtonWithMenu = ({
|
||||
@@ -17,6 +17,7 @@ const IconButtonWithMenu = ({
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
const menuOptions = Array.isArray(options) ? options : []
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
@@ -85,7 +86,7 @@ const IconButtonWithMenu = ({
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{options?.map(item => (
|
||||
{menuOptions.map(item => (
|
||||
<MenuItem
|
||||
key={`${k}-${item?.id}`}
|
||||
onClick={() => {
|
||||
|
||||
@@ -272,18 +272,6 @@ const MyChores = () => {
|
||||
// Don't set choreSections here - let the dedicated effect handle it
|
||||
// This prevents caching issues when switching between projects
|
||||
|
||||
if (localStorage.getItem('openChoreSections') === null) {
|
||||
setSelectedChoreSectionWithCache(selectedChoreSection)
|
||||
const openSections = processedSections.reduce(
|
||||
(acc, _section, index) => {
|
||||
acc[index] = true
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
setOpenChoreSections(openSections)
|
||||
}
|
||||
|
||||
if (await canScheduleNotification()) {
|
||||
console.log('Scheduling chore notifications...')
|
||||
scheduleChoreNotification(
|
||||
@@ -303,10 +291,8 @@ const MyChores = () => {
|
||||
choresData?.res,
|
||||
membersData?.res,
|
||||
processedChores, // Added to ensure local state syncs when query data updates
|
||||
processedSections,
|
||||
userProfile,
|
||||
impersonatedUser?.userId,
|
||||
selectedChoreSection,
|
||||
])
|
||||
|
||||
// Auto-update sections when processedSections changes
|
||||
@@ -1296,8 +1282,7 @@ const MyChores = () => {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{searchTerm?.length > 0 &&
|
||||
viewMode !== 'calendar' && (
|
||||
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
||||
<ChoreListView
|
||||
chores={getFilteredChores}
|
||||
viewMode={viewMode}
|
||||
@@ -1492,8 +1477,7 @@ const MyChores = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{searchTerm.length === 0 &&
|
||||
viewMode !== 'calendar' && (
|
||||
{searchTerm.length === 0 && viewMode !== 'calendar' && (
|
||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||
{choreSections.map((section, index) => {
|
||||
if (section.content.length === 0) return null
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useArchiveChore } from '../../../queries/ChoreQueries'
|
||||
import {
|
||||
useArchiveChore,
|
||||
useUnArchiveChore,
|
||||
} from '../../../queries/ChoreQueries'
|
||||
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
|
||||
import { commandQueue, CommandType } from '../../../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
DeleteChore,
|
||||
@@ -13,6 +17,10 @@ import {
|
||||
UpdateChoreAssignee,
|
||||
UpdateDueDate,
|
||||
} from '../../../utils/Fetcher'
|
||||
import { offlineDB } from '../../../utils/OfflineDB'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
|
||||
export const useChoreActions = ({
|
||||
chores,
|
||||
@@ -35,11 +43,12 @@ export const useChoreActions = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const archiveChore = useArchiveChore()
|
||||
const unarchiveChore = useUnArchiveChore()
|
||||
const startChore = useStartChore()
|
||||
const pauseChore = usePauseChore()
|
||||
|
||||
const updateChoreInState = useCallback(
|
||||
(updatedChore, event) => {
|
||||
(updatedChore, event, { skipInvalidation = false } = {}) => {
|
||||
let newChores = chores.map(c =>
|
||||
c.id === updatedChore.id ? updatedChore : c,
|
||||
)
|
||||
@@ -61,7 +70,9 @@ export const useChoreActions = ({
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
if (!skipInvalidation) {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
|
||||
const undoableActions = {
|
||||
completed: 'Task completed',
|
||||
@@ -77,7 +88,7 @@ export const useChoreActions = ({
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(updatedChore.id)
|
||||
if (undoResponse.ok) {
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
const undoMessages = {
|
||||
completed: 'Task completion has been undone.',
|
||||
approved: 'Task approval has been undone.',
|
||||
@@ -121,7 +132,8 @@ export const useChoreActions = ({
|
||||
archive: {
|
||||
type: 'success',
|
||||
title: 'Task Archived',
|
||||
message: 'The task has been archived and hidden from the active list.',
|
||||
message:
|
||||
'The task has been archived and hidden from the active list.',
|
||||
},
|
||||
started: {
|
||||
type: 'success',
|
||||
@@ -147,47 +159,56 @@ export const useChoreActions = ({
|
||||
notifyFn({ title: notification.title, message: notification.message })
|
||||
}
|
||||
},
|
||||
[chores, filteredChores, setChores, setFilteredChores, queryClient, showSuccess, showError, showWarning, showUndo, refetchChores],
|
||||
[
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
queryClient,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showUndo,
|
||||
],
|
||||
)
|
||||
|
||||
const handleChoreAction = useCallback(
|
||||
async (action, chore, extraData = {}) => {
|
||||
switch (action) {
|
||||
case 'complete':
|
||||
// 1. Instantly hide the chore from the UI and Cache
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
|
||||
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
|
||||
if (!oldData || !oldData.res) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.filter(c => c.id !== chore.id),
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
if (response.ok) {
|
||||
// 2. Show the success notification with Undo
|
||||
// Online: hide the chore and show undo
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
|
||||
if (!oldData || !oldData.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.filter(c => c.id !== chore.id),
|
||||
}
|
||||
})
|
||||
showSuccess({
|
||||
message: 'Task completed',
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(chore.id)
|
||||
if (undoResponse.ok) {
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: 'Task completion has been undone.',
|
||||
})
|
||||
} else throw new Error('Failed to undo')
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
@@ -195,60 +216,162 @@ export const useChoreActions = ({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Fetch the fresh active list from the server silently
|
||||
// (This brings in the next occurrence if recurring, without showing the completed one)
|
||||
queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
} else {
|
||||
refetchChores() // Network failed, revert to truth
|
||||
refetchChores()
|
||||
}
|
||||
} catch (error) {
|
||||
refetchChores() // Network failed, revert to truth
|
||||
if (error?.queued) {
|
||||
showError({
|
||||
title: 'Update Failed',
|
||||
message: 'Request will be reattempt when you are online',
|
||||
if (isNetworkError(error)) {
|
||||
// Offline — queue and show pending badge on the chore (don't hide it)
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.COMPLETE_CHORE,
|
||||
chore.id,
|
||||
{
|
||||
id: chore.id,
|
||||
body: impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
completedDate: null,
|
||||
performer: null,
|
||||
},
|
||||
)
|
||||
await offlineDB.savePendingHistory({
|
||||
id: -Date.now(),
|
||||
choreId: chore.id,
|
||||
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
|
||||
performedAt: new Date().toISOString(),
|
||||
dueDate: chore.nextDueDate || null,
|
||||
notes: null,
|
||||
status: 1,
|
||||
points: chore.points || 0,
|
||||
pending: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
title: 'Task completion pending',
|
||||
message:
|
||||
"You're offline — completion will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to update',
|
||||
message: error,
|
||||
title: 'Failed to complete',
|
||||
message: error?.message || 'Unable to complete chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
startChore.mutate(chore.id, {
|
||||
onSuccess: async res => {
|
||||
const data = await res.json()
|
||||
const newChore = { ...chore, status: data.res.status }
|
||||
updateChoreInState(newChore, 'started')
|
||||
case 'start': {
|
||||
const startedChore = { ...chore, status: 1 }
|
||||
try {
|
||||
await startChore.mutateAsync(chore.id)
|
||||
queryClient.cancelQueries(['chores'])
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(c =>
|
||||
c.id === chore.id ? startedChore : c,
|
||||
),
|
||||
}
|
||||
})
|
||||
updateChoreInState(startedChore, 'started', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.START_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
updateChoreInState(startedChore, 'started', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
updateChoreInState(
|
||||
{ ...chore, status: previousStatus },
|
||||
previousStatus === 2 ? 'paused' : 'started',
|
||||
{ skipInvalidation: true },
|
||||
)
|
||||
},
|
||||
onError: error => {
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to start',
|
||||
message: error.message || 'Unable to start chore',
|
||||
})
|
||||
},
|
||||
message: error?.message || 'Unable to start chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
pauseChore.mutate(chore.id, {
|
||||
onSuccess: async res => {
|
||||
const data = await res.json()
|
||||
const newChore = { ...chore, status: data.res.status }
|
||||
updateChoreInState(newChore, 'paused')
|
||||
case 'pause': {
|
||||
const pausedChore = { ...chore, status: 2 }
|
||||
try {
|
||||
await pauseChore.mutateAsync(chore.id)
|
||||
queryClient.cancelQueries(['chores'])
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(c =>
|
||||
c.id === chore.id ? pausedChore : c,
|
||||
),
|
||||
}
|
||||
})
|
||||
updateChoreInState(pausedChore, 'paused', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.PAUSE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
updateChoreInState(pausedChore, 'paused', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
updateChoreInState(
|
||||
{ ...chore, status: previousStatus },
|
||||
previousStatus === 2 ? 'paused' : 'started',
|
||||
{ skipInvalidation: true },
|
||||
)
|
||||
},
|
||||
onError: error => {
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to pause',
|
||||
message: error.message || 'Unable to pause chore',
|
||||
})
|
||||
},
|
||||
message: error?.message || 'Unable to pause chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'approve':
|
||||
try {
|
||||
@@ -305,12 +428,39 @@ export const useChoreActions = ({
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.DELETE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev =>
|
||||
prev.filter(c => c.id !== chore.id),
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — deletion will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
setChores(prev => [...prev, chore])
|
||||
setFilteredChores(prev => [...prev, chore])
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
message: error,
|
||||
message: error?.message || 'Unable to delete chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
@@ -324,32 +474,123 @@ export const useChoreActions = ({
|
||||
updateChoreInState(data, 'archive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.ARCHIVE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev =>
|
||||
prev.filter(c => c.id !== chore.id),
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — archive will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
setChores(prev => [...prev, chore])
|
||||
setFilteredChores(prev => [...prev, chore])
|
||||
},
|
||||
})
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
message: error.message || 'Unable to archive chore',
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
} catch (error) {}
|
||||
break
|
||||
|
||||
case 'unarchive':
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
unarchiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState({ ...chore, isActive: true }, 'unarchive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.UNARCHIVE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — restore will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
},
|
||||
})
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
message: error.message || 'Unable to restore chore',
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {}
|
||||
break
|
||||
|
||||
case 'skip':
|
||||
try {
|
||||
const response = await SkipChore(chore.id)
|
||||
if (response.ok) {
|
||||
// Online: update in place (chore gets new due date)
|
||||
const data = await response.json()
|
||||
updateChoreInState(data.res, 'skipped')
|
||||
} else {
|
||||
refetchChores()
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
// Offline — queue and show pending badge on the chore
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.SKIP_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — skip will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to skip',
|
||||
message: error,
|
||||
message: error?.message || 'Unable to skip chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'changeDueDate':
|
||||
@@ -363,6 +604,40 @@ export const useChoreActions = ({
|
||||
updateChoreInState(chore, eventType)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const oldDueDate = chore.nextDueDate
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.RESCHEDULE_CHORE,
|
||||
chore.id,
|
||||
{
|
||||
id: chore.id,
|
||||
dueDate: extraData.date,
|
||||
},
|
||||
)
|
||||
const eventType =
|
||||
extraData.date === null ? 'due-date-removed' : 'rescheduled'
|
||||
updateChoreInState(
|
||||
{ ...chore, nextDueDate: extraData.date },
|
||||
eventType,
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — reschedule will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
const undoEventType =
|
||||
oldDueDate === null ? 'due-date-removed' : 'rescheduled'
|
||||
updateChoreInState(
|
||||
{ ...chore, nextDueDate: oldDueDate },
|
||||
undoEventType,
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title:
|
||||
extraData.date === null
|
||||
@@ -371,6 +646,7 @@ export const useChoreActions = ({
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openModal(action, chore, extraData)
|
||||
}
|
||||
@@ -400,26 +676,67 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig,
|
||||
openModal,
|
||||
archiveChore,
|
||||
unarchiveChore,
|
||||
startChore,
|
||||
pauseChore,
|
||||
],
|
||||
)
|
||||
|
||||
const handleChangeDueDate = useCallback(
|
||||
newDate => {
|
||||
async newDate => {
|
||||
if (!modalChore) return
|
||||
UpdateDueDate(modalChore.id, newDate).then(response => {
|
||||
closeModal()
|
||||
try {
|
||||
const response = await UpdateDueDate(modalChore.id, newDate)
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = modalChore
|
||||
newChore.nextDueDate = newDate
|
||||
updateChoreInState(newChore, 'rescheduled')
|
||||
updateChoreInState(
|
||||
{ ...modalChore, nextDueDate: newDate },
|
||||
'rescheduled',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const oldDueDate = modalChore.nextDueDate
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.RESCHEDULE_CHORE,
|
||||
modalChore.id,
|
||||
{
|
||||
id: modalChore.id,
|
||||
dueDate: newDate,
|
||||
},
|
||||
)
|
||||
updateChoreInState(
|
||||
{ ...modalChore, nextDueDate: newDate },
|
||||
'rescheduled',
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — reschedule will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
updateChoreInState(
|
||||
{ ...modalChore, nextDueDate: oldDueDate },
|
||||
'rescheduled',
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to reschedule',
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
},
|
||||
[modalChore, updateChoreInState, closeModal],
|
||||
[
|
||||
modalChore,
|
||||
updateChoreInState,
|
||||
closeModal,
|
||||
showSuccess,
|
||||
showError,
|
||||
queryClient,
|
||||
],
|
||||
)
|
||||
|
||||
const handleCompleteWithPastDate = useCallback(
|
||||
@@ -568,7 +885,15 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
impersonatedUser,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
@@ -603,8 +928,7 @@ export const useChoreActions = ({
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
if (archivedTasks.length > 0) {
|
||||
showSuccess({
|
||||
@@ -630,7 +954,17 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
archiveChore,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
@@ -690,7 +1024,18 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkSkip = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
@@ -726,7 +1071,7 @@ export const useChoreActions = ({
|
||||
for (const chore of skippedTasks) {
|
||||
await UndoChoreAction(chore.id)
|
||||
}
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
@@ -760,7 +1105,15 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
showSuccess,
|
||||
showError,
|
||||
showUndo,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
return {
|
||||
handleChoreAction,
|
||||
|
||||
@@ -20,10 +20,11 @@ import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreHistory,
|
||||
useDeleteChoreHistory,
|
||||
@@ -48,15 +49,33 @@ const ChoreHistory = () => {
|
||||
const { fmt } = useLocalization()
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { showSuccess } = useNotification()
|
||||
// React Query hooks
|
||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const updateChoreHistory = useUpdateChoreHistory()
|
||||
const deleteChoreHistory = useDeleteChoreHistory()
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
const performers = circleMembersData?.res || []
|
||||
const pendingByHistoryId = useMemo(() => {
|
||||
if (!pendingCmds?.length) return {}
|
||||
return pendingCmds.reduce((acc, cmd) => {
|
||||
if (
|
||||
cmd.commandType !== 'update_chore_history' &&
|
||||
cmd.commandType !== 'delete_chore_history'
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
const historyId =
|
||||
cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1])
|
||||
if (!historyId) return acc
|
||||
if (!acc[historyId]) acc[historyId] = []
|
||||
acc[historyId].push(cmd)
|
||||
return acc
|
||||
}, {})
|
||||
}, [pendingCmds])
|
||||
|
||||
const handleDelete = historyEntry => {
|
||||
showConfirmation(
|
||||
@@ -366,6 +385,7 @@ const ChoreHistory = () => {
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
@@ -407,13 +427,21 @@ const ChoreHistory = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: data => {
|
||||
setIsEditModalOpen(false)
|
||||
setEditHistory(null)
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Update Queued',
|
||||
message:
|
||||
'You are offline. The history update will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Updated',
|
||||
message: `The history record has been updated successfully.`,
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Failed to update chore history:', error)
|
||||
@@ -429,13 +457,21 @@ const ChoreHistory = () => {
|
||||
historyId: editHistory.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: data => {
|
||||
setIsEditModalOpen(false)
|
||||
setEditHistory(null)
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Delete Queued',
|
||||
message:
|
||||
'You are offline. The history delete will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Deleted',
|
||||
message: `The history record has been deleted successfully.`,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,9 +17,14 @@ import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) {
|
||||
if (
|
||||
historyEntry.status === 0 ||
|
||||
historyEntry.status === 5 ||
|
||||
historyEntry.status === 6
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -94,6 +99,7 @@ const HistoryCard = ({
|
||||
performers,
|
||||
historyEntry,
|
||||
index,
|
||||
pendingCommands,
|
||||
onToggleActions,
|
||||
onViewNote,
|
||||
}) => {
|
||||
@@ -330,6 +336,18 @@ const HistoryCard = ({
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{pendingCommands?.length > 0 && (
|
||||
<PendingBadge
|
||||
commands={pendingCommands}
|
||||
size='s'
|
||||
sx={{
|
||||
mr: 0.5,
|
||||
position: 'absolute',
|
||||
right: -3,
|
||||
top: -3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
|
||||
export const useLabels = () => {
|
||||
return useQuery({
|
||||
queryKey: ['labels'],
|
||||
queryFn: GetLabels,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await GetLabels()
|
||||
const labels = Array.isArray(data?.res)
|
||||
? data.res
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
|
||||
if (labels.length > 0) {
|
||||
offlineDB.saveKV('labels', labels)
|
||||
}
|
||||
return labels
|
||||
} catch {
|
||||
const cached = await offlineDB.getKV('labels')
|
||||
if (Array.isArray(cached)) return cached
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ function ConfirmationModal({ config }) {
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
unmountDelay={250}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
CreateProject,
|
||||
DeleteProject,
|
||||
GetProjects,
|
||||
UpdateProject,
|
||||
} from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
|
||||
// Query hook for fetching all projects
|
||||
export const useProjects = () => {
|
||||
@@ -10,22 +16,15 @@ export const useProjects = () => {
|
||||
const response = await GetProjects()
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return data.res || data
|
||||
const projects = data.res || data
|
||||
offlineDB.saveKV('projects', projects)
|
||||
return projects
|
||||
}
|
||||
throw new Error('Failed to fetch projects')
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error)
|
||||
// Return default project if API fails
|
||||
return [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
description: 'Your default project workspace',
|
||||
color: '#1976d2',
|
||||
created_by: 'system',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
]
|
||||
} catch {
|
||||
const cached = await offlineDB.getKV('projects')
|
||||
if (cached) return cached
|
||||
return []
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
@@ -39,7 +38,7 @@ export const useCreateProject = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (projectData) => {
|
||||
mutationFn: async projectData => {
|
||||
try {
|
||||
const response = await CreateProject(projectData)
|
||||
if (response.ok) {
|
||||
@@ -59,7 +58,7 @@ export const useCreateProject = () => {
|
||||
return localProject
|
||||
}
|
||||
},
|
||||
onSuccess: (newProject) => {
|
||||
onSuccess: newProject => {
|
||||
// Update the projects cache
|
||||
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
||||
const updatedProjects = [...oldProjects, newProject]
|
||||
@@ -69,7 +68,7 @@ export const useCreateProject = () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['projects'])
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
console.error('Create project mutation failed:', error)
|
||||
},
|
||||
})
|
||||
@@ -98,18 +97,18 @@ export const useUpdateProject = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (updatedProject) => {
|
||||
onSuccess: updatedProject => {
|
||||
// Update the projects cache
|
||||
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
||||
return oldProjects.map(project =>
|
||||
project.id === updatedProject.id ? updatedProject : project
|
||||
project.id === updatedProject.id ? updatedProject : project,
|
||||
)
|
||||
})
|
||||
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['projects'])
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
console.error('Update project mutation failed:', error)
|
||||
},
|
||||
})
|
||||
@@ -120,7 +119,7 @@ export const useDeleteProject = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (projectId) => {
|
||||
mutationFn: async projectId => {
|
||||
try {
|
||||
// Prevent deletion of default project
|
||||
if (projectId === 'default') {
|
||||
@@ -147,14 +146,14 @@ export const useDeleteProject = () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['projects'])
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
console.error('Delete project mutation failed:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Hook to get a specific project by ID
|
||||
export const useProject = (projectId) => {
|
||||
export const useProject = projectId => {
|
||||
return useQuery({
|
||||
queryKey: ['projects', projectId],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControl,
|
||||
@@ -9,22 +8,43 @@ import {
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import {
|
||||
clearBrowserCacheStorage,
|
||||
isOfflineFeatureEnabled,
|
||||
setOfflineFeatureEnabled,
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const AdvancedSettings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const queryClient = useQueryClient()
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
const [userCircles, setUserCircles] = useState([])
|
||||
const [webhookURL, setWebhookURL] = useState(null)
|
||||
const [webhookError, setWebhookError] = useState(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [offlineEnabled, setOfflineEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
const [offlineLoading, setOfflineLoading] = useState(false)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToOfflineFeature(setOfflineEnabled)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
GetUserCircle().then(resp => {
|
||||
@@ -42,21 +62,120 @@ const AdvancedSettings = () => {
|
||||
}
|
||||
}, [userCircles])
|
||||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
<SettingsLayout title="Advanced Settings">
|
||||
<div>Loading...</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
const disableOfflineSupport = async () => {
|
||||
setOfflineLoading(true)
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
await clearBrowserCacheStorage()
|
||||
setOfflineFeatureEnabled(false)
|
||||
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
|
||||
queryClient.removeQueries({ queryKey: ['chores'] })
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Offline support disabled and local offline data cleared',
|
||||
})
|
||||
} catch {
|
||||
setOfflineFeatureEnabled(false)
|
||||
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
|
||||
queryClient.removeQueries({ queryKey: ['chores'] })
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Offline support disabled, but some local cache items may not have been cleared',
|
||||
})
|
||||
} finally {
|
||||
setOfflineLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showDisableOfflineConfirmation = () => {
|
||||
setConfirmModalConfig({
|
||||
isOpen: true,
|
||||
title: 'Disable Offline Support',
|
||||
message:
|
||||
'Disabling offline support will remove queued offline actions and local cached offline data on this device/browser. Do you want to continue?',
|
||||
confirmText: 'Disable & Clear',
|
||||
cancelText: 'Cancel',
|
||||
color: 'danger',
|
||||
onClose: isConfirmed => {
|
||||
setConfirmModalConfig({})
|
||||
if (isConfirmed) {
|
||||
disableOfflineSupport()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOfflineToggle = async event => {
|
||||
const nextEnabled = !!event.target.checked
|
||||
|
||||
if (nextEnabled) {
|
||||
setOfflineFeatureEnabled(true)
|
||||
await syncEngine.sync()
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Offline support enabled for this device/browser',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showDisableOfflineConfirmation()
|
||||
}
|
||||
|
||||
// if (!userProfile) {
|
||||
// return (
|
||||
// <SettingsLayout title="Advanced Settings">
|
||||
// <div>Loading...</div>
|
||||
// </SettingsLayout>
|
||||
// )
|
||||
// }
|
||||
|
||||
return (
|
||||
<SettingsLayout title="Advanced Settings">
|
||||
<SettingsLayout title='Advanced Settings'>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Configure advanced features like webhooks and real-time updates for enhanced productivity.
|
||||
Configure advanced features like webhooks and real-time updates for
|
||||
enhanced productivity.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<Typography level='title-lg'>Offline Support</Typography>
|
||||
<Chip
|
||||
variant='outlined'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: '20px',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'warning.main',
|
||||
borderColor: 'warning.main',
|
||||
}}
|
||||
>
|
||||
Early Access
|
||||
</Chip>
|
||||
</Box>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Enable offline queue and local cache for this device/browser.
|
||||
Disabling removes pending offline actions and cached offline data.
|
||||
</Typography>
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
checked={offlineEnabled}
|
||||
onChange={handleOfflineToggle}
|
||||
variant='soft'
|
||||
label='Enable Offline Support'
|
||||
disabled={offlineLoading}
|
||||
overlay
|
||||
/>
|
||||
<FormHelperText>
|
||||
When disabled, queued changes and offline cache are cleared from
|
||||
this device/browser.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Webhook Settings - Only show for admins */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -152,9 +271,14 @@ const AdvancedSettings = () => {
|
||||
Real-time Updates
|
||||
</Typography>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Configure how you receive live updates when tasks and activities change in your circle.
|
||||
Configure how you receive live updates when tasks and activities
|
||||
change in your circle.
|
||||
</Typography>
|
||||
<RealTimeSettings />
|
||||
|
||||
{confirmModalConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Refresh, Token } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import { RefreshToken } from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
@@ -31,9 +40,48 @@ const DeveloperSettings = () => {
|
||||
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
||||
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
||||
const [isResettingSync, setIsResettingSync] = useState(false)
|
||||
const [syncDiagnostics, setSyncDiagnostics] = useState({
|
||||
cursor: null,
|
||||
lastSync: null,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
syncing: false,
|
||||
syncError: null,
|
||||
isOnline: networkManager.isOnline,
|
||||
isNetworkOn: networkManager.isNetworkOn,
|
||||
offlineSince: networkManager.offlineSince,
|
||||
lastChecked: networkManager.lastChecked,
|
||||
})
|
||||
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
const refreshSyncDiagnostics = useCallback(async () => {
|
||||
try {
|
||||
const [cursor, lastSync, pendingCommands, failedCommands] =
|
||||
await Promise.all([
|
||||
offlineDB.getSyncCursor(),
|
||||
offlineDB.getLastSyncTime(),
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
|
||||
setSyncDiagnostics(prev => ({
|
||||
...prev,
|
||||
cursor,
|
||||
lastSync,
|
||||
pendingCount: pendingCommands.length,
|
||||
failedCount: failedCommands.length,
|
||||
isOnline: networkManager.isOnline,
|
||||
isNetworkOn: networkManager.isNetworkOn,
|
||||
offlineSince: networkManager.offlineSince,
|
||||
lastChecked: networkManager.lastChecked,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync diagnostics:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativePlatform(isNative())
|
||||
|
||||
@@ -54,12 +102,8 @@ const DeveloperSettings = () => {
|
||||
const pending = await LocalNotifications.getPending()
|
||||
// Sort by schedule time (earliest first)
|
||||
const sorted = pending.notifications.sort((a, b) => {
|
||||
const timeA = a.schedule?.at
|
||||
? new Date(a.schedule.at).getTime()
|
||||
: 0
|
||||
const timeB = b.schedule?.at
|
||||
? new Date(b.schedule.at).getTime()
|
||||
: 0
|
||||
const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0
|
||||
const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0
|
||||
return timeA - timeB
|
||||
})
|
||||
setScheduledNotifications(sorted)
|
||||
@@ -73,7 +117,41 @@ const DeveloperSettings = () => {
|
||||
|
||||
loadTokenData()
|
||||
loadScheduledNotifications()
|
||||
}, [])
|
||||
refreshSyncDiagnostics()
|
||||
}, [refreshSyncDiagnostics])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeSync = syncEngine.onSyncStateChange(state => {
|
||||
setSyncDiagnostics(prev => ({
|
||||
...prev,
|
||||
syncing:
|
||||
typeof state.syncing === 'boolean' ? state.syncing : prev.syncing,
|
||||
syncError: Object.prototype.hasOwnProperty.call(state, 'error')
|
||||
? state.error
|
||||
: prev.syncError,
|
||||
lastSync: state.lastSync ?? prev.lastSync,
|
||||
}))
|
||||
})
|
||||
|
||||
networkManager.registerNetworkListener(() => {
|
||||
setSyncDiagnostics(prev => ({
|
||||
...prev,
|
||||
isOnline: networkManager.isOnline,
|
||||
isNetworkOn: networkManager.isNetworkOn,
|
||||
offlineSince: networkManager.offlineSince,
|
||||
lastChecked: networkManager.lastChecked,
|
||||
}))
|
||||
})
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshSyncDiagnostics()
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
unsubscribeSync()
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [refreshSyncDiagnostics])
|
||||
|
||||
useEffect(() => {
|
||||
const calculateTimeLeft = () => {
|
||||
@@ -239,6 +317,51 @@ const DeveloperSettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetDatabaseAndResync = async () => {
|
||||
showConfirmation(
|
||||
'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?',
|
||||
'Clear Local DB & Re-Sync',
|
||||
async () => {
|
||||
setIsResettingSync(true)
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Local offline database cleared. Starting full sync...',
|
||||
})
|
||||
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
await queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Full sync completed from the beginning',
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Database cleared. Full sync did not run (likely offline or already syncing).',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database and resync:', error)
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Reset/resync failed: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
await refreshSyncDiagnostics()
|
||||
setIsResettingSync(false)
|
||||
}
|
||||
},
|
||||
'Clear & Re-Sync',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}
|
||||
|
||||
const getNotificationStatusColor = scheduleTime => {
|
||||
if (!scheduleTime) return 'neutral'
|
||||
|
||||
@@ -252,6 +375,11 @@ const DeveloperSettings = () => {
|
||||
return 'success' // More than 1 hour
|
||||
}
|
||||
|
||||
const formatDateTime = timestamp => {
|
||||
if (!timestamp) return 'N/A'
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='developer'>
|
||||
<Typography level='h3'>Developer Settings</Typography>
|
||||
@@ -377,6 +505,143 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-lg'>Sync & Network Diagnostics</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Refresh />}
|
||||
onClick={refreshSyncDiagnostics}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Network Status</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Connection:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.isOnline ? 'success' : 'danger'}
|
||||
>
|
||||
{syncDiagnostics.isOnline ? 'Online' : 'Offline'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Device Network:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={
|
||||
syncDiagnostics.isNetworkOn === false
|
||||
? 'danger'
|
||||
: syncDiagnostics.isNetworkOn === true
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{syncDiagnostics.isNetworkOn === false
|
||||
? 'Disconnected'
|
||||
: syncDiagnostics.isNetworkOn === true
|
||||
? 'Connected'
|
||||
: 'Unknown'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Offline Since: {formatDateTime(syncDiagnostics.offlineSince)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Sync Offset Information</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Sync Cursor:{' '}
|
||||
<Chip size='sm' variant='soft'>
|
||||
{syncDiagnostics.cursor ?? 'N/A'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Last Sync:{' '}
|
||||
<Chip size='sm' variant='soft' color='primary'>
|
||||
{formatDateTime(syncDiagnostics.lastSync)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Sync State:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.syncing ? 'warning' : 'success'}
|
||||
>
|
||||
{syncDiagnostics.syncing ? 'Syncing' : 'Idle'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Pending Commands:{' '}
|
||||
<Chip size='sm' variant='soft' color='warning'>
|
||||
{syncDiagnostics.pendingCount}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Failed Commands:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.failedCount > 0 ? 'danger' : 'success'}
|
||||
>
|
||||
{syncDiagnostics.failedCount}
|
||||
</Chip>
|
||||
</Typography>
|
||||
{syncDiagnostics.syncError && (
|
||||
<Typography level='body-sm' color='danger'>
|
||||
Sync Error: {syncDiagnostics.syncError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Recovery Actions</Typography>
|
||||
<Typography level='body-xs' color='warning'>
|
||||
Clears local offline cache, sync cursor, and queued commands, then
|
||||
re-syncs from the beginning.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='soft'
|
||||
onClick={handleResetDatabaseAndResync}
|
||||
loading={isResettingSync}
|
||||
disabled={isResettingSync || syncDiagnostics.syncing}
|
||||
>
|
||||
Clear DB & Full Re-Sync
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{isNativePlatform && (
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -436,9 +701,7 @@ const DeveloperSettings = () => {
|
||||
? new Date(scheduleTime)
|
||||
: null
|
||||
const now = new Date()
|
||||
const timeUntil = scheduledDate
|
||||
? scheduledDate - now
|
||||
: null
|
||||
const timeUntil = scheduledDate ? scheduledDate - now : null
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -726,6 +989,8 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,11 @@ import {
|
||||
Card,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
FEATURES,
|
||||
isFeatureEnabled,
|
||||
setFeatureEnabled,
|
||||
} from '../../utils/FeatureToggle'
|
||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
@@ -26,9 +20,6 @@ const StorageSettings = () => {
|
||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
const [offlineModeEnabled, setOfflineModeEnabledState] = useState(
|
||||
isFeatureEnabled(FEATURES.OFFLINE_MODE),
|
||||
)
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
@@ -54,11 +45,6 @@ const StorageSettings = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleOfflineModeToggle = enabled => {
|
||||
setOfflineModeEnabledState(enabled)
|
||||
setFeatureEnabled(FEATURES.OFFLINE_MODE, enabled)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlusAccount(userProfile)) {
|
||||
GetStorageUsage().then(resp => {
|
||||
@@ -127,48 +113,14 @@ const StorageSettings = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
Experimental Features
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Coming Soon
|
||||
</Chip>
|
||||
</Typography>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className='flex-1'>
|
||||
<Typography level='body-md' sx={{ mb: 0.5 }}>
|
||||
Enable Offline Mode
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Allows the app to work offline by caching data locally. This is
|
||||
experimental and may cause some slowness. If you experience
|
||||
performance issues, we recommend turning this off.
|
||||
</Typography>
|
||||
</div>
|
||||
<Switch
|
||||
checked={offlineModeEnabled}
|
||||
disabled={true}
|
||||
onChange={event => handleOfflineModeToggle(event.target.checked)}
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
</div>
|
||||
{offlineModeEnabled && (
|
||||
<Typography level='body-xs' color='warning' sx={{ mt: 1 }}>
|
||||
⚠️ Offline mode is enabled. If you experience slowness, disable
|
||||
this setting.
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
|
||||
Cache
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This is data stored locally in your browser for faster access and
|
||||
offline use. Clearing this will not affect your server data, but may
|
||||
log you out or remove offline tasks.
|
||||
This is data stored locally in your browser for faster access.
|
||||
Clearing this will not affect your server data, but may log you out.
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
@@ -189,27 +141,6 @@ const StorageSettings = () => {
|
||||
>
|
||||
Clear All Local Storage and Cache
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to clear only the offline cache and tasks?',
|
||||
'Clear Offline Cache',
|
||||
() => {
|
||||
localStorage.removeItem('offline_cache')
|
||||
localStorage.removeItem('offline_request_queue')
|
||||
localStorage.removeItem('offlineTasks')
|
||||
},
|
||||
'Clear Cache',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Offline Cache and Offline Tasks
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
|
||||
@@ -47,10 +47,14 @@ import {
|
||||
} from '../../queries/TimeQueries'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getSafeBottom } from '../../utils/SafeAreaUtils'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
|
||||
const TimerDetails = () => {
|
||||
const { choreId } = useParams()
|
||||
const { fmt } = useLocalization()
|
||||
@@ -256,7 +260,23 @@ const TimerDetails = () => {
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
onError: () => {
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.START_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Start queued',
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to start timer',
|
||||
message: 'Please try again.',
|
||||
@@ -278,7 +298,23 @@ const TimerDetails = () => {
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
onError: () => {
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.PAUSE_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Pause queued',
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to pause timer',
|
||||
message: 'Please try again.',
|
||||
@@ -928,9 +964,7 @@ const TimerDetails = () => {
|
||||
'MMM DD',
|
||||
)
|
||||
const startTime = fmt.time(pause.start)
|
||||
const endTime = pause.end
|
||||
? fmt.time(pause.end)
|
||||
: null
|
||||
const endTime = pause.end ? fmt.time(pause.end) : null
|
||||
|
||||
const realTimeDuration = isOngoing
|
||||
? Math.max(
|
||||
|
||||
@@ -633,32 +633,28 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
createChoreMutation
|
||||
.mutateAsync(chore)
|
||||
.then(resp => {
|
||||
resp.json().then(data => {
|
||||
if (resp.status !== 200) {
|
||||
console.error('Error creating chore:', data)
|
||||
return
|
||||
.then(result => {
|
||||
const choreData = result
|
||||
if (choreData?._pendingCreate) {
|
||||
// Offline: task queued, add temp chore to UI immediately
|
||||
onChoreUpdate(choreData)
|
||||
} else {
|
||||
// Online: choreData is the created chore object returned by the mutation
|
||||
onChoreUpdate({
|
||||
...chore,
|
||||
id: data.res,
|
||||
...choreData,
|
||||
id: choreData?.id,
|
||||
nextDueDate: chore.dueDate,
|
||||
})
|
||||
|
||||
handleCloseModal(false)
|
||||
}
|
||||
handleCloseModal()
|
||||
setTaskText('')
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.queued) {
|
||||
handleCloseModal(true)
|
||||
}
|
||||
console.error('Error creating chore:', error)
|
||||
})
|
||||
handleCloseModal(false)
|
||||
}
|
||||
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
|
||||
if (isCircleMembersLoading || isProjectsLoading) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
@@ -199,6 +200,7 @@ const NavBar = () => {
|
||||
{getMenuIcon()}
|
||||
<Box className='flex-1' />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SyncStatusIndicator />
|
||||
<UserProfileAvatar />
|
||||
{/* <ThemeToggleButton /> */}
|
||||
</Box>
|
||||
|
||||
206
src/views/components/PendingBadge.jsx
Normal file
206
src/views/components/PendingBadge.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Close, CloudSync } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
|
||||
const LABELS = {
|
||||
complete_chore: 'Complete pending',
|
||||
skip_chore: 'Skip pending',
|
||||
update_chore: 'Update pending',
|
||||
create_chore: 'Create pending',
|
||||
delete_chore: 'Delete pending',
|
||||
update_chore_history: 'Edit history pending',
|
||||
delete_chore_history: 'Delete history pending',
|
||||
reschedule_chore: 'Reschedule pending',
|
||||
archive_chore: 'Archive pending',
|
||||
unarchive_chore: 'Restore pending',
|
||||
start_chore: 'Start pending',
|
||||
pause_chore: 'Pause pending',
|
||||
}
|
||||
|
||||
const formatCommandLabel = commandType => {
|
||||
return (
|
||||
LABELS[commandType] ||
|
||||
commandType
|
||||
?.replace(/_/g, ' ')
|
||||
?.replace(/\b\w/g, letter => letter.toUpperCase()) ||
|
||||
'Pending action'
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const queryClient = useQueryClient()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [cancelingIds, setCancelingIds] = useState({})
|
||||
const [isCancelingAll, setIsCancelingAll] = useState(false)
|
||||
|
||||
const pendingSyncLabel = `${commands?.length || 0} pending action${commands?.length === 1 ? '' : 's'} to sync`
|
||||
|
||||
if (!commands || commands.length === 0) return null
|
||||
|
||||
const stopEvent = e => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const invalidatePending = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['choreHistory'] })
|
||||
}
|
||||
|
||||
const handleUndo = async (e, cmdId) => {
|
||||
e.stopPropagation()
|
||||
setCancelingIds(prev => ({ ...prev, [cmdId]: true }))
|
||||
|
||||
try {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await invalidatePending()
|
||||
} finally {
|
||||
setCancelingIds(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[cmdId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelAll = async e => {
|
||||
e.stopPropagation()
|
||||
if (commands.length === 0) return
|
||||
|
||||
setIsCancelingAll(true)
|
||||
try {
|
||||
await Promise.all(commands.map(cmd => commandQueue.cancel(cmd.id)))
|
||||
await invalidatePending()
|
||||
setIsOpen(false)
|
||||
} finally {
|
||||
setIsCancelingAll(false)
|
||||
setCancelingIds({})
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = e => {
|
||||
e.stopPropagation()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleClose = e => {
|
||||
if (e?.stopPropagation) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isXs = size === 'xs'
|
||||
|
||||
return (
|
||||
<Box data-no-chore-nav='true' sx={{ mt: isXs ? 0 : 0.5, ...sx }}>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='warning'
|
||||
size='sm'
|
||||
onClick={handleOpen}
|
||||
onMouseDown={stopEvent}
|
||||
onPointerDown={stopEvent}
|
||||
aria-label={pendingSyncLabel}
|
||||
title={pendingSyncLabel}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
...(isXs && {
|
||||
width: 18,
|
||||
height: 18,
|
||||
minWidth: 18,
|
||||
minHeight: 18,
|
||||
p: 0.25,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* <Badge
|
||||
badgeContent={commands.length}
|
||||
size='sm'
|
||||
color='warning'
|
||||
sx={{
|
||||
'& .MuiBadge-badge': { fontSize: 10, minWidth: 16, height: 16 },
|
||||
}}
|
||||
> */}
|
||||
<CloudSync sx={{ fontSize: isXs ? 14 : 16 }} />
|
||||
{/* </Badge> */}
|
||||
</IconButton>
|
||||
|
||||
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
|
||||
<Typography level='title-lg' mb={0.5}>
|
||||
Pending actions
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
|
||||
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
|
||||
synced.
|
||||
</Typography>
|
||||
|
||||
<List sx={{ '--List-gap': '8px', p: 0, mb: 1 }}>
|
||||
{commands.map(cmd => (
|
||||
<ListItem
|
||||
key={cmd.id}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
|
||||
{formatCommandLabel(cmd.commandType)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{new Date(cmd.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => handleUndo(e, cmd.id)}
|
||||
disabled={Boolean(cancelingIds[cmd.id]) || isCancelingAll}
|
||||
>
|
||||
<Close sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleCancelAll}
|
||||
loading={isCancelingAll}
|
||||
disabled={commands.length === 0}
|
||||
>
|
||||
Cancel all
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PendingBadge
|
||||
522
src/views/components/SyncStatusIndicator.jsx
Normal file
522
src/views/components/SyncStatusIndicator.jsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
CheckCircleOutline,
|
||||
ClearAll,
|
||||
CloudDone,
|
||||
CloudQueue,
|
||||
CloudSync,
|
||||
Refresh,
|
||||
WifiOff,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Dropdown,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import {
|
||||
isOfflineFeatureEnabled,
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
|
||||
const COMMAND_LABELS = {
|
||||
create_chore: 'Create chore',
|
||||
update_chore: 'Update chore',
|
||||
update_chore_history: 'Edit history',
|
||||
complete_chore: 'Complete chore',
|
||||
skip_chore: 'Skip chore',
|
||||
start_chore: 'Start chore',
|
||||
pause_chore: 'Pause chore',
|
||||
delete_chore: 'Delete chore',
|
||||
delete_chore_history: 'Delete history',
|
||||
reschedule_chore: 'Reschedule chore',
|
||||
archive_chore: 'Archive chore',
|
||||
unarchive_chore: 'Restore chore',
|
||||
}
|
||||
|
||||
const formatCommandLabel = commandType => {
|
||||
return (
|
||||
COMMAND_LABELS[commandType] ||
|
||||
commandType
|
||||
?.replace(/_/g, ' ')
|
||||
?.replace(/\b\w/g, letter => letter.toUpperCase()) ||
|
||||
'Pending action'
|
||||
)
|
||||
}
|
||||
|
||||
const RETRY_INTERVAL = 30
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
const [failedCommands, setFailedCommands] = useState([])
|
||||
const [syncState, setSyncState] = useState({
|
||||
syncing: false,
|
||||
lastSync: null,
|
||||
error: null,
|
||||
})
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
|
||||
const [retryIn, setRetryIn] = useState(RETRY_INTERVAL)
|
||||
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToOfflineFeature(setOfflineFeatureEnabled)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = syncEngine.onSyncStateChange(state => {
|
||||
setSyncState(prev => ({ ...prev, ...state }))
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncState.syncing) {
|
||||
setRetryIn(RETRY_INTERVAL)
|
||||
}
|
||||
}, [syncState.syncing, syncState.lastSync])
|
||||
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(online => {
|
||||
setIsOnline(online)
|
||||
if (!online) setOfflineSince(networkManager.offlineSince)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline || syncState.syncing) return
|
||||
const interval = setInterval(() => {
|
||||
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isOnline, syncState.syncing, syncState.lastSync])
|
||||
|
||||
useEffect(() => {
|
||||
const update = async () => {
|
||||
try {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
setPendingCommands(pending)
|
||||
setFailedCommands(failed)
|
||||
} catch {
|
||||
// OfflineDB may not be initialized yet
|
||||
}
|
||||
}
|
||||
update()
|
||||
const interval = setInterval(update, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [syncState])
|
||||
|
||||
const refreshCommands = async () => {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
setPendingCommands(pending)
|
||||
setFailedCommands(failed)
|
||||
}
|
||||
|
||||
const handleForceSync = async () => {
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) queryClient.invalidateQueries()
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const handleDismissFailed = async id => {
|
||||
await commandQueue.cancel(id)
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const handleCancelAll = async () => {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
const allCommands = [...pending, ...failed]
|
||||
await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.id)))
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const formatTime = timestamp => {
|
||||
if (!timestamp) return 'Never'
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (seconds < 10) return 'Just now'
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
const formatOfflineDuration = timestamp => {
|
||||
if (!timestamp) return ''
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
const groupedPending = Object.entries(
|
||||
pendingCommands.reduce((acc, cmd) => {
|
||||
acc[cmd.commandType] = (acc[cmd.commandType] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
|
||||
const pendingCount = pendingCommands.length
|
||||
const failedCount = failedCommands.length
|
||||
const totalBadge = pendingCount + failedCount
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (syncState.syncing)
|
||||
return <CloudSync sx={{ fontSize: 20, color: 'primary.500' }} />
|
||||
if (!isOnline) return <WifiOff sx={{ fontSize: 20, color: 'danger.500' }} />
|
||||
if (failedCount > 0)
|
||||
return <CloudQueue sx={{ fontSize: 20, color: 'danger.400' }} />
|
||||
if (pendingCount > 0)
|
||||
return <CloudQueue sx={{ fontSize: 20, color: 'warning.500' }} />
|
||||
return <CloudDone sx={{ fontSize: 20, color: 'success.500' }} />
|
||||
}
|
||||
|
||||
if (!offlineFeatureEnabled) return null
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
aria-label='Open sync and network status'
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 0.5,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', position: 'relative' }}
|
||||
>
|
||||
{syncState.syncing && (
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
'--CircularProgress-size': '28px',
|
||||
'--CircularProgress-trackThickness': '2px',
|
||||
'--CircularProgress-progressThickness': '2px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{totalBadge > 0 ? (
|
||||
<Badge
|
||||
badgeContent={totalBadge}
|
||||
size='sm'
|
||||
color={failedCount > 0 ? 'danger' : 'warning'}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: 9,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
</Badge>
|
||||
) : (
|
||||
getStatusIcon()
|
||||
)}
|
||||
</Box>
|
||||
</MenuButton>
|
||||
|
||||
<Menu
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Sheet sx={{ p: 1.5, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOnline
|
||||
? 'var(--joy-palette-success-500)'
|
||||
: 'var(--joy-palette-danger-500)',
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{syncState.syncing && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-primary-500)' }}
|
||||
>
|
||||
Syncing...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Last sync: {formatTime(syncState.lastSync)}
|
||||
</Typography>
|
||||
|
||||
{!isOnline && offlineSince && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-400)', mt: 0.25 }}
|
||||
>
|
||||
Offline since {formatOfflineDuration(offlineSince)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{syncState.error && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
|
||||
>
|
||||
Error: {syncState.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Sheet>
|
||||
|
||||
{/* Pending actions */}
|
||||
{groupedPending.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Pending ({pendingCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
{groupedPending.map(([type, count]) => (
|
||||
<Box
|
||||
key={type}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>
|
||||
{formatCommandLabel(type)}
|
||||
</Typography>
|
||||
<Chip size='sm' color='warning' variant='soft'>
|
||||
{count}
|
||||
</Chip>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Failed actions */}
|
||||
{failedCommands.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-danger-500)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Failed ({failedCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
{failedCommands.map(cmd => (
|
||||
<Box
|
||||
key={cmd.id}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
mb: 0.5,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor: 'var(--joy-palette-danger-softBg)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-danger-700)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatCommandLabel(cmd.commandType)}
|
||||
</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
sx={{ fontSize: 11, py: 0, minHeight: 'unset', px: 0.5 }}
|
||||
onClick={() => handleDismissFailed(cmd.id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
{cmd.error && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
|
||||
>
|
||||
{cmd.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* All clear */}
|
||||
{pendingCount === 0 && failedCount === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutline
|
||||
sx={{ fontSize: 16, color: 'var(--joy-palette-success-500)' }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'var(--joy-palette-text-secondary)' }}
|
||||
>
|
||||
All changes synced
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Next retry / offline hint */}
|
||||
{isOnline && !syncState.syncing && pendingCount > 0 && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Next auto-sync in {retryIn}s
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isOnline && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Will sync when back online
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
|
||||
<MenuItem
|
||||
disabled={syncState.syncing || totalBadge === 0}
|
||||
onClick={handleCancelAll}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<ClearAll sx={{ fontSize: 18 }} />
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Cancel All
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */}
|
||||
<MenuItem
|
||||
disabled={syncState.syncing}
|
||||
onClick={handleForceSync}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{syncState.syncing ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : (
|
||||
<Refresh sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
{syncState.syncing ? 'Syncing...' : 'Sync Now'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default SyncStatusIndicator
|
||||
Reference in New Issue
Block a user