From 2c353628a3baa2d2e0c22258797ad803b5d48e10 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 11 May 2026 17:27:29 -0400 Subject: [PATCH 01/37] 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 From 07c31ebde1fa8ad1241da5c4a1bcd21511efb813 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 12 May 2026 01:10:49 -0400 Subject: [PATCH 02/37] feat: enhance chore management with history and sync improvements - Added support for starting and pausing chores with offline handling. - Implemented chore history updates and deletions with pending command management. - Enhanced sync engine to handle chore history changes and deletions. - Introduced a PendingBadge component to display pending actions for chore history. - Updated DeveloperSettings to include sync diagnostics and reset functionality. - Improved user notifications for offline actions and pending commands. - Refactored ChoreView to dynamically generate info cards based on chore history. - Added sync status indicator with cancel all functionality for pending commands. --- src/queries/ChoreQueries.jsx | 164 ++++++++- src/utils/CommandQueue.js | 16 +- src/utils/OfflineDB.js | 340 +++++++++++++++++- src/utils/SyncEngine.js | 37 +- src/views/ChoreEdit/ChoreView.jsx | 201 ++++++++--- src/views/Chores/hooks/useChoreActions.js | 130 +++++-- src/views/History/ChoreHistory.jsx | 60 +++- src/views/History/HistoryCard.jsx | 20 +- src/views/Modals/Inputs/ConfirmationModal.jsx | 2 +- src/views/Settings/DeveloperSettings.jsx | 287 ++++++++++++++- src/views/Timer/TimerDetails.jsx | 44 ++- src/views/components/PendingBadge.jsx | 15 +- src/views/components/SyncStatusIndicator.jsx | 47 ++- 13 files changed, 1218 insertions(+), 145 deletions(-) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 3670dd9..86dc132 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -257,8 +257,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => { const { data, error, isLoading } = useQuery({ queryKey: ['choresHistory', limit], queryFn: async () => { - const resp = await GetChoresHistory(limit, includeMembers) - return resp?.res || [] + try { + const resp = await GetChoresHistory(limit, includeMembers) + const entries = resp?.res || [] + // Cache for offline use — fire-and-forget so a cache failure never + // degrades the online experience + if (entries.length > 0) { + offlineDB + .saveHistory(entries) + .catch(err => console.error('Failed to cache chores history:', err)) + } + return entries + } catch { + return offlineDB.getHistoryByDays(limit) + } }, staleTime: 0, }) @@ -353,11 +365,30 @@ export const useChoreHistory = choreId => { if (!choreId) { throw new Error('Chore ID is required to fetch history') } - const response = await GetChoreHistory(choreId) - if (response && response.ok) { - return await response.json() + let json + try { + const response = await GetChoreHistory(choreId) + if (response && response.ok) { + json = await response.json() + } else { + throw new Error('Failed to fetch chore history') + } + } catch { + const cached = await offlineDB.getHistoryByChore(choreId) + return { res: cached } } - throw new Error('Failed to fetch chore history') + // Cache for offline use — fire-and-forget so a cache failure never + // degrades the online view. Inject choreId since the single-chore + // endpoint may omit it from each entry. + const entries = (json?.res || []).map(e => + e.choreId != null ? e : { ...e, choreId: Number(choreId) }, + ) + if (entries.length > 0) { + offlineDB + .saveHistory(entries) + .catch(err => console.error('Failed to cache chore history:', err)) + } + return json }, enabled: !!choreId, staleTime: 0, // Always consider data stale @@ -371,10 +402,62 @@ export const useUpdateChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, historyId, historyData }) => - UpdateChoreHistory(choreId, historyId, historyData), + mutationFn: async ({ choreId, historyId, historyData }) => { + const applyOptimisticUpdate = async () => { + queryClient.setQueryData(['choreHistory', choreId], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(entry => + entry.id === historyId + ? { ...entry, ...historyData, _pendingUpdate: true } + : entry, + ), + } + }) + await offlineDB.updateHistoryEntry(choreId, historyId, { + ...historyData, + _pendingUpdate: true, + }) + return { queued: true } + } + + if (!networkManager.isOnline) { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId, historyData }, + ) + return applyOptimisticUpdate() + } + + try { + const response = await UpdateChoreHistory( + choreId, + historyId, + historyData, + ) + if (!response || !response.ok) { + throw new Error('Failed to update chore history') + } + return response + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId, historyData }, + ) + return applyOptimisticUpdate() + } + throw error + } + }, onSuccess: (data, { choreId }) => { - queryClient.invalidateQueries(['choreHistory', choreId]) + if (!data?.queued) { + queryClient.invalidateQueries(['choreHistory', choreId]) + } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -383,10 +466,57 @@ export const useDeleteChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, historyId }) => - DeleteChoreHistory(choreId, historyId), + mutationFn: async ({ choreId, historyId }) => { + const applyOptimisticDelete = async () => { + queryClient.setQueryData(['choreHistory', choreId], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(entry => + entry.id === historyId + ? { ...entry, _pendingDelete: true } + : entry, + ), + } + }) + await offlineDB.updateHistoryEntry(choreId, historyId, { + _pendingDelete: true, + }) + return { queued: true } + } + + if (!networkManager.isOnline) { + await commandQueue.enqueue( + CommandType.DELETE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId }, + ) + return applyOptimisticDelete() + } + + try { + const response = await DeleteChoreHistory(choreId, historyId) + if (!response || !response.ok) { + throw new Error('Failed to delete chore history') + } + return response + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue( + CommandType.DELETE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId }, + ) + return applyOptimisticDelete() + } + throw error + } + }, onSuccess: (data, { choreId }) => { - queryClient.invalidateQueries(['choreHistory', choreId]) + if (!data?.queued) { + queryClient.invalidateQueries(['choreHistory', choreId]) + } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -403,6 +533,16 @@ export const useMarkChoreComplete = () => { completedDate, performer, }) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: body?.completedBy || 0, + performedAt: completedDate || new Date().toISOString(), + notes: body?.note || null, + status: 1, + points: 0, + pending: true, + }) // Optimistically update the cache to show pending state queryClient.setQueryData(['chores'], oldData => { if (!oldData) return oldData diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index ecb33c6..58566b2 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -5,9 +5,13 @@ import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' export const CommandType = { CREATE_CHORE: 'create_chore', UPDATE_CHORE: 'update_chore', + UPDATE_CHORE_HISTORY: 'update_chore_history', COMPLETE_CHORE: 'complete_chore', SKIP_CHORE: 'skip_chore', + START_CHORE: 'start_chore', + PAUSE_CHORE: 'pause_chore', DELETE_CHORE: 'delete_chore', + DELETE_CHORE_HISTORY: 'delete_chore_history', RESCHEDULE_CHORE: 'reschedule_chore', ARCHIVE_CHORE: 'archive_chore', UNARCHIVE_CHORE: 'unarchive_chore', @@ -52,9 +56,17 @@ class CommandQueue { // Get pending commands for a specific entity (for undo/UI) async getPendingForEntity(entityId) { if (!isOfflineFeatureEnabled()) return [] - const commands = await offlineDB.getCommandsByEntity(String(entityId)) + const allCommands = await offlineDB.getCommands() + const key = String(entityId) + const commands = allCommands + .filter( + c => + c.entityId === key || + (typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)), + ) + .sort((a, b) => a.createdAt - b.createdAt) return commands - .filter(c => c.status === 'pending') + .filter(c => c.status === 'pending' || c.status === 'syncing') .map(c => ({ ...c, payload: JSON.parse(c.payload) })) } diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 08f28c1..29be31d 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -3,9 +3,9 @@ import { Capacitor } from '@capacitor/core' import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' const DB_NAME = 'donetick_offline' -const DB_VERSION = 1 +const DB_VERSION = 2 const IDB_NAME = 'donetick_offline' -const IDB_VERSION = 1 +const IDB_VERSION = 2 // Cache platform detection let _isNative = null @@ -63,6 +63,19 @@ class SQLiteBackend { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS cached_history ( + id INTEGER PRIMARY KEY, + chore_id INTEGER NOT NULL, + data TEXT NOT NULL, + performed_at INTEGER NOT NULL, + pending INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_history_chore_id ON cached_history(chore_id); + CREATE INDEX IF NOT EXISTS idx_history_performed_at ON cached_history(performed_at DESC); + CREATE INDEX IF NOT EXISTS idx_history_pending ON cached_history(pending); `, }) @@ -134,6 +147,131 @@ class SQLiteBackend { }) } + // ── History cache ── + + async saveHistory(entries) { + if (!entries.length) return + // Delete pending entries for the affected chore IDs first + const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] + if (choreIds.length) { + const placeholders = choreIds.map(() => '?').join(', ') + await CapacitorSQLite.run({ + database: DB_NAME, + statement: `DELETE FROM cached_history WHERE pending = 1 AND chore_id IN (${placeholders})`, + values: choreIds, + }) + } + const statements = entries.map(entry => ({ + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + entry.id, + Number(entry.choreId), + JSON.stringify(entry), + new Date(entry.performedAt).getTime(), + 0, + Date.now(), + ], + })) + await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements }) + } + + async savePendingHistory(entry) { + await CapacitorSQLite.run({ + database: DB_NAME, + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + entry.id, + Number(entry.choreId), + JSON.stringify(entry), + new Date(entry.performedAt).getTime(), + 1, + Date.now(), + ], + }) + } + + async getHistoryByChore(choreId) { + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: + 'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC', + values: [Number(choreId)], + }) + return (result.values || []).map(row => JSON.parse(row.data)) + } + + async getHistoryByDays(days) { + const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000 + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: + since === 0 + ? 'SELECT data FROM cached_history ORDER BY performed_at DESC' + : 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC', + values: since === 0 ? [] : [since], + }) + return (result.values || []).map(row => JSON.parse(row.data)) + } + + async deleteHistory(ids) { + if (!ids.length) return + const statements = ids.map(id => ({ + statement: 'DELETE FROM cached_history WHERE id = ?', + values: [id], + })) + await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements }) + } + + async updateHistoryEntry(choreId, historyId, updates) { + const existing = await CapacitorSQLite.query({ + database: DB_NAME, + statement: 'SELECT data, pending FROM cached_history WHERE id = ?', + values: [historyId], + }) + + if (!existing.values?.length) return + + const row = existing.values[0] + const current = JSON.parse(row.data) + const merged = { + ...current, + ...updates, + id: historyId, + choreId: Number(choreId), + } + + await CapacitorSQLite.run({ + database: DB_NAME, + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + historyId, + Number(choreId), + JSON.stringify(merged), + new Date(merged.performedAt).getTime(), + row.pending || 0, + Date.now(), + ], + }) + } + + async deleteHistoryEntry(historyId) { + await CapacitorSQLite.run({ + database: DB_NAME, + statement: 'DELETE FROM cached_history WHERE id = ?', + values: [historyId], + }) + } + + async clearHistory() { + await CapacitorSQLite.execute({ + database: DB_NAME, + statements: 'DELETE FROM cached_history', + }) + } + // ── Command queue ── async enqueueCommand(command) { @@ -286,6 +424,7 @@ class SQLiteBackend { DELETE FROM cached_chores; DELETE FROM command_queue; DELETE FROM sync_meta; + DELETE FROM cached_history; `, }) } @@ -323,6 +462,17 @@ class IndexedDBBackend { if (!db.objectStoreNames.contains('sync_meta')) { db.createObjectStore('sync_meta', { keyPath: 'key' }) } + + if (!db.objectStoreNames.contains('cached_history')) { + const histStore = db.createObjectStore('cached_history', { + keyPath: 'id', + }) + histStore.createIndex('chore_id', 'choreId', { unique: false }) + histStore.createIndex('performed_at', 'performedAt', { + unique: false, + }) + histStore.createIndex('pending', 'pending', { unique: false }) + } } request.onsuccess = event => resolve(event.target.result) @@ -428,6 +578,135 @@ class IndexedDBBackend { await this._request(store.clear()) } + // ── History cache ── + + async saveHistory(entries) { + if (!entries.length) return + // Delete pending entries for the affected chore IDs first + const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] + await this._deletePendingHistoryByChoreIds(choreIds) + // Upsert real entries + const { tx, store } = await this._tx('cached_history', 'readwrite') + for (const entry of entries) { + store.put({ + id: entry.id, + choreId: Number(entry.choreId), + data: entry, + performedAt: new Date(entry.performedAt).getTime(), + pending: 0, + cachedAt: Date.now(), + }) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async savePendingHistory(entry) { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request( + store.put({ + id: entry.id, + choreId: Number(entry.choreId), + data: entry, + performedAt: new Date(entry.performedAt).getTime(), + pending: 1, + cachedAt: Date.now(), + }), + ) + } + + async _deletePendingHistoryByChoreIds(choreIds) { + if (!choreIds.length) return + const choreIdSet = new Set(choreIds) + // Tx 1: read all pending entries + const { store: readStore } = await this._tx('cached_history') + const index = readStore.index('pending') + const rows = await this._request(index.getAll(1)) + const toDelete = rows + .filter(row => choreIdSet.has(Number(row.choreId))) + .map(row => row.id) + if (!toDelete.length) return + // Tx 2: delete them + const { tx, store: writeStore } = await this._tx( + 'cached_history', + 'readwrite', + ) + for (const id of toDelete) { + writeStore.delete(id) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async getHistoryByChore(choreId) { + const { store } = await this._tx('cached_history') + const index = store.index('chore_id') + const rows = await this._request(index.getAll(Number(choreId))) + return rows + .map(row => row.data) + .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt)) + } + + async getHistoryByDays(days) { + const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000 + const { store } = await this._tx('cached_history') + const rows = await this._request(store.getAll()) + return rows + .map(row => row.data) + .filter(entry => + since === 0 ? true : new Date(entry.performedAt).getTime() >= since, + ) + .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt)) + } + + async deleteHistory(ids) { + if (!ids.length) return + const { tx, store } = await this._tx('cached_history', 'readwrite') + for (const id of ids) { + store.delete(id) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async updateHistoryEntry(choreId, historyId, updates) { + const { store } = await this._tx('cached_history') + const existing = await this._request(store.get(historyId)) + if (!existing) return + + const merged = { + ...existing, + choreId: Number(choreId), + data: { + ...existing.data, + ...updates, + id: historyId, + choreId: Number(choreId), + }, + } + merged.performedAt = new Date(merged.data.performedAt).getTime() + merged.cachedAt = Date.now() + + const { store: writeStore } = await this._tx('cached_history', 'readwrite') + await this._request(writeStore.put(merged)) + } + + async deleteHistoryEntry(historyId) { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request(store.delete(historyId)) + } + + async clearHistory() { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request(store.clear()) + } + // ── Command queue ── async enqueueCommand(command) { @@ -526,7 +805,12 @@ class IndexedDBBackend { } async clearAll() { - const storeNames = ['cached_chores', 'command_queue', 'sync_meta'] + const storeNames = [ + 'cached_chores', + 'command_queue', + 'sync_meta', + 'cached_history', + ] for (const storeName of storeNames) { const { store } = await this._tx(storeName, 'readwrite') await this._request(store.clear()) @@ -666,6 +950,56 @@ class OfflineDB { return this.backend.setLastSyncTime(time) } + // History cache + async saveHistory(entries) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.saveHistory(entries) + } + + async savePendingHistory(entry) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.savePendingHistory(entry) + } + + async getHistoryByChore(choreId) { + if (!isOfflineFeatureEnabled()) return [] + await this._ensureInit() + console.log('MO: Fetching history for chore', choreId) + return this.backend.getHistoryByChore(choreId) + } + + async getHistoryByDays(days) { + if (!isOfflineFeatureEnabled()) return [] + await this._ensureInit() + return this.backend.getHistoryByDays(days) + } + + async deleteHistory(ids) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.deleteHistory(ids) + } + + async updateHistoryEntry(choreId, historyId, updates) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.updateHistoryEntry(choreId, historyId, updates) + } + + async deleteHistoryEntry(historyId) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.deleteHistoryEntry(historyId) + } + + async clearHistory() { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.clearHistory() + } + // General key-value cache (uses sync_meta store) async saveKV(key, value) { if (!isOfflineFeatureEnabled()) return diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 56fc455..0caf2ef 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -5,10 +5,14 @@ import { ArchiveChore, CreateChore, DeleteChore, + DeleteChoreHistory, MarkChoreComplete, + PauseChore, SaveChore, SkipChore, + StartChore, UnArchiveChore, + UpdateChoreHistory, UpdateDueDate, } from './Fetcher' import { offlineDB } from './OfflineDB' @@ -121,10 +125,30 @@ class SyncEngine { response = await SkipChore(cmd.payload.id || cmd.entityId) break + case CommandType.START_CHORE: + response = await StartChore(cmd.payload.id || cmd.entityId) + break + + case CommandType.PAUSE_CHORE: + response = await PauseChore(cmd.payload.id || cmd.entityId) + break + case CommandType.DELETE_CHORE: response = await DeleteChore(cmd.payload.id || cmd.entityId) break + case CommandType.UPDATE_CHORE_HISTORY: { + const { choreId, historyId, historyData } = cmd.payload + response = await UpdateChoreHistory(choreId, historyId, historyData) + break + } + + case CommandType.DELETE_CHORE_HISTORY: { + const { choreId, historyId } = cmd.payload + response = await DeleteChoreHistory(choreId, historyId) + break + } + case CommandType.RESCHEDULE_CHORE: { const { id, dueDate } = cmd.payload response = await UpdateDueDate(id, dueDate) @@ -153,7 +177,7 @@ class SyncEngine { } async _deltaSync() { - const cursor = (await offlineDB.getSyncCursor()) || 0 + const cursor = (await offlineDB.getSyncCursor()) || -1 let hasMore = true let currentCursor = cursor @@ -182,12 +206,23 @@ class SyncEngine { await offlineDB.saveChores(changedChores) } + // Upsert changed history entries (also clears any pending entries for the same chore IDs) + const changedHistory = data.changes?.choreHistories ?? [] + if (changedHistory.length > 0) { + await offlineDB.saveHistory(changedHistory) + } + // Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both) const deletedIds = data.deletions?.chores ?? [] if (deletedIds.length > 0) { await offlineDB.deleteChores(deletedIds) } + const deletedHistoryIds = data.deletions?.choreHistories ?? [] + if (deletedHistoryIds.length > 0) { + await offlineDB.deleteHistory(deletedHistoryIds) + } + // Always advance the cursor, even when there are no changes if (data.cursor) { currentCursor = data.cursor diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 1917ff0..7373559 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -45,7 +45,10 @@ 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 { + useChoreDetails, + useChoreHistory, +} from '../../queries/ChoreQueries.jsx' import { useChoreTimer, useDeleteTimeSession, @@ -55,7 +58,11 @@ import { } from '../../queries/TimeQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' -import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx' +import { + ChoreHistoryStatus, + ChoreStatus, + notInCompletionWindow, +} from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { commandQueue, CommandType } from '../../utils/CommandQueue' import { @@ -68,6 +75,7 @@ import { UndoChoreAction, UpdateChorePriority, } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' import Priorities from '../../utils/Priorities' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -123,8 +131,21 @@ const ChoreView = () => { const { data: choreData, isLoading: isChoreLoading } = useChoreDetails(choreId) + const { data: choreHistoryData } = useChoreHistory(choreId) const { data: pendingCmds } = usePendingCommands(choreId) + const choreHistory = choreHistoryData?.res || [] + const historyCompletionCount = choreHistory.filter(historyEntry => { + const status = Number(historyEntry?.status) + return ( + status === ChoreHistoryStatus.COMPLETED || + status === ChoreHistoryStatus.SKIPPED + ) + }).length + const completionCount = choreHistoryData + ? historyCompletionCount + : chore.totalCompletedCount || 0 + const startChore = useStartChore() const pauseChore = usePauseChore() const deleteTimeSession = useDeleteTimeSession() @@ -148,9 +169,61 @@ const ChoreView = () => { useEffect(() => { if (chore && performers?.length > 0) { - generateInfoCards(chore) + const cards = [ + { + size: 6, + icon: , + title: t('choreView.assignment'), + text: `${t('choreView.assigned')}: ${ + performers.find(p => p.userId === chore.assignedTo)?.displayName || + t('choreView.na') + }`, + subtext: ` ${t('choreView.last')}: ${ + chore.lastCompletedDate + ? performers.find(p => p.userId === chore.lastCompletedBy) + ?.displayName + : 'N/A' + }`, + }, + { + size: 6, + icon: , + title: t('choreView.schedule'), + text: `${t('choreView.due')}: ${ + chore.nextDueDate + ? moment(chore.nextDueDate).fromNow() + : t('choreView.na') + }`, + subtext: `${t('choreView.last')}: ${ + chore.lastCompletedDate + ? moment(chore.lastCompletedDate).fromNow() + : t('choreView.na') + }`, + + subtext2: + chore.deadlineOffset > 0 && chore.nextDueDate + ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` + : null, + }, + { + size: 6, + icon: , + title: t('choreView.statistics'), + text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`, + }, + { + size: 6, + icon: , + title: t('choreView.details'), + subtext: `${t('choreView.createdBy')}: ${ + performers.find(p => p.userId === chore.createdBy)?.displayName || + t('choreView.na') + }`, + }, + ] + setInfoCards(cards) } - }, [chore, performers]) + }, [chore, performers, completionCount, t]) const handleUpdatePriority = priority => { UpdateChorePriority(choreId, priority.value).then(response => { if (response.ok) { @@ -161,61 +234,6 @@ const ChoreView = () => { } }) } - const generateInfoCards = chore => { - const cards = [ - { - size: 6, - icon: , - title: t('choreView.assignment'), - text: `${t('choreView.assigned')}: ${ - performers.find(p => p.userId === chore.assignedTo)?.displayName || - t('choreView.na') - }`, - subtext: ` ${t('choreView.last')}: ${ - chore.lastCompletedDate - ? performers.find(p => p.userId === chore.lastCompletedBy) - ?.displayName - : 'N/A' - }`, - }, - { - size: 6, - icon: , - title: t('choreView.schedule'), - text: `${t('choreView.due')}: ${ - chore.nextDueDate - ? moment(chore.nextDueDate).fromNow() - : t('choreView.na') - }`, - subtext: `${t('choreView.last')}: ${ - chore.lastCompletedDate - ? moment(chore.lastCompletedDate).fromNow() - : t('choreView.na') - }`, - - subtext2: - chore.deadlineOffset > 0 && chore.nextDueDate - ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` - : null, - }, - { - size: 6, - icon: , - title: t('choreView.statistics'), - text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`, - }, - { - size: 6, - icon: , - title: t('choreView.details'), - subtext: `${t('choreView.createdBy')}: ${ - performers.find(p => p.userId === chore.createdBy)?.displayName || - t('choreView.na') - }`, - }, - ] - setInfoCards(cards) - } const handleTaskCompletion = async () => { try { const resp = await MarkChoreComplete( @@ -279,6 +297,17 @@ const ChoreView = () => { performer: null, }, ) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: impersonatedUser?.userId || userProfile?.id || 0, + performedAt: completedDate || new Date().toISOString(), + dueDate: chore.nextDueDate || null, + notes: note || null, + status: 1, + points: chore.points || 0, + pending: true, + }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ message: "You're offline — completion will sync when back online", @@ -354,6 +383,7 @@ const ChoreView = () => { } } const handleChoreStart = () => { + const startedChore = { ...chore, status: ChoreStatus.ACTIVE } startChore.mutate(choreId, { onSuccess: data => { const newChore = { @@ -362,10 +392,37 @@ const ChoreView = () => { } setChore(newChore) }, + onError: async error => { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + choreId, + { id: choreId }, + ) + setChore(startedChore) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + setChore({ ...chore, status: previousStatus }) + }, + }) + return + } + + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to start task', + }) + }, }) } const handleChorePause = () => { + const pausedChore = { ...chore, status: ChoreStatus.PAUSED } pauseChore.mutate(choreId, { onSuccess: data => { const newChore = { @@ -374,6 +431,32 @@ const ChoreView = () => { } setChore(newChore) }, + onError: async error => { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + choreId, + { id: choreId }, + ) + setChore(pausedChore) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + setChore({ ...chore, status: previousStatus }) + }, + }) + return + } + + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to pause task', + }) + }, }) } diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 39119f3..1c4e3cf 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -17,6 +17,7 @@ import { UpdateChoreAssignee, UpdateDueDate, } from '../../../utils/Fetcher' +import { offlineDB } from '../../../utils/OfflineDB' const isNetworkError = err => err instanceof TypeError && err.message === 'Failed to fetch' @@ -234,6 +235,17 @@ export const useChoreActions = ({ performer: null, }, ) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: chore.id, + completedBy: impersonatedUser?.userId || userProfile?.id || 0, + performedAt: new Date().toISOString(), + dueDate: chore.nextDueDate || null, + notes: null, + status: 1, + points: chore.points || 0, + pending: true, + }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ title: 'Task completion pending', @@ -257,57 +269,107 @@ export const useChoreActions = ({ case 'start': { const startedChore = { ...chore, status: 1 } - startChore.mutate(chore.id, { - 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, - ), - } - }) + try { + await startChore.mutateAsync(chore.id) + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? startedChore : c, + ), + } + }) + updateChoreInState(startedChore, 'started', { + skipInvalidation: true, + }) + } catch (error) { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + chore.id, + { id: chore.id }, + ) updateChoreInState(startedChore, 'started', { skipInvalidation: true, }) - }, - onError: error => { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + updateChoreInState( + { ...chore, status: previousStatus }, + previousStatus === 2 ? 'paused' : 'started', + { skipInvalidation: true }, + ) + }, + }) + } else { showError({ title: 'Failed to start', - message: error.message || 'Unable to start chore', + message: error?.message || 'Unable to start chore', }) - }, - }) + } + } break } case 'pause': { const pausedChore = { ...chore, status: 2 } - pauseChore.mutate(chore.id, { - 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, - ), - } - }) + try { + await pauseChore.mutateAsync(chore.id) + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? pausedChore : c, + ), + } + }) + updateChoreInState(pausedChore, 'paused', { + skipInvalidation: true, + }) + } catch (error) { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + chore.id, + { id: chore.id }, + ) updateChoreInState(pausedChore, 'paused', { skipInvalidation: true, }) - }, - onError: error => { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + updateChoreInState( + { ...chore, status: previousStatus }, + previousStatus === 2 ? 'paused' : 'started', + { skipInvalidation: true }, + ) + }, + }) + } else { showError({ title: 'Failed to pause', - message: error.message || 'Unable to pause chore', + message: error?.message || 'Unable to pause chore', }) - }, - }) + } + } break } diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 17ed410..b7d8543 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -20,10 +20,11 @@ import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy' import moment from 'moment' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useLocalization } from '../../contexts/LocalizationContext' import useConfirmationModal from '../../hooks/useConfirmationModal' +import { usePendingCommands } from '../../hooks/usePendingCommands' import { useChoreHistory, useDeleteChoreHistory, @@ -48,15 +49,33 @@ const ChoreHistory = () => { const { fmt } = useLocalization() const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) - const { showSuccess, showError } = useNotification() + const { showSuccess } = useNotification() // React Query hooks const { data: choreHistoryData, isLoading } = useChoreHistory(choreId) const { data: circleMembersData } = useCircleMembers() const updateChoreHistory = useUpdateChoreHistory() const deleteChoreHistory = useDeleteChoreHistory() + const { data: pendingCmds } = usePendingCommands(choreId) const choreHistory = choreHistoryData?.res || [] const performers = circleMembersData?.res || [] + const pendingByHistoryId = useMemo(() => { + if (!pendingCmds?.length) return {} + return pendingCmds.reduce((acc, cmd) => { + if ( + cmd.commandType !== 'update_chore_history' && + cmd.commandType !== 'delete_chore_history' + ) { + return acc + } + const historyId = + cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1]) + if (!historyId) return acc + if (!acc[historyId]) acc[historyId] = [] + acc[historyId].push(cmd) + return acc + }, {}) + }, [pendingCmds]) const handleDelete = historyEntry => { showConfirmation( @@ -366,6 +385,7 @@ const ChoreHistory = () => { performers={performers} allHistory={choreHistory} index={index} + pendingCommands={pendingByHistoryId[historyEntry.id] || []} onViewNote={notes => { setNoteViewerConfig({ isOpen: true, @@ -407,13 +427,21 @@ const ChoreHistory = () => { }, }, { - onSuccess: () => { + onSuccess: data => { setIsEditModalOpen(false) setEditHistory(null) - showSuccess({ - title: 'History Updated', - message: `The history record has been updated successfully.`, - }) + if (data?.queued) { + showSuccess({ + title: 'History Update Queued', + message: + 'You are offline. The history update will sync when connection is restored.', + }) + } else { + showSuccess({ + title: 'History Updated', + message: `The history record has been updated successfully.`, + }) + } }, onError: error => { console.error('Failed to update chore history:', error) @@ -429,13 +457,21 @@ const ChoreHistory = () => { historyId: editHistory.id, }, { - onSuccess: () => { + onSuccess: data => { setIsEditModalOpen(false) setEditHistory(null) - showSuccess({ - title: 'History Deleted', - message: `The history record has been deleted successfully.`, - }) + if (data?.queued) { + showSuccess({ + title: 'History Delete Queued', + message: + 'You are offline. The history delete will sync when connection is restored.', + }) + } else { + showSuccess({ + title: 'History Deleted', + message: `The history record has been deleted successfully.`, + }) + } }, }, ) diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index cd81f81..81b94e6 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -17,9 +17,14 @@ import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy' import moment from 'moment' import { useLocalization } from '../../contexts/LocalizationContext' import { TASK_COLOR } from '../../utils/Colors.jsx' +import PendingBadge from '../components/PendingBadge' const getCompletedChip = historyEntry => { - if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) { + if ( + historyEntry.status === 0 || + historyEntry.status === 5 || + historyEntry.status === 6 + ) { return null } @@ -94,6 +99,7 @@ const HistoryCard = ({ performers, historyEntry, index, + pendingCommands, onToggleActions, onViewNote, }) => { @@ -330,6 +336,18 @@ const HistoryCard = ({ )} + {pendingCommands?.length > 0 && ( + + )}
) } diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx index f86668e..e2ae0b8 100644 --- a/src/views/Modals/Inputs/ConfirmationModal.jsx +++ b/src/views/Modals/Inputs/ConfirmationModal.jsx @@ -73,7 +73,7 @@ function ConfirmationModal({ config }) { return ( handleAction(false)} size='sm' unmountDelay={250} > diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx index 35ef183..df72c41 100644 --- a/src/views/Settings/DeveloperSettings.jsx +++ b/src/views/Settings/DeveloperSettings.jsx @@ -1,14 +1,23 @@ +import { LocalNotifications } from '@capacitor/local-notifications' import { Refresh, Token } from '@mui/icons-material' import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' -import { useEffect, useState } from 'react' -import { LocalNotifications } from '@capacitor/local-notifications' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { networkManager } from '../../hooks/NetworkManager' +import useConfirmationModal from '../../hooks/useConfirmationModal' import { useSSEContext } from '../../hooks/useSSEContext' import { useNotification } from '../../service/NotificationProvider' import { apiClient } from '../../utils/ApiClient' +import { commandQueue } from '../../utils/CommandQueue' import { RefreshToken } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' +import { syncEngine } from '../../utils/SyncEngine' import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' const DeveloperSettings = () => { + const queryClient = useQueryClient() + const { confirmModalConfig, showConfirmation } = useConfirmationModal() const { isConnected, isConnecting, @@ -31,9 +40,48 @@ const DeveloperSettings = () => { const [isRefreshingDirect, setIsRefreshingDirect] = useState(false) const [scheduledNotifications, setScheduledNotifications] = useState([]) const [isLoadingNotifications, setIsLoadingNotifications] = useState(false) + const [isResettingSync, setIsResettingSync] = useState(false) + const [syncDiagnostics, setSyncDiagnostics] = useState({ + cursor: null, + lastSync: null, + pendingCount: 0, + failedCount: 0, + syncing: false, + syncError: null, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + }) const { showNotification } = useNotification() + const refreshSyncDiagnostics = useCallback(async () => { + try { + const [cursor, lastSync, pendingCommands, failedCommands] = + await Promise.all([ + offlineDB.getSyncCursor(), + offlineDB.getLastSyncTime(), + commandQueue.getPending(), + commandQueue.getFailed(), + ]) + + setSyncDiagnostics(prev => ({ + ...prev, + cursor, + lastSync, + pendingCount: pendingCommands.length, + failedCount: failedCommands.length, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + })) + } catch (error) { + console.error('Failed to load sync diagnostics:', error) + } + }, []) + useEffect(() => { setIsNativePlatform(isNative()) @@ -54,12 +102,8 @@ const DeveloperSettings = () => { const pending = await LocalNotifications.getPending() // Sort by schedule time (earliest first) const sorted = pending.notifications.sort((a, b) => { - const timeA = a.schedule?.at - ? new Date(a.schedule.at).getTime() - : 0 - const timeB = b.schedule?.at - ? new Date(b.schedule.at).getTime() - : 0 + const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0 + const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0 return timeA - timeB }) setScheduledNotifications(sorted) @@ -73,7 +117,39 @@ const DeveloperSettings = () => { loadTokenData() loadScheduledNotifications() - }, []) + refreshSyncDiagnostics() + }, [refreshSyncDiagnostics]) + + useEffect(() => { + const unsubscribeSync = syncEngine.onSyncStateChange(state => { + setSyncDiagnostics(prev => ({ + ...prev, + syncing: + typeof state.syncing === 'boolean' ? state.syncing : prev.syncing, + syncError: state.error ?? prev.syncError, + lastSync: state.lastSync ?? prev.lastSync, + })) + }) + + networkManager.registerNetworkListener(() => { + setSyncDiagnostics(prev => ({ + ...prev, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + })) + }) + + const interval = setInterval(() => { + refreshSyncDiagnostics() + }, 5000) + + return () => { + unsubscribeSync() + clearInterval(interval) + } + }, [refreshSyncDiagnostics]) useEffect(() => { const calculateTimeLeft = () => { @@ -239,6 +315,51 @@ const DeveloperSettings = () => { } } + const handleResetDatabaseAndResync = async () => { + showConfirmation( + 'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?', + 'Clear Local DB & Re-Sync', + async () => { + setIsResettingSync(true) + try { + await offlineDB.clearAll() + + showNotification({ + type: 'success', + message: 'Local offline database cleared. Starting full sync...', + }) + + const didSync = await syncEngine.sync() + if (didSync) { + await queryClient.invalidateQueries() + showNotification({ + type: 'success', + message: 'Full sync completed from the beginning', + }) + } else { + showNotification({ + type: 'warning', + message: + 'Database cleared. Full sync did not run (likely offline or already syncing).', + }) + } + } catch (error) { + console.error('Failed to reset database and resync:', error) + showNotification({ + type: 'error', + message: `Reset/resync failed: ${error.message}`, + }) + } finally { + await refreshSyncDiagnostics() + setIsResettingSync(false) + } + }, + 'Clear & Re-Sync', + 'Cancel', + 'danger', + ) + } + const getNotificationStatusColor = scheduleTime => { if (!scheduleTime) return 'neutral' @@ -252,6 +373,11 @@ const DeveloperSettings = () => { return 'success' // More than 1 hour } + const formatDateTime = timestamp => { + if (!timestamp) return 'N/A' + return new Date(timestamp).toLocaleString() + } + return (
Developer Settings @@ -377,6 +503,143 @@ const DeveloperSettings = () => { + + + + Sync & Network Diagnostics + + + + + + + Network Status + + Connection:{' '} + + {syncDiagnostics.isOnline ? 'Online' : 'Offline'} + + + + Device Network:{' '} + + {syncDiagnostics.isNetworkOn === false + ? 'Disconnected' + : syncDiagnostics.isNetworkOn === true + ? 'Connected' + : 'Unknown'} + + + + Offline Since: {formatDateTime(syncDiagnostics.offlineSince)} + + + Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)} + + + + + + + Sync Offset Information + + Sync Cursor:{' '} + + {syncDiagnostics.cursor ?? 'N/A'} + + + + Last Sync:{' '} + + {formatDateTime(syncDiagnostics.lastSync)} + + + + Sync State:{' '} + + {syncDiagnostics.syncing ? 'Syncing' : 'Idle'} + + + + Pending Commands:{' '} + + {syncDiagnostics.pendingCount} + + + + Failed Commands:{' '} + 0 ? 'danger' : 'success'} + > + {syncDiagnostics.failedCount} + + + {syncDiagnostics.syncError && ( + + Sync Error: {syncDiagnostics.syncError} + + )} + + + + + + Recovery Actions + + Clears local offline cache, sync cursor, and queued commands, then + re-syncs from the beginning. + + + + + + + + {isNativePlatform && ( @@ -436,9 +699,7 @@ const DeveloperSettings = () => { ? new Date(scheduleTime) : null const now = new Date() - const timeUntil = scheduledDate - ? scheduledDate - now - : null + const timeUntil = scheduledDate ? scheduledDate - now : null return ( { + +
) } diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index 349e15b..5cd8d9e 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -47,10 +47,14 @@ import { } from '../../queries/TimeQueries' import { useCircleMembers } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' +import { commandQueue, CommandType } from '../../utils/CommandQueue' import { resolvePhotoURL } from '../../utils/Helpers' import { getSafeBottom } from '../../utils/SafeAreaUtils' import LoadingComponent from '../components/Loading' +const isNetworkError = err => + err instanceof TypeError && err.message === 'Failed to fetch' + const TimerDetails = () => { const { choreId } = useParams() const { fmt } = useLocalization() @@ -256,7 +260,23 @@ const TimerDetails = () => { }) refetchTimer() }, - onError: () => { + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + choreId, + { id: choreId }, + ) + showSuccess({ + title: 'Start queued', + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + }, + }) + return + } + showError({ title: 'Failed to start timer', message: 'Please try again.', @@ -278,7 +298,23 @@ const TimerDetails = () => { }) refetchTimer() }, - onError: () => { + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + choreId, + { id: choreId }, + ) + showSuccess({ + title: 'Pause queued', + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + }, + }) + return + } + showError({ title: 'Failed to pause timer', message: 'Please try again.', @@ -928,9 +964,7 @@ const TimerDetails = () => { 'MMM DD', ) const startTime = fmt.time(pause.start) - const endTime = pause.end - ? fmt.time(pause.end) - : null + const endTime = pause.end ? fmt.time(pause.end) : null const realTimeDuration = isOngoing ? Math.max( diff --git a/src/views/components/PendingBadge.jsx b/src/views/components/PendingBadge.jsx index 8b659ce..aba2ace 100644 --- a/src/views/components/PendingBadge.jsx +++ b/src/views/components/PendingBadge.jsx @@ -20,6 +20,8 @@ const LABELS = { update_chore: 'Update pending', create_chore: 'Create pending', delete_chore: 'Delete pending', + update_chore_history: 'Edit history pending', + delete_chore_history: 'Delete history pending', reschedule_chore: 'Reschedule pending', archive_chore: 'Archive pending', unarchive_chore: 'Restore pending', @@ -27,6 +29,16 @@ const LABELS = { pause_chore: 'Pause pending', } +const formatCommandLabel = commandType => { + return ( + LABELS[commandType] || + commandType + ?.replace(/_/g, ' ') + ?.replace(/\b\w/g, letter => letter.toUpperCase()) || + 'Pending action' + ) +} + function PendingBadge({ commands, size = 'sm', sx = {} }) { const { ResponsiveModal } = useResponsiveModal() const queryClient = useQueryClient() @@ -45,6 +57,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) { const invalidatePending = async () => { await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) await queryClient.invalidateQueries({ queryKey: ['chores'] }) + await queryClient.invalidateQueries({ queryKey: ['choreHistory'] }) } const handleUndo = async (e, cmdId) => { @@ -150,7 +163,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) { > - {LABELS[cmd.commandType] || 'Pending action'} + {formatCommandLabel(cmd.commandType)} {new Date(cmd.createdAt).toLocaleString()} diff --git a/src/views/components/SyncStatusIndicator.jsx b/src/views/components/SyncStatusIndicator.jsx index aaf5645..f8ef7e7 100644 --- a/src/views/components/SyncStatusIndicator.jsx +++ b/src/views/components/SyncStatusIndicator.jsx @@ -1,5 +1,6 @@ import { CheckCircleOutline, + ClearAll, CloudDone, CloudQueue, CloudSync, @@ -34,14 +35,28 @@ import { syncEngine } from '../../utils/SyncEngine' const COMMAND_LABELS = { create_chore: 'Create chore', update_chore: 'Update chore', + update_chore_history: 'Edit history', complete_chore: 'Complete chore', skip_chore: 'Skip chore', + start_chore: 'Start chore', + pause_chore: 'Pause chore', delete_chore: 'Delete chore', + delete_chore_history: 'Delete history', reschedule_chore: 'Reschedule chore', archive_chore: 'Archive chore', unarchive_chore: 'Restore chore', } +const formatCommandLabel = commandType => { + return ( + COMMAND_LABELS[commandType] || + commandType + ?.replace(/_/g, ' ') + ?.replace(/\b\w/g, letter => letter.toUpperCase()) || + 'Pending action' + ) +} + const RETRY_INTERVAL = 30 function SyncStatusIndicator() { @@ -131,6 +146,16 @@ function SyncStatusIndicator() { await refreshCommands() } + const handleCancelAll = async () => { + const [pending, failed] = await Promise.all([ + commandQueue.getPending(), + commandQueue.getFailed(), + ]) + const allCommands = [...pending, ...failed] + await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.id))) + await refreshCommands() + } + const formatTime = timestamp => { if (!timestamp) return 'Never' const seconds = Math.floor((Date.now() - timestamp) / 1000) @@ -323,7 +348,7 @@ function SyncStatusIndicator() { }} > - {COMMAND_LABELS[type] || type} + {formatCommandLabel(type)} {count} @@ -375,7 +400,7 @@ function SyncStatusIndicator() { fontWeight: 500, }} > - {COMMAND_LABELS[cmd.commandType] || cmd.commandType} + {formatCommandLabel(cmd.commandType)} - {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 5a74b29..c073e9a 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 From 5869b39035fc6559effc09d0e61f93fb2cd66ac3 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 12 May 2026 01:10:49 -0400 Subject: [PATCH 13/37] feat: enhance chore management with history and sync improvements - Added support for starting and pausing chores with offline handling. - Implemented chore history updates and deletions with pending command management. - Enhanced sync engine to handle chore history changes and deletions. - Introduced a PendingBadge component to display pending actions for chore history. - Updated DeveloperSettings to include sync diagnostics and reset functionality. - Improved user notifications for offline actions and pending commands. - Refactored ChoreView to dynamically generate info cards based on chore history. - Added sync status indicator with cancel all functionality for pending commands. --- src/queries/ChoreQueries.jsx | 164 ++++++++- src/utils/CommandQueue.js | 16 +- src/utils/OfflineDB.js | 340 +++++++++++++++++- src/utils/SyncEngine.js | 37 +- src/views/ChoreEdit/ChoreView.jsx | 201 ++++++++--- src/views/Chores/hooks/useChoreActions.js | 130 +++++-- src/views/History/ChoreHistory.jsx | 60 +++- src/views/History/HistoryCard.jsx | 20 +- src/views/Modals/Inputs/ConfirmationModal.jsx | 2 +- src/views/Settings/DeveloperSettings.jsx | 287 ++++++++++++++- src/views/Timer/TimerDetails.jsx | 44 ++- src/views/components/PendingBadge.jsx | 15 +- src/views/components/SyncStatusIndicator.jsx | 47 ++- 13 files changed, 1218 insertions(+), 145 deletions(-) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 3670dd9..86dc132 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -257,8 +257,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => { const { data, error, isLoading } = useQuery({ queryKey: ['choresHistory', limit], queryFn: async () => { - const resp = await GetChoresHistory(limit, includeMembers) - return resp?.res || [] + try { + const resp = await GetChoresHistory(limit, includeMembers) + const entries = resp?.res || [] + // Cache for offline use — fire-and-forget so a cache failure never + // degrades the online experience + if (entries.length > 0) { + offlineDB + .saveHistory(entries) + .catch(err => console.error('Failed to cache chores history:', err)) + } + return entries + } catch { + return offlineDB.getHistoryByDays(limit) + } }, staleTime: 0, }) @@ -353,11 +365,30 @@ export const useChoreHistory = choreId => { if (!choreId) { throw new Error('Chore ID is required to fetch history') } - const response = await GetChoreHistory(choreId) - if (response && response.ok) { - return await response.json() + let json + try { + const response = await GetChoreHistory(choreId) + if (response && response.ok) { + json = await response.json() + } else { + throw new Error('Failed to fetch chore history') + } + } catch { + const cached = await offlineDB.getHistoryByChore(choreId) + return { res: cached } } - throw new Error('Failed to fetch chore history') + // Cache for offline use — fire-and-forget so a cache failure never + // degrades the online view. Inject choreId since the single-chore + // endpoint may omit it from each entry. + const entries = (json?.res || []).map(e => + e.choreId != null ? e : { ...e, choreId: Number(choreId) }, + ) + if (entries.length > 0) { + offlineDB + .saveHistory(entries) + .catch(err => console.error('Failed to cache chore history:', err)) + } + return json }, enabled: !!choreId, staleTime: 0, // Always consider data stale @@ -371,10 +402,62 @@ export const useUpdateChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, historyId, historyData }) => - UpdateChoreHistory(choreId, historyId, historyData), + mutationFn: async ({ choreId, historyId, historyData }) => { + const applyOptimisticUpdate = async () => { + queryClient.setQueryData(['choreHistory', choreId], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(entry => + entry.id === historyId + ? { ...entry, ...historyData, _pendingUpdate: true } + : entry, + ), + } + }) + await offlineDB.updateHistoryEntry(choreId, historyId, { + ...historyData, + _pendingUpdate: true, + }) + return { queued: true } + } + + if (!networkManager.isOnline) { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId, historyData }, + ) + return applyOptimisticUpdate() + } + + try { + const response = await UpdateChoreHistory( + choreId, + historyId, + historyData, + ) + if (!response || !response.ok) { + throw new Error('Failed to update chore history') + } + return response + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId, historyData }, + ) + return applyOptimisticUpdate() + } + throw error + } + }, onSuccess: (data, { choreId }) => { - queryClient.invalidateQueries(['choreHistory', choreId]) + if (!data?.queued) { + queryClient.invalidateQueries(['choreHistory', choreId]) + } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -383,10 +466,57 @@ export const useDeleteChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, historyId }) => - DeleteChoreHistory(choreId, historyId), + mutationFn: async ({ choreId, historyId }) => { + const applyOptimisticDelete = async () => { + queryClient.setQueryData(['choreHistory', choreId], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(entry => + entry.id === historyId + ? { ...entry, _pendingDelete: true } + : entry, + ), + } + }) + await offlineDB.updateHistoryEntry(choreId, historyId, { + _pendingDelete: true, + }) + return { queued: true } + } + + if (!networkManager.isOnline) { + await commandQueue.enqueue( + CommandType.DELETE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId }, + ) + return applyOptimisticDelete() + } + + try { + const response = await DeleteChoreHistory(choreId, historyId) + if (!response || !response.ok) { + throw new Error('Failed to delete chore history') + } + return response + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue( + CommandType.DELETE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId }, + ) + return applyOptimisticDelete() + } + throw error + } + }, onSuccess: (data, { choreId }) => { - queryClient.invalidateQueries(['choreHistory', choreId]) + if (!data?.queued) { + queryClient.invalidateQueries(['choreHistory', choreId]) + } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -403,6 +533,16 @@ export const useMarkChoreComplete = () => { completedDate, performer, }) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: body?.completedBy || 0, + performedAt: completedDate || new Date().toISOString(), + notes: body?.note || null, + status: 1, + points: 0, + pending: true, + }) // Optimistically update the cache to show pending state queryClient.setQueryData(['chores'], oldData => { if (!oldData) return oldData diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index ecb33c6..58566b2 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -5,9 +5,13 @@ import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' export const CommandType = { CREATE_CHORE: 'create_chore', UPDATE_CHORE: 'update_chore', + UPDATE_CHORE_HISTORY: 'update_chore_history', COMPLETE_CHORE: 'complete_chore', SKIP_CHORE: 'skip_chore', + START_CHORE: 'start_chore', + PAUSE_CHORE: 'pause_chore', DELETE_CHORE: 'delete_chore', + DELETE_CHORE_HISTORY: 'delete_chore_history', RESCHEDULE_CHORE: 'reschedule_chore', ARCHIVE_CHORE: 'archive_chore', UNARCHIVE_CHORE: 'unarchive_chore', @@ -52,9 +56,17 @@ class CommandQueue { // Get pending commands for a specific entity (for undo/UI) async getPendingForEntity(entityId) { if (!isOfflineFeatureEnabled()) return [] - const commands = await offlineDB.getCommandsByEntity(String(entityId)) + const allCommands = await offlineDB.getCommands() + const key = String(entityId) + const commands = allCommands + .filter( + c => + c.entityId === key || + (typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)), + ) + .sort((a, b) => a.createdAt - b.createdAt) return commands - .filter(c => c.status === 'pending') + .filter(c => c.status === 'pending' || c.status === 'syncing') .map(c => ({ ...c, payload: JSON.parse(c.payload) })) } diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 08f28c1..29be31d 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -3,9 +3,9 @@ import { Capacitor } from '@capacitor/core' import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' const DB_NAME = 'donetick_offline' -const DB_VERSION = 1 +const DB_VERSION = 2 const IDB_NAME = 'donetick_offline' -const IDB_VERSION = 1 +const IDB_VERSION = 2 // Cache platform detection let _isNative = null @@ -63,6 +63,19 @@ class SQLiteBackend { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS cached_history ( + id INTEGER PRIMARY KEY, + chore_id INTEGER NOT NULL, + data TEXT NOT NULL, + performed_at INTEGER NOT NULL, + pending INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_history_chore_id ON cached_history(chore_id); + CREATE INDEX IF NOT EXISTS idx_history_performed_at ON cached_history(performed_at DESC); + CREATE INDEX IF NOT EXISTS idx_history_pending ON cached_history(pending); `, }) @@ -134,6 +147,131 @@ class SQLiteBackend { }) } + // ── History cache ── + + async saveHistory(entries) { + if (!entries.length) return + // Delete pending entries for the affected chore IDs first + const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] + if (choreIds.length) { + const placeholders = choreIds.map(() => '?').join(', ') + await CapacitorSQLite.run({ + database: DB_NAME, + statement: `DELETE FROM cached_history WHERE pending = 1 AND chore_id IN (${placeholders})`, + values: choreIds, + }) + } + const statements = entries.map(entry => ({ + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + entry.id, + Number(entry.choreId), + JSON.stringify(entry), + new Date(entry.performedAt).getTime(), + 0, + Date.now(), + ], + })) + await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements }) + } + + async savePendingHistory(entry) { + await CapacitorSQLite.run({ + database: DB_NAME, + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + entry.id, + Number(entry.choreId), + JSON.stringify(entry), + new Date(entry.performedAt).getTime(), + 1, + Date.now(), + ], + }) + } + + async getHistoryByChore(choreId) { + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: + 'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC', + values: [Number(choreId)], + }) + return (result.values || []).map(row => JSON.parse(row.data)) + } + + async getHistoryByDays(days) { + const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000 + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: + since === 0 + ? 'SELECT data FROM cached_history ORDER BY performed_at DESC' + : 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC', + values: since === 0 ? [] : [since], + }) + return (result.values || []).map(row => JSON.parse(row.data)) + } + + async deleteHistory(ids) { + if (!ids.length) return + const statements = ids.map(id => ({ + statement: 'DELETE FROM cached_history WHERE id = ?', + values: [id], + })) + await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements }) + } + + async updateHistoryEntry(choreId, historyId, updates) { + const existing = await CapacitorSQLite.query({ + database: DB_NAME, + statement: 'SELECT data, pending FROM cached_history WHERE id = ?', + values: [historyId], + }) + + if (!existing.values?.length) return + + const row = existing.values[0] + const current = JSON.parse(row.data) + const merged = { + ...current, + ...updates, + id: historyId, + choreId: Number(choreId), + } + + await CapacitorSQLite.run({ + database: DB_NAME, + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + historyId, + Number(choreId), + JSON.stringify(merged), + new Date(merged.performedAt).getTime(), + row.pending || 0, + Date.now(), + ], + }) + } + + async deleteHistoryEntry(historyId) { + await CapacitorSQLite.run({ + database: DB_NAME, + statement: 'DELETE FROM cached_history WHERE id = ?', + values: [historyId], + }) + } + + async clearHistory() { + await CapacitorSQLite.execute({ + database: DB_NAME, + statements: 'DELETE FROM cached_history', + }) + } + // ── Command queue ── async enqueueCommand(command) { @@ -286,6 +424,7 @@ class SQLiteBackend { DELETE FROM cached_chores; DELETE FROM command_queue; DELETE FROM sync_meta; + DELETE FROM cached_history; `, }) } @@ -323,6 +462,17 @@ class IndexedDBBackend { if (!db.objectStoreNames.contains('sync_meta')) { db.createObjectStore('sync_meta', { keyPath: 'key' }) } + + if (!db.objectStoreNames.contains('cached_history')) { + const histStore = db.createObjectStore('cached_history', { + keyPath: 'id', + }) + histStore.createIndex('chore_id', 'choreId', { unique: false }) + histStore.createIndex('performed_at', 'performedAt', { + unique: false, + }) + histStore.createIndex('pending', 'pending', { unique: false }) + } } request.onsuccess = event => resolve(event.target.result) @@ -428,6 +578,135 @@ class IndexedDBBackend { await this._request(store.clear()) } + // ── History cache ── + + async saveHistory(entries) { + if (!entries.length) return + // Delete pending entries for the affected chore IDs first + const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] + await this._deletePendingHistoryByChoreIds(choreIds) + // Upsert real entries + const { tx, store } = await this._tx('cached_history', 'readwrite') + for (const entry of entries) { + store.put({ + id: entry.id, + choreId: Number(entry.choreId), + data: entry, + performedAt: new Date(entry.performedAt).getTime(), + pending: 0, + cachedAt: Date.now(), + }) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async savePendingHistory(entry) { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request( + store.put({ + id: entry.id, + choreId: Number(entry.choreId), + data: entry, + performedAt: new Date(entry.performedAt).getTime(), + pending: 1, + cachedAt: Date.now(), + }), + ) + } + + async _deletePendingHistoryByChoreIds(choreIds) { + if (!choreIds.length) return + const choreIdSet = new Set(choreIds) + // Tx 1: read all pending entries + const { store: readStore } = await this._tx('cached_history') + const index = readStore.index('pending') + const rows = await this._request(index.getAll(1)) + const toDelete = rows + .filter(row => choreIdSet.has(Number(row.choreId))) + .map(row => row.id) + if (!toDelete.length) return + // Tx 2: delete them + const { tx, store: writeStore } = await this._tx( + 'cached_history', + 'readwrite', + ) + for (const id of toDelete) { + writeStore.delete(id) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async getHistoryByChore(choreId) { + const { store } = await this._tx('cached_history') + const index = store.index('chore_id') + const rows = await this._request(index.getAll(Number(choreId))) + return rows + .map(row => row.data) + .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt)) + } + + async getHistoryByDays(days) { + const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000 + const { store } = await this._tx('cached_history') + const rows = await this._request(store.getAll()) + return rows + .map(row => row.data) + .filter(entry => + since === 0 ? true : new Date(entry.performedAt).getTime() >= since, + ) + .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt)) + } + + async deleteHistory(ids) { + if (!ids.length) return + const { tx, store } = await this._tx('cached_history', 'readwrite') + for (const id of ids) { + store.delete(id) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async updateHistoryEntry(choreId, historyId, updates) { + const { store } = await this._tx('cached_history') + const existing = await this._request(store.get(historyId)) + if (!existing) return + + const merged = { + ...existing, + choreId: Number(choreId), + data: { + ...existing.data, + ...updates, + id: historyId, + choreId: Number(choreId), + }, + } + merged.performedAt = new Date(merged.data.performedAt).getTime() + merged.cachedAt = Date.now() + + const { store: writeStore } = await this._tx('cached_history', 'readwrite') + await this._request(writeStore.put(merged)) + } + + async deleteHistoryEntry(historyId) { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request(store.delete(historyId)) + } + + async clearHistory() { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request(store.clear()) + } + // ── Command queue ── async enqueueCommand(command) { @@ -526,7 +805,12 @@ class IndexedDBBackend { } async clearAll() { - const storeNames = ['cached_chores', 'command_queue', 'sync_meta'] + const storeNames = [ + 'cached_chores', + 'command_queue', + 'sync_meta', + 'cached_history', + ] for (const storeName of storeNames) { const { store } = await this._tx(storeName, 'readwrite') await this._request(store.clear()) @@ -666,6 +950,56 @@ class OfflineDB { return this.backend.setLastSyncTime(time) } + // History cache + async saveHistory(entries) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.saveHistory(entries) + } + + async savePendingHistory(entry) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.savePendingHistory(entry) + } + + async getHistoryByChore(choreId) { + if (!isOfflineFeatureEnabled()) return [] + await this._ensureInit() + console.log('MO: Fetching history for chore', choreId) + return this.backend.getHistoryByChore(choreId) + } + + async getHistoryByDays(days) { + if (!isOfflineFeatureEnabled()) return [] + await this._ensureInit() + return this.backend.getHistoryByDays(days) + } + + async deleteHistory(ids) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.deleteHistory(ids) + } + + async updateHistoryEntry(choreId, historyId, updates) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.updateHistoryEntry(choreId, historyId, updates) + } + + async deleteHistoryEntry(historyId) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.deleteHistoryEntry(historyId) + } + + async clearHistory() { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.clearHistory() + } + // General key-value cache (uses sync_meta store) async saveKV(key, value) { if (!isOfflineFeatureEnabled()) return diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 56fc455..0caf2ef 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -5,10 +5,14 @@ import { ArchiveChore, CreateChore, DeleteChore, + DeleteChoreHistory, MarkChoreComplete, + PauseChore, SaveChore, SkipChore, + StartChore, UnArchiveChore, + UpdateChoreHistory, UpdateDueDate, } from './Fetcher' import { offlineDB } from './OfflineDB' @@ -121,10 +125,30 @@ class SyncEngine { response = await SkipChore(cmd.payload.id || cmd.entityId) break + case CommandType.START_CHORE: + response = await StartChore(cmd.payload.id || cmd.entityId) + break + + case CommandType.PAUSE_CHORE: + response = await PauseChore(cmd.payload.id || cmd.entityId) + break + case CommandType.DELETE_CHORE: response = await DeleteChore(cmd.payload.id || cmd.entityId) break + case CommandType.UPDATE_CHORE_HISTORY: { + const { choreId, historyId, historyData } = cmd.payload + response = await UpdateChoreHistory(choreId, historyId, historyData) + break + } + + case CommandType.DELETE_CHORE_HISTORY: { + const { choreId, historyId } = cmd.payload + response = await DeleteChoreHistory(choreId, historyId) + break + } + case CommandType.RESCHEDULE_CHORE: { const { id, dueDate } = cmd.payload response = await UpdateDueDate(id, dueDate) @@ -153,7 +177,7 @@ class SyncEngine { } async _deltaSync() { - const cursor = (await offlineDB.getSyncCursor()) || 0 + const cursor = (await offlineDB.getSyncCursor()) || -1 let hasMore = true let currentCursor = cursor @@ -182,12 +206,23 @@ class SyncEngine { await offlineDB.saveChores(changedChores) } + // Upsert changed history entries (also clears any pending entries for the same chore IDs) + const changedHistory = data.changes?.choreHistories ?? [] + if (changedHistory.length > 0) { + await offlineDB.saveHistory(changedHistory) + } + // Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both) const deletedIds = data.deletions?.chores ?? [] if (deletedIds.length > 0) { await offlineDB.deleteChores(deletedIds) } + const deletedHistoryIds = data.deletions?.choreHistories ?? [] + if (deletedHistoryIds.length > 0) { + await offlineDB.deleteHistory(deletedHistoryIds) + } + // Always advance the cursor, even when there are no changes if (data.cursor) { currentCursor = data.cursor diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 1917ff0..7373559 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -45,7 +45,10 @@ 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 { + useChoreDetails, + useChoreHistory, +} from '../../queries/ChoreQueries.jsx' import { useChoreTimer, useDeleteTimeSession, @@ -55,7 +58,11 @@ import { } from '../../queries/TimeQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' -import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx' +import { + ChoreHistoryStatus, + ChoreStatus, + notInCompletionWindow, +} from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { commandQueue, CommandType } from '../../utils/CommandQueue' import { @@ -68,6 +75,7 @@ import { UndoChoreAction, UpdateChorePriority, } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' import Priorities from '../../utils/Priorities' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -123,8 +131,21 @@ const ChoreView = () => { const { data: choreData, isLoading: isChoreLoading } = useChoreDetails(choreId) + const { data: choreHistoryData } = useChoreHistory(choreId) const { data: pendingCmds } = usePendingCommands(choreId) + const choreHistory = choreHistoryData?.res || [] + const historyCompletionCount = choreHistory.filter(historyEntry => { + const status = Number(historyEntry?.status) + return ( + status === ChoreHistoryStatus.COMPLETED || + status === ChoreHistoryStatus.SKIPPED + ) + }).length + const completionCount = choreHistoryData + ? historyCompletionCount + : chore.totalCompletedCount || 0 + const startChore = useStartChore() const pauseChore = usePauseChore() const deleteTimeSession = useDeleteTimeSession() @@ -148,9 +169,61 @@ const ChoreView = () => { useEffect(() => { if (chore && performers?.length > 0) { - generateInfoCards(chore) + const cards = [ + { + size: 6, + icon: , + title: t('choreView.assignment'), + text: `${t('choreView.assigned')}: ${ + performers.find(p => p.userId === chore.assignedTo)?.displayName || + t('choreView.na') + }`, + subtext: ` ${t('choreView.last')}: ${ + chore.lastCompletedDate + ? performers.find(p => p.userId === chore.lastCompletedBy) + ?.displayName + : 'N/A' + }`, + }, + { + size: 6, + icon: , + title: t('choreView.schedule'), + text: `${t('choreView.due')}: ${ + chore.nextDueDate + ? moment(chore.nextDueDate).fromNow() + : t('choreView.na') + }`, + subtext: `${t('choreView.last')}: ${ + chore.lastCompletedDate + ? moment(chore.lastCompletedDate).fromNow() + : t('choreView.na') + }`, + + subtext2: + chore.deadlineOffset > 0 && chore.nextDueDate + ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` + : null, + }, + { + size: 6, + icon: , + title: t('choreView.statistics'), + text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`, + }, + { + size: 6, + icon: , + title: t('choreView.details'), + subtext: `${t('choreView.createdBy')}: ${ + performers.find(p => p.userId === chore.createdBy)?.displayName || + t('choreView.na') + }`, + }, + ] + setInfoCards(cards) } - }, [chore, performers]) + }, [chore, performers, completionCount, t]) const handleUpdatePriority = priority => { UpdateChorePriority(choreId, priority.value).then(response => { if (response.ok) { @@ -161,61 +234,6 @@ const ChoreView = () => { } }) } - const generateInfoCards = chore => { - const cards = [ - { - size: 6, - icon: , - title: t('choreView.assignment'), - text: `${t('choreView.assigned')}: ${ - performers.find(p => p.userId === chore.assignedTo)?.displayName || - t('choreView.na') - }`, - subtext: ` ${t('choreView.last')}: ${ - chore.lastCompletedDate - ? performers.find(p => p.userId === chore.lastCompletedBy) - ?.displayName - : 'N/A' - }`, - }, - { - size: 6, - icon: , - title: t('choreView.schedule'), - text: `${t('choreView.due')}: ${ - chore.nextDueDate - ? moment(chore.nextDueDate).fromNow() - : t('choreView.na') - }`, - subtext: `${t('choreView.last')}: ${ - chore.lastCompletedDate - ? moment(chore.lastCompletedDate).fromNow() - : t('choreView.na') - }`, - - subtext2: - chore.deadlineOffset > 0 && chore.nextDueDate - ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` - : null, - }, - { - size: 6, - icon: , - title: t('choreView.statistics'), - text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`, - }, - { - size: 6, - icon: , - title: t('choreView.details'), - subtext: `${t('choreView.createdBy')}: ${ - performers.find(p => p.userId === chore.createdBy)?.displayName || - t('choreView.na') - }`, - }, - ] - setInfoCards(cards) - } const handleTaskCompletion = async () => { try { const resp = await MarkChoreComplete( @@ -279,6 +297,17 @@ const ChoreView = () => { performer: null, }, ) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: impersonatedUser?.userId || userProfile?.id || 0, + performedAt: completedDate || new Date().toISOString(), + dueDate: chore.nextDueDate || null, + notes: note || null, + status: 1, + points: chore.points || 0, + pending: true, + }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ message: "You're offline — completion will sync when back online", @@ -354,6 +383,7 @@ const ChoreView = () => { } } const handleChoreStart = () => { + const startedChore = { ...chore, status: ChoreStatus.ACTIVE } startChore.mutate(choreId, { onSuccess: data => { const newChore = { @@ -362,10 +392,37 @@ const ChoreView = () => { } setChore(newChore) }, + onError: async error => { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + choreId, + { id: choreId }, + ) + setChore(startedChore) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + setChore({ ...chore, status: previousStatus }) + }, + }) + return + } + + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to start task', + }) + }, }) } const handleChorePause = () => { + const pausedChore = { ...chore, status: ChoreStatus.PAUSED } pauseChore.mutate(choreId, { onSuccess: data => { const newChore = { @@ -374,6 +431,32 @@ const ChoreView = () => { } setChore(newChore) }, + onError: async error => { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + choreId, + { id: choreId }, + ) + setChore(pausedChore) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + setChore({ ...chore, status: previousStatus }) + }, + }) + return + } + + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to pause task', + }) + }, }) } diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 39119f3..1c4e3cf 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -17,6 +17,7 @@ import { UpdateChoreAssignee, UpdateDueDate, } from '../../../utils/Fetcher' +import { offlineDB } from '../../../utils/OfflineDB' const isNetworkError = err => err instanceof TypeError && err.message === 'Failed to fetch' @@ -234,6 +235,17 @@ export const useChoreActions = ({ performer: null, }, ) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: chore.id, + completedBy: impersonatedUser?.userId || userProfile?.id || 0, + performedAt: new Date().toISOString(), + dueDate: chore.nextDueDate || null, + notes: null, + status: 1, + points: chore.points || 0, + pending: true, + }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ title: 'Task completion pending', @@ -257,57 +269,107 @@ export const useChoreActions = ({ case 'start': { const startedChore = { ...chore, status: 1 } - startChore.mutate(chore.id, { - 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, - ), - } - }) + try { + await startChore.mutateAsync(chore.id) + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? startedChore : c, + ), + } + }) + updateChoreInState(startedChore, 'started', { + skipInvalidation: true, + }) + } catch (error) { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + chore.id, + { id: chore.id }, + ) updateChoreInState(startedChore, 'started', { skipInvalidation: true, }) - }, - onError: error => { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + updateChoreInState( + { ...chore, status: previousStatus }, + previousStatus === 2 ? 'paused' : 'started', + { skipInvalidation: true }, + ) + }, + }) + } else { showError({ title: 'Failed to start', - message: error.message || 'Unable to start chore', + message: error?.message || 'Unable to start chore', }) - }, - }) + } + } break } case 'pause': { const pausedChore = { ...chore, status: 2 } - pauseChore.mutate(chore.id, { - 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, - ), - } - }) + try { + await pauseChore.mutateAsync(chore.id) + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? pausedChore : c, + ), + } + }) + updateChoreInState(pausedChore, 'paused', { + skipInvalidation: true, + }) + } catch (error) { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + chore.id, + { id: chore.id }, + ) updateChoreInState(pausedChore, 'paused', { skipInvalidation: true, }) - }, - onError: error => { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + updateChoreInState( + { ...chore, status: previousStatus }, + previousStatus === 2 ? 'paused' : 'started', + { skipInvalidation: true }, + ) + }, + }) + } else { showError({ title: 'Failed to pause', - message: error.message || 'Unable to pause chore', + message: error?.message || 'Unable to pause chore', }) - }, - }) + } + } break } diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 17ed410..b7d8543 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -20,10 +20,11 @@ import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy' import moment from 'moment' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useLocalization } from '../../contexts/LocalizationContext' import useConfirmationModal from '../../hooks/useConfirmationModal' +import { usePendingCommands } from '../../hooks/usePendingCommands' import { useChoreHistory, useDeleteChoreHistory, @@ -48,15 +49,33 @@ const ChoreHistory = () => { const { fmt } = useLocalization() const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) - const { showSuccess, showError } = useNotification() + const { showSuccess } = useNotification() // React Query hooks const { data: choreHistoryData, isLoading } = useChoreHistory(choreId) const { data: circleMembersData } = useCircleMembers() const updateChoreHistory = useUpdateChoreHistory() const deleteChoreHistory = useDeleteChoreHistory() + const { data: pendingCmds } = usePendingCommands(choreId) const choreHistory = choreHistoryData?.res || [] const performers = circleMembersData?.res || [] + const pendingByHistoryId = useMemo(() => { + if (!pendingCmds?.length) return {} + return pendingCmds.reduce((acc, cmd) => { + if ( + cmd.commandType !== 'update_chore_history' && + cmd.commandType !== 'delete_chore_history' + ) { + return acc + } + const historyId = + cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1]) + if (!historyId) return acc + if (!acc[historyId]) acc[historyId] = [] + acc[historyId].push(cmd) + return acc + }, {}) + }, [pendingCmds]) const handleDelete = historyEntry => { showConfirmation( @@ -366,6 +385,7 @@ const ChoreHistory = () => { performers={performers} allHistory={choreHistory} index={index} + pendingCommands={pendingByHistoryId[historyEntry.id] || []} onViewNote={notes => { setNoteViewerConfig({ isOpen: true, @@ -407,13 +427,21 @@ const ChoreHistory = () => { }, }, { - onSuccess: () => { + onSuccess: data => { setIsEditModalOpen(false) setEditHistory(null) - showSuccess({ - title: 'History Updated', - message: `The history record has been updated successfully.`, - }) + if (data?.queued) { + showSuccess({ + title: 'History Update Queued', + message: + 'You are offline. The history update will sync when connection is restored.', + }) + } else { + showSuccess({ + title: 'History Updated', + message: `The history record has been updated successfully.`, + }) + } }, onError: error => { console.error('Failed to update chore history:', error) @@ -429,13 +457,21 @@ const ChoreHistory = () => { historyId: editHistory.id, }, { - onSuccess: () => { + onSuccess: data => { setIsEditModalOpen(false) setEditHistory(null) - showSuccess({ - title: 'History Deleted', - message: `The history record has been deleted successfully.`, - }) + if (data?.queued) { + showSuccess({ + title: 'History Delete Queued', + message: + 'You are offline. The history delete will sync when connection is restored.', + }) + } else { + showSuccess({ + title: 'History Deleted', + message: `The history record has been deleted successfully.`, + }) + } }, }, ) diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index cd81f81..81b94e6 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -17,9 +17,14 @@ import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy' import moment from 'moment' import { useLocalization } from '../../contexts/LocalizationContext' import { TASK_COLOR } from '../../utils/Colors.jsx' +import PendingBadge from '../components/PendingBadge' const getCompletedChip = historyEntry => { - if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) { + if ( + historyEntry.status === 0 || + historyEntry.status === 5 || + historyEntry.status === 6 + ) { return null } @@ -94,6 +99,7 @@ const HistoryCard = ({ performers, historyEntry, index, + pendingCommands, onToggleActions, onViewNote, }) => { @@ -330,6 +336,18 @@ const HistoryCard = ({ )} + {pendingCommands?.length > 0 && ( + + )}
) } diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx index f86668e..e2ae0b8 100644 --- a/src/views/Modals/Inputs/ConfirmationModal.jsx +++ b/src/views/Modals/Inputs/ConfirmationModal.jsx @@ -73,7 +73,7 @@ function ConfirmationModal({ config }) { return ( handleAction(false)} size='sm' unmountDelay={250} > diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx index 35ef183..df72c41 100644 --- a/src/views/Settings/DeveloperSettings.jsx +++ b/src/views/Settings/DeveloperSettings.jsx @@ -1,14 +1,23 @@ +import { LocalNotifications } from '@capacitor/local-notifications' import { Refresh, Token } from '@mui/icons-material' import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' -import { useEffect, useState } from 'react' -import { LocalNotifications } from '@capacitor/local-notifications' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { networkManager } from '../../hooks/NetworkManager' +import useConfirmationModal from '../../hooks/useConfirmationModal' import { useSSEContext } from '../../hooks/useSSEContext' import { useNotification } from '../../service/NotificationProvider' import { apiClient } from '../../utils/ApiClient' +import { commandQueue } from '../../utils/CommandQueue' import { RefreshToken } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' +import { syncEngine } from '../../utils/SyncEngine' import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' const DeveloperSettings = () => { + const queryClient = useQueryClient() + const { confirmModalConfig, showConfirmation } = useConfirmationModal() const { isConnected, isConnecting, @@ -31,9 +40,48 @@ const DeveloperSettings = () => { const [isRefreshingDirect, setIsRefreshingDirect] = useState(false) const [scheduledNotifications, setScheduledNotifications] = useState([]) const [isLoadingNotifications, setIsLoadingNotifications] = useState(false) + const [isResettingSync, setIsResettingSync] = useState(false) + const [syncDiagnostics, setSyncDiagnostics] = useState({ + cursor: null, + lastSync: null, + pendingCount: 0, + failedCount: 0, + syncing: false, + syncError: null, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + }) const { showNotification } = useNotification() + const refreshSyncDiagnostics = useCallback(async () => { + try { + const [cursor, lastSync, pendingCommands, failedCommands] = + await Promise.all([ + offlineDB.getSyncCursor(), + offlineDB.getLastSyncTime(), + commandQueue.getPending(), + commandQueue.getFailed(), + ]) + + setSyncDiagnostics(prev => ({ + ...prev, + cursor, + lastSync, + pendingCount: pendingCommands.length, + failedCount: failedCommands.length, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + })) + } catch (error) { + console.error('Failed to load sync diagnostics:', error) + } + }, []) + useEffect(() => { setIsNativePlatform(isNative()) @@ -54,12 +102,8 @@ const DeveloperSettings = () => { const pending = await LocalNotifications.getPending() // Sort by schedule time (earliest first) const sorted = pending.notifications.sort((a, b) => { - const timeA = a.schedule?.at - ? new Date(a.schedule.at).getTime() - : 0 - const timeB = b.schedule?.at - ? new Date(b.schedule.at).getTime() - : 0 + const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0 + const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0 return timeA - timeB }) setScheduledNotifications(sorted) @@ -73,7 +117,39 @@ const DeveloperSettings = () => { loadTokenData() loadScheduledNotifications() - }, []) + refreshSyncDiagnostics() + }, [refreshSyncDiagnostics]) + + useEffect(() => { + const unsubscribeSync = syncEngine.onSyncStateChange(state => { + setSyncDiagnostics(prev => ({ + ...prev, + syncing: + typeof state.syncing === 'boolean' ? state.syncing : prev.syncing, + syncError: state.error ?? prev.syncError, + lastSync: state.lastSync ?? prev.lastSync, + })) + }) + + networkManager.registerNetworkListener(() => { + setSyncDiagnostics(prev => ({ + ...prev, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + })) + }) + + const interval = setInterval(() => { + refreshSyncDiagnostics() + }, 5000) + + return () => { + unsubscribeSync() + clearInterval(interval) + } + }, [refreshSyncDiagnostics]) useEffect(() => { const calculateTimeLeft = () => { @@ -239,6 +315,51 @@ const DeveloperSettings = () => { } } + const handleResetDatabaseAndResync = async () => { + showConfirmation( + 'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?', + 'Clear Local DB & Re-Sync', + async () => { + setIsResettingSync(true) + try { + await offlineDB.clearAll() + + showNotification({ + type: 'success', + message: 'Local offline database cleared. Starting full sync...', + }) + + const didSync = await syncEngine.sync() + if (didSync) { + await queryClient.invalidateQueries() + showNotification({ + type: 'success', + message: 'Full sync completed from the beginning', + }) + } else { + showNotification({ + type: 'warning', + message: + 'Database cleared. Full sync did not run (likely offline or already syncing).', + }) + } + } catch (error) { + console.error('Failed to reset database and resync:', error) + showNotification({ + type: 'error', + message: `Reset/resync failed: ${error.message}`, + }) + } finally { + await refreshSyncDiagnostics() + setIsResettingSync(false) + } + }, + 'Clear & Re-Sync', + 'Cancel', + 'danger', + ) + } + const getNotificationStatusColor = scheduleTime => { if (!scheduleTime) return 'neutral' @@ -252,6 +373,11 @@ const DeveloperSettings = () => { return 'success' // More than 1 hour } + const formatDateTime = timestamp => { + if (!timestamp) return 'N/A' + return new Date(timestamp).toLocaleString() + } + return (
Developer Settings @@ -377,6 +503,143 @@ const DeveloperSettings = () => { + + + + Sync & Network Diagnostics + + + + + + + Network Status + + Connection:{' '} + + {syncDiagnostics.isOnline ? 'Online' : 'Offline'} + + + + Device Network:{' '} + + {syncDiagnostics.isNetworkOn === false + ? 'Disconnected' + : syncDiagnostics.isNetworkOn === true + ? 'Connected' + : 'Unknown'} + + + + Offline Since: {formatDateTime(syncDiagnostics.offlineSince)} + + + Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)} + + + + + + + Sync Offset Information + + Sync Cursor:{' '} + + {syncDiagnostics.cursor ?? 'N/A'} + + + + Last Sync:{' '} + + {formatDateTime(syncDiagnostics.lastSync)} + + + + Sync State:{' '} + + {syncDiagnostics.syncing ? 'Syncing' : 'Idle'} + + + + Pending Commands:{' '} + + {syncDiagnostics.pendingCount} + + + + Failed Commands:{' '} + 0 ? 'danger' : 'success'} + > + {syncDiagnostics.failedCount} + + + {syncDiagnostics.syncError && ( + + Sync Error: {syncDiagnostics.syncError} + + )} + + + + + + Recovery Actions + + Clears local offline cache, sync cursor, and queued commands, then + re-syncs from the beginning. + + + + + + + + {isNativePlatform && ( @@ -436,9 +699,7 @@ const DeveloperSettings = () => { ? new Date(scheduleTime) : null const now = new Date() - const timeUntil = scheduledDate - ? scheduledDate - now - : null + const timeUntil = scheduledDate ? scheduledDate - now : null return ( { + +
) } diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index 349e15b..5cd8d9e 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -47,10 +47,14 @@ import { } from '../../queries/TimeQueries' import { useCircleMembers } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' +import { commandQueue, CommandType } from '../../utils/CommandQueue' import { resolvePhotoURL } from '../../utils/Helpers' import { getSafeBottom } from '../../utils/SafeAreaUtils' import LoadingComponent from '../components/Loading' +const isNetworkError = err => + err instanceof TypeError && err.message === 'Failed to fetch' + const TimerDetails = () => { const { choreId } = useParams() const { fmt } = useLocalization() @@ -256,7 +260,23 @@ const TimerDetails = () => { }) refetchTimer() }, - onError: () => { + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + choreId, + { id: choreId }, + ) + showSuccess({ + title: 'Start queued', + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + }, + }) + return + } + showError({ title: 'Failed to start timer', message: 'Please try again.', @@ -278,7 +298,23 @@ const TimerDetails = () => { }) refetchTimer() }, - onError: () => { + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + choreId, + { id: choreId }, + ) + showSuccess({ + title: 'Pause queued', + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + }, + }) + return + } + showError({ title: 'Failed to pause timer', message: 'Please try again.', @@ -928,9 +964,7 @@ const TimerDetails = () => { 'MMM DD', ) const startTime = fmt.time(pause.start) - const endTime = pause.end - ? fmt.time(pause.end) - : null + const endTime = pause.end ? fmt.time(pause.end) : null const realTimeDuration = isOngoing ? Math.max( diff --git a/src/views/components/PendingBadge.jsx b/src/views/components/PendingBadge.jsx index 8b659ce..aba2ace 100644 --- a/src/views/components/PendingBadge.jsx +++ b/src/views/components/PendingBadge.jsx @@ -20,6 +20,8 @@ const LABELS = { update_chore: 'Update pending', create_chore: 'Create pending', delete_chore: 'Delete pending', + update_chore_history: 'Edit history pending', + delete_chore_history: 'Delete history pending', reschedule_chore: 'Reschedule pending', archive_chore: 'Archive pending', unarchive_chore: 'Restore pending', @@ -27,6 +29,16 @@ const LABELS = { pause_chore: 'Pause pending', } +const formatCommandLabel = commandType => { + return ( + LABELS[commandType] || + commandType + ?.replace(/_/g, ' ') + ?.replace(/\b\w/g, letter => letter.toUpperCase()) || + 'Pending action' + ) +} + function PendingBadge({ commands, size = 'sm', sx = {} }) { const { ResponsiveModal } = useResponsiveModal() const queryClient = useQueryClient() @@ -45,6 +57,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) { const invalidatePending = async () => { await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) await queryClient.invalidateQueries({ queryKey: ['chores'] }) + await queryClient.invalidateQueries({ queryKey: ['choreHistory'] }) } const handleUndo = async (e, cmdId) => { @@ -150,7 +163,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) { > - {LABELS[cmd.commandType] || 'Pending action'} + {formatCommandLabel(cmd.commandType)} {new Date(cmd.createdAt).toLocaleString()} diff --git a/src/views/components/SyncStatusIndicator.jsx b/src/views/components/SyncStatusIndicator.jsx index aaf5645..f8ef7e7 100644 --- a/src/views/components/SyncStatusIndicator.jsx +++ b/src/views/components/SyncStatusIndicator.jsx @@ -1,5 +1,6 @@ import { CheckCircleOutline, + ClearAll, CloudDone, CloudQueue, CloudSync, @@ -34,14 +35,28 @@ import { syncEngine } from '../../utils/SyncEngine' const COMMAND_LABELS = { create_chore: 'Create chore', update_chore: 'Update chore', + update_chore_history: 'Edit history', complete_chore: 'Complete chore', skip_chore: 'Skip chore', + start_chore: 'Start chore', + pause_chore: 'Pause chore', delete_chore: 'Delete chore', + delete_chore_history: 'Delete history', reschedule_chore: 'Reschedule chore', archive_chore: 'Archive chore', unarchive_chore: 'Restore chore', } +const formatCommandLabel = commandType => { + return ( + COMMAND_LABELS[commandType] || + commandType + ?.replace(/_/g, ' ') + ?.replace(/\b\w/g, letter => letter.toUpperCase()) || + 'Pending action' + ) +} + const RETRY_INTERVAL = 30 function SyncStatusIndicator() { @@ -131,6 +146,16 @@ function SyncStatusIndicator() { await refreshCommands() } + const handleCancelAll = async () => { + const [pending, failed] = await Promise.all([ + commandQueue.getPending(), + commandQueue.getFailed(), + ]) + const allCommands = [...pending, ...failed] + await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.id))) + await refreshCommands() + } + const formatTime = timestamp => { if (!timestamp) return 'Never' const seconds = Math.floor((Date.now() - timestamp) / 1000) @@ -323,7 +348,7 @@ function SyncStatusIndicator() { }} > - {COMMAND_LABELS[type] || type} + {formatCommandLabel(type)} {count} @@ -375,7 +400,7 @@ function SyncStatusIndicator() { fontWeight: 500, }} > - {COMMAND_LABELS[cmd.commandType] || cmd.commandType} + {formatCommandLabel(cmd.commandType)} + + ) + } + + return ( + <> + + {nfcStatus === 'error' + ? errorMessage + : 'Press the button below to write to NFC.'} + + { + navigator.clipboard.writeText(getURL()) + alert('URL copied to clipboard!') + }} + /> + } + /> + + setIsAutoCompleteWhenScan(e.target.checked)} + label='Auto-complete when scanned' + /> + + + + + + ) } + return ( {nfcStatus === 'success' ? 'Success!' : 'Write to NFC'} - - {nfcStatus === 'success' ? ( - - URL written to NFC tag successfully! - - ) : ( - <> - - {nfcStatus === 'error' - ? errorMessage - : 'Press the button below to write to NFC.'} - - { - navigator.clipboard.writeText(getURL()) - alert('URL copied to clipboard!') - }} - /> - } - /> - - setIsAutoCompleteWhenScan(e.target.checked)} - label='Auto-complete when scanned' - /> - - - - - - - )} + {renderBody()} ) } From 08f7e4900c73c6e4a5f94d6142c4e49bdda8c947 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:48:05 -0400 Subject: [PATCH 26/37] Improve anyone UX --- .editorconfig | 9 ++++++ .github/workflows/build.yml | 2 -- src/views/ChoreEdit/ChoreEdit.jsx | 51 +++++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fb081ea --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# EditorConfig is awesome: https://EditorConfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8087038..a49b178 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,7 @@ name: Build validation on: push: - branches: [ "main", "develop" ] pull_request: - branches: [ "main", "develop" ] jobs: build: diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 8dcf06b..50eee74 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -83,7 +83,8 @@ const ChoreEdit = () => { const [name, setName] = useState('') const [description, setDescription] = useState('') const [confirmModelConfig, setConfirmModelConfig] = useState({}) - const [assignees, setAssignees] = useState([]) + const [anyone, setAnyone] = useState(false) + const [assignableTo, setAssignableTo] = useState([]) const [performers, setPerformers] = useState([]) const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2]) const [dueDate, setDueDate] = useState(null) @@ -158,6 +159,7 @@ const ChoreEdit = () => { const Navigate = useNavigate() + const assignees = anyone ? performers : assignableTo const HandleValidateChore = () => { const errors = {} @@ -330,6 +332,7 @@ const ChoreEdit = () => { if (searchParams.get('clone') === 'true') { newChoreId = null } + const assignees = anyone ? [] : assignableTo const chore = { id: Number(newChoreId), name: name, @@ -407,15 +410,29 @@ const ChoreEdit = () => { setIsNotificable(JSON.parse(defaultNotificationSetting)) } + const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting') + if (defaultAnyoneSetting != null) { + const savedAnyone = JSON.parse(defaultAnyoneSetting) + setAnyone(savedAnyone) + } + const defaultAssigneeSetting = localStorage.getItem( 'defaultAssigneeSetting', ) if (defaultAssigneeSetting !== null) { const savedAssignees = JSON.parse(defaultAssigneeSetting) - setAssignees(savedAssignees) + setAssignableTo(savedAssignees) } } }, []) + useEffect(() => { + const anyoneSetting = localStorage.getItem('defaultAnyoneSetting') + const anyoneDirty = anyoneSetting !== JSON.stringify(anyone) + const assigneeSetting = localStorage.getItem('defaultAssigneeSetting') + const assigneeDirty = assigneeSetting !== JSON.stringify(assignableTo) + const dirty = anyoneDirty || (!anyone && assigneeDirty) + setShowSaveAssigneeDefault(dirty) + }, [anyone, assignableTo]) // Keyboard shortcuts useEffect(() => { @@ -465,7 +482,8 @@ const ChoreEdit = () => { setChore(data.res) setName(data.res.name ? data.res.name : '') setDescription(data.res.description ? data.res.description : '') - setAssignees(data.res.assignees ? data.res.assignees : []) + setAssignableTo(data.res.assignees ? data.res.assignees : []) + setAnyone((data.res.assignees?.length || 0) === 0) setAssignedTo(data.res.assignedTo) setFrequencyType(data.res.frequencyType ? data.res.frequencyType : 'once') @@ -585,7 +603,7 @@ const ChoreEdit = () => { setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed } } - }, [assignees, assignStrategy]) + }, [assignStrategy, assignedTo, assignees]) // useEffect(() => { // if (performers.length > 0 && assignees.length === 0 && userProfile) { @@ -602,7 +620,7 @@ const ChoreEdit = () => { if (attemptToSave) { HandleValidateChore() } - }, [assignees, name, frequencyMetadata, attemptToSave, dueDate]) + }, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate]) const handleDelete = () => { setConfirmModelConfig({ @@ -929,9 +947,9 @@ const ChoreEdit = () => { { - setAssignees([]) + setAnyone(!anyone) setIsPrivate(false) }} overlay @@ -944,10 +962,16 @@ const ChoreEdit = () => { {performers?.map((item, index) => ( a.userId == item.userId) != null - } + checked={assignableTo.some(a => a.userId == item.userId)} + disabled={anyone} onClick={() => { + if (anyone) { + setAnyone(false) + setAssignableTo([{ userId: item.userId }]) + return + } + const assignees = assignableTo + const setAssignees = setAssignableTo if (assignees.some(a => a.userId === item.userId)) { const newAssignees = assignees.filter( a => a.userId !== item.userId, @@ -956,7 +980,6 @@ const ChoreEdit = () => { } else { setAssignees([...assignees, { userId: item.userId }]) } - setShowSaveAssigneeDefault(true) }} overlay disableIcon @@ -986,9 +1009,13 @@ const ChoreEdit = () => { }, }} onClick={() => { + localStorage.setItem( + 'defaultAnyoneSetting', + JSON.stringify(anyone), + ) localStorage.setItem( 'defaultAssigneeSetting', - JSON.stringify(assignees), + JSON.stringify(assignableTo), ) setShowSaveAssigneeDefault(false) }} From aeb391674125770fa11b98d33307353f8a194915 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:55:53 -0400 Subject: [PATCH 27/37] Set anyone when everyone is deselected --- src/views/ChoreEdit/ChoreEdit.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 50eee74..0f63e15 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -976,6 +976,7 @@ const ChoreEdit = () => { const newAssignees = assignees.filter( a => a.userId !== item.userId, ) + setAnyone(newAssignees.length === 0) setAssignees(newAssignees) } else { setAssignees([...assignees, { userId: item.userId }]) From 878031cae84dc010be60aecd05d5a9ab54e15aa0 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 27 Jun 2026 22:19:12 -0400 Subject: [PATCH 28/37] Fix: Make sure notification skip for achieve tasks https://github.com/donetick/donetick/issues/695 --- src/views/Chores/LocalNotificationScheduler.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/views/Chores/LocalNotificationScheduler.js b/src/views/Chores/LocalNotificationScheduler.js index 7ca1968..d5f5572 100644 --- a/src/views/Chores/LocalNotificationScheduler.js +++ b/src/views/Chores/LocalNotificationScheduler.js @@ -190,7 +190,11 @@ const scheduleChoreNotification = async ( for (let i = 0; i < chores.length; i++) { const chore = chores[i] try { - if (chore.notification === false || chore.nextDueDate === null) { + if ( + chore.notification === false || + chore.nextDueDate === null || + chore.isActive === false + ) { continue } scheduleNotificationFromTemplate( From b2fdb049e279a20acb4e0dbeb2c960aa150ffbb4 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 27 Jun 2026 22:22:57 -0400 Subject: [PATCH 29/37] Fix: update chore state handling on delete and archive actions Response from backend just say sucessfull so we need to use the chore optimisitcally --- src/views/Chores/hooks/useChoreActions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 6bf279e..25b5820 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -420,8 +420,8 @@ export const useChoreActions = ({ c => c.id !== chore.id, ) setChores(newChores) - updateChoreInState(chore.id, 'deleted') setFilteredChores(newFilteredChores) + queryClient.invalidateQueries(['chores']) showSuccess({ title: 'Task Deleted', message: 'The task has been deleted successfully.', @@ -471,7 +471,7 @@ export const useChoreActions = ({ await new Promise((resolve, reject) => { archiveChore.mutate(chore.id, { onSuccess: data => { - updateChoreInState(data, 'archive') + updateChoreInState(chore, 'archive') resolve(data) }, onError: async error => { From 489d46e922ae7eace3cd0cd46d981507d06f0d4c Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 27 Jun 2026 22:24:15 -0400 Subject: [PATCH 30/37] Fix : Display better login errors --- src/hooks/useAuth.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 4154abb..6fcc744 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => { // Ensure apiClient is initialized with the correct URL await apiClient.init() const currentBaseURL = apiClient.getApiURL() - + const isNative = typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.() @@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => { const response = await fetch(`${currentBaseURL}/auth/login`, config) if (!response.ok) { - const error = await response.json() - return { success: false, error: error.message || 'Login failed' } + const res = await response.json() + return { success: false, error: res?.error || 'Login failed' } } const data = await response.json() From e85220d727e28855f5cd54bf52bea96b34655780 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 30 Jun 2026 01:17:52 -0400 Subject: [PATCH 31/37] Fix: Changing URL for backend requiring restart. Fix: Add better feedback so user know if the url invalid or unreachable --- src/views/Authorization/LoginSettings.jsx | 242 +++++++++++++++------- 1 file changed, 172 insertions(+), 70 deletions(-) diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 1a85de3..e8fe85e 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -1,17 +1,32 @@ import { Preferences } from '@capacitor/preferences' -import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import WifiIcon from '@mui/icons-material/Wifi' +import { + Alert, + Box, + Button, + CircularProgress, + Container, + Input, + Sheet, + Typography, +} from '@mui/joy' import React from 'react' import { useNavigate } from 'react-router-dom' import { API_URL } from '../../Config' import Logo from '../../Logo' import { useResource } from '../../queries/ResourceQueries' -import { useNotification } from '../../service/NotificationProvider' import { apiClient } from '../../utils/ApiClient' + +const CONNECTION_TIMEOUT_MS = 8000 + const LoginSettings = () => { const Navigate = useNavigate() const { refetch: refetchResource } = useResource() const [serverURL, setServerURL] = React.useState('') - const { showError } = useNotification() + const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error' + const [errorMessage, setErrorMessage] = React.useState('') React.useEffect(() => { Preferences.get({ key: 'customServerUrl' }).then(result => { @@ -19,10 +34,95 @@ const LoginSettings = () => { }) }, []) - const isValidServerURL = () => { - return serverURL.match(/^(http|https):\/\/[^ "]+$/) + const isValidURL = url => { + return /^(http|https):\/\/[^ "]+$/.test(url.trim()) } + const testConnection = async url => { + const controller = new AbortController() + const timeoutId = setTimeout( + () => controller.abort(), + CONNECTION_TIMEOUT_MS, + ) + try { + const testURL = url.replace(/\/+$/, '') + '/api/v1/resource' + const response = await fetch(testURL, { + method: 'GET', + signal: controller.signal, + }) + clearTimeout(timeoutId) + // Any HTTP response (even 401/404) means the server is reachable + if (response.status < 500) { + return { ok: true } + } + return { + ok: false, + message: `Server responded with error ${response.status}. Please check your Donetick server.`, + } + } catch (err) { + clearTimeout(timeoutId) + if (err.name === 'AbortError') { + return { + ok: false, + message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`, + } + } + return { + ok: false, + message: + 'Unable to reach the server. Check the URL, port, and network connection.', + } + } + } + + const handleSave = async () => { + const trimmedURL = serverURL.trim() + + if (trimmedURL === '') { + await Preferences.set({ key: 'customServerUrl', value: API_URL }) + Navigate('/login') + return + } + + if (!isValidURL(trimmedURL)) { + setStatus('error') + setErrorMessage( + 'Invalid URL format. Include the protocol (http:// or https://) and port if needed.', + ) + return + } + + setStatus('testing') + setErrorMessage('') + + const result = await testConnection(trimmedURL) + + if (!result.ok) { + setStatus('error') + setErrorMessage(result.message) + return + } + + await Preferences.set({ key: 'customServerUrl', value: trimmedURL }) + await apiClient.init(true) + refetchResource() + setStatus('success') + + setTimeout(() => { + Navigate('/login') + }, 1200) + } + + const handleURLChange = e => { + setServerURL(e.target.value) + if (status !== 'idle') { + setStatus('idle') + setErrorMessage('') + } + } + + const isTesting = status === 'testing' + return ( { sx={{ mt: 1, width: '100%', - display: 'flex', flexDirection: 'column', alignItems: 'center', @@ -51,13 +150,7 @@ const LoginSettings = () => { Done - - tick - + tick @@ -71,9 +164,22 @@ const LoginSettings = () => { name='serverURL' autoFocus value={serverURL} - onChange={e => { - setServerURL(e.target.value) - }} + onChange={handleURLChange} + disabled={isTesting} + color={ + status === 'success' + ? 'success' + : status === 'error' + ? 'danger' + : 'neutral' + } + endDecorator={ + status === 'success' ? ( + + ) : status === 'error' ? ( + + ) : null + } /> @@ -81,72 +187,68 @@ const LoginSettings = () => { own self-hosted Donetick server. - Please ensure to include the protocol (http:// or https://) and the - port number if necessary (default Donetick port is 2021). + Include the protocol (http:// or https://) and port if necessary + (default Donetick port is 2021). + + {status === 'error' && ( + } + sx={{ mt: 2, width: '100%' }} + > + {errorMessage} + + )} + + {status === 'success' && ( + } + sx={{ mt: 2, width: '100%' }} + > + Connected! Redirecting to login... + + )} + + {status === 'testing' && ( + } + sx={{ mt: 2, width: '100%' }} + > + Testing connection to server... + + )} + )} + + { + setMessage('Authentication failed') + setSubMessage('Two-factor authentication failed. Please try again') + }} + /> ) From f11a1fd9cc996724eded4c3c601637116d463f9a Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 2 Jul 2026 20:51:53 -0400 Subject: [PATCH 34/37] feat: add DueDatePickerPreview component for managing due dates and times feat: implement LabelsPickerField component for selecting multiple labels feat: create NotificationPickerField component for managing notification reminders feat: add PriorityPickerField component for selecting task priority levels feat: implement ProjectPickerField component for selecting projects feat: create RepeatPickerField component for setting up recurring tasks feat: add RepeatPickerPreview component for displaying and selecting repeat options --- src/views/ChoreEdit/RepeatSection.jsx | 2 +- src/views/components/AddTaskModal.jsx | 302 +++++---- src/views/components/AssigneePickerField.jsx | 43 ++ .../components/AssigneePickerPreview.jsx | 40 ++ .../components/AttachmentPickerField.jsx | 258 +++++++ src/views/components/BaseOptionPicker.jsx | 253 +++++++ src/views/components/DueDatePickerField.jsx | 304 +++++++++ src/views/components/DueDatePickerPreview.jsx | 221 ++++++ src/views/components/LabelsPickerField.jsx | 48 ++ .../components/NotificationPickerField.jsx | 160 +++++ src/views/components/PriorityPickerField.jsx | 74 ++ src/views/components/ProjectPickerField.jsx | 48 ++ src/views/components/RepeatPickerField.jsx | 639 ++++++++++++++++++ src/views/components/RepeatPickerPreview.jsx | 211 ++++++ 14 files changed, 2452 insertions(+), 151 deletions(-) create mode 100644 src/views/components/AssigneePickerField.jsx create mode 100644 src/views/components/AssigneePickerPreview.jsx create mode 100644 src/views/components/AttachmentPickerField.jsx create mode 100644 src/views/components/BaseOptionPicker.jsx create mode 100644 src/views/components/DueDatePickerField.jsx create mode 100644 src/views/components/DueDatePickerPreview.jsx create mode 100644 src/views/components/LabelsPickerField.jsx create mode 100644 src/views/components/NotificationPickerField.jsx create mode 100644 src/views/components/PriorityPickerField.jsx create mode 100644 src/views/components/ProjectPickerField.jsx create mode 100644 src/views/components/RepeatPickerField.jsx create mode 100644 src/views/components/RepeatPickerPreview.jsx diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx index a7d3c44..4cb4fa9 100644 --- a/src/views/ChoreEdit/RepeatSection.jsx +++ b/src/views/ChoreEdit/RepeatSection.jsx @@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => { return `Every ${dayNames} at ${timeStr}` } -const RepeatOnSections = ({ +export const RepeatOnSections = ({ frequencyType, frequency, onFrequencyUpdate, diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 806a897..1bb2e07 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,15 +1,6 @@ -import { Add, EditNotifications } from '@mui/icons-material' -import { - Box, - Button, - Checkbox, - FormHelperText, - Input, - Option, - Select, - Typography, -} from '@mui/joy' -import { FormControl } from '@mui/material' +import { Add } from '@mui/icons-material' +import { Box, Button, Typography } from '@mui/joy' +import { useMediaQuery } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useRef, useState } from 'react' @@ -30,8 +21,15 @@ import { import SmartTaskTitleInput from './SmartTaskTitleInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' -import NotificationTemplate from '../../components/NotificationTemplate' +import { TASK_COLOR } from '../../utils/Colors' +import AssigneePickerField from './AssigneePickerField' +import AttachmentPickerField from './AttachmentPickerField' +import DueDatePickerField from './DueDatePickerField' +import LabelsPickerField from './LabelsPickerField' import LearnMoreButton from './LearnMore' +import NotificationPickerField from './NotificationPickerField' +import PriorityPickerField from './PriorityPickerField' +import RepeatPickerField from './RepeatPickerField' import RichTextEditor from './RichTextEditor' import SubTasks from './SubTask' const getDefaultNotification = () => { @@ -54,6 +52,8 @@ const getDefaultNotification = () => { const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const { ResponsiveModal } = useResponsiveModal() + const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) + const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text' const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: circleMembers, isLoading: isCircleMembersLoading } = useCircleMembers() @@ -98,7 +98,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [isAnyoneTask, setIsAnyoneTask] = useState(false) const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) - const [hasNotifications, setHasNotifications] = useState(false) const [hasDeadline, setHasDeadline] = useState(false) const [deadlineOffset, setDeadlineOffset] = useState(-1) const [dueDateOnly, setDueDateOnly] = useState(null) @@ -106,6 +105,24 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [useCustomTime, setUseCustomTime] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject()) + const [attachments, setAttachments] = useState([]) + + // Priority colors + const priorityColors = { + 0: TASK_COLOR.NO_PRIORITY, + 1: TASK_COLOR.PRIORITY_1, + 2: TASK_COLOR.PRIORITY_2, + 3: TASK_COLOR.PRIORITY_3, + 4: TASK_COLOR.PRIORITY_4, + } + + const priorityLabels = { + 0: '--', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'P4', + } // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { @@ -262,20 +279,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { resolvedHighlights.push(current) } } else { - // No overlap, add the current highlight resolvedHighlights.push(current) } } for (const highlight of resolvedHighlights) { - // Add the text before the highlight if (highlight.start > lastIndex) { const textBefore = sentence.substring(lastIndex, highlight.start) parts.push(textBefore) plainText += textBefore } - // Determine the class name based on the highlight type let className = '' switch (highlight.type) { case 'repeat': @@ -300,7 +314,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { break } - // Add the highlighted span const highlightedText = sentence.substring( highlight.start, highlight.end, @@ -310,9 +323,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { key={highlight.start} className={className} style={{ - // text underline: textDecoration: 'underline', - // textDecorationColor: 'red', textDecorationThickness: '2px', textDecorationStyle: 'dashed', }} @@ -321,11 +332,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { , ) - // Update the last index to the end of the current highlight lastIndex = highlight.end } - // Add any remaining text after the last highlight if (lastIndex < sentence.length) { const remainingText = sentence.substring(lastIndex) parts.push(remainingText) @@ -342,12 +351,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const processText = useCallback( sentence => { - // Parse everything from the original sentence to get correct highlight positions const priority = parsePriority(sentence) const pointsParsed = parsePoints(sentence) const labels = parseLabels(sentence, userLabels || []) - // Parse assignees using circle members const circleMembersList = circleMembers?.res || [] const assigneesForParsing = circleMembersList.map(member => ({ userId: member.userId, @@ -364,9 +371,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const dueDateParsed = parseDueDate(sentence, chrono) // Set all the parsed values - if (priority.result) setPriority(priority.result) + if (priority.result) setPriority(parseInt(priority.result, 10)) if (pointsParsed.result) setPoints(pointsParsed.result) - if (labels.result) setLabelsV2(labels.result) + if (labels.result) { + // parseLabels returns array of label objects, extract their IDs + const labelIds = labels.result + .filter(label => label.id) // Only labels with IDs (existing labels) + .map(label => label.id) + setLabelsV2(labelIds) + } if (assigneesResult.isAnyone) { // @Anyone was used - set empty assignees (anyone can do the task) @@ -500,6 +513,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'), ) } else { + setUseCustomTime(false) + setDueTime(null) setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss')) } } @@ -508,9 +523,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const timeValue = e.target.value setDueTime(timeValue) if (dueDateOnly) { - setDueDate( - moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'), - ) + if (timeValue) { + setUseCustomTime(true) + setDueDate( + moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'), + ) + } else { + setUseCustomTime(false) + setDueDate( + moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), + ) + } } } @@ -518,13 +541,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setUseCustomTime(checked) if (checked) { const defaultTime = dueTime || '18:00' - setDueTime(defaultTime) + if (!dueTime) { + setDueTime(defaultTime) + } if (dueDateOnly) { setDueDate( moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'), ) } } else { + setDueTime(null) if (dueDateOnly) { setDueDate( moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), @@ -605,6 +631,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { notificationMetadata: {}, subTasks: subTasks?.length > 0 ? subTasks : null, projectId: projectId === 'default' ? null : projectId, + attachments: attachments.length > 0 ? attachments : null, } if (frequency) { @@ -793,6 +820,93 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { }} />
+ + { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + }} + /> + setFrequency(null)} + /> + setPriority(0)} + emptyDisplay={pickerEmptyDisplay} + priorityColors={priorityColors} + priorityLabels={priorityLabels} + /> + { + if (!userId) { + setAssignees([]) + } else { + setAssignees([{ userId }]) + } + }} + onClear={() => setAssignees([])} + currentUserId={userProfile?.id} + members={circleMembers?.res || []} + /> + + setLabelsV2([])} + labels={userLabels || []} + // emptyDisplay='icon-text' + /> + {/* setProjectId(getInitialProject())} there should be no option to unselect a project, so we don't need an onClear handler + projects={projects || []} + emptyDisplay={pickerEmptyDisplay} + /> */} + setAttachments([])} + emptyDisplay={pickerEmptyDisplay} + entityType='chore_attachment' + /> + setNotificationMetadata({ templates: [] })} + emptyDisplay={pickerEmptyDisplay} + /> + {/* Title: { /> */} - + {!hasDescription && ( )} - {!dueDate && ( - - )} - {!hasNotifications && dueDate && ( - - )} + {/* {!hasDeadline && dueDate && ( + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {attachments.length > 0 && ( + + {attachments.map((attachment, index) => ( + + { + e.target.style.display = 'none' + e.target.nextSibling.style.display = 'flex' + }} + /> + + + + + {attachment.name} + + handleRemove(index)} + sx={{ flexShrink: 0 }} + > + + + + ))} + + )} + + + + + + )} + + ) +} + +export default AttachmentPickerField diff --git a/src/views/components/BaseOptionPicker.jsx b/src/views/components/BaseOptionPicker.jsx new file mode 100644 index 0000000..c176020 --- /dev/null +++ b/src/views/components/BaseOptionPicker.jsx @@ -0,0 +1,253 @@ +import { Close } from '@mui/icons-material' +import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy' +import { ClickAwayListener, Popper } from '@mui/material' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + +const BaseOptionPicker = ({ + items = [], + value = null, + values = [], + multiple = false, + onChange, + onValuesChange, + emptyDisplay = 'icon', + emptyLabel = 'Select', + placement = 'top-start', + menuMinWidth = 180, + menuMaxHeight = 280, + getItemValue = item => item.id, + getItemLabel = item => item.label, + renderItemStart, + renderTriggerIcon, + getItemColor, + getTriggerText, + onClear, +}) => { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleEscape = event => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const selectedItems = useMemo(() => { + if (multiple) { + const selectedSet = new Set(values) + return items.filter(item => selectedSet.has(getItemValue(item))) + } + + if (value === null || value === undefined) return [] + return items.filter(item => getItemValue(item) === value) + }, [items, multiple, value, values, getItemValue]) + + const isEmpty = selectedItems.length === 0 + const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text' + + const triggerText = getTriggerText + ? getTriggerText({ selectedItems, isEmpty }) + : isEmpty + ? emptyLabel + : getItemLabel(selectedItems[0]) + + const triggerColor = isEmpty + ? undefined + : getItemColor + ? getItemColor(selectedItems[0]) + : undefined + + const handleSelect = selectedValue => { + if (multiple) { + const selectedSet = new Set(values) + if (selectedSet.has(selectedValue)) { + selectedSet.delete(selectedValue) + } else { + selectedSet.add(selectedValue) + } + onValuesChange?.(Array.from(selectedSet)) + return + } + + onChange?.(selectedValue) + setIsOpen(false) + } + + const isSelected = item => { + const optionValue = getItemValue(item) + if (multiple) { + return values.includes(optionValue) + } + return value === optionValue + } + + const handleClear = e => { + e.stopPropagation() + onClear?.() + } + + return ( + <> + + + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {items.map((item, index) => { + const optionValue = getItemValue(item) + const selected = isSelected(item) + const itemColor = getItemColor ? getItemColor(item) : undefined + + return ( + + ) + })} + + + + )} + + ) +} + +export default BaseOptionPicker diff --git a/src/views/components/DueDatePickerField.jsx b/src/views/components/DueDatePickerField.jsx new file mode 100644 index 0000000..6410f8e --- /dev/null +++ b/src/views/components/DueDatePickerField.jsx @@ -0,0 +1,304 @@ +import { + CalendarMonth, + Close, + NextWeek, + Today, + WbSunny, + Weekend, +} from '@mui/icons-material' +import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy' +import { ClickAwayListener, Popper } from '@mui/material' +import moment from 'moment' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + +const DueDatePickerField = ({ + dueDateOnly, + dueTime, + useCustomTime, + onDueDateChange, + onDueTimeChange, + onUseCustomTimeChange, + onClear, + emptyDisplay = 'icon-text', + size = 'sm', +}) => { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + const getQuickScheduleDate = option => { + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + + switch (option) { + case 'today': + return today + case 'tomorrow': { + const tomorrow = new Date(today) + tomorrow.setDate(today.getDate() + 1) + return tomorrow + } + case 'weekend': { + const weekend = new Date(today) + const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7 + weekend.setDate(today.getDate() + daysUntilSaturday) + return weekend + } + case 'next-week': { + const nextWeek = new Date(today) + const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7 + nextWeek.setDate(today.getDate() + daysUntilMonday) + return nextWeek + } + default: + return today + } + } + + const handleQuickSchedule = option => { + const date = getQuickScheduleDate(option) + const dateStr = date.toISOString().split('T')[0] + onDueDateChange?.({ target: { value: dateStr } }) + setIsOpen(false) + } + + useEffect(() => { + if (!isOpen) return + + const handleEscape = event => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const hasDueDate = Boolean(dueDateOnly) + const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text' + + const dueDateLabel = useMemo(() => { + if (!dueDateOnly) { + return 'Due' + } + + const formattedDate = moment(dueDateOnly).format('MMM D') + if (useCustomTime && dueTime) { + return `${formattedDate}, ${dueTime}` + } + + return formattedDate + }, [dueDateOnly, dueTime, useCustomTime]) + + return ( + + + {hasDueDate && onClear && ( + { + e.stopPropagation() + onClear?.() + }} + sx={{ + position: 'absolute', + top: -12, + right: -16, + zIndex: 10, + maxHeight: 18, + maxWidth: 18, + borderRadius: '50%', + '&:hover': { + bgcolor: 'danger.softBg', + }, + }} + > + + + )} + + {isOpen && ( + + setIsOpen(false)}> + + + Due Date + + + + + + + + + + Due time (optional) + + { + if (!useCustomTime) { + onUseCustomTimeChange?.(true) + } + onDueTimeChange?.(e) + }} + sx={{ maxWidth: 200, mb: 1 }} + /> + + + + + {hasDueDate && ( + + )} + + + + )} + + ) +} + +export default DueDatePickerField diff --git a/src/views/components/DueDatePickerPreview.jsx b/src/views/components/DueDatePickerPreview.jsx new file mode 100644 index 0000000..989b59a --- /dev/null +++ b/src/views/components/DueDatePickerPreview.jsx @@ -0,0 +1,221 @@ +import { CalendarMonth, Close } from '@mui/icons-material' +import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy' +import { ClickAwayListener, Popper } from '@mui/material' +import moment from 'moment' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + +const DueDatePickerPreview = ({ + dueDateOnly, + dueTime, + useCustomTime, + onDueDateChange, + onDueTimeChange, + onUseCustomTimeChange, + onClear, + emptyDisplay = 'icon-text', + size = 'sm', +}) => { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleEscape = event => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const hasDueDate = Boolean(dueDateOnly) + const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text' + + const dueDateLabel = useMemo(() => { + if (!dueDateOnly) { + return 'Due' + } + + const formattedDate = moment(dueDateOnly).format('MMM D') + if (useCustomTime && dueTime) { + return `${formattedDate}, ${dueTime}` + } + + return formattedDate + }, [dueDateOnly, dueTime, useCustomTime]) + + return ( + + + {hasDueDate && onClear && ( + { + e.stopPropagation() + onClear?.() + }} + sx={{ + position: 'absolute', + top: -12, + right: -16, + zIndex: 10, + maxHeight: 18, + maxWidth: 18, + borderRadius: '50%', + '&:hover': { + bgcolor: 'danger.softBg', + }, + }} + > + + + )} + + {isOpen && ( + + setIsOpen(false)}> + + + Due Date + + + + Due time (optional) + + { + if (!useCustomTime) { + onUseCustomTimeChange?.(true) + } + onDueTimeChange?.(e) + }} + sx={{ maxWidth: 200, mb: 1 }} + /> + + + + + {hasDueDate && ( + + )} + + + + )} + + ) +} + +export default DueDatePickerPreview diff --git a/src/views/components/LabelsPickerField.jsx b/src/views/components/LabelsPickerField.jsx new file mode 100644 index 0000000..bf74602 --- /dev/null +++ b/src/views/components/LabelsPickerField.jsx @@ -0,0 +1,48 @@ +import { Label } from '@mui/icons-material' +import BaseOptionPicker from './BaseOptionPicker' + +const LabelsPickerField = ({ + values = [], + onChange, + onClear, + labels = [], + emptyDisplay = 'icon-text', +}) => { + const options = labels.map(label => ({ + id: label.id, + name: label.name, + color: label.color, + })) + + return ( + item.id} + getItemLabel={item => item.name} + getItemColor={item => item.color} + renderTriggerIcon={() => )} - - {/* {projects.length >= 1 && ( - - Project - - - )} */} - - {/* - Assignees - - {assignees.length > 0 ? ( - assignees.map((assignee, index) => ( - - {assignee.displayName || assignee.username} - - )) - ) : ( - - {userProfile.displayName} - - )} - - */} - {/* {hasDeadline && dueDate && ( - - Deadline - - - after due date - - - )} */} - ) } diff --git a/src/views/components/SmartTaskTitleInput.css b/src/views/components/SmartTaskTitleInput.css index 34ebe4a..c2bb002 100644 --- a/src/views/components/SmartTaskTitleInput.css +++ b/src/views/components/SmartTaskTitleInput.css @@ -1,3 +1,18 @@ +:root, +[data-joy-color-scheme='light'] { + --highlight-date-color: #b45309; + --highlight-repeat-color: #15803d; + --highlight-label-color: #1d4ed8; + --highlight-priority-color: #be123c; +} + +[data-joy-color-scheme='dark'] { + --highlight-date-color: #fca5a5; + --highlight-repeat-color: #86efac; + --highlight-label-color: #93c5fd; + --highlight-priority-color: #f9a8d4; +} + .smart-task-display { position: absolute; width: 100%; @@ -11,27 +26,36 @@ white-space: pre-wrap; box-sizing: border-box; } + .smart-task-common { font-size: 1.2em; line-height: 1.2em; font-family: inherit; - caret-color: #f08080; + caret-color: var(--highlight-date-color); } .highlight-date { - color: #f08080; + color: var(--highlight-date-color); } .highlight-repeat { - color: #90ee90; + color: var(--highlight-repeat-color); } .highlight-label { - color: #add8e6; + color: var(--highlight-label-color); } .highlight-priority { - color: #ffb6c1; + color: var(--highlight-priority-color); +} + +.highlight-assignee { + color: var(--highlight-repeat-color); +} + +.highlight-points { + color: var(--highlight-label-color); } .task-input { @@ -39,4 +63,6 @@ width: 100%; border-radius: 8px; box-sizing: border-box; + border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd); + overflow: auto; } diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx index 8432c44..afc873e 100644 --- a/src/views/components/SmartTaskTitleInput.jsx +++ b/src/views/components/SmartTaskTitleInput.jsx @@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({ return (
-
+