feat: implement offline support for labels and projects, enhance advanced settings with offline feature toggle, and add sync status indicator
- Added offline support for fetching and caching labels and projects using `offlineDB`. - Enhanced `AdvancedSettings` to include an offline feature toggle with confirmation modal for disabling offline support. - Introduced `SyncStatusIndicator` component to display sync status, pending commands, and failed commands. - Created `PendingBadge` component to manage and display pending actions for synchronization. - Removed deprecated offline mode toggle from `StorageSettings`. - Improved error handling and user notifications for offline actions and sync processes.
This commit is contained in:
@@ -11,6 +11,7 @@ import SSEProvider from './contexts/SSEContext'
|
|||||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||||
import { useNotification } from './service/NotificationProvider'
|
import { useNotification } from './service/NotificationProvider'
|
||||||
|
|
||||||
|
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||||
import NetworkBanner from './views/components/NetworkBanner'
|
import NetworkBanner from './views/components/NetworkBanner'
|
||||||
|
|
||||||
const add = className => {
|
const add = className => {
|
||||||
@@ -34,6 +35,7 @@ const startOpenReplay = () => {
|
|||||||
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
|
useSyncOnReconnect()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Network } from '@capacitor/network'
|
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 {
|
class NetworkManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.isOnline = true
|
this.isOnline = true
|
||||||
@@ -21,35 +20,12 @@ class NetworkManager {
|
|||||||
this.isNetworkOn = status.connected
|
this.isNetworkOn = status.connected
|
||||||
this.lastChecked = Date.now()
|
this.lastChecked = Date.now()
|
||||||
this.isOnline = status.connected
|
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() {
|
setOffline() {
|
||||||
|
|||||||
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
66
src/hooks/useSyncOnReconnect.js
Normal file
66
src/hooks/useSyncOnReconnect.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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(() => {
|
||||||
|
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)
|
||||||
|
networkManager.registerNetworkListener(async isOnline => {
|
||||||
|
if (isOnline) {
|
||||||
|
await runSync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
runSync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Browser online event (fires when device network is restored)
|
||||||
|
window.addEventListener('online', () => runSync())
|
||||||
|
|
||||||
|
// 4. Retry pending commands every 30s (catches backend restart)
|
||||||
|
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)
|
||||||
|
setInterval(() => {
|
||||||
|
runSync()
|
||||||
|
}, CACHE_REFRESH_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSync = async () => {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
const didSync = await syncEngine.sync()
|
||||||
|
if (didSync) {
|
||||||
|
queryClient.invalidateQueries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
}, [queryClient])
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { networkManager } from '../hooks/NetworkManager'
|
import { networkManager } from '../hooks/NetworkManager'
|
||||||
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
|
import { commandQueue, CommandType } from '../utils/CommandQueue'
|
||||||
import {
|
import {
|
||||||
ApproveChore,
|
ApproveChore,
|
||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
@@ -20,51 +20,81 @@ import {
|
|||||||
UnArchiveChore,
|
UnArchiveChore,
|
||||||
UpdateChoreHistory,
|
UpdateChoreHistory,
|
||||||
} from '../utils/Fetcher'
|
} 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)))
|
||||||
|
.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({
|
return useQuery({
|
||||||
queryKey: ['chores', includeArchive],
|
queryKey: ['chores', includeArchive],
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const onlineChores = await GetChoresNew(includeArchive)
|
if (isOfflineFeatureEnabled()) {
|
||||||
|
// Sync from server first (no-op if already syncing or offline)
|
||||||
// Only handle offline tasks if experimental offline mode is enabled
|
if (networkManager.isOnline) {
|
||||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
await syncEngine.sync()
|
||||||
return onlineChores
|
}
|
||||||
|
const cursor = await offlineDB.getSyncCursor()
|
||||||
|
if (cursor > 0) {
|
||||||
|
const cached = await offlineDB.getChores()
|
||||||
|
const merged = await mergePendingCreates(cached || [])
|
||||||
|
return { res: merged }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
// Offline feature disabled — fetch from API.
|
||||||
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
|
try {
|
||||||
var finalChores = []
|
const data = await GetChoresNew(includeArchive)
|
||||||
if (onlineChores && onlineChores.res) {
|
if (data?.res) {
|
||||||
finalChores = onlineChores.res.filter(
|
syncEngine.cacheChores(data.res)
|
||||||
onlineChore =>
|
}
|
||||||
!offlineTasks.some(offlineTask => {
|
const merged = await mergePendingCreates(data?.res || [])
|
||||||
// Match by id or tempId
|
return { ...data, res: merged }
|
||||||
return (
|
} catch {
|
||||||
String(onlineChore.id) === String(offlineTask.id) ||
|
// API failed — fall back to whatever is in the cache
|
||||||
(offlineTask.tempId &&
|
const cached = await offlineDB.getChores()
|
||||||
String(onlineChore.id) === String(offlineTask.tempId))
|
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 +103,14 @@ export const useDeleteChores = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async choreIds => {
|
mutationFn: async choreIds => {
|
||||||
// If offline mode is enabled and we're offline, handle deletion locally
|
if (!networkManager.isOnline) {
|
||||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
await Promise.all(
|
||||||
const offlineTasks =
|
choreIds.map(async id => {
|
||||||
(await localStore.getFromCache('offlineTasks')) || []
|
await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id })
|
||||||
const updatedOfflineTasks = offlineTasks.filter(
|
}),
|
||||||
task =>
|
|
||||||
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
|
|
||||||
)
|
)
|
||||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
|
||||||
// Force the chores query to refetch
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If online, proceed with server-side deletion
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
choreIds.map(async id => {
|
choreIds.map(async id => {
|
||||||
const resp = await DeleteChore(id)
|
const resp = await DeleteChore(id)
|
||||||
@@ -99,75 +122,69 @@ export const useDeleteChores = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
queryClient.invalidateQueries(['pendingCommands'])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
export const useCreateChore = () => {
|
export const useCreateChore = () => {
|
||||||
const queryClient = useQueryClient()
|
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 { res: offlineChore }
|
||||||
|
}
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async newTask => {
|
mutationFn: async newTask => {
|
||||||
const resp = await CreateChore(newTask)
|
if (!networkManager.isOnline) {
|
||||||
if (!resp || !resp.ok) {
|
return queueOfflineCreate(newTask)
|
||||||
throw new Error('Failed to create chore')
|
|
||||||
}
|
}
|
||||||
const createdChore = await resp.json()
|
|
||||||
if (!createdChore) {
|
try {
|
||||||
throw new Error('Failed to get created chore data')
|
const resp = await CreateChore(newTask)
|
||||||
|
if (!resp || !resp.ok) {
|
||||||
|
throw new Error('Failed to create chore')
|
||||||
|
}
|
||||||
|
const createdChore = await resp.json()
|
||||||
|
if (!createdChore) {
|
||||||
|
throw new Error('Failed to get created chore data')
|
||||||
|
}
|
||||||
|
// Successfully created the chore on the server, return the created chore
|
||||||
|
// update the local chores cache with the new chore:
|
||||||
|
queryClient.setQueryData(['chores'], oldData => {
|
||||||
|
if (!oldData) return { res: [createdChore.res] }
|
||||||
|
return { res: [...oldData.res, createdChore.res] }
|
||||||
|
})
|
||||||
|
return { res: createdChore }
|
||||||
|
} catch (error) {
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
return queueOfflineCreate(newTask)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
// Successfully created the chore on the server, return the created chore
|
|
||||||
// update the local chores cache with the new chore:
|
|
||||||
queryClient.setQueryData(['chores'], oldData => {
|
|
||||||
if (!oldData) return { res: [createdChore.res] }
|
|
||||||
return { res: [...oldData.res, createdChore.res] }
|
|
||||||
})
|
|
||||||
return { res: createdChore }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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: () => {
|
onSuccess: () => {
|
||||||
// Invalidate the chores query to refresh the data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
queryClient.invalidateQueries(['pendingCommands'])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -177,44 +194,31 @@ export const useUpdateChore = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async updatedChore => {
|
mutationFn: async updatedChore => {
|
||||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
const queueOfflineUpdate = async () => {
|
||||||
updatedChore['updatedAt'] = new Date().toISOString()
|
await commandQueue.enqueue(
|
||||||
if (!updatedChore['nextDueDate']) {
|
CommandType.UPDATE_CHORE,
|
||||||
updatedChore['nextDueDate'] = updatedChore['dueDate']
|
updatedChore.id,
|
||||||
}
|
updatedChore,
|
||||||
const offlineTasks =
|
)
|
||||||
(await localStore.getFromCache('offlineTasks')) || []
|
const pendingChore = { ...updatedChore, _pendingUpdate: true }
|
||||||
|
// Persist to offline DB so cache fallback reads the updated data
|
||||||
for (const task of offlineTasks) {
|
await offlineDB.saveChores([pendingChore])
|
||||||
// Find the task with the same id or tempId and update it
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
|
if (!oldData) return { res: [pendingChore] }
|
||||||
// Update the task in local storage
|
return {
|
||||||
const updatedTask = { ...task, ...updatedChore }
|
res: oldData.res.map(chore =>
|
||||||
const updatedOfflineTasks = offlineTasks.map(t =>
|
chore.id === updatedChore.id ? pendingChore : chore,
|
||||||
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 {
|
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
|
||||||
// Call the API to update the chore
|
if (!oldData) return { res: pendingChore }
|
||||||
|
return { ...oldData, res: pendingChore }
|
||||||
|
})
|
||||||
|
return pendingChore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const resp = await SaveChore(updatedChore)
|
const resp = await SaveChore(updatedChore)
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
throw new Error('Failed to save chore')
|
throw new Error('Failed to save chore')
|
||||||
@@ -223,9 +227,7 @@ export const useUpdateChore = () => {
|
|||||||
if (!updatedChoreRes) {
|
if (!updatedChoreRes) {
|
||||||
throw new Error('Failed to get updated chore data')
|
throw new Error('Failed to get updated chore data')
|
||||||
}
|
}
|
||||||
// Successfully updated the chore on the server, return the updated chore
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
// update the local chores cache with the updated chore:
|
|
||||||
queryClient.setQueryData(['chores'], oldData => {
|
|
||||||
if (!oldData) return { res: [updatedChore] }
|
if (!oldData) return { res: [updatedChore] }
|
||||||
return {
|
return {
|
||||||
res: oldData.res.map(chore =>
|
res: oldData.res.map(chore =>
|
||||||
@@ -234,19 +236,17 @@ export const useUpdateChore = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
return updatedChoreRes?.res || updatedChoreRes
|
return updatedChoreRes?.res || updatedChoreRes
|
||||||
|
} catch (error) {
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
return queueOfflineUpdate()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
// Invalidate the chores query to refresh the data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
// Invalidate history for the specific chore
|
|
||||||
queryClient.invalidateQueries(['choreHistory', variables.id])
|
queryClient.invalidateQueries(['choreHistory', variables.id])
|
||||||
},
|
queryClient.invalidateQueries(['pendingCommands'])
|
||||||
onMutate: async updatedChore => {
|
|
||||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
|
||||||
// Handle offline case here if needed
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -275,30 +275,20 @@ export const useChoreDetails = choreId => {
|
|||||||
queryKey: ['choreDetails', choreId],
|
queryKey: ['choreDetails', choreId],
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
var onlineChore = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await GetChoreDetailById(choreId)
|
const response = await GetChoreDetailById(choreId)
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
onlineChore = await response.json()
|
return await response.json()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
throw new Error('Failed to fetch chore detail')
|
||||||
console.error('Error fetching chore detail:', error)
|
} catch {
|
||||||
|
// Fall back to cached chore (without timer details)
|
||||||
|
const cached = await offlineDB.getChore(choreId)
|
||||||
|
if (cached) {
|
||||||
|
return { res: cached }
|
||||||
|
}
|
||||||
|
throw new Error('Chore detail not available offline')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check offline tasks if experimental offline mode is enabled
|
|
||||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
|
||||||
return onlineChore
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +302,21 @@ export const useChore = choreId => {
|
|||||||
if (!choreId) {
|
if (!choreId) {
|
||||||
throw new Error('Chore ID is required to fetch chore details')
|
throw new Error('Chore ID is required to fetch chore details')
|
||||||
}
|
}
|
||||||
var onlineChore = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await GetChoreByID(choreId)
|
const response = await GetChoreByID(choreId)
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
onlineChore = await response.json()
|
return await response.json()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
throw new Error('Failed to fetch chore')
|
||||||
console.error('Error fetching chore detail:', error)
|
} catch {
|
||||||
|
// API failed — try offline cache
|
||||||
|
const cached = await offlineDB.getChore(choreId)
|
||||||
|
if (cached) {
|
||||||
|
return { res: cached }
|
||||||
|
}
|
||||||
|
throw new Error('Chore not available offline')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check offline tasks if experimental offline mode is enabled
|
|
||||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
|
||||||
return onlineChore
|
|
||||||
}
|
|
||||||
|
|
||||||
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
@@ -416,12 +395,32 @@ export const useMarkChoreComplete = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ choreId, body, completedDate, performer }) =>
|
mutationFn: async ({ choreId, body, completedDate, performer }) => {
|
||||||
MarkChoreComplete(choreId, body, completedDate, performer),
|
if (!networkManager.isOnline) {
|
||||||
onSuccess: (data, { choreId }) => {
|
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
|
||||||
|
id: choreId,
|
||||||
|
body,
|
||||||
|
completedDate,
|
||||||
|
performer,
|
||||||
|
})
|
||||||
|
// 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(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||||
|
queryClient.invalidateQueries(['pendingCommands'])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -430,11 +429,29 @@ export const useSkipChore = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: SkipChore,
|
mutationFn: async choreId => {
|
||||||
onSuccess: (data, 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(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||||
|
queryClient.invalidateQueries(['pendingCommands'])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -444,7 +461,7 @@ export const useApproveChore = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ApproveChore,
|
mutationFn: ApproveChore,
|
||||||
onSuccess: (data, choreId) => {
|
onSuccess: (_, choreId) => {
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||||
@@ -457,7 +474,7 @@ export const useRejectChore = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: RejectChore,
|
mutationFn: RejectChore,
|
||||||
onSuccess: (data, choreId) => {
|
onSuccess: (_, choreId) => {
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||||
|
|||||||
@@ -1,73 +1,26 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { networkManager } from '../hooks/NetworkManager'
|
import { networkManager } from '../hooks/NetworkManager'
|
||||||
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
||||||
import { localStore } from '../utils/LocalStore'
|
|
||||||
|
|
||||||
export const useUpdate = () => {
|
export const useUpdate = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async updatedChore => {
|
mutationFn: async updatedChore => {
|
||||||
if (!networkManager.isOnline) {
|
const resp = await SaveChore(updatedChore)
|
||||||
updatedChore['updatedAt'] = new Date().toISOString()
|
if (!resp || !resp.ok) {
|
||||||
if (!updatedChore['nextDueDate']) {
|
throw new Error('Failed to save chore')
|
||||||
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')
|
|
||||||
}
|
|
||||||
const updatedChoreRes = await resp.json()
|
|
||||||
if (!updatedChoreRes) {
|
|
||||||
throw new Error('Failed to get updated chore data')
|
|
||||||
}
|
|
||||||
// Successfully updated the chore on the server, return the updated chore
|
|
||||||
return updatedChoreRes?.res || updatedChoreRes
|
|
||||||
}
|
}
|
||||||
|
const updatedChoreRes = await resp.json()
|
||||||
|
if (!updatedChoreRes) {
|
||||||
|
throw new Error('Failed to get updated chore data')
|
||||||
|
}
|
||||||
|
// Successfully updated the chore on the server, return the updated chore
|
||||||
|
return updatedChoreRes?.res || updatedChoreRes
|
||||||
},
|
},
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: () => {
|
||||||
// Invalidate the chores query to refresh the data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
},
|
},
|
||||||
onMutate: async updatedChore => {
|
|
||||||
if (!networkManager.isOnline) {
|
|
||||||
// Handle offline case here if needed
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ export const useStartChore = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: StartChore,
|
mutationFn: StartChore,
|
||||||
onSuccess: (data, choreId) => {
|
onSuccess: (_, choreId) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
queryClient.invalidateQueries(['chores'])
|
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -44,9 +43,8 @@ export const usePauseChore = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: PauseChore,
|
mutationFn: PauseChore,
|
||||||
onSuccess: (data, choreId) => {
|
onSuccess: (_, choreId) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
queryClient.invalidateQueries(['chores'])
|
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -58,7 +56,7 @@ export const useUpdateTimeSession = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
||||||
UpdateTimeSession(choreId, sessionId, sessionData),
|
UpdateTimeSession(choreId, sessionId, sessionData),
|
||||||
onSuccess: (data, { choreId }) => {
|
onSuccess: (_, { choreId }) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
@@ -72,7 +70,7 @@ export const useDeleteTimeSession = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ choreId, sessionId }) =>
|
mutationFn: ({ choreId, sessionId }) =>
|
||||||
DeleteTimeSession(choreId, sessionId),
|
DeleteTimeSession(choreId, sessionId),
|
||||||
onSuccess: (data, { choreId }) => {
|
onSuccess: (_, { choreId }) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
@@ -85,7 +83,7 @@ export const useResetChoreTimer = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ResetChoreTimer,
|
mutationFn: ResetChoreTimer,
|
||||||
onSuccess: (data, choreId) => {
|
onSuccess: (_, choreId) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
@@ -98,7 +96,7 @@ export const useClearChoreTimer = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ClearChoreTimer,
|
mutationFn: ClearChoreTimer,
|
||||||
onSuccess: (data, choreId) => {
|
onSuccess: (_, choreId) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
GetDeviceTokens,
|
GetDeviceTokens,
|
||||||
GetUserProfile,
|
GetUserProfile,
|
||||||
} from '../utils/Fetcher'
|
} from '../utils/Fetcher'
|
||||||
|
import { offlineDB } from '../utils/OfflineDB'
|
||||||
|
|
||||||
// Helper to check if we have a valid token
|
// Helper to check if we have a valid token
|
||||||
const isTokenValid = () => {
|
const isTokenValid = () => {
|
||||||
@@ -30,7 +31,20 @@ export const useCircleMembers = () => {
|
|||||||
|
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ['allCircleMembers'],
|
queryKey: ['allCircleMembers'],
|
||||||
queryFn: GetAllCircleMembers,
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const result = await GetAllCircleMembers()
|
||||||
|
// Cache for offline use
|
||||||
|
if (result?.res) {
|
||||||
|
offlineDB.saveKV('circle_members', result.res)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
const cached = await offlineDB.getKV('circle_members')
|
||||||
|
if (cached) return { res: cached }
|
||||||
|
return { res: [] }
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleRefetch = () => {
|
const handleRefetch = () => {
|
||||||
@@ -42,19 +56,31 @@ export const useCircleMembers = () => {
|
|||||||
|
|
||||||
export const useUserProfile = () => {
|
export const useUserProfile = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
const { data, error, isLoading } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ['userProfile'],
|
queryKey: ['userProfile', token],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const resp = await GetUserProfile()
|
if (!token) {
|
||||||
const result = await resp.json()
|
return null
|
||||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
}
|
||||||
|
|
||||||
return result.res || 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
|
staleTime: 30 * 60 * 1000,
|
||||||
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
gcTime: 30 * 60 * 1000,
|
||||||
enabled: isTokenValid(), // Only run query when we have a valid token
|
enabled: !!token,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init(force = false) {
|
async init(force = false) {
|
||||||
if (this.initPromise) {
|
if (!force && this.initPromise) {
|
||||||
return this.initPromise
|
return this.initPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
128
src/utils/CommandQueue.js
Normal file
128
src/utils/CommandQueue.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { offlineDB } from './OfflineDB'
|
||||||
|
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||||
|
|
||||||
|
// Domain command types
|
||||||
|
export const CommandType = {
|
||||||
|
CREATE_CHORE: 'create_chore',
|
||||||
|
UPDATE_CHORE: 'update_chore',
|
||||||
|
COMPLETE_CHORE: 'complete_chore',
|
||||||
|
SKIP_CHORE: 'skip_chore',
|
||||||
|
DELETE_CHORE: 'delete_chore',
|
||||||
|
RESCHEDULE_CHORE: 'reschedule_chore',
|
||||||
|
ARCHIVE_CHORE: 'archive_chore',
|
||||||
|
UNARCHIVE_CHORE: 'unarchive_chore',
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandQueue {
|
||||||
|
// 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 commands = await offlineDB.getCommandsByEntity(String(entityId))
|
||||||
|
return commands
|
||||||
|
.filter(c => c.status === 'pending')
|
||||||
|
.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) {
|
||||||
|
if (cmd.commandType === CommandType.UPDATE_CHORE) {
|
||||||
|
const prev = seen.get(cmd.entityId)
|
||||||
|
if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
|
||||||
|
// Merge: keep latest payload, remove older
|
||||||
|
toRemove.push(prev.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.set(cmd.entityId, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of 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
|
* Check if the current instance is the official donetick.com service
|
||||||
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
|
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
|
||||||
@@ -102,7 +35,11 @@ export const isOfficialDonetickInstanceSync = () => {
|
|||||||
// Dynamic import to avoid circular dependencies
|
// Dynamic import to avoid circular dependencies
|
||||||
return import('./ApiClient')
|
return import('./ApiClient')
|
||||||
.then(({ 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
|
// Check if the API URL contains donetick.com
|
||||||
return currentApiUrl.toLowerCase().includes('donetick.com')
|
return currentApiUrl.toLowerCase().includes('donetick.com')
|
||||||
})
|
})
|
||||||
@@ -123,12 +60,6 @@ export const isOfficialDonetickInstanceSync = () => {
|
|||||||
|
|
||||||
// Export default object for easier imports
|
// Export default object for easier imports
|
||||||
export default {
|
export default {
|
||||||
FEATURES,
|
|
||||||
isFeatureEnabled,
|
|
||||||
setFeatureEnabled,
|
|
||||||
toggleFeature,
|
|
||||||
getAllFeatureStates,
|
|
||||||
clearAllFeatures,
|
|
||||||
isOfficialDonetickInstance,
|
isOfficialDonetickInstance,
|
||||||
isOfficialDonetickInstanceSync,
|
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()
|
|
||||||
688
src/utils/OfflineDB.js
Normal file
688
src/utils/OfflineDB.js
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||||
|
|
||||||
|
const DB_NAME = 'donetick_offline'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const IDB_NAME = 'donetick_offline'
|
||||||
|
const IDB_VERSION = 1
|
||||||
|
|
||||||
|
// Cache platform detection
|
||||||
|
let _isNative = null
|
||||||
|
const isNative = () => {
|
||||||
|
if (_isNative === null) {
|
||||||
|
try {
|
||||||
|
_isNative = Capacitor.isNativePlatform()
|
||||||
|
} catch {
|
||||||
|
_isNative = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _isNative
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite backend (iOS/Android) ──
|
||||||
|
|
||||||
|
class SQLiteBackend {
|
||||||
|
constructor() {
|
||||||
|
this.db = null
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
|
||||||
|
this.db = await CapacitorSQLite.createConnection({
|
||||||
|
database: DB_NAME,
|
||||||
|
version: DB_VERSION,
|
||||||
|
encrypted: false,
|
||||||
|
mode: 'no-encryption',
|
||||||
|
})
|
||||||
|
await CapacitorSQLite.open({ database: DB_NAME })
|
||||||
|
|
||||||
|
await CapacitorSQLite.execute({
|
||||||
|
database: DB_NAME,
|
||||||
|
statements: `
|
||||||
|
CREATE TABLE IF NOT EXISTS cached_chores (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
sync_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS command_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
command_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chore cache ──
|
||||||
|
|
||||||
|
async saveChores(chores) {
|
||||||
|
if (!chores.length) return
|
||||||
|
|
||||||
|
const statements = chores.map(chore => ({
|
||||||
|
statement:
|
||||||
|
'INSERT OR REPLACE INTO cached_chores (id, data, sync_version, cached_at) VALUES (?, ?, ?, ?)',
|
||||||
|
values: [
|
||||||
|
chore.id,
|
||||||
|
JSON.stringify(chore),
|
||||||
|
chore.syncVersion || 0,
|
||||||
|
Date.now(),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
await CapacitorSQLite.executeSet({
|
||||||
|
database: DB_NAME,
|
||||||
|
set: statements,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChores() {
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'SELECT data FROM cached_chores',
|
||||||
|
values: [],
|
||||||
|
})
|
||||||
|
return (result.values || [])
|
||||||
|
.map(row => JSON.parse(row.data))
|
||||||
|
.filter(chore => chore.isActive !== false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChore(id) {
|
||||||
|
const numericId = Number(id)
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'SELECT data FROM cached_chores WHERE id = ?',
|
||||||
|
values: [isNaN(numericId) ? id : numericId],
|
||||||
|
})
|
||||||
|
if (result.values && result.values.length > 0) {
|
||||||
|
return JSON.parse(result.values[0].data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChores(ids) {
|
||||||
|
if (!ids.length) return
|
||||||
|
const statements = ids.map(id => ({
|
||||||
|
statement: 'DELETE FROM cached_chores WHERE id = ?',
|
||||||
|
values: [id],
|
||||||
|
}))
|
||||||
|
await CapacitorSQLite.executeSet({
|
||||||
|
database: DB_NAME,
|
||||||
|
set: statements,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearChores() {
|
||||||
|
await CapacitorSQLite.execute({
|
||||||
|
database: DB_NAME,
|
||||||
|
statements: 'DELETE FROM cached_chores',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command queue ──
|
||||||
|
|
||||||
|
async enqueueCommand(command) {
|
||||||
|
const result = await CapacitorSQLite.run({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: `INSERT INTO command_queue (command_type, entity_id, payload, created_at, status, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
values: [
|
||||||
|
command.commandType,
|
||||||
|
command.entityId,
|
||||||
|
command.payload,
|
||||||
|
command.createdAt,
|
||||||
|
command.status,
|
||||||
|
command.error,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return result.changes?.lastId
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommands() {
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'SELECT * FROM command_queue ORDER BY created_at ASC',
|
||||||
|
values: [],
|
||||||
|
})
|
||||||
|
return (result.values || []).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
commandType: row.command_type,
|
||||||
|
entityId: row.entity_id,
|
||||||
|
payload: row.payload,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
status: row.status,
|
||||||
|
error: row.error,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommandsByEntity(entityId) {
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement:
|
||||||
|
'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC',
|
||||||
|
values: [entityId],
|
||||||
|
})
|
||||||
|
return (result.values || []).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
commandType: row.command_type,
|
||||||
|
entityId: row.entity_id,
|
||||||
|
payload: row.payload,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
status: row.status,
|
||||||
|
error: row.error,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCommandStatus(id, status, error) {
|
||||||
|
await CapacitorSQLite.run({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'UPDATE command_queue SET status = ?, error = ? WHERE id = ?',
|
||||||
|
values: [status, error, id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCommand(id) {
|
||||||
|
await CapacitorSQLite.run({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'DELETE FROM command_queue WHERE id = ?',
|
||||||
|
values: [id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCommands() {
|
||||||
|
await CapacitorSQLite.execute({
|
||||||
|
database: DB_NAME,
|
||||||
|
statements: 'DELETE FROM command_queue',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync metadata ──
|
||||||
|
|
||||||
|
async getSyncCursor() {
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'",
|
||||||
|
values: [],
|
||||||
|
})
|
||||||
|
if (result.values && result.values.length > 0) {
|
||||||
|
return Number(result.values[0].value)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSyncCursor(cursor) {
|
||||||
|
await CapacitorSQLite.run({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement:
|
||||||
|
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('sync_cursor', ?)",
|
||||||
|
values: [String(cursor)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastSyncTime() {
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'",
|
||||||
|
values: [],
|
||||||
|
})
|
||||||
|
if (result.values && result.values.length > 0) {
|
||||||
|
return Number(result.values[0].value)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLastSyncTime(time) {
|
||||||
|
await CapacitorSQLite.run({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement:
|
||||||
|
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync_time', ?)",
|
||||||
|
values: [String(time)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveKV(key, value) {
|
||||||
|
await CapacitorSQLite.run({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)',
|
||||||
|
values: [key, JSON.stringify(value)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKV(key) {
|
||||||
|
const result = await CapacitorSQLite.query({
|
||||||
|
database: DB_NAME,
|
||||||
|
statement: 'SELECT value FROM sync_meta WHERE key = ?',
|
||||||
|
values: [key],
|
||||||
|
})
|
||||||
|
if (result.values && result.values.length > 0) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(result.values[0].value)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll() {
|
||||||
|
await CapacitorSQLite.execute({
|
||||||
|
database: DB_NAME,
|
||||||
|
statements: `
|
||||||
|
DELETE FROM cached_chores;
|
||||||
|
DELETE FROM command_queue;
|
||||||
|
DELETE FROM sync_meta;
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IndexedDB backend (Web) ──
|
||||||
|
|
||||||
|
class IndexedDBBackend {
|
||||||
|
constructor() {
|
||||||
|
this.db = null
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
_open() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(IDB_NAME, IDB_VERSION)
|
||||||
|
|
||||||
|
request.onupgradeneeded = event => {
|
||||||
|
const db = event.target.result
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('cached_chores')) {
|
||||||
|
db.createObjectStore('cached_chores', { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('command_queue')) {
|
||||||
|
const cmdStore = db.createObjectStore('command_queue', {
|
||||||
|
keyPath: 'id',
|
||||||
|
autoIncrement: true,
|
||||||
|
})
|
||||||
|
cmdStore.createIndex('entity_id', 'entityId', { unique: false })
|
||||||
|
cmdStore.createIndex('created_at', 'createdAt', { unique: false })
|
||||||
|
cmdStore.createIndex('status', 'status', { unique: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('sync_meta')) {
|
||||||
|
db.createObjectStore('sync_meta', { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onsuccess = event => resolve(event.target.result)
|
||||||
|
request.onerror = event => reject(event.target.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
this.db = await this._open()
|
||||||
|
// Re-open if browser closes the connection (e.g. after device sleep)
|
||||||
|
this.db.onclose = () => {
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async _tx(storeName, mode = 'readonly') {
|
||||||
|
// If the connection was closed (e.g. laptop sleep), re-open transparently
|
||||||
|
if (!this.initialized || !this.db) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tx = this.db.transaction(storeName, mode)
|
||||||
|
const store = tx.objectStore(storeName)
|
||||||
|
return { tx, store }
|
||||||
|
} catch (err) {
|
||||||
|
// InvalidStateError = connection closed; re-open once and retry
|
||||||
|
if (
|
||||||
|
err.name === 'InvalidStateError' ||
|
||||||
|
err.name === 'TransactionInactiveError'
|
||||||
|
) {
|
||||||
|
this.initialized = false
|
||||||
|
await this.init()
|
||||||
|
const tx = this.db.transaction(storeName, mode)
|
||||||
|
const store = tx.objectStore(storeName)
|
||||||
|
return { tx, store }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_request(idbRequest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
idbRequest.onsuccess = () => resolve(idbRequest.result)
|
||||||
|
idbRequest.onerror = () => reject(idbRequest.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chore cache ──
|
||||||
|
|
||||||
|
async saveChores(chores) {
|
||||||
|
if (!chores.length) return
|
||||||
|
|
||||||
|
const { tx, store } = await this._tx('cached_chores', 'readwrite')
|
||||||
|
|
||||||
|
for (const chore of chores) {
|
||||||
|
store.put({
|
||||||
|
id: chore.id,
|
||||||
|
data: chore,
|
||||||
|
syncVersion: chore.syncVersion || 0,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChores() {
|
||||||
|
const { store } = await this._tx('cached_chores')
|
||||||
|
const rows = await this._request(store.getAll())
|
||||||
|
return rows.map(row => row.data).filter(chore => chore.isActive !== false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChore(id) {
|
||||||
|
const { store } = await this._tx('cached_chores')
|
||||||
|
// Try numeric ID first (chores are stored with numeric keys from the server)
|
||||||
|
// URL params are strings so we need to coerce
|
||||||
|
const numericId = Number(id)
|
||||||
|
const row = await this._request(
|
||||||
|
store.get(isNaN(numericId) ? id : numericId),
|
||||||
|
)
|
||||||
|
return row ? row.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChores(ids) {
|
||||||
|
if (!ids.length) return
|
||||||
|
const { tx, store } = await this._tx('cached_chores', 'readwrite')
|
||||||
|
for (const id of ids) {
|
||||||
|
store.delete(id)
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearChores() {
|
||||||
|
const { store } = await this._tx('cached_chores', 'readwrite')
|
||||||
|
await this._request(store.clear())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command queue ──
|
||||||
|
|
||||||
|
async enqueueCommand(command) {
|
||||||
|
const { store } = await this._tx('command_queue', 'readwrite')
|
||||||
|
const id = await this._request(
|
||||||
|
store.add({
|
||||||
|
commandType: command.commandType,
|
||||||
|
entityId: command.entityId,
|
||||||
|
payload: command.payload,
|
||||||
|
createdAt: command.createdAt,
|
||||||
|
status: command.status,
|
||||||
|
error: command.error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommands() {
|
||||||
|
const { store } = await this._tx('command_queue')
|
||||||
|
const index = store.index('created_at')
|
||||||
|
const rows = await this._request(index.getAll())
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommandsByEntity(entityId) {
|
||||||
|
const { store } = await this._tx('command_queue')
|
||||||
|
const index = store.index('entity_id')
|
||||||
|
const rows = await this._request(index.getAll(entityId))
|
||||||
|
return rows.sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCommandStatus(id, status, error) {
|
||||||
|
const { store } = await this._tx('command_queue', 'readwrite')
|
||||||
|
const row = await this._request(store.get(id))
|
||||||
|
if (row) {
|
||||||
|
row.status = status
|
||||||
|
row.error = error
|
||||||
|
await this._request(store.put(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCommand(id) {
|
||||||
|
const { store } = await this._tx('command_queue', 'readwrite')
|
||||||
|
await this._request(store.delete(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCommands() {
|
||||||
|
const { store } = await this._tx('command_queue', 'readwrite')
|
||||||
|
await this._request(store.clear())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync metadata ──
|
||||||
|
|
||||||
|
async getSyncCursor() {
|
||||||
|
const { store } = await this._tx('sync_meta')
|
||||||
|
const row = await this._request(store.get('sync_cursor'))
|
||||||
|
return row ? Number(row.value) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSyncCursor(cursor) {
|
||||||
|
const { store } = await this._tx('sync_meta', 'readwrite')
|
||||||
|
await this._request(
|
||||||
|
store.put({ key: 'sync_cursor', value: String(cursor) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastSyncTime() {
|
||||||
|
const { store } = await this._tx('sync_meta')
|
||||||
|
const row = await this._request(store.get('last_sync_time'))
|
||||||
|
return row ? Number(row.value) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLastSyncTime(time) {
|
||||||
|
const { store } = await this._tx('sync_meta', 'readwrite')
|
||||||
|
await this._request(
|
||||||
|
store.put({ key: 'last_sync_time', value: String(time) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveKV(key, value) {
|
||||||
|
const { store } = await this._tx('sync_meta', 'readwrite')
|
||||||
|
await this._request(store.put({ key, value: JSON.stringify(value) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKV(key) {
|
||||||
|
const { store } = await this._tx('sync_meta')
|
||||||
|
const row = await this._request(store.get(key))
|
||||||
|
if (row) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll() {
|
||||||
|
const storeNames = ['cached_chores', 'command_queue', 'sync_meta']
|
||||||
|
for (const storeName of storeNames) {
|
||||||
|
const { store } = await this._tx(storeName, 'readwrite')
|
||||||
|
await this._request(store.clear())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OfflineDB facade ──
|
||||||
|
|
||||||
|
class OfflineDB {
|
||||||
|
constructor() {
|
||||||
|
this.backend = null
|
||||||
|
this.initialized = false
|
||||||
|
|
||||||
|
// When the tab becomes visible after being hidden (laptop wake/tab switch),
|
||||||
|
// reset so the next operation re-validates the IDB connection.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && this.backend) {
|
||||||
|
// Signal the IDB backend to re-open on next use
|
||||||
|
this.backend.initialized = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
if (this._initPromise) return this._initPromise
|
||||||
|
|
||||||
|
this._initPromise = (async () => {
|
||||||
|
this.backend = isNative() ? new SQLiteBackend() : new IndexedDBBackend()
|
||||||
|
await this.backend.init()
|
||||||
|
this.initialized = true
|
||||||
|
this._initPromise = null
|
||||||
|
})()
|
||||||
|
|
||||||
|
return this._initPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async _ensureInit() {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chore cache
|
||||||
|
async saveChores(chores) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.saveChores(chores)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChores() {
|
||||||
|
if (!isOfflineFeatureEnabled()) return []
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getChores()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChore(id) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return null
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getChore(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChores(ids) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.deleteChores(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearChores() {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.clearChores()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command queue
|
||||||
|
async enqueueCommand(command) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return null
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.enqueueCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommands() {
|
||||||
|
if (!isOfflineFeatureEnabled()) return []
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommandsByEntity(entityId) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return []
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getCommandsByEntity(entityId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCommandStatus(id, status, error) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.updateCommandStatus(id, status, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCommand(id) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.removeCommand(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCommands() {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.clearCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync metadata
|
||||||
|
async getSyncCursor() {
|
||||||
|
if (!isOfflineFeatureEnabled()) return 0
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getSyncCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSyncCursor(cursor) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.setSyncCursor(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastSyncTime() {
|
||||||
|
if (!isOfflineFeatureEnabled()) return null
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getLastSyncTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLastSyncTime(time) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.setLastSyncTime(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// General key-value cache (uses sync_meta store)
|
||||||
|
async saveKV(key, value) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.saveKV(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKV(key) {
|
||||||
|
if (!isOfflineFeatureEnabled()) return null
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.getKV(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll() {
|
||||||
|
await this._ensureInit()
|
||||||
|
return this.backend.clearAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const offlineDB = new OfflineDB()
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/utils/SyncEngine.js
Normal file
211
src/utils/SyncEngine.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { networkManager } from '../hooks/NetworkManager'
|
||||||
|
import { apiClient } from './ApiClient'
|
||||||
|
import { commandQueue, CommandType } from './CommandQueue'
|
||||||
|
import {
|
||||||
|
ArchiveChore,
|
||||||
|
CreateChore,
|
||||||
|
DeleteChore,
|
||||||
|
MarkChoreComplete,
|
||||||
|
SaveChore,
|
||||||
|
SkipChore,
|
||||||
|
UnArchiveChore,
|
||||||
|
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)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.DELETE_CHORE:
|
||||||
|
response = await DeleteChore(cmd.payload.id || cmd.entityId)
|
||||||
|
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()) || 0
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Clear localStorage
|
||||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
|
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
|
||||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
|
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY)
|
||||||
// Clean up legacy keys
|
// Clean up legacy keys
|
||||||
localStorage.removeItem('ca_token')
|
localStorage.removeItem('ca_token')
|
||||||
localStorage.removeItem('ca_expiration')
|
localStorage.removeItem('ca_expiration')
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ const ChoreEdit = () => {
|
|||||||
description: description,
|
description: description,
|
||||||
assignees: assignees,
|
assignees: assignees,
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||||
|
nextDueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||||
frequencyType: frequencyType,
|
frequencyType: frequencyType,
|
||||||
frequency: Number(frequency),
|
frequency: Number(frequency),
|
||||||
frequencyMetadata: frequencyMetadata,
|
frequencyMetadata: frequencyMetadata,
|
||||||
@@ -365,11 +366,18 @@ const ChoreEdit = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SaveFunction(chore)
|
SaveFunction(chore)
|
||||||
.then(() => {
|
.then(result => {
|
||||||
showSuccess({
|
if (result?._pendingUpdate || result?._pendingCreate) {
|
||||||
title: 'Chore Saved',
|
showSuccess({
|
||||||
message: 'Your task has been saved successfully!',
|
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')
|
Navigate('/chores')
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|||||||
|
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
||||||
import {
|
import {
|
||||||
useChoreTimer,
|
useChoreTimer,
|
||||||
@@ -56,6 +57,7 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
|||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
|
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
|
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||||
import {
|
import {
|
||||||
ApproveChore,
|
ApproveChore,
|
||||||
GetChoreDetailById,
|
GetChoreDetailById,
|
||||||
@@ -71,11 +73,28 @@ import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
|||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||||
import LoadingComponent from '../components/Loading.jsx'
|
import LoadingComponent from '../components/Loading.jsx'
|
||||||
|
import PendingBadge from '../components/PendingBadge'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
import SubTasks from '../components/SubTask.jsx'
|
import SubTasks from '../components/SubTask.jsx'
|
||||||
import TimePassedCard from './TimePassedCard.jsx'
|
import TimePassedCard from './TimePassedCard.jsx'
|
||||||
import TimerSplitButton from './TimerSplitButton.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 ChoreView = () => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const { fmt } = useLocalization()
|
const { fmt } = useLocalization()
|
||||||
@@ -104,6 +123,7 @@ const ChoreView = () => {
|
|||||||
|
|
||||||
const { data: choreData, isLoading: isChoreLoading } =
|
const { data: choreData, isLoading: isChoreLoading } =
|
||||||
useChoreDetails(choreId)
|
useChoreDetails(choreId)
|
||||||
|
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||||
|
|
||||||
const startChore = useStartChore()
|
const startChore = useStartChore()
|
||||||
const pauseChore = usePauseChore()
|
const pauseChore = usePauseChore()
|
||||||
@@ -136,7 +156,6 @@ const ChoreView = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(() => {
|
response.json().then(() => {
|
||||||
setChorePriority(priority)
|
setChorePriority(priority)
|
||||||
// Invalidate chores cache to refetch data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -164,7 +183,9 @@ const ChoreView = () => {
|
|||||||
icon: <CalendarMonth />,
|
icon: <CalendarMonth />,
|
||||||
title: t('choreView.schedule'),
|
title: t('choreView.schedule'),
|
||||||
text: `${t('choreView.due')}: ${
|
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')}: ${
|
subtext: `${t('choreView.last')}: ${
|
||||||
chore.lastCompletedDate
|
chore.lastCompletedDate
|
||||||
@@ -195,39 +216,26 @@ const ChoreView = () => {
|
|||||||
]
|
]
|
||||||
setInfoCards(cards)
|
setInfoCards(cards)
|
||||||
}
|
}
|
||||||
const handleTaskCompletion = () => {
|
const handleTaskCompletion = async () => {
|
||||||
MarkChoreComplete(
|
try {
|
||||||
choreId,
|
const resp = await MarkChoreComplete(
|
||||||
impersonatedUser
|
choreId,
|
||||||
? { completedBy: impersonatedUser.userId, note }
|
impersonatedUser
|
||||||
: { note },
|
? { completedBy: impersonatedUser.userId, note }
|
||||||
completedDate,
|
: { note },
|
||||||
null,
|
completedDate,
|
||||||
)
|
null,
|
||||||
.then(resp => {
|
)
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
return resp.json().then(data => {
|
const data = await resp.json()
|
||||||
setNote(null)
|
setNote(null)
|
||||||
setChore(data.res)
|
setChore(data.res)
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Invalidate chores cache to refetch data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
})
|
const detailResp = await GetChoreDetailById(choreId)
|
||||||
.then(() => {
|
if (detailResp.ok) {
|
||||||
// refetch the chore details
|
const detailData = await detailResp.json()
|
||||||
GetChoreDetailById(choreId).then(resp => {
|
setChore(detailData.res)
|
||||||
if (resp.ok) {
|
}
|
||||||
return resp.json().then(data => {
|
|
||||||
setChore(data.res)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Show undo notification
|
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: t('choreView.taskCompleted'),
|
title: t('choreView.taskCompleted'),
|
||||||
message: t('choreView.taskCompletedMessage'),
|
message: t('choreView.taskCompletedMessage'),
|
||||||
@@ -235,7 +243,6 @@ const ChoreView = () => {
|
|||||||
try {
|
try {
|
||||||
const undoResponse = await UndoChoreAction(choreId)
|
const undoResponse = await UndoChoreAction(choreId)
|
||||||
if (undoResponse.ok) {
|
if (undoResponse.ok) {
|
||||||
// Refetch chore details after undo
|
|
||||||
const detailResponse = await GetChoreDetailById(choreId)
|
const detailResponse = await GetChoreDetailById(choreId)
|
||||||
if (detailResponse.ok) {
|
if (detailResponse.ok) {
|
||||||
const detailData = await detailResponse.json()
|
const detailData = await detailResponse.json()
|
||||||
@@ -257,49 +264,94 @@ const ChoreView = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
}
|
} catch (error) {
|
||||||
const handleSkippingTask = () => {
|
if (isNetworkError(error)) {
|
||||||
SkipChore(choreId).then(response => {
|
const cmdId = await commandQueue.enqueue(
|
||||||
if (response.ok) {
|
CommandType.COMPLETE_CHORE,
|
||||||
response.json().then(data => {
|
choreId,
|
||||||
const newChore = data.res
|
{
|
||||||
setChore(newChore)
|
id: choreId,
|
||||||
// Invalidate chores cache to refetch data
|
body: impersonatedUser
|
||||||
queryClient.invalidateQueries(['chores'])
|
? { completedBy: impersonatedUser.userId, note }
|
||||||
|
: { note },
|
||||||
// Show undo notification
|
completedDate: completedDate || null,
|
||||||
showSuccess({
|
performer: null,
|
||||||
message: t('choreView.skipTask'),
|
},
|
||||||
undoAction: async () => {
|
)
|
||||||
try {
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
const undoResponse = await UndoChoreAction(choreId)
|
showSuccess({
|
||||||
if (undoResponse.ok) {
|
message: "You're offline — completion will sync when back online",
|
||||||
// Refetch chore details after undo
|
undoAction: async () => {
|
||||||
const detailResponse = await GetChoreDetailById(choreId)
|
await commandQueue.cancel(cmdId)
|
||||||
if (detailResponse.ok) {
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
const detailData = await detailResponse.json()
|
},
|
||||||
setChore(detailData.res)
|
})
|
||||||
queryClient.invalidateQueries(['chores'])
|
} else {
|
||||||
}
|
showError({
|
||||||
showUndo({
|
title: t('choreView.undoFailed'),
|
||||||
title: t('choreView.undoSuccessful'),
|
message: error?.message || 'Unable to complete task',
|
||||||
message: t('choreView.taskSkipUndone'),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to undo')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError({
|
|
||||||
title: t('choreView.undoFailed'),
|
|
||||||
message: t('choreView.undoFailedMessage'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
const handleSkippingTask = async () => {
|
||||||
|
try {
|
||||||
|
const response = await SkipChore(choreId)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setChore(data.res)
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
showSuccess({
|
||||||
|
message: t('choreView.skipTask'),
|
||||||
|
undoAction: async () => {
|
||||||
|
try {
|
||||||
|
const undoResponse = await UndoChoreAction(choreId)
|
||||||
|
if (undoResponse.ok) {
|
||||||
|
const detailResponse = await GetChoreDetailById(choreId)
|
||||||
|
if (detailResponse.ok) {
|
||||||
|
const detailData = await detailResponse.json()
|
||||||
|
setChore(detailData.res)
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
}
|
||||||
|
showUndo({
|
||||||
|
title: t('choreView.undoSuccessful'),
|
||||||
|
message: t('choreView.taskSkipUndone'),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to undo')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError({
|
||||||
|
title: t('choreView.undoFailed'),
|
||||||
|
message: t('choreView.undoFailedMessage'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} 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 handleChoreStart = () => {
|
||||||
startChore.mutate(choreId, {
|
startChore.mutate(choreId, {
|
||||||
@@ -383,7 +435,6 @@ const ChoreView = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(data => {
|
response.json().then(data => {
|
||||||
setChore(data.res)
|
setChore(data.res)
|
||||||
// Invalidate chores cache to refetch data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -395,23 +446,45 @@ const ChoreView = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(data => {
|
response.json().then(data => {
|
||||||
setChore(data.res)
|
setChore(data.res)
|
||||||
// Invalidate chores cache to refetch data
|
|
||||||
queryClient.invalidateQueries(['chores'])
|
queryClient.invalidateQueries(['chores'])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUnarchiveChore = () => {
|
const handleUnarchiveChore = async () => {
|
||||||
UnArchiveChore(choreId).then(response => {
|
try {
|
||||||
|
const response = await UnArchiveChore(choreId)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(data => {
|
setChore({ ...chore, isActive: true })
|
||||||
setChore({ ...chore, isActive: true })
|
queryClient.invalidateQueries(['chores'])
|
||||||
// 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)
|
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||||
@@ -458,16 +531,19 @@ const ChoreView = () => {
|
|||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Box
|
||||||
level='h3'
|
|
||||||
// textAlign={'center'}
|
|
||||||
sx={{
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1,
|
||||||
mt: 1,
|
mt: 1,
|
||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chore.name}
|
<Typography level='h3'>{chore.name}</Typography>
|
||||||
</Typography>
|
<PendingBadge commands={pendingCmds} />
|
||||||
|
</Box>
|
||||||
{chore.isActive === false && (
|
{chore.isActive === false && (
|
||||||
<Chip
|
<Chip
|
||||||
startDecorator={<Archive />}
|
startDecorator={<Archive />}
|
||||||
@@ -747,7 +823,30 @@ const ChoreView = () => {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RichTextEditor value={chore.description} isEditable={false} />
|
{(() => {
|
||||||
|
const content = decodeHtmlEntities(chore.description || '')
|
||||||
|
const shouldRenderHtml = hasHtmlTags(content)
|
||||||
|
|
||||||
|
return shouldRenderHtml ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
@@ -792,7 +891,30 @@ const ChoreView = () => {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RichTextEditor value={chore.notes} isEditable={false} />
|
{(() => {
|
||||||
|
const content = decodeHtmlEntities(chore.notes || '')
|
||||||
|
const shouldRenderHtml = hasHtmlTags(content)
|
||||||
|
|
||||||
|
return shouldRenderHtml ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
level='body-md'
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
@@ -28,6 +29,7 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
|||||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
|
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
@@ -41,6 +43,7 @@ const ArchivedTasks = () => {
|
|||||||
useUserProfile()
|
useUserProfile()
|
||||||
const { showSuccess, showError } = useNotification()
|
const { showSuccess, showError } = useNotification()
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const unArchiveChore = useUnArchiveChore()
|
const unArchiveChore = useUnArchiveChore()
|
||||||
const [archivedChores, setArchivedChores] = useState([])
|
const [archivedChores, setArchivedChores] = useState([])
|
||||||
const [filteredChores, setFilteredChores] = useState([])
|
const [filteredChores, setFilteredChores] = useState([])
|
||||||
@@ -319,6 +322,10 @@ const ArchivedTasks = () => {
|
|||||||
const restoredTasks = []
|
const restoredTasks = []
|
||||||
const failedTasks = []
|
const failedTasks = []
|
||||||
|
|
||||||
|
const isNetworkError = err =>
|
||||||
|
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||||
|
const queuedTasks = []
|
||||||
|
|
||||||
for (const chore of selectedData) {
|
for (const chore of selectedData) {
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -327,9 +334,15 @@ const ArchivedTasks = () => {
|
|||||||
restoredTasks.push(chore)
|
restoredTasks.push(chore)
|
||||||
resolve(data)
|
resolve(data)
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: async error => {
|
||||||
failedTasks.push(chore)
|
if (isNetworkError(error)) {
|
||||||
reject(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) {
|
const allRestored = [...restoredTasks, ...queuedTasks]
|
||||||
showSuccess({
|
if (allRestored.length > 0) {
|
||||||
title: '📤 Tasks Restored',
|
const offlineNote = queuedTasks.length > 0 ? " (queued — will sync when back online)" : ''
|
||||||
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
|
// 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))
|
||||||
// Remove restored tasks from archived list
|
const newFilteredChores = filteredChores.filter(c => !restoredIds.has(c.id))
|
||||||
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),
|
|
||||||
)
|
|
||||||
setArchivedChores(newArchivedChores)
|
setArchivedChores(newArchivedChores)
|
||||||
setFilteredChores(newFilteredChores)
|
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) {
|
if (failedTasks.length > 0) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Pause,
|
Pause,
|
||||||
PlayArrow,
|
PlayArrow,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Schedule,
|
||||||
ThumbUp,
|
ThumbUp,
|
||||||
TimesOneMobiledata,
|
TimesOneMobiledata,
|
||||||
Toll,
|
Toll,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import {
|
import {
|
||||||
getDueDateChipColor,
|
getDueDateChipColor,
|
||||||
@@ -32,6 +34,7 @@ import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
|||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import Priorities from '../../utils/Priorities'
|
import Priorities from '../../utils/Priorities'
|
||||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||||
|
import PendingBadge from '../components/PendingBadge'
|
||||||
const ChoreCard = ({
|
const ChoreCard = ({
|
||||||
chore,
|
chore,
|
||||||
performers,
|
performers,
|
||||||
@@ -47,6 +50,7 @@ const ChoreCard = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const { timeFormat } = useLocalization()
|
const { timeFormat } = useLocalization()
|
||||||
|
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||||
|
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
|
|
||||||
@@ -86,7 +90,11 @@ const ChoreCard = ({
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box key={chore.id + '-box'} minWidth={'100%'}>
|
<Box
|
||||||
|
key={chore.id + '-box'}
|
||||||
|
minWidth={'100%'}
|
||||||
|
sx={{ position: 'relative' }}
|
||||||
|
>
|
||||||
<Chip
|
<Chip
|
||||||
variant='soft'
|
variant='soft'
|
||||||
sx={{
|
sx={{
|
||||||
@@ -122,6 +130,9 @@ const ChoreCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
||||||
|
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||||
|
<PendingBadge commands={pendingCmds} />
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -342,9 +353,31 @@ const ChoreCard = ({
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
justifyContent: 'center',
|
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 && (
|
{showActions && (
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
display='flex'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import {
|
import {
|
||||||
getDueDateChipColor,
|
getDueDateChipColor,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
} from '../../utils/Colors.jsx'
|
} from '../../utils/Colors.jsx'
|
||||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||||
|
import PendingBadge from '../components/PendingBadge'
|
||||||
|
|
||||||
const CompactChoreCard = ({
|
const CompactChoreCard = ({
|
||||||
chore,
|
chore,
|
||||||
@@ -44,6 +46,7 @@ const CompactChoreCard = ({
|
|||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const { timeFormat } = useLocalization()
|
const { timeFormat } = useLocalization()
|
||||||
const { data: circleMembersData } = useCircleMembers()
|
const { data: circleMembersData } = useCircleMembers()
|
||||||
|
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||||
|
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
|
|
||||||
@@ -383,7 +386,9 @@ const CompactChoreCard = ({
|
|||||||
>
|
>
|
||||||
{chore.name}
|
{chore.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{(chore._pending || (pendingCmds && pendingCmds.length > 0)) && (
|
||||||
|
<PendingBadge commands={pendingCmds} size='xs' sx={{ mr: -0.5 }} />
|
||||||
|
)}
|
||||||
{/* Due Date - Inline with name */}
|
{/* Due Date - Inline with name */}
|
||||||
<Chip
|
<Chip
|
||||||
variant='soft'
|
variant='soft'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
||||||
import IconButton from '@mui/joy/IconButton'
|
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'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
|
|
||||||
const IconButtonWithMenu = ({
|
const IconButtonWithMenu = ({
|
||||||
@@ -17,6 +17,7 @@ const IconButtonWithMenu = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
const menuRef = useRef(null)
|
const menuRef = useRef(null)
|
||||||
|
const menuOptions = Array.isArray(options) ? options : []
|
||||||
|
|
||||||
const handleMenuOpen = event => {
|
const handleMenuOpen = event => {
|
||||||
setAnchorEl(event.currentTarget)
|
setAnchorEl(event.currentTarget)
|
||||||
@@ -85,7 +86,7 @@ const IconButtonWithMenu = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{options?.map(item => (
|
{menuOptions.map(item => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`${k}-${item?.id}`}
|
key={`${k}-${item?.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -272,18 +272,6 @@ const MyChores = () => {
|
|||||||
// Don't set choreSections here - let the dedicated effect handle it
|
// Don't set choreSections here - let the dedicated effect handle it
|
||||||
// This prevents caching issues when switching between projects
|
// 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()) {
|
if (await canScheduleNotification()) {
|
||||||
console.log('Scheduling chore notifications...')
|
console.log('Scheduling chore notifications...')
|
||||||
scheduleChoreNotification(
|
scheduleChoreNotification(
|
||||||
@@ -303,10 +291,8 @@ const MyChores = () => {
|
|||||||
choresData?.res,
|
choresData?.res,
|
||||||
membersData?.res,
|
membersData?.res,
|
||||||
processedChores, // Added to ensure local state syncs when query data updates
|
processedChores, // Added to ensure local state syncs when query data updates
|
||||||
processedSections,
|
|
||||||
userProfile,
|
userProfile,
|
||||||
impersonatedUser?.userId,
|
impersonatedUser?.userId,
|
||||||
selectedChoreSection,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// Auto-update sections when processedSections changes
|
// Auto-update sections when processedSections changes
|
||||||
@@ -1296,20 +1282,19 @@ const MyChores = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{searchTerm?.length > 0 &&
|
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
||||||
viewMode !== 'calendar' && (
|
<ChoreListView
|
||||||
<ChoreListView
|
chores={getFilteredChores}
|
||||||
chores={getFilteredChores}
|
viewMode={viewMode}
|
||||||
viewMode={viewMode}
|
membersData={membersData}
|
||||||
membersData={membersData}
|
userLabels={userLabels}
|
||||||
userLabels={userLabels}
|
handleLabelFiltering={handleLabelFiltering}
|
||||||
handleLabelFiltering={handleLabelFiltering}
|
handleChoreAction={handleChoreAction}
|
||||||
handleChoreAction={handleChoreAction}
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
isMultiSelectMode={isMultiSelectMode}
|
selectedChores={selectedChores}
|
||||||
selectedChores={selectedChores}
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
{viewMode === 'calendar' && (
|
{viewMode === 'calendar' && (
|
||||||
<>
|
<>
|
||||||
{/* Summary Chips when no date selected */}
|
{/* Summary Chips when no date selected */}
|
||||||
@@ -1492,87 +1477,86 @@ const MyChores = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{searchTerm.length === 0 &&
|
{searchTerm.length === 0 && viewMode !== 'calendar' && (
|
||||||
viewMode !== 'calendar' && (
|
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
{choreSections.map((section, index) => {
|
||||||
{choreSections.map((section, index) => {
|
if (section.content.length === 0) return null
|
||||||
if (section.content.length === 0) return null
|
return (
|
||||||
return (
|
<Accordion
|
||||||
<Accordion
|
key={section.name + index}
|
||||||
key={section.name + index}
|
sx={{
|
||||||
sx={{
|
my: 0,
|
||||||
my: 0,
|
px: 0,
|
||||||
px: 0,
|
}}
|
||||||
}}
|
expanded={Boolean(openChoreSections[index])}
|
||||||
expanded={Boolean(openChoreSections[index])}
|
>
|
||||||
>
|
<Divider orientation='horizontal'>
|
||||||
<Divider orientation='horizontal'>
|
<Chip
|
||||||
<Chip
|
variant='soft'
|
||||||
variant='soft'
|
color='neutral'
|
||||||
color='neutral'
|
size='md'
|
||||||
size='md'
|
onClick={() => {
|
||||||
onClick={() => {
|
if (openChoreSections[index]) {
|
||||||
if (openChoreSections[index]) {
|
const newOpenChoreSections = {
|
||||||
const newOpenChoreSections = {
|
...openChoreSections,
|
||||||
...openChoreSections,
|
|
||||||
}
|
|
||||||
delete newOpenChoreSections[index]
|
|
||||||
setOpenChoreSectionsWithCache(newOpenChoreSections)
|
|
||||||
} else {
|
|
||||||
setOpenChoreSectionsWithCache({
|
|
||||||
...openChoreSections,
|
|
||||||
[index]: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}}
|
delete newOpenChoreSections[index]
|
||||||
endDecorator={
|
setOpenChoreSectionsWithCache(newOpenChoreSections)
|
||||||
openChoreSections[index] ? (
|
} else {
|
||||||
<ExpandCircleDown
|
setOpenChoreSectionsWithCache({
|
||||||
color='primary'
|
...openChoreSections,
|
||||||
sx={{ transform: 'rotate(180deg)' }}
|
[index]: true,
|
||||||
/>
|
})
|
||||||
) : (
|
|
||||||
<ExpandCircleDown color='primary' />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
startDecorator={
|
|
||||||
<>
|
|
||||||
<Chip color='primary' size='sm' variant='soft'>
|
|
||||||
{section?.content?.length}
|
|
||||||
</Chip>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{section.name}
|
|
||||||
</Chip>
|
|
||||||
</Divider>
|
|
||||||
<AccordionDetails
|
|
||||||
sx={{
|
|
||||||
flexDirection: 'column',
|
|
||||||
['& > *']: {
|
|
||||||
// px: 0.5,
|
|
||||||
px: 0.5,
|
|
||||||
// pr: 0,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
|
endDecorator={
|
||||||
|
openChoreSections[index] ? (
|
||||||
|
<ExpandCircleDown
|
||||||
|
color='primary'
|
||||||
|
sx={{ transform: 'rotate(180deg)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExpandCircleDown color='primary' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
startDecorator={
|
||||||
|
<>
|
||||||
|
<Chip color='primary' size='sm' variant='soft'>
|
||||||
|
{section?.content?.length}
|
||||||
|
</Chip>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ChoreListView
|
{section.name}
|
||||||
chores={section.content}
|
</Chip>
|
||||||
viewMode={viewMode}
|
</Divider>
|
||||||
membersData={membersData}
|
<AccordionDetails
|
||||||
userLabels={userLabels}
|
sx={{
|
||||||
handleLabelFiltering={handleLabelFiltering}
|
flexDirection: 'column',
|
||||||
handleChoreAction={handleChoreAction}
|
['& > *']: {
|
||||||
isMultiSelectMode={isMultiSelectMode}
|
// px: 0.5,
|
||||||
selectedChores={selectedChores}
|
px: 0.5,
|
||||||
toggleChoreSelection={toggleChoreSelection}
|
// pr: 0,
|
||||||
/>
|
},
|
||||||
</AccordionDetails>
|
}}
|
||||||
</Accordion>
|
>
|
||||||
)
|
<ChoreListView
|
||||||
})}
|
chores={section.content}
|
||||||
</AccordionGroup>
|
viewMode={viewMode}
|
||||||
)}
|
membersData={membersData}
|
||||||
|
userLabels={userLabels}
|
||||||
|
handleLabelFiltering={handleLabelFiltering}
|
||||||
|
handleChoreAction={handleChoreAction}
|
||||||
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
|
selectedChores={selectedChores}
|
||||||
|
toggleChoreSelection={toggleChoreSelection}
|
||||||
|
/>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AccordionGroup>
|
||||||
|
)}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
// center the button
|
// center the button
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useArchiveChore } from '../../../queries/ChoreQueries'
|
|
||||||
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
|
|
||||||
import {
|
import {
|
||||||
ApproveChore,
|
useArchiveChore,
|
||||||
DeleteChore,
|
useUnArchiveChore,
|
||||||
MarkChoreComplete,
|
} from '../../../queries/ChoreQueries'
|
||||||
NudgeChore,
|
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
|
||||||
RejectChore,
|
import { commandQueue, CommandType } from '../../../utils/CommandQueue'
|
||||||
SkipChore,
|
import {
|
||||||
UndoChoreAction,
|
ApproveChore,
|
||||||
UpdateChoreAssignee,
|
DeleteChore,
|
||||||
UpdateDueDate,
|
MarkChoreComplete,
|
||||||
|
NudgeChore,
|
||||||
|
RejectChore,
|
||||||
|
SkipChore,
|
||||||
|
UndoChoreAction,
|
||||||
|
UpdateChoreAssignee,
|
||||||
|
UpdateDueDate,
|
||||||
} from '../../../utils/Fetcher'
|
} from '../../../utils/Fetcher'
|
||||||
|
|
||||||
|
const isNetworkError = err =>
|
||||||
|
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||||
|
|
||||||
export const useChoreActions = ({
|
export const useChoreActions = ({
|
||||||
chores,
|
chores,
|
||||||
filteredChores,
|
filteredChores,
|
||||||
@@ -35,11 +42,12 @@ export const useChoreActions = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const archiveChore = useArchiveChore()
|
const archiveChore = useArchiveChore()
|
||||||
|
const unarchiveChore = useUnArchiveChore()
|
||||||
const startChore = useStartChore()
|
const startChore = useStartChore()
|
||||||
const pauseChore = usePauseChore()
|
const pauseChore = usePauseChore()
|
||||||
|
|
||||||
const updateChoreInState = useCallback(
|
const updateChoreInState = useCallback(
|
||||||
(updatedChore, event) => {
|
(updatedChore, event, { skipInvalidation = false } = {}) => {
|
||||||
let newChores = chores.map(c =>
|
let newChores = chores.map(c =>
|
||||||
c.id === updatedChore.id ? updatedChore : c,
|
c.id === updatedChore.id ? updatedChore : c,
|
||||||
)
|
)
|
||||||
@@ -61,7 +69,9 @@ export const useChoreActions = ({
|
|||||||
setChores(newChores)
|
setChores(newChores)
|
||||||
setFilteredChores(newFilteredChores)
|
setFilteredChores(newFilteredChores)
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['chores'] })
|
if (!skipInvalidation) {
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
}
|
||||||
|
|
||||||
const undoableActions = {
|
const undoableActions = {
|
||||||
completed: 'Task completed',
|
completed: 'Task completed',
|
||||||
@@ -77,7 +87,7 @@ export const useChoreActions = ({
|
|||||||
try {
|
try {
|
||||||
const undoResponse = await UndoChoreAction(updatedChore.id)
|
const undoResponse = await UndoChoreAction(updatedChore.id)
|
||||||
if (undoResponse.ok) {
|
if (undoResponse.ok) {
|
||||||
refetchChores()
|
queryClient.invalidateQueries(['chores'])
|
||||||
const undoMessages = {
|
const undoMessages = {
|
||||||
completed: 'Task completion has been undone.',
|
completed: 'Task completion has been undone.',
|
||||||
approved: 'Task approval has been undone.',
|
approved: 'Task approval has been undone.',
|
||||||
@@ -121,7 +131,8 @@ export const useChoreActions = ({
|
|||||||
archive: {
|
archive: {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Task Archived',
|
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: {
|
started: {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@@ -147,47 +158,56 @@ export const useChoreActions = ({
|
|||||||
notifyFn({ title: notification.title, message: notification.message })
|
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(
|
const handleChoreAction = useCallback(
|
||||||
async (action, chore, extraData = {}) => {
|
async (action, chore, extraData = {}) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'complete':
|
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 {
|
try {
|
||||||
const response = await MarkChoreComplete(
|
const response = await MarkChoreComplete(
|
||||||
chore.id,
|
chore.id,
|
||||||
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
impersonatedUser
|
||||||
|
? { completedBy: impersonatedUser.userId }
|
||||||
|
: null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
if (response.ok) {
|
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({
|
showSuccess({
|
||||||
message: 'Task completed',
|
message: 'Task completed',
|
||||||
undoAction: async () => {
|
undoAction: async () => {
|
||||||
try {
|
try {
|
||||||
const undoResponse = await UndoChoreAction(chore.id)
|
const undoResponse = await UndoChoreAction(chore.id)
|
||||||
if (undoResponse.ok) {
|
if (undoResponse.ok) {
|
||||||
refetchChores()
|
queryClient.invalidateQueries(['chores'])
|
||||||
showUndo({
|
showUndo({
|
||||||
title: 'Undo Successful',
|
title: 'Undo Successful',
|
||||||
message: 'Task completion has been undone.',
|
message: 'Task completion has been undone.',
|
||||||
})
|
})
|
||||||
} else throw new Error('Failed to undo')
|
} else throw new Error('Failed to undo')
|
||||||
} catch (error) {
|
} catch {
|
||||||
showError({
|
showError({
|
||||||
title: 'Undo Failed',
|
title: 'Undo Failed',
|
||||||
message: 'Unable to undo the action. Please try again.',
|
message: 'Unable to undo the action. Please try again.',
|
||||||
@@ -195,35 +215,63 @@ export const useChoreActions = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
// 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'] })
|
|
||||||
} else {
|
} else {
|
||||||
refetchChores() // Network failed, revert to truth
|
refetchChores()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
refetchChores() // Network failed, revert to truth
|
if (isNetworkError(error)) {
|
||||||
if (error?.queued) {
|
// Offline — queue and show pending badge on the chore (don't hide it)
|
||||||
showError({
|
const cmdId = await commandQueue.enqueue(
|
||||||
title: 'Update Failed',
|
CommandType.COMPLETE_CHORE,
|
||||||
message: 'Request will be reattempt when you are online',
|
chore.id,
|
||||||
|
{
|
||||||
|
id: chore.id,
|
||||||
|
body: impersonatedUser
|
||||||
|
? { completedBy: impersonatedUser.userId }
|
||||||
|
: null,
|
||||||
|
completedDate: null,
|
||||||
|
performer: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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 {
|
} else {
|
||||||
showError({
|
showError({
|
||||||
title: 'Failed to update',
|
title: 'Failed to complete',
|
||||||
message: error,
|
message: error?.message || 'Unable to complete chore',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'start':
|
case 'start': {
|
||||||
|
const startedChore = { ...chore, status: 1 }
|
||||||
startChore.mutate(chore.id, {
|
startChore.mutate(chore.id, {
|
||||||
onSuccess: async res => {
|
onSuccess: () => {
|
||||||
const data = await res.json()
|
queryClient.cancelQueries(['chores'])
|
||||||
const newChore = { ...chore, status: data.res.status }
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
updateChoreInState(newChore, 'started')
|
if (!oldData?.res) return oldData
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
res: oldData.res.map(c =>
|
||||||
|
c.id === chore.id ? startedChore : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
updateChoreInState(startedChore, 'started', {
|
||||||
|
skipInvalidation: true,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
showError({
|
showError({
|
||||||
@@ -233,13 +281,25 @@ export const useChoreActions = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'pause':
|
case 'pause': {
|
||||||
|
const pausedChore = { ...chore, status: 2 }
|
||||||
pauseChore.mutate(chore.id, {
|
pauseChore.mutate(chore.id, {
|
||||||
onSuccess: async res => {
|
onSuccess: () => {
|
||||||
const data = await res.json()
|
queryClient.cancelQueries(['chores'])
|
||||||
const newChore = { ...chore, status: data.res.status }
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
updateChoreInState(newChore, 'paused')
|
if (!oldData?.res) return oldData
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
res: oldData.res.map(c =>
|
||||||
|
c.id === chore.id ? pausedChore : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
updateChoreInState(pausedChore, 'paused', {
|
||||||
|
skipInvalidation: true,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
showError({
|
showError({
|
||||||
@@ -249,6 +309,7 @@ export const useChoreActions = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'approve':
|
case 'approve':
|
||||||
try {
|
try {
|
||||||
@@ -305,10 +366,37 @@ export const useChoreActions = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError({
|
if (isNetworkError(error)) {
|
||||||
title: 'Failed to delete',
|
const cmdId = await commandQueue.enqueue(
|
||||||
message: error,
|
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 || 'Unable to delete chore',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setConfirmModelConfig({})
|
setConfirmModelConfig({})
|
||||||
@@ -324,31 +412,122 @@ export const useChoreActions = ({
|
|||||||
updateChoreInState(data, 'archive')
|
updateChoreInState(data, 'archive')
|
||||||
resolve(data)
|
resolve(data)
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: async error => {
|
||||||
showError({
|
if (isNetworkError(error)) {
|
||||||
title: 'Failed to archive',
|
const cmdId = await commandQueue.enqueue(
|
||||||
message: error.message || 'Unable to archive chore',
|
CommandType.ARCHIVE_CHORE,
|
||||||
})
|
chore.id,
|
||||||
reject(error)
|
{ 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
|
break
|
||||||
|
|
||||||
case 'skip':
|
case 'skip':
|
||||||
try {
|
try {
|
||||||
const response = await SkipChore(chore.id)
|
const response = await SkipChore(chore.id)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
// Online: update in place (chore gets new due date)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
updateChoreInState(data.res, 'skipped')
|
updateChoreInState(data.res, 'skipped')
|
||||||
|
} else {
|
||||||
|
refetchChores()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError({
|
if (isNetworkError(error)) {
|
||||||
title: 'Failed to skip',
|
// Offline — queue and show pending badge on the chore
|
||||||
message: error,
|
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 || 'Unable to skip chore',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -363,13 +542,48 @@ export const useChoreActions = ({
|
|||||||
updateChoreInState(chore, eventType)
|
updateChoreInState(chore, eventType)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError({
|
if (isNetworkError(error)) {
|
||||||
title:
|
const oldDueDate = chore.nextDueDate
|
||||||
extraData.date === null
|
const cmdId = await commandQueue.enqueue(
|
||||||
? 'Failed to remove due date'
|
CommandType.RESCHEDULE_CHORE,
|
||||||
: 'Failed to reschedule',
|
chore.id,
|
||||||
message: error.message || 'Unable to update due date',
|
{
|
||||||
})
|
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
|
||||||
|
? 'Failed to remove due date'
|
||||||
|
: 'Failed to reschedule',
|
||||||
|
message: error.message || 'Unable to update due date',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
openModal(action, chore, extraData)
|
openModal(action, chore, extraData)
|
||||||
@@ -400,26 +614,60 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig,
|
setConfirmModelConfig,
|
||||||
openModal,
|
openModal,
|
||||||
archiveChore,
|
archiveChore,
|
||||||
|
unarchiveChore,
|
||||||
startChore,
|
startChore,
|
||||||
pauseChore,
|
pauseChore,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleChangeDueDate = useCallback(
|
const handleChangeDueDate = useCallback(
|
||||||
newDate => {
|
async newDate => {
|
||||||
if (!modalChore) return
|
if (!modalChore) return
|
||||||
UpdateDueDate(modalChore.id, newDate).then(response => {
|
closeModal()
|
||||||
|
try {
|
||||||
|
const response = await UpdateDueDate(modalChore.id, newDate)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(data => {
|
updateChoreInState(
|
||||||
const newChore = modalChore
|
{ ...modalChore, nextDueDate: newDate },
|
||||||
newChore.nextDueDate = newDate
|
'rescheduled',
|
||||||
updateChoreInState(newChore, '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],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCompleteWithPastDate = useCallback(
|
const handleCompleteWithPastDate = useCallback(
|
||||||
@@ -568,7 +816,15 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig({})
|
setConfirmModelConfig({})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
}, [
|
||||||
|
getSelectedChoresData,
|
||||||
|
impersonatedUser,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
refetchChores,
|
||||||
|
clearSelection,
|
||||||
|
setConfirmModelConfig,
|
||||||
|
])
|
||||||
|
|
||||||
const handleBulkArchive = useCallback(async () => {
|
const handleBulkArchive = useCallback(async () => {
|
||||||
const selectedData = getSelectedChoresData(chores)
|
const selectedData = getSelectedChoresData(chores)
|
||||||
@@ -603,8 +859,7 @@ export const useChoreActions = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (archivedTasks.length > 0) {
|
if (archivedTasks.length > 0) {
|
||||||
showSuccess({
|
showSuccess({
|
||||||
@@ -630,7 +885,17 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig({})
|
setConfirmModelConfig({})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
}, [
|
||||||
|
getSelectedChoresData,
|
||||||
|
archiveChore,
|
||||||
|
setChores,
|
||||||
|
setFilteredChores,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
refetchChores,
|
||||||
|
clearSelection,
|
||||||
|
setConfirmModelConfig,
|
||||||
|
])
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(async () => {
|
const handleBulkDelete = useCallback(async () => {
|
||||||
const selectedData = getSelectedChoresData(chores)
|
const selectedData = getSelectedChoresData(chores)
|
||||||
@@ -690,7 +955,18 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig({})
|
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 handleBulkSkip = useCallback(async () => {
|
||||||
const selectedData = getSelectedChoresData(chores)
|
const selectedData = getSelectedChoresData(chores)
|
||||||
@@ -726,7 +1002,7 @@ export const useChoreActions = ({
|
|||||||
for (const chore of skippedTasks) {
|
for (const chore of skippedTasks) {
|
||||||
await UndoChoreAction(chore.id)
|
await UndoChoreAction(chore.id)
|
||||||
}
|
}
|
||||||
refetchChores()
|
queryClient.invalidateQueries(['chores'])
|
||||||
showUndo({
|
showUndo({
|
||||||
title: 'Undo Successful',
|
title: 'Undo Successful',
|
||||||
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||||
@@ -760,7 +1036,15 @@ export const useChoreActions = ({
|
|||||||
setConfirmModelConfig({})
|
setConfirmModelConfig({})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
|
}, [
|
||||||
|
getSelectedChoresData,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
showUndo,
|
||||||
|
refetchChores,
|
||||||
|
clearSelection,
|
||||||
|
setConfirmModelConfig,
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
|
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
|
||||||
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
|
|
||||||
export const useLabels = () => {
|
export const useLabels = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['labels'],
|
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 []
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
|
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
|
||||||
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
|
|
||||||
// Query hook for fetching all projects
|
// Query hook for fetching all projects
|
||||||
export const useProjects = () => {
|
export const useProjects = () => {
|
||||||
@@ -10,22 +11,15 @@ export const useProjects = () => {
|
|||||||
const response = await GetProjects()
|
const response = await GetProjects()
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
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')
|
throw new Error('Failed to fetch projects')
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error fetching projects:', error)
|
const cached = await offlineDB.getKV('projects')
|
||||||
// Return default project if API fails
|
if (cached) return cached
|
||||||
return [
|
return []
|
||||||
{
|
|
||||||
id: 'default',
|
|
||||||
name: 'Default Project',
|
|
||||||
description: 'Your default project workspace',
|
|
||||||
color: '#1976d2',
|
|
||||||
created_by: 'system',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -9,22 +8,43 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher'
|
import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
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'
|
import SettingsLayout from './SettingsLayout'
|
||||||
|
|
||||||
const AdvancedSettings = () => {
|
const AdvancedSettings = () => {
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
|
|
||||||
const [userCircles, setUserCircles] = useState([])
|
const [userCircles, setUserCircles] = useState([])
|
||||||
const [webhookURL, setWebhookURL] = useState(null)
|
const [webhookURL, setWebhookURL] = useState(null)
|
||||||
const [webhookError, setWebhookError] = useState(null)
|
const [webhookError, setWebhookError] = useState(null)
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
GetUserCircle().then(resp => {
|
GetUserCircle().then(resp => {
|
||||||
@@ -42,21 +62,120 @@ const AdvancedSettings = () => {
|
|||||||
}
|
}
|
||||||
}, [userCircles])
|
}, [userCircles])
|
||||||
|
|
||||||
if (!userProfile) {
|
const disableOfflineSupport = async () => {
|
||||||
return (
|
setOfflineLoading(true)
|
||||||
<SettingsLayout title="Advanced Settings">
|
try {
|
||||||
<div>Loading...</div>
|
await offlineDB.clearAll()
|
||||||
</SettingsLayout>
|
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 (
|
return (
|
||||||
<SettingsLayout title="Advanced Settings">
|
<SettingsLayout title='Advanced Settings'>
|
||||||
<div className='grid gap-4'>
|
<div className='grid gap-4'>
|
||||||
<Typography level='body-md'>
|
<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>
|
</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 */}
|
{/* Webhook Settings - Only show for admins */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
@@ -152,9 +271,14 @@ const AdvancedSettings = () => {
|
|||||||
Real-time Updates
|
Real-time Updates
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-md' mt={-1}>
|
<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>
|
</Typography>
|
||||||
<RealTimeSettings />
|
<RealTimeSettings />
|
||||||
|
|
||||||
|
{confirmModalConfig?.isOpen && (
|
||||||
|
<ConfirmationModal config={confirmModalConfig} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,17 +4,11 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Switch,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import {
|
|
||||||
FEATURES,
|
|
||||||
isFeatureEnabled,
|
|
||||||
setFeatureEnabled,
|
|
||||||
} from '../../utils/FeatureToggle'
|
|
||||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
@@ -26,9 +20,6 @@ const StorageSettings = () => {
|
|||||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||||
const [offlineModeEnabled, setOfflineModeEnabledState] = useState(
|
|
||||||
isFeatureEnabled(FEATURES.OFFLINE_MODE),
|
|
||||||
)
|
|
||||||
|
|
||||||
const showConfirmation = (
|
const showConfirmation = (
|
||||||
message,
|
message,
|
||||||
@@ -54,11 +45,6 @@ const StorageSettings = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOfflineModeToggle = enabled => {
|
|
||||||
setOfflineModeEnabledState(enabled)
|
|
||||||
setFeatureEnabled(FEATURES.OFFLINE_MODE, enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPlusAccount(userProfile)) {
|
if (isPlusAccount(userProfile)) {
|
||||||
GetStorageUsage().then(resp => {
|
GetStorageUsage().then(resp => {
|
||||||
@@ -127,48 +113,14 @@ const StorageSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</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 }}>
|
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||||
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
|
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
|
||||||
Cache
|
Cache
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||||
This is data stored locally in your browser for faster access and
|
This is data stored locally in your browser for faster access.
|
||||||
offline use. Clearing this will not affect your server data, but may
|
Clearing this will not affect your server data, but may log you out.
|
||||||
log you out or remove offline tasks.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -189,27 +141,6 @@ const StorageSettings = () => {
|
|||||||
>
|
>
|
||||||
Clear All Local Storage and Cache
|
Clear All Local Storage and Cache
|
||||||
</Button>
|
</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>
|
</Card>
|
||||||
|
|
||||||
{Capacitor.isNativePlatform() && (
|
{Capacitor.isNativePlatform() && (
|
||||||
|
|||||||
@@ -633,32 +633,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
|
|
||||||
createChoreMutation
|
createChoreMutation
|
||||||
.mutateAsync(chore)
|
.mutateAsync(chore)
|
||||||
.then(resp => {
|
.then(result => {
|
||||||
resp.json().then(data => {
|
const choreData = result?.res
|
||||||
if (resp.status !== 200) {
|
if (choreData?._pendingCreate) {
|
||||||
console.error('Error creating chore:', data)
|
// Offline: task queued, add temp chore to UI immediately
|
||||||
return
|
onChoreUpdate(choreData)
|
||||||
} else {
|
} else {
|
||||||
onChoreUpdate({
|
// Online: choreData is the server's parsed response ({ res: id })
|
||||||
...chore,
|
onChoreUpdate({
|
||||||
id: data.res,
|
...chore,
|
||||||
nextDueDate: chore.dueDate,
|
id: choreData?.res || choreData?.id,
|
||||||
})
|
nextDueDate: chore.dueDate,
|
||||||
|
})
|
||||||
handleCloseModal(false)
|
}
|
||||||
}
|
setTaskText('')
|
||||||
handleCloseModal()
|
|
||||||
setTaskText('')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (error?.queued) {
|
console.error('Error creating chore:', error)
|
||||||
handleCloseModal(true)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
handleCloseModal(false)
|
handleCloseModal(false)
|
||||||
}
|
}
|
||||||
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
|
if (isCircleMembersLoading || isProjectsLoading) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { version } from '../../../package.json'
|
|||||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import NavBarLink from './NavBarLink'
|
import NavBarLink from './NavBarLink'
|
||||||
|
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||||
|
|
||||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||||
import Z_INDEX from '../../constants/zIndex'
|
import Z_INDEX from '../../constants/zIndex'
|
||||||
@@ -199,6 +200,7 @@ const NavBar = () => {
|
|||||||
{getMenuIcon()}
|
{getMenuIcon()}
|
||||||
<Box className='flex-1' />
|
<Box className='flex-1' />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SyncStatusIndicator />
|
||||||
<UserProfileAvatar />
|
<UserProfileAvatar />
|
||||||
{/* <ThemeToggleButton /> */}
|
{/* <ThemeToggleButton /> */}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
193
src/views/components/PendingBadge.jsx
Normal file
193
src/views/components/PendingBadge.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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',
|
||||||
|
reschedule_chore: 'Reschedule pending',
|
||||||
|
archive_chore: 'Archive pending',
|
||||||
|
unarchive_chore: 'Restore pending',
|
||||||
|
start_chore: 'Start pending',
|
||||||
|
pause_chore: 'Pause pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
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'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }}>
|
||||||
|
{LABELS[cmd.commandType] || 'Pending action'}
|
||||||
|
</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
|
||||||
478
src/views/components/SyncStatusIndicator.jsx
Normal file
478
src/views/components/SyncStatusIndicator.jsx
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleOutline,
|
||||||
|
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',
|
||||||
|
complete_chore: 'Complete chore',
|
||||||
|
skip_chore: 'Skip chore',
|
||||||
|
delete_chore: 'Delete chore',
|
||||||
|
reschedule_chore: 'Reschedule chore',
|
||||||
|
archive_chore: 'Archive chore',
|
||||||
|
unarchive_chore: 'Restore chore',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
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'>
|
||||||
|
{COMMAND_LABELS[type] || 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COMMAND_LABELS[cmd.commandType] || 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 }} />
|
||||||
|
|
||||||
|
{/* 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