From 2c353628a3baa2d2e0c22258797ad803b5d48e10 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 11 May 2026 17:27:29 -0400 Subject: [PATCH] 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. --- src/App.jsx | 2 + src/hooks/NetworkManager.jsx | 34 +- src/hooks/usePendingCommands.js | 25 + src/hooks/useSyncOnReconnect.js | 66 ++ src/queries/ChoreQueries.jsx | 415 +++++------ src/queries/SubtaskQueries.jsx | 67 +- src/queries/TimeQueries.jsx | 14 +- src/queries/UserQueries.jsx | 44 +- src/utils/ApiClient.js | 2 +- src/utils/CommandQueue.js | 128 ++++ src/utils/FeatureToggle.js | 79 +-- src/utils/LocalStore.jsx | 223 ------ src/utils/OfflineDB.js | 688 +++++++++++++++++++ src/utils/OfflineFeatureToggle.js | 58 ++ src/utils/SyncEngine.js | 211 ++++++ src/utils/SyncManager.jsx | 33 - src/utils/TokenStorage.js | 1 + src/views/ChoreEdit/ChoreEdit.jsx | 18 +- src/views/ChoreEdit/ChoreView.jsx | 306 ++++++--- src/views/Chores/ArchivedTasks.jsx | 47 +- src/views/Chores/ChoreCard.jsx | 35 +- src/views/Chores/CompactChoreCard.jsx | 7 +- src/views/Chores/IconButtonWithMenu.jsx | 5 +- src/views/Chores/MyChores.jsx | 194 +++--- src/views/Chores/hooks/useChoreActions.js | 470 ++++++++++--- src/views/Labels/LabelQueries.jsx | 21 +- src/views/Projects/ProjectQueries.js | 22 +- src/views/Settings/AdvancedSettings.jsx | 146 +++- src/views/Settings/StorageSettings.jsx | 73 +- src/views/components/AddTaskModal.jsx | 37 +- src/views/components/NavBar.jsx | 2 + src/views/components/PendingBadge.jsx | 193 ++++++ src/views/components/SyncStatusIndicator.jsx | 478 +++++++++++++ 33 files changed, 3077 insertions(+), 1067 deletions(-) create mode 100644 src/hooks/usePendingCommands.js create mode 100644 src/hooks/useSyncOnReconnect.js create mode 100644 src/utils/CommandQueue.js delete mode 100644 src/utils/LocalStore.jsx create mode 100644 src/utils/OfflineDB.js create mode 100644 src/utils/OfflineFeatureToggle.js create mode 100644 src/utils/SyncEngine.js delete mode 100644 src/utils/SyncManager.jsx create mode 100644 src/views/components/PendingBadge.jsx create mode 100644 src/views/components/SyncStatusIndicator.jsx diff --git a/src/App.jsx b/src/App.jsx index 8d207be..7eeb5c6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import SSEProvider from './contexts/SSEContext' import { AuthProvider } from './hooks/useAuth.jsx' import { useNotification } from './service/NotificationProvider' +import { useSyncOnReconnect } from './hooks/useSyncOnReconnect' import NetworkBanner from './views/components/NetworkBanner' const add = className => { @@ -34,6 +35,7 @@ const startOpenReplay = () => { const AppContent = () => { const { showNotification } = useNotification() + useSyncOnReconnect() const { needRefresh: [needRefresh, setNeedRefresh], diff --git a/src/hooks/NetworkManager.jsx b/src/hooks/NetworkManager.jsx index d9c43e4..6afa8d5 100644 --- a/src/hooks/NetworkManager.jsx +++ b/src/hooks/NetworkManager.jsx @@ -1,6 +1,5 @@ import { Network } from '@capacitor/network' -import { localStore } from '../utils/LocalStore' -import { syncManager } from '../utils/SyncManager.jsx' // Ensure you import syncManager if needed for syncing + class NetworkManager { constructor() { this.isOnline = true @@ -21,35 +20,12 @@ class NetworkManager { this.isNetworkOn = status.connected this.lastChecked = Date.now() this.isOnline = status.connected + if (!status.connected) { + this.offlineSince = Date.now() + } + this.notifyConnectionStatus() } }) - const syncQueue = () => { - localStore - .syncQueuedRequests() - .then(hasMessages => { - console.log( - 'Queued requests synced successfully. Queue has messaage is: ', - hasMessages, - ) - if (hasMessages) { - this.notifyBackendSync() - } - }) - .catch(error => { - console.error('Error syncing queued requests:', error) - }) - } - this.registerNetworkListener(async isOnline => { - if (isOnline && this.isNetworkOn) { - // TODO: Delete when Sync manager Implemented - syncQueue() - console.log('NetworkManager: Network is back online. with SYNCMANAGER') - - await syncManager.syncTasks() - console.log('Finished syncing queued requests.') - } - }) - syncQueue() } setOffline() { diff --git a/src/hooks/usePendingCommands.js b/src/hooks/usePendingCommands.js new file mode 100644 index 0000000..47df642 --- /dev/null +++ b/src/hooks/usePendingCommands.js @@ -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, + }) +} diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js new file mode 100644 index 0000000..8cf7f97 --- /dev/null +++ b/src/hooks/useSyncOnReconnect.js @@ -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]) +} diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index c17bf8b..3670dd9 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { networkManager } from '../hooks/NetworkManager' -import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle' +import { commandQueue, CommandType } from '../utils/CommandQueue' import { ApproveChore, ArchiveChore, @@ -20,51 +20,81 @@ import { UnArchiveChore, UpdateChoreHistory, } from '../utils/Fetcher' -import { localStore } from '../utils/LocalStore' +import { offlineDB } from '../utils/OfflineDB' +import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle' +import { syncEngine } from '../utils/SyncEngine' -export const useChores = includeArchive => { +const mergePendingCreates = async chores => { + const pending = await commandQueue.getPending() + const pendingCreates = pending.filter( + cmd => cmd.commandType === CommandType.CREATE_CHORE, + ) + + 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({ queryKey: ['chores', includeArchive], refetchOnWindowFocus: true, queryFn: async () => { - const onlineChores = await GetChoresNew(includeArchive) - - // Only handle offline tasks if experimental offline mode is enabled - if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - return onlineChores + if (isOfflineFeatureEnabled()) { + // Sync from server first (no-op if already syncing or offline) + if (networkManager.isOnline) { + await syncEngine.sync() + } + const cursor = await offlineDB.getSyncCursor() + if (cursor > 0) { + const cached = await offlineDB.getChores() + const merged = await mergePendingCreates(cached || []) + return { res: merged } + } } - const offlineTasks = (await localStore.getFromCache('offlineTasks')) || [] - // go throught each and if there is two chores with same id in offline and online, prefer the offline one: - var finalChores = [] - if (onlineChores && onlineChores.res) { - finalChores = onlineChores.res.filter( - onlineChore => - !offlineTasks.some(offlineTask => { - // Match by id or tempId - return ( - String(onlineChore.id) === String(offlineTask.id) || - (offlineTask.tempId && - String(onlineChore.id) === String(offlineTask.tempId)) - ) - }), + // Offline feature disabled — fetch from API. + try { + const data = await GetChoresNew(includeArchive) + if (data?.res) { + syncEngine.cacheChores(data.res) + } + const merged = await mergePendingCreates(data?.res || []) + return { ...data, res: merged } + } catch { + // API failed — fall back to whatever is in the cache + const cached = await offlineDB.getChores() + 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({ mutationFn: async choreIds => { - // If offline mode is enabled and we're offline, handle deletion locally - if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - const offlineTasks = - (await localStore.getFromCache('offlineTasks')) || [] - const updatedOfflineTasks = offlineTasks.filter( - task => - !choreIds.includes(task.id) && !choreIds.includes(task.tempId), + if (!networkManager.isOnline) { + await Promise.all( + choreIds.map(async id => { + await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id }) + }), ) - await localStore.saveToCache('offlineTasks', updatedOfflineTasks) - // Force the chores query to refetch - queryClient.invalidateQueries(['chores']) return } - - // If online, proceed with server-side deletion await Promise.all( choreIds.map(async id => { const resp = await DeleteChore(id) @@ -99,75 +122,69 @@ export const useDeleteChores = () => { }, onSuccess: () => { queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['pendingCommands']) }, }) } export const useCreateChore = () => { const queryClient = useQueryClient() + const queueOfflineCreate = async newTask => { + const offlineChore = buildOfflineChore(newTask) + await commandQueue.enqueue( + CommandType.CREATE_CHORE, + offlineChore.id, + newTask, + ) + + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) { + return { res: [offlineChore] } + } + + const alreadyExists = oldData.res.some( + chore => String(chore.id) === String(offlineChore.id), + ) + if (alreadyExists) return oldData + + return { ...oldData, res: [...oldData.res, offlineChore] } + }) + + return { res: offlineChore } + } + return useMutation({ mutationFn: async newTask => { - const resp = await CreateChore(newTask) - if (!resp || !resp.ok) { - throw new Error('Failed to create chore') + if (!networkManager.isOnline) { + return queueOfflineCreate(newTask) } - const createdChore = await resp.json() - if (!createdChore) { - throw new Error('Failed to get created chore data') + + try { + 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: () => { - // Invalidate the chores query to refresh the data queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -177,44 +194,31 @@ export const useUpdateChore = () => { return useMutation({ mutationFn: async updatedChore => { - if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - updatedChore['updatedAt'] = new Date().toISOString() - if (!updatedChore['nextDueDate']) { - updatedChore['nextDueDate'] = updatedChore['dueDate'] - } - const offlineTasks = - (await localStore.getFromCache('offlineTasks')) || [] - - for (const task of offlineTasks) { - // Find the task with the same id or tempId and update it - if (task.id === updatedChore.id || task.tempId === updatedChore.id) { - // Update the task in local storage - const updatedTask = { ...task, ...updatedChore } - const updatedOfflineTasks = offlineTasks.map(t => - t.id === task.id ? updatedTask : t, - ) - await localStore.saveToCache('offlineTasks', updatedOfflineTasks) - return new Promise((resolve, reject) => { - resolve(updatedTask) - }) + const queueOfflineUpdate = async () => { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE, + updatedChore.id, + updatedChore, + ) + const pendingChore = { ...updatedChore, _pendingUpdate: true } + // Persist to offline DB so cache fallback reads the updated data + await offlineDB.saveChores([pendingChore]) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData) return { res: [pendingChore] } + return { + res: oldData.res.map(chore => + chore.id === updatedChore.id ? pendingChore : chore, + ), } - } - const newTaskId = crypto.randomUUID() - const updatedChoreWithNewId = { - ...updatedChore, - tempId: newTaskId, - } - - 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 + queryClient.setQueryData(['chore', updatedChore.id], oldData => { + if (!oldData) return { res: pendingChore } + return { ...oldData, res: pendingChore } + }) + return pendingChore + } + + try { const resp = await SaveChore(updatedChore) if (!resp || !resp.ok) { throw new Error('Failed to save chore') @@ -223,9 +227,7 @@ export const useUpdateChore = () => { if (!updatedChoreRes) { throw new Error('Failed to get updated chore data') } - // Successfully updated the chore on the server, return the updated chore - // update the local chores cache with the updated chore: - queryClient.setQueryData(['chores'], oldData => { + queryClient.setQueryData(['chores', false], oldData => { if (!oldData) return { res: [updatedChore] } return { res: oldData.res.map(chore => @@ -234,19 +236,17 @@ export const useUpdateChore = () => { } }) return updatedChoreRes?.res || updatedChoreRes + } catch (error) { + if (isNetworkError(error)) { + return queueOfflineUpdate() + } + throw error } }, - onSuccess: (data, variables) => { - // Invalidate the chores query to refresh the data + onSuccess: (_, variables) => { queryClient.invalidateQueries(['chores']) - // Invalidate history for the specific chore queryClient.invalidateQueries(['choreHistory', variables.id]) - }, - onMutate: async updatedChore => { - if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) { - // Handle offline case here if needed - return - } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -275,30 +275,20 @@ export const useChoreDetails = choreId => { queryKey: ['choreDetails', choreId], refetchOnWindowFocus: true, queryFn: async () => { - var onlineChore = null - try { const response = await GetChoreDetailById(choreId) - if (response && response.ok) { - onlineChore = await response.json() + return await response.json() } - } catch (error) { - console.error('Error fetching chore detail:', error) + throw new Error('Failed to fetch chore detail') + } catch { + // Fall back to cached chore (without timer details) + const cached = await offlineDB.getChore(choreId) + if (cached) { + return { res: cached } + } + 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) { throw new Error('Chore ID is required to fetch chore details') } - var onlineChore = null try { const response = await GetChoreByID(choreId) - if (response && response.ok) { - onlineChore = await response.json() + return await response.json() } - } catch (error) { - console.error('Error fetching chore detail:', error) + throw new Error('Failed to fetch chore') + } catch { + // API failed — try offline cache + const cached = await offlineDB.getChore(choreId) + if (cached) { + return { res: cached } + } + 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: () => { queryClient.invalidateQueries(['chores']) @@ -416,12 +395,32 @@ export const useMarkChoreComplete = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, body, completedDate, performer }) => - MarkChoreComplete(choreId, body, completedDate, performer), - onSuccess: (data, { choreId }) => { + mutationFn: async ({ choreId, body, completedDate, performer }) => { + if (!networkManager.isOnline) { + await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, { + id: choreId, + body, + completedDate, + performer, + }) + // Optimistically update the cache to show pending state + queryClient.setQueryData(['chores'], oldData => { + if (!oldData) return oldData + return { + res: oldData.res.map(chore => + chore.id === choreId ? { ...chore, _pending: 'complete' } : chore, + ), + } + }) + return { res: { _pending: 'complete' } } + } + return MarkChoreComplete(choreId, body, completedDate, performer) + }, + onSuccess: (_, { choreId }) => { queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) queryClient.invalidateQueries(['choreDetails', choreId]) + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -430,11 +429,29 @@ export const useSkipChore = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: SkipChore, - onSuccess: (data, choreId) => { + mutationFn: async choreId => { + if (!networkManager.isOnline) { + await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, { + id: choreId, + }) + // Optimistically update the cache to show pending state + queryClient.setQueryData(['chores'], oldData => { + if (!oldData) return oldData + return { + res: oldData.res.map(chore => + chore.id === choreId ? { ...chore, _pending: 'skip' } : chore, + ), + } + }) + return { res: { _pending: 'skip' } } + } + return SkipChore(choreId) + }, + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) queryClient.invalidateQueries(['choreDetails', choreId]) + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -444,7 +461,7 @@ export const useApproveChore = () => { return useMutation({ mutationFn: ApproveChore, - onSuccess: (data, choreId) => { + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) queryClient.invalidateQueries(['choreDetails', choreId]) @@ -457,7 +474,7 @@ export const useRejectChore = () => { return useMutation({ mutationFn: RejectChore, - onSuccess: (data, choreId) => { + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) queryClient.invalidateQueries(['choreDetails', choreId]) diff --git a/src/queries/SubtaskQueries.jsx b/src/queries/SubtaskQueries.jsx index 616b703..14f57ab 100644 --- a/src/queries/SubtaskQueries.jsx +++ b/src/queries/SubtaskQueries.jsx @@ -1,73 +1,26 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { networkManager } from '../hooks/NetworkManager' import { CompleteSubTask, SaveChore } from '../utils/Fetcher' -import { localStore } from '../utils/LocalStore' export const useUpdate = () => { const queryClient = useQueryClient() return useMutation({ mutationFn: async updatedChore => { - if (!networkManager.isOnline) { - updatedChore['updatedAt'] = new Date().toISOString() - if (!updatedChore['nextDueDate']) { - updatedChore['nextDueDate'] = updatedChore['dueDate'] - } - const offlineTasks = - (await localStore.getFromCache('offlineTasks')) || [] - - for (const task of offlineTasks) { - // Find the task with the same id or tempId and update it - if (task.id === updatedChore.id || task.tempId === updatedChore.id) { - // Update the task in local storage - const updatedTask = { ...task, ...updatedChore } - const updatedOfflineTasks = offlineTasks.map(t => - t.id === task.id ? updatedTask : t, - ) - await localStore.saveToCache('offlineTasks', updatedOfflineTasks) - return new Promise((resolve, reject) => { - resolve(updatedTask) - }) - } - } - const newTaskId = crypto.randomUUID() - const updatedChoreWithNewId = { - ...updatedChore, - tempId: newTaskId, - } - - await localStore.saveToCache('offlineTasks', [ - ...offlineTasks, - updatedChoreWithNewId, - ]) - return new Promise((resolve, reject) => { - // Resolve with the updated task - resolve(updatedChoreWithNewId) - }) - } else { - // Call the API to update the chore - const resp = await SaveChore(updatedChore) - if (!resp || !resp.ok) { - throw new Error('Failed to save chore') - } - 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 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 }, - onSuccess: (data, variables) => { - // Invalidate the chores query to refresh the data + onSuccess: () => { queryClient.invalidateQueries(['chores']) }, - onMutate: async updatedChore => { - if (!networkManager.isOnline) { - // Handle offline case here if needed - return - } - }, }) } diff --git a/src/queries/TimeQueries.jsx b/src/queries/TimeQueries.jsx index 009ea99..9cc9155 100644 --- a/src/queries/TimeQueries.jsx +++ b/src/queries/TimeQueries.jsx @@ -31,9 +31,8 @@ export const useStartChore = () => { return useMutation({ mutationFn: StartChore, - onSuccess: (data, choreId) => { + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['choreTimer', choreId]) - queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) }, }) @@ -44,9 +43,8 @@ export const usePauseChore = () => { return useMutation({ mutationFn: PauseChore, - onSuccess: (data, choreId) => { + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['choreTimer', choreId]) - queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) }, }) @@ -58,7 +56,7 @@ export const useUpdateTimeSession = () => { return useMutation({ mutationFn: ({ choreId, sessionId, sessionData }) => UpdateTimeSession(choreId, sessionId, sessionData), - onSuccess: (data, { choreId }) => { + onSuccess: (_, { choreId }) => { queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) @@ -72,7 +70,7 @@ export const useDeleteTimeSession = () => { return useMutation({ mutationFn: ({ choreId, sessionId }) => DeleteTimeSession(choreId, sessionId), - onSuccess: (data, { choreId }) => { + onSuccess: (_, { choreId }) => { queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) @@ -85,7 +83,7 @@ export const useResetChoreTimer = () => { return useMutation({ mutationFn: ResetChoreTimer, - onSuccess: (data, choreId) => { + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) @@ -98,7 +96,7 @@ export const useClearChoreTimer = () => { return useMutation({ mutationFn: ClearChoreTimer, - onSuccess: (data, choreId) => { + onSuccess: (_, choreId) => { queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index 160de07..20202c7 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -6,6 +6,7 @@ import { GetDeviceTokens, GetUserProfile, } from '../utils/Fetcher' +import { offlineDB } from '../utils/OfflineDB' // Helper to check if we have a valid token const isTokenValid = () => { @@ -30,7 +31,20 @@ export const useCircleMembers = () => { const { data, error, isLoading } = useQuery({ queryKey: ['allCircleMembers'], - queryFn: GetAllCircleMembers, + queryFn: async () => { + try { + const result = await GetAllCircleMembers() + // Cache for offline use + if (result?.res) { + offlineDB.saveKV('circle_members', result.res) + } + return result + } catch { + const cached = await offlineDB.getKV('circle_members') + if (cached) return { res: cached } + return { res: [] } + } + }, }) const handleRefetch = () => { @@ -42,19 +56,31 @@ export const useCircleMembers = () => { export const useUserProfile = () => { const queryClient = useQueryClient() + const token = localStorage.getItem('token') const { data, error, isLoading } = useQuery({ - queryKey: ['userProfile'], + queryKey: ['userProfile', token], queryFn: async () => { - 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 (!token) { + return null + } - 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 - gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds - enabled: isTokenValid(), // Only run query when we have a valid token + staleTime: 30 * 60 * 1000, + gcTime: 30 * 60 * 1000, + enabled: !!token, }) return { data, diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 1fa60af..52d926b 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -17,7 +17,7 @@ class ApiClient { } async init(force = false) { - if (this.initPromise) { + if (!force && this.initPromise) { return this.initPromise } diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js new file mode 100644 index 0000000..ecb33c6 --- /dev/null +++ b/src/utils/CommandQueue.js @@ -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() diff --git a/src/utils/FeatureToggle.js b/src/utils/FeatureToggle.js index 212a2df..3174a04 100644 --- a/src/utils/FeatureToggle.js +++ b/src/utils/FeatureToggle.js @@ -1,70 +1,3 @@ -export const FEATURES = { - OFFLINE_MODE: 'experimental_feature_offline_mode', -} - -/** - * Get the current state of a feature flag from localStorage - * @param {string} featureKey - The feature key from FEATURES constant - * @param {boolean} defaultValue - Default value if feature is not set (default: false) - * @returns {boolean} - Whether the feature is enabled - */ -export const isFeatureEnabled = (featureKey, defaultValue = false) => { - try { - const value = localStorage.getItem(featureKey) - - if (value === 'true') return true - if (value === 'false') return false - - if (value === null || value === undefined) return defaultValue - - return Boolean(value) - } catch (error) { - console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error) - return defaultValue - } -} - -/** - * Set the state of a feature flag in localStorage - * @param {string} featureKey - The feature key from FEATURES constant - * @param {boolean} enabled - Whether to enable the feature - */ -export const setFeatureEnabled = (featureKey, enabled) => { - try { - localStorage.setItem(featureKey, enabled.toString()) - } catch (error) { - console.error( - `FeatureToggle: Error setting feature "${featureKey}":`, - error, - ) - } -} - -export const toggleFeature = featureKey => { - const currentState = isFeatureEnabled(featureKey) - const newState = !currentState - setFeatureEnabled(featureKey, newState) - return newState -} - -export const getAllFeatureStates = () => { - const states = {} - Object.entries(FEATURES).forEach(([name, key]) => { - states[name] = isFeatureEnabled(key) - }) - return states -} - -export const clearAllFeatures = () => { - try { - Object.values(FEATURES).forEach(featureKey => { - localStorage.removeItem(featureKey) - }) - } catch (error) { - console.error('FeatureToggle: Error clearing features:', error) - } -} - /** * Check if the current instance is the official donetick.com service * @returns {Promise} - Whether this is the official donetick.com instance @@ -102,7 +35,11 @@ export const isOfficialDonetickInstanceSync = () => { // Dynamic import to avoid circular dependencies return import('./ApiClient') .then(({ apiClient }) => { - const currentApiUrl = apiClient.baseURL + const currentApiUrl = + apiClient.baseURL || apiClient.customServerURL || '' + if (!currentApiUrl || typeof currentApiUrl !== 'string') { + return false + } // Check if the API URL contains donetick.com return currentApiUrl.toLowerCase().includes('donetick.com') }) @@ -123,12 +60,6 @@ export const isOfficialDonetickInstanceSync = () => { // Export default object for easier imports export default { - FEATURES, - isFeatureEnabled, - setFeatureEnabled, - toggleFeature, - getAllFeatureStates, - clearAllFeatures, isOfficialDonetickInstance, isOfficialDonetickInstanceSync, } diff --git a/src/utils/LocalStore.jsx b/src/utils/LocalStore.jsx deleted file mode 100644 index 0573af7..0000000 --- a/src/utils/LocalStore.jsx +++ /dev/null @@ -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() diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js new file mode 100644 index 0000000..08f28c1 --- /dev/null +++ b/src/utils/OfflineDB.js @@ -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() diff --git a/src/utils/OfflineFeatureToggle.js b/src/utils/OfflineFeatureToggle.js new file mode 100644 index 0000000..cb2cb2b --- /dev/null +++ b/src/utils/OfflineFeatureToggle.js @@ -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 + } +} diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js new file mode 100644 index 0000000..56fc455 --- /dev/null +++ b/src/utils/SyncEngine.js @@ -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() diff --git a/src/utils/SyncManager.jsx b/src/utils/SyncManager.jsx deleted file mode 100644 index 679019a..0000000 --- a/src/utils/SyncManager.jsx +++ /dev/null @@ -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() diff --git a/src/utils/TokenStorage.js b/src/utils/TokenStorage.js index cae31c3..6710ca2 100644 --- a/src/utils/TokenStorage.js +++ b/src/utils/TokenStorage.js @@ -163,6 +163,7 @@ export const clearAllTokens = async () => { // Clear localStorage localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN) localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY) + localStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY) // Clean up legacy keys localStorage.removeItem('ca_token') localStorage.removeItem('ca_expiration') diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 8dcf06b..dd8b9f8 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -336,6 +336,7 @@ const ChoreEdit = () => { description: description, assignees: assignees, dueDate: dueDate ? new Date(dueDate).toISOString() : null, + nextDueDate: dueDate ? new Date(dueDate).toISOString() : null, frequencyType: frequencyType, frequency: Number(frequency), frequencyMetadata: frequencyMetadata, @@ -365,11 +366,18 @@ const ChoreEdit = () => { } SaveFunction(chore) - .then(() => { - showSuccess({ - title: 'Chore Saved', - message: 'Your task has been saved successfully!', - }) + .then(result => { + if (result?._pendingUpdate || result?._pendingCreate) { + showSuccess({ + title: 'Saved Offline', + message: 'Your changes will sync when you are back online.', + }) + } else { + showSuccess({ + title: 'Chore Saved', + message: 'Your task has been saved successfully!', + }) + } Navigate('/chores') }) .catch(error => { diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 9548bc8..1917ff0 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -44,6 +44,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useLocalization } from '../../contexts/LocalizationContext' +import { usePendingCommands } from '../../hooks/usePendingCommands' import { useChoreDetails } from '../../queries/ChoreQueries.jsx' import { useChoreTimer, @@ -56,6 +57,7 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' +import { commandQueue, CommandType } from '../../utils/CommandQueue' import { ApproveChore, GetChoreDetailById, @@ -71,11 +73,28 @@ import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' import LoadingComponent from '../components/Loading.jsx' +import PendingBadge from '../components/PendingBadge' import RichTextEditor from '../components/RichTextEditor.jsx' import SubTasks from '../components/SubTask.jsx' import TimePassedCard from './TimePassedCard.jsx' import TimerSplitButton from './TimerSplitButton.jsx' +const isNetworkError = err => + err instanceof TypeError && err.message === 'Failed to fetch' + +const decodeHtmlEntities = value => { + if (typeof value !== 'string') return '' + + return value + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&') +} + +const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value) + const ChoreView = () => { const { t } = useTranslation('chores') const { fmt } = useLocalization() @@ -104,6 +123,7 @@ const ChoreView = () => { const { data: choreData, isLoading: isChoreLoading } = useChoreDetails(choreId) + const { data: pendingCmds } = usePendingCommands(choreId) const startChore = useStartChore() const pauseChore = usePauseChore() @@ -136,7 +156,6 @@ const ChoreView = () => { if (response.ok) { response.json().then(() => { setChorePriority(priority) - // Invalidate chores cache to refetch data queryClient.invalidateQueries(['chores']) }) } @@ -164,7 +183,9 @@ const ChoreView = () => { icon: , title: t('choreView.schedule'), text: `${t('choreView.due')}: ${ - chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : t('choreView.na') + chore.nextDueDate + ? moment(chore.nextDueDate).fromNow() + : t('choreView.na') }`, subtext: `${t('choreView.last')}: ${ chore.lastCompletedDate @@ -195,39 +216,26 @@ const ChoreView = () => { ] setInfoCards(cards) } - const handleTaskCompletion = () => { - MarkChoreComplete( - choreId, - impersonatedUser - ? { completedBy: impersonatedUser.userId, note } - : { note }, - completedDate, - null, - ) - .then(resp => { - if (resp.ok) { - return resp.json().then(data => { - setNote(null) - setChore(data.res) - }) - } - }) - .then(() => { - // Invalidate chores cache to refetch data + const handleTaskCompletion = async () => { + try { + const resp = await MarkChoreComplete( + choreId, + impersonatedUser + ? { completedBy: impersonatedUser.userId, note } + : { note }, + completedDate, + null, + ) + if (resp.ok) { + const data = await resp.json() + setNote(null) + setChore(data.res) queryClient.invalidateQueries(['chores']) - }) - .then(() => { - // refetch the chore details - GetChoreDetailById(choreId).then(resp => { - if (resp.ok) { - return resp.json().then(data => { - setChore(data.res) - }) - } - }) - }) - .then(() => { - // Show undo notification + const detailResp = await GetChoreDetailById(choreId) + if (detailResp.ok) { + const detailData = await detailResp.json() + setChore(detailData.res) + } showSuccess({ title: t('choreView.taskCompleted'), message: t('choreView.taskCompletedMessage'), @@ -235,7 +243,6 @@ const ChoreView = () => { try { const undoResponse = await UndoChoreAction(choreId) if (undoResponse.ok) { - // Refetch chore details after undo const detailResponse = await GetChoreDetailById(choreId) if (detailResponse.ok) { const detailData = await detailResponse.json() @@ -257,49 +264,94 @@ const ChoreView = () => { } }, }) - }) - } - const handleSkippingTask = () => { - SkipChore(choreId).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = data.res - setChore(newChore) - // Invalidate chores cache to refetch data - queryClient.invalidateQueries(['chores']) - - // Show undo notification - showSuccess({ - message: t('choreView.skipTask'), - undoAction: async () => { - try { - const undoResponse = await UndoChoreAction(choreId) - if (undoResponse.ok) { - // Refetch chore details after undo - const detailResponse = await GetChoreDetailById(choreId) - if (detailResponse.ok) { - const detailData = await detailResponse.json() - 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.COMPLETE_CHORE, + choreId, + { + id: choreId, + body: impersonatedUser + ? { completedBy: impersonatedUser.userId, note } + : { note }, + completedDate: completedDate || null, + performer: null, + }, + ) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — completion will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + }, + }) + } else { + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to complete task', }) } - }) + } + } + const handleSkippingTask = 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 = () => { startChore.mutate(choreId, { @@ -383,7 +435,6 @@ const ChoreView = () => { if (response.ok) { response.json().then(data => { setChore(data.res) - // Invalidate chores cache to refetch data queryClient.invalidateQueries(['chores']) }) } @@ -395,23 +446,45 @@ const ChoreView = () => { if (response.ok) { response.json().then(data => { setChore(data.res) - // Invalidate chores cache to refetch data queryClient.invalidateQueries(['chores']) }) } }) } - const handleUnarchiveChore = () => { - UnArchiveChore(choreId).then(response => { + const handleUnarchiveChore = async () => { + try { + const response = await UnArchiveChore(choreId) if (response.ok) { - response.json().then(data => { - setChore({ ...chore, isActive: true }) - // Invalidate chores cache to refetch data - queryClient.invalidateQueries(['chores']) + setChore({ ...chore, isActive: true }) + queryClient.invalidateQueries(['chores']) + } + } catch (error) { + const isNetworkError = err => + err instanceof TypeError && err.message === 'Failed to fetch' + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.UNARCHIVE_CHORE, + choreId, + { id: choreId }, + ) + setChore({ ...chore, isActive: true }) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — restore will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + setChore({ ...chore, isActive: false }) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + }, + }) + } else { + showError({ + title: 'Failed to restore', + message: error.message || 'Unable to restore task', }) } - }) + } } // Check if the current user can approve/reject (admin, manager, or task owner) @@ -458,16 +531,19 @@ const ChoreView = () => { mb: 1, }} > - - {chore.name} - + {chore.name} + + {chore.isActive === false && ( } @@ -747,7 +823,30 @@ const ChoreView = () => { overflow: 'hidden', }} > - + {(() => { + const content = decodeHtmlEntities(chore.description || '') + const shouldRenderHtml = hasHtmlTags(content) + + return shouldRenderHtml ? ( + + ) : ( + + {content} + + ) + })()} @@ -792,7 +891,30 @@ const ChoreView = () => { overflow: 'hidden', }} > - + {(() => { + const content = decodeHtmlEntities(chore.notes || '') + const shouldRenderHtml = hasHtmlTags(content) + + return shouldRenderHtml ? ( + + ) : ( + + {content} + + ) + })()} diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index afc12cb..27250e6 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -20,6 +20,7 @@ import { Stack, Typography, } from '@mui/joy' +import { useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -28,6 +29,7 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useUnArchiveChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' +import { commandQueue, CommandType } from '../../utils/CommandQueue' import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher' import LoadingComponent from '../components/Loading' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -41,6 +43,7 @@ const ArchivedTasks = () => { useUserProfile() const { showSuccess, showError } = useNotification() const { impersonatedUser } = useImpersonateUser() + const queryClient = useQueryClient() const unArchiveChore = useUnArchiveChore() const [archivedChores, setArchivedChores] = useState([]) const [filteredChores, setFilteredChores] = useState([]) @@ -319,6 +322,10 @@ const ArchivedTasks = () => { const restoredTasks = [] const failedTasks = [] + const isNetworkError = err => + err instanceof TypeError && err.message === 'Failed to fetch' + const queuedTasks = [] + for (const chore of selectedData) { try { await new Promise((resolve, reject) => { @@ -327,9 +334,15 @@ const ArchivedTasks = () => { restoredTasks.push(chore) resolve(data) }, - onError: error => { - failedTasks.push(chore) - reject(error) + onError: async error => { + if (isNetworkError(error)) { + await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id }) + queuedTasks.push(chore) + resolve() + } else { + failedTasks.push(chore) + reject(error) + } }, }) }) @@ -338,22 +351,22 @@ const ArchivedTasks = () => { } } - if (restoredTasks.length > 0) { - showSuccess({ - title: '📤 Tasks Restored', - message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`, - }) - - // Remove restored tasks from archived list - const restoredIds = new Set(restoredTasks.map(c => c.id)) - const newArchivedChores = archivedChores.filter( - c => !restoredIds.has(c.id), - ) - const newFilteredChores = filteredChores.filter( - c => !restoredIds.has(c.id), - ) + const allRestored = [...restoredTasks, ...queuedTasks] + if (allRestored.length > 0) { + const offlineNote = queuedTasks.length > 0 ? " (queued — will sync when back online)" : '' + // Remove from archived view optimistically for both online and queued + const restoredIds = new Set(allRestored.map(c => c.id)) + const newArchivedChores = archivedChores.filter(c => !restoredIds.has(c.id)) + const newFilteredChores = filteredChores.filter(c => !restoredIds.has(c.id)) setArchivedChores(newArchivedChores) setFilteredChores(newFilteredChores) + if (queuedTasks.length > 0) { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + } + showSuccess({ + title: '📤 Tasks Restored', + message: `Restored ${allRestored.length} task${allRestored.length > 1 ? 's' : ''}${offlineNote}.`, + }) } if (failedTasks.length > 0) { diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 5b4ce40..8c955d3 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -5,6 +5,7 @@ import { Pause, PlayArrow, Repeat, + Schedule, ThumbUp, TimesOneMobiledata, Toll, @@ -22,6 +23,7 @@ import { } from '@mui/joy' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useLocalization } from '../../contexts/LocalizationContext' +import { usePendingCommands } from '../../hooks/usePendingCommands' import { useUserProfile } from '../../queries/UserQueries.jsx' import { getDueDateChipColor, @@ -32,6 +34,7 @@ import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import Priorities from '../../utils/Priorities' import ChoreActionMenu from '../components/ChoreActionMenu' +import PendingBadge from '../components/PendingBadge' const ChoreCard = ({ chore, performers, @@ -47,6 +50,7 @@ const ChoreCard = ({ }) => { const { data: userProfile } = useUserProfile() const { timeFormat } = useLocalization() + const { data: pendingCmds } = usePendingCommands(chore.id) const { impersonatedUser } = useImpersonateUser() @@ -86,7 +90,11 @@ const ChoreCard = ({ return name } return ( - + + + + + {chore.status === 3 && ( + + + Pending + + )} {showActions && ( {chore.name} - + {(chore._pending || (pendingCmds && pendingCmds.length > 0)) && ( + + )} {/* Due Date - Inline with name */} { const [anchorEl, setAnchorEl] = useState(null) const menuRef = useRef(null) + const menuOptions = Array.isArray(options) ? options : [] const handleMenuOpen = event => { setAnchorEl(event.currentTarget) @@ -85,7 +86,7 @@ const IconButtonWithMenu = ({ )} - {options?.map(item => ( + {menuOptions.map(item => ( { diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index fe94981..4a70442 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -272,18 +272,6 @@ const MyChores = () => { // Don't set choreSections here - let the dedicated effect handle it // This prevents caching issues when switching between projects - if (localStorage.getItem('openChoreSections') === null) { - setSelectedChoreSectionWithCache(selectedChoreSection) - const openSections = processedSections.reduce( - (acc, _section, index) => { - acc[index] = true - return acc - }, - {}, - ) - setOpenChoreSections(openSections) - } - if (await canScheduleNotification()) { console.log('Scheduling chore notifications...') scheduleChoreNotification( @@ -303,10 +291,8 @@ const MyChores = () => { choresData?.res, membersData?.res, processedChores, // Added to ensure local state syncs when query data updates - processedSections, userProfile, impersonatedUser?.userId, - selectedChoreSection, ]) // Auto-update sections when processedSections changes @@ -1296,20 +1282,19 @@ const MyChores = () => { )} )} - {searchTerm?.length > 0 && - viewMode !== 'calendar' && ( - - )} + {searchTerm?.length > 0 && viewMode !== 'calendar' && ( + + )} {viewMode === 'calendar' && ( <> {/* Summary Chips when no date selected */} @@ -1492,87 +1477,86 @@ const MyChores = () => { )} )} - {searchTerm.length === 0 && - viewMode !== 'calendar' && ( - - {choreSections.map((section, index) => { - if (section.content.length === 0) return null - return ( - - - { - if (openChoreSections[index]) { - const newOpenChoreSections = { - ...openChoreSections, - } - delete newOpenChoreSections[index] - setOpenChoreSectionsWithCache(newOpenChoreSections) - } else { - setOpenChoreSectionsWithCache({ - ...openChoreSections, - [index]: true, - }) + {searchTerm.length === 0 && viewMode !== 'calendar' && ( + + {choreSections.map((section, index) => { + if (section.content.length === 0) return null + return ( + + + { + if (openChoreSections[index]) { + const newOpenChoreSections = { + ...openChoreSections, } - }} - endDecorator={ - openChoreSections[index] ? ( - - ) : ( - - ) + delete newOpenChoreSections[index] + setOpenChoreSectionsWithCache(newOpenChoreSections) + } else { + setOpenChoreSectionsWithCache({ + ...openChoreSections, + [index]: true, + }) } - startDecorator={ - <> - - {section?.content?.length} - - - } - > - {section.name} - - - *']: { - // px: 0.5, - px: 0.5, - // pr: 0, - }, }} + endDecorator={ + openChoreSections[index] ? ( + + ) : ( + + ) + } + startDecorator={ + <> + + {section?.content?.length} + + + } > - - - - ) - })} - - )} + {section.name} + + + *']: { + // px: 0.5, + px: 0.5, + // pr: 0, + }, + }} + > + + + + ) + })} + + )} + err instanceof TypeError && err.message === 'Failed to fetch' + export const useChoreActions = ({ chores, filteredChores, @@ -35,11 +42,12 @@ export const useChoreActions = ({ }) => { const queryClient = useQueryClient() const archiveChore = useArchiveChore() + const unarchiveChore = useUnArchiveChore() const startChore = useStartChore() const pauseChore = usePauseChore() const updateChoreInState = useCallback( - (updatedChore, event) => { + (updatedChore, event, { skipInvalidation = false } = {}) => { let newChores = chores.map(c => c.id === updatedChore.id ? updatedChore : c, ) @@ -61,7 +69,9 @@ export const useChoreActions = ({ setChores(newChores) setFilteredChores(newFilteredChores) - queryClient.invalidateQueries({ queryKey: ['chores'] }) + if (!skipInvalidation) { + queryClient.invalidateQueries(['chores']) + } const undoableActions = { completed: 'Task completed', @@ -77,7 +87,7 @@ export const useChoreActions = ({ try { const undoResponse = await UndoChoreAction(updatedChore.id) if (undoResponse.ok) { - refetchChores() + queryClient.invalidateQueries(['chores']) const undoMessages = { completed: 'Task completion has been undone.', approved: 'Task approval has been undone.', @@ -121,7 +131,8 @@ export const useChoreActions = ({ archive: { type: 'success', title: 'Task Archived', - message: 'The task has been archived and hidden from the active list.', + message: + 'The task has been archived and hidden from the active list.', }, started: { type: 'success', @@ -147,47 +158,56 @@ export const useChoreActions = ({ notifyFn({ title: notification.title, message: notification.message }) } }, - [chores, filteredChores, setChores, setFilteredChores, queryClient, showSuccess, showError, showWarning, showUndo, refetchChores], + [ + chores, + filteredChores, + setChores, + setFilteredChores, + queryClient, + showSuccess, + showError, + showWarning, + showUndo, + ], ) const handleChoreAction = useCallback( async (action, chore, extraData = {}) => { switch (action) { case 'complete': - // 1. Instantly hide the chore from the UI and Cache - setChores(prev => prev.filter(c => c.id !== chore.id)) - setFilteredChores(prev => prev.filter(c => c.id !== chore.id)) - - queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => { - if (!oldData || !oldData.res) return oldData; - return { - ...oldData, - res: oldData.res.filter(c => c.id !== chore.id), - } - }); - try { const response = await MarkChoreComplete( chore.id, - impersonatedUser ? { completedBy: impersonatedUser.userId } : null, + impersonatedUser + ? { completedBy: impersonatedUser.userId } + : null, null, null, ) if (response.ok) { - // 2. Show the success notification with Undo + // Online: hide the chore and show undo + setChores(prev => prev.filter(c => c.id !== chore.id)) + setFilteredChores(prev => prev.filter(c => c.id !== chore.id)) + queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => { + if (!oldData || !oldData.res) return oldData + return { + ...oldData, + res: oldData.res.filter(c => c.id !== chore.id), + } + }) showSuccess({ message: 'Task completed', undoAction: async () => { try { const undoResponse = await UndoChoreAction(chore.id) if (undoResponse.ok) { - refetchChores() + queryClient.invalidateQueries(['chores']) showUndo({ title: 'Undo Successful', message: 'Task completion has been undone.', }) } else throw new Error('Failed to undo') - } catch (error) { + } catch { showError({ title: 'Undo Failed', message: 'Unable to undo the action. Please try again.', @@ -195,35 +215,63 @@ export const useChoreActions = ({ } }, }) - - // 3. Fetch the fresh active list from the server silently - // (This brings in the next occurrence if recurring, without showing the completed one) - queryClient.invalidateQueries({ queryKey: ['chores'] }) + queryClient.invalidateQueries(['chores']) } else { - refetchChores() // Network failed, revert to truth + refetchChores() } } catch (error) { - refetchChores() // Network failed, revert to truth - if (error?.queued) { - showError({ - title: 'Update Failed', - message: 'Request will be reattempt when you are online', + if (isNetworkError(error)) { + // Offline — queue and show pending badge on the chore (don't hide it) + const cmdId = await commandQueue.enqueue( + CommandType.COMPLETE_CHORE, + chore.id, + { + id: chore.id, + body: impersonatedUser + ? { completedBy: impersonatedUser.userId } + : null, + completedDate: null, + performer: null, + }, + ) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + title: 'Task completion pending', + message: + "You're offline — completion will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + }, }) } else { showError({ - title: 'Failed to update', - message: error, + title: 'Failed to complete', + message: error?.message || 'Unable to complete chore', }) } } break - case 'start': + case 'start': { + const startedChore = { ...chore, status: 1 } startChore.mutate(chore.id, { - onSuccess: async res => { - const data = await res.json() - const newChore = { ...chore, status: data.res.status } - updateChoreInState(newChore, 'started') + onSuccess: () => { + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? startedChore : c, + ), + } + }) + updateChoreInState(startedChore, 'started', { + skipInvalidation: true, + }) }, onError: error => { showError({ @@ -233,13 +281,25 @@ export const useChoreActions = ({ }, }) break + } - case 'pause': + case 'pause': { + const pausedChore = { ...chore, status: 2 } pauseChore.mutate(chore.id, { - onSuccess: async res => { - const data = await res.json() - const newChore = { ...chore, status: data.res.status } - updateChoreInState(newChore, 'paused') + onSuccess: () => { + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? pausedChore : c, + ), + } + }) + updateChoreInState(pausedChore, 'paused', { + skipInvalidation: true, + }) }, onError: error => { showError({ @@ -249,6 +309,7 @@ export const useChoreActions = ({ }, }) break + } case 'approve': try { @@ -305,10 +366,37 @@ export const useChoreActions = ({ }) } } catch (error) { - showError({ - title: 'Failed to delete', - message: error, - }) + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.DELETE_CHORE, + chore.id, + { id: chore.id }, + ) + setChores(prev => prev.filter(c => c.id !== chore.id)) + setFilteredChores(prev => + prev.filter(c => c.id !== chore.id), + ) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + showSuccess({ + message: + "You're offline — deletion will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + setChores(prev => [...prev, chore]) + setFilteredChores(prev => [...prev, chore]) + }, + }) + } else { + showError({ + title: 'Failed to delete', + message: error?.message || 'Unable to delete chore', + }) + } } } setConfirmModelConfig({}) @@ -324,31 +412,122 @@ export const useChoreActions = ({ updateChoreInState(data, 'archive') resolve(data) }, - onError: error => { - showError({ - title: 'Failed to archive', - message: error.message || 'Unable to archive chore', - }) - reject(error) + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.ARCHIVE_CHORE, + chore.id, + { id: chore.id }, + ) + setChores(prev => prev.filter(c => c.id !== chore.id)) + setFilteredChores(prev => + prev.filter(c => c.id !== chore.id), + ) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + showSuccess({ + message: + "You're offline — archive will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + setChores(prev => [...prev, chore]) + setFilteredChores(prev => [...prev, chore]) + }, + }) + resolve() + } else { + showError({ + title: 'Failed to archive', + message: error.message || 'Unable to archive chore', + }) + reject(error) + } }, }) }) - } catch (error) { - } + } catch (error) {} + break + + case 'unarchive': + try { + await new Promise((resolve, reject) => { + unarchiveChore.mutate(chore.id, { + onSuccess: data => { + updateChoreInState({ ...chore, isActive: true }, 'unarchive') + resolve(data) + }, + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.UNARCHIVE_CHORE, + chore.id, + { id: chore.id }, + ) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + showSuccess({ + message: + "You're offline — restore will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + }, + }) + resolve() + } else { + showError({ + title: 'Failed to restore', + message: error.message || 'Unable to restore chore', + }) + reject(error) + } + }, + }) + }) + } catch (error) {} break case 'skip': try { const response = await SkipChore(chore.id) if (response.ok) { + // Online: update in place (chore gets new due date) const data = await response.json() updateChoreInState(data.res, 'skipped') + } else { + refetchChores() } } catch (error) { - showError({ - title: 'Failed to skip', - message: error, - }) + if (isNetworkError(error)) { + // Offline — queue and show pending badge on the chore + const cmdId = await commandQueue.enqueue( + CommandType.SKIP_CHORE, + chore.id, + { id: chore.id }, + ) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — skip will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + }, + }) + } else { + showError({ + title: 'Failed to skip', + message: error?.message || 'Unable to skip chore', + }) + } } break @@ -363,13 +542,48 @@ export const useChoreActions = ({ updateChoreInState(chore, eventType) } } catch (error) { - showError({ - title: - extraData.date === null - ? 'Failed to remove due date' - : 'Failed to reschedule', - message: error.message || 'Unable to update due date', - }) + if (isNetworkError(error)) { + const oldDueDate = chore.nextDueDate + const cmdId = await commandQueue.enqueue( + CommandType.RESCHEDULE_CHORE, + chore.id, + { + id: chore.id, + dueDate: extraData.date, + }, + ) + const eventType = + extraData.date === null ? 'due-date-removed' : 'rescheduled' + updateChoreInState( + { ...chore, nextDueDate: extraData.date }, + eventType, + ) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: + "You're offline — reschedule will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + const undoEventType = + oldDueDate === null ? 'due-date-removed' : 'rescheduled' + updateChoreInState( + { ...chore, nextDueDate: oldDueDate }, + undoEventType, + ) + }, + }) + } else { + showError({ + title: + extraData.date === null + ? 'Failed to remove due date' + : 'Failed to reschedule', + message: error.message || 'Unable to update due date', + }) + } } } else { openModal(action, chore, extraData) @@ -400,26 +614,60 @@ export const useChoreActions = ({ setConfirmModelConfig, openModal, archiveChore, + unarchiveChore, startChore, pauseChore, ], ) const handleChangeDueDate = useCallback( - newDate => { + async newDate => { if (!modalChore) return - UpdateDueDate(modalChore.id, newDate).then(response => { + closeModal() + try { + const response = await UpdateDueDate(modalChore.id, newDate) if (response.ok) { - response.json().then(data => { - const newChore = modalChore - newChore.nextDueDate = newDate - updateChoreInState(newChore, 'rescheduled') + updateChoreInState( + { ...modalChore, nextDueDate: newDate }, + 'rescheduled', + ) + } + } catch (error) { + if (isNetworkError(error)) { + const oldDueDate = modalChore.nextDueDate + const cmdId = await commandQueue.enqueue( + CommandType.RESCHEDULE_CHORE, + modalChore.id, + { + id: modalChore.id, + dueDate: newDate, + }, + ) + updateChoreInState( + { ...modalChore, nextDueDate: newDate }, + 'rescheduled', + ) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — reschedule will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + updateChoreInState( + { ...modalChore, nextDueDate: oldDueDate }, + 'rescheduled', + ) + }, + }) + } else { + showError({ + title: 'Failed to reschedule', + message: error.message || 'Unable to update due date', }) } - }) - closeModal() + } }, - [modalChore, updateChoreInState, closeModal], + [modalChore, updateChoreInState, closeModal, showSuccess, showError], ) const handleCompleteWithPastDate = useCallback( @@ -568,7 +816,15 @@ export const useChoreActions = ({ setConfirmModelConfig({}) }, }) - }, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) + }, [ + getSelectedChoresData, + impersonatedUser, + showSuccess, + showError, + refetchChores, + clearSelection, + setConfirmModelConfig, + ]) const handleBulkArchive = useCallback(async () => { const selectedData = getSelectedChoresData(chores) @@ -603,8 +859,7 @@ export const useChoreActions = ({ }, }) }) - } catch (error) { - } + } catch (error) {} } if (archivedTasks.length > 0) { showSuccess({ @@ -630,7 +885,17 @@ export const useChoreActions = ({ setConfirmModelConfig({}) }, }) - }, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) + }, [ + getSelectedChoresData, + archiveChore, + setChores, + setFilteredChores, + showSuccess, + showError, + refetchChores, + clearSelection, + setConfirmModelConfig, + ]) const handleBulkDelete = useCallback(async () => { const selectedData = getSelectedChoresData(chores) @@ -690,7 +955,18 @@ export const useChoreActions = ({ setConfirmModelConfig({}) }, }) - }, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig]) + }, [ + getSelectedChoresData, + chores, + filteredChores, + setChores, + setFilteredChores, + showSuccess, + showError, + refetchChores, + clearSelection, + setConfirmModelConfig, + ]) const handleBulkSkip = useCallback(async () => { const selectedData = getSelectedChoresData(chores) @@ -726,7 +1002,7 @@ export const useChoreActions = ({ for (const chore of skippedTasks) { await UndoChoreAction(chore.id) } - refetchChores() + queryClient.invalidateQueries(['chores']) showUndo({ title: 'Undo Successful', message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`, @@ -760,7 +1036,15 @@ export const useChoreActions = ({ setConfirmModelConfig({}) }, }) - }, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig]) + }, [ + getSelectedChoresData, + showSuccess, + showError, + showUndo, + refetchChores, + clearSelection, + setConfirmModelConfig, + ]) return { handleChoreAction, diff --git a/src/views/Labels/LabelQueries.jsx b/src/views/Labels/LabelQueries.jsx index 5fec321..ac4598b 100644 --- a/src/views/Labels/LabelQueries.jsx +++ b/src/views/Labels/LabelQueries.jsx @@ -1,10 +1,29 @@ import { useQuery } from '@tanstack/react-query' import { CreateLabel, GetLabels } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' export const useLabels = () => { return useQuery({ queryKey: ['labels'], - queryFn: GetLabels, + queryFn: async () => { + try { + const data = await GetLabels() + const labels = Array.isArray(data?.res) + ? data.res + : Array.isArray(data) + ? data + : [] + + if (labels.length > 0) { + offlineDB.saveKV('labels', labels) + } + return labels + } catch { + const cached = await offlineDB.getKV('labels') + if (Array.isArray(cached)) return cached + return [] + } + }, }) } diff --git a/src/views/Projects/ProjectQueries.js b/src/views/Projects/ProjectQueries.js index e2a71b4..83d8386 100644 --- a/src/views/Projects/ProjectQueries.js +++ b/src/views/Projects/ProjectQueries.js @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' // Query hook for fetching all projects export const useProjects = () => { @@ -10,22 +11,15 @@ export const useProjects = () => { const response = await GetProjects() if (response.ok) { const data = await response.json() - return data.res || data + const projects = data.res || data + offlineDB.saveKV('projects', projects) + return projects } throw new Error('Failed to fetch projects') - } catch (error) { - console.error('Error fetching projects:', error) - // Return default project if API fails - return [ - { - id: 'default', - name: 'Default Project', - description: 'Your default project workspace', - color: '#1976d2', - created_by: 'system', - created_at: new Date().toISOString(), - } - ] + } catch { + const cached = await offlineDB.getKV('projects') + if (cached) return cached + return [] } }, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/src/views/Settings/AdvancedSettings.jsx b/src/views/Settings/AdvancedSettings.jsx index 7200844..b3f723d 100644 --- a/src/views/Settings/AdvancedSettings.jsx +++ b/src/views/Settings/AdvancedSettings.jsx @@ -1,7 +1,6 @@ import { Box, Button, - Card, Checkbox, Chip, FormControl, @@ -9,22 +8,43 @@ import { Input, Typography, } from '@mui/joy' +import { useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import RealTimeSettings from '../../components/RealTimeSettings' import { useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher' import { isPlusAccount } from '../../utils/Helpers' +import { offlineDB } from '../../utils/OfflineDB' +import { + clearBrowserCacheStorage, + isOfflineFeatureEnabled, + setOfflineFeatureEnabled, + subscribeToOfflineFeature, +} from '../../utils/OfflineFeatureToggle' +import { syncEngine } from '../../utils/SyncEngine' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import SettingsLayout from './SettingsLayout' const AdvancedSettings = () => { const { data: userProfile } = useUserProfile() + const queryClient = useQueryClient() const { showNotification } = useNotification() const [userCircles, setUserCircles] = useState([]) const [webhookURL, setWebhookURL] = useState(null) const [webhookError, setWebhookError] = useState(null) const [isAdmin, setIsAdmin] = useState(false) + const [offlineEnabled, setOfflineEnabled] = useState( + isOfflineFeatureEnabled(), + ) + const [offlineLoading, setOfflineLoading] = useState(false) + const [confirmModalConfig, setConfirmModalConfig] = useState({}) + + useEffect(() => { + const unsubscribe = subscribeToOfflineFeature(setOfflineEnabled) + return unsubscribe + }, []) useEffect(() => { GetUserCircle().then(resp => { @@ -42,21 +62,120 @@ const AdvancedSettings = () => { } }, [userCircles]) - if (!userProfile) { - return ( - -
Loading...
-
- ) + const disableOfflineSupport = async () => { + setOfflineLoading(true) + try { + await offlineDB.clearAll() + await clearBrowserCacheStorage() + setOfflineFeatureEnabled(false) + queryClient.removeQueries({ queryKey: ['pendingCommands'] }) + queryClient.removeQueries({ queryKey: ['chores'] }) + queryClient.invalidateQueries() + showNotification({ + type: 'success', + message: 'Offline support disabled and local offline data cleared', + }) + } catch { + setOfflineFeatureEnabled(false) + queryClient.removeQueries({ queryKey: ['pendingCommands'] }) + queryClient.removeQueries({ queryKey: ['chores'] }) + queryClient.invalidateQueries() + showNotification({ + type: 'warning', + message: + 'Offline support disabled, but some local cache items may not have been cleared', + }) + } finally { + setOfflineLoading(false) + } } + const showDisableOfflineConfirmation = () => { + setConfirmModalConfig({ + isOpen: true, + title: 'Disable Offline Support', + message: + 'Disabling offline support will remove queued offline actions and local cached offline data on this device/browser. Do you want to continue?', + confirmText: 'Disable & Clear', + cancelText: 'Cancel', + color: 'danger', + onClose: isConfirmed => { + setConfirmModalConfig({}) + if (isConfirmed) { + disableOfflineSupport() + } + }, + }) + } + + const handleOfflineToggle = async event => { + const nextEnabled = !!event.target.checked + + if (nextEnabled) { + setOfflineFeatureEnabled(true) + await syncEngine.sync() + queryClient.invalidateQueries() + showNotification({ + type: 'success', + message: 'Offline support enabled for this device/browser', + }) + return + } + + showDisableOfflineConfirmation() + } + + // if (!userProfile) { + // return ( + // + //
Loading...
+ //
+ // ) + // } + return ( - +
- Configure advanced features like webhooks and real-time updates for enhanced productivity. + Configure advanced features like webhooks and real-time updates for + enhanced productivity. + + Offline Support + + Early Access + + + + Enable offline queue and local cache for this device/browser. + Disabling removes pending offline actions and cached offline data. + + + + + When disabled, queued changes and offline cache are cleared from + this device/browser. + + + {/* Webhook Settings - Only show for admins */} {isAdmin && ( <> @@ -152,12 +271,17 @@ const AdvancedSettings = () => { Real-time Updates - 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. + + {confirmModalConfig?.isOpen && ( + + )}
) } -export default AdvancedSettings \ No newline at end of file +export default AdvancedSettings diff --git a/src/views/Settings/StorageSettings.jsx b/src/views/Settings/StorageSettings.jsx index 66259c8..284710b 100644 --- a/src/views/Settings/StorageSettings.jsx +++ b/src/views/Settings/StorageSettings.jsx @@ -4,17 +4,11 @@ import { Card, Chip, LinearProgress, - Switch, Typography, } from '@mui/joy' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useUserProfile } from '../../queries/UserQueries' -import { - FEATURES, - isFeatureEnabled, - setFeatureEnabled, -} from '../../utils/FeatureToggle' import { GetStorageUsage } from '../../utils/Fetcher' import { isPlusAccount } from '../../utils/Helpers' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -26,9 +20,6 @@ const StorageSettings = () => { const [usage, setUsage] = useState({ used: 0, total: 0 }) const [loading, setLoading] = useState(true) const [confirmModalConfig, setConfirmModalConfig] = useState({}) - const [offlineModeEnabled, setOfflineModeEnabledState] = useState( - isFeatureEnabled(FEATURES.OFFLINE_MODE), - ) const showConfirmation = ( message, @@ -54,11 +45,6 @@ const StorageSettings = () => { }) } - const handleOfflineModeToggle = enabled => { - setOfflineModeEnabledState(enabled) - setFeatureEnabled(FEATURES.OFFLINE_MODE, enabled) - } - useEffect(() => { if (isPlusAccount(userProfile)) { GetStorageUsage().then(resp => { @@ -127,48 +113,14 @@ const StorageSettings = () => { )} - - - Experimental Features - - Coming Soon - - -
-
- - Enable Offline Mode - - - 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. - -
- handleOfflineModeToggle(event.target.checked)} - sx={{ ml: 2 }} - /> -
- {offlineModeEnabled && ( - - ⚠️ Offline mode is enabled. If you experience slowness, disable - this setting. - - )} -
- {Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage & Cache - This is data stored locally in your browser for faster access and - offline use. Clearing this will not affect your server data, but may - log you out or remove offline tasks. + This is data stored locally in your browser for faster access. + Clearing this will not affect your server data, but may log you out. - {Capacitor.isNativePlatform() && ( diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 2254cf7..66987c8 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -633,32 +633,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { createChoreMutation .mutateAsync(chore) - .then(resp => { - resp.json().then(data => { - if (resp.status !== 200) { - console.error('Error creating chore:', data) - return - } else { - onChoreUpdate({ - ...chore, - id: data.res, - nextDueDate: chore.dueDate, - }) - - handleCloseModal(false) - } - handleCloseModal() - setTaskText('') - }) + .then(result => { + const choreData = result?.res + if (choreData?._pendingCreate) { + // Offline: task queued, add temp chore to UI immediately + onChoreUpdate(choreData) + } else { + // Online: choreData is the server's parsed response ({ res: id }) + onChoreUpdate({ + ...chore, + id: choreData?.res || choreData?.id, + nextDueDate: chore.dueDate, + }) + } + setTaskText('') }) .catch(error => { - if (error?.queued) { - handleCloseModal(true) - } + console.error('Error creating chore:', error) }) handleCloseModal(false) } - if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) { + if (isCircleMembersLoading || isProjectsLoading) { return <> } diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 98b2483..30b5352 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -31,6 +31,7 @@ import { version } from '../../../package.json' import UserProfileAvatar from '../../components/UserProfileAvatar' import { useLocalization } from '../../contexts/LocalizationContext' import NavBarLink from './NavBarLink' +import SyncStatusIndicator from './SyncStatusIndicator' import { SafeArea } from 'capacitor-plugin-safe-area' import Z_INDEX from '../../constants/zIndex' @@ -199,6 +200,7 @@ const NavBar = () => { {getMenuIcon()} + {/* */} diff --git a/src/views/components/PendingBadge.jsx b/src/views/components/PendingBadge.jsx new file mode 100644 index 0000000..8b659ce --- /dev/null +++ b/src/views/components/PendingBadge.jsx @@ -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 ( + + + {/* */} + + {/* */} + + + + + Pending actions + + + {commands.length} action{commands.length > 1 ? 's' : ''} waiting to be + synced. + + + + {commands.map(cmd => ( + + + + {LABELS[cmd.commandType] || 'Pending action'} + + + {new Date(cmd.createdAt).toLocaleString()} + + + + handleUndo(e, cmd.id)} + disabled={Boolean(cancelingIds[cmd.id]) || isCancelingAll} + > + + + + ))} + + + + + + + + + + + ) +} + +export default PendingBadge diff --git a/src/views/components/SyncStatusIndicator.jsx b/src/views/components/SyncStatusIndicator.jsx new file mode 100644 index 0000000..aaf5645 --- /dev/null +++ b/src/views/components/SyncStatusIndicator.jsx @@ -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 + if (!isOnline) return + if (failedCount > 0) + return + if (pendingCount > 0) + return + return + } + + if (!offlineFeatureEnabled) return null + + return ( + + + + {syncState.syncing && ( + + )} + {totalBadge > 0 ? ( + 0 ? 'danger' : 'warning'} + sx={{ + '& .MuiBadge-badge': { + fontSize: 9, + minWidth: 16, + height: 16, + }, + }} + > + {getStatusIcon()} + + ) : ( + getStatusIcon() + )} + + + + + {/* Header */} + + + + + + {isOnline ? 'Online' : 'Offline'} + + + {syncState.syncing && ( + + Syncing... + + )} + + + + Last sync: {formatTime(syncState.lastSync)} + + + {!isOnline && offlineSince && ( + + Offline since {formatOfflineDuration(offlineSince)} + + )} + + {syncState.error && ( + + Error: {syncState.error} + + )} + + + {/* Pending actions */} + {groupedPending.length > 0 && ( + <> + + + Pending ({pendingCount}) + + + {groupedPending.map(([type, count]) => ( + + + {COMMAND_LABELS[type] || type} + + + {count} + + + ))} + + + )} + + {/* Failed actions */} + {failedCommands.length > 0 && ( + <> + + + Failed ({failedCount}) + + + {failedCommands.map(cmd => ( + + + + {COMMAND_LABELS[cmd.commandType] || cmd.commandType} + + + + {cmd.error && ( + + {cmd.error} + + )} + + ))} + + + )} + + {/* All clear */} + {pendingCount === 0 && failedCount === 0 && ( + + + + All changes synced + + + )} + + {/* Next retry / offline hint */} + {isOnline && !syncState.syncing && pendingCount > 0 && ( + + + Next auto-sync in {retryIn}s + + + )} + {!isOnline && ( + + + Will sync when back online + + + )} + + + + {/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */} + + + {syncState.syncing ? ( + + ) : ( + + )} + + + {syncState.syncing ? 'Syncing...' : 'Sync Now'} + + + + + ) +} + +export default SyncStatusIndicator