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:
Mo Tarbin
2026-05-11 17:27:29 -04:00
parent 1f9d1edc5c
commit d981d57a8f
32 changed files with 3076 additions and 1066 deletions

View File

@@ -15,6 +15,7 @@ import './styles/safe-area.css'
import SSEProvider from './contexts/SSEContext' import SSEProvider from './contexts/SSEContext'
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 => {
@@ -30,6 +31,7 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
const AppContent = () => { const AppContent = () => {
const { showNotification } = useNotification() const { showNotification } = useNotification()
useSyncOnReconnect()
// Initialize status bar with theme-aware configuration // Initialize status bar with theme-aware configuration
useStatusBar() useStatusBar()

View File

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

View 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,
})
}

View 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])
}

View File

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

View File

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

View File

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

View File

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

128
src/utils/CommandQueue.js Normal file
View 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()

View File

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

View File

@@ -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
View 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()

View 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
View 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()

View File

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

View File

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

View File

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

View File

@@ -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('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&')
}
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>
</> </>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 []
}
},
}) })
} }

View File

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

View File

@@ -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,12 +271,17 @@ 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>
) )
} }
export default AdvancedSettings export default AdvancedSettings

View File

@@ -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() && (

View File

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

View File

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

View 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

View 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