From 8058f8f376161d5cfd99cb453252391202e18242 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 25 May 2026 16:51:34 -0400 Subject: [PATCH 1/2] feat: implement offline support for chore archiving and unarchiving, enhance state management for pending commands --- src/utils/CommandQueue.js | 33 +++++++ src/views/ChoreEdit/ChoreView.jsx | 5 + src/views/Chores/ArchivedTasks.jsx | 111 ++++++++++++++++------ src/views/Chores/hooks/useChoreActions.js | 12 +++ 4 files changed, 133 insertions(+), 28 deletions(-) diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index 44eb303..f9f8794 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -26,6 +26,34 @@ class CommandQueue { return sanitized } + _clearPendingFlags(chore = {}) { + const next = { ...chore } + delete next._pending + delete next._pendingUpdate + return next + } + + async _rollbackCancelledCommand(command) { + if (!command) return + + if ( + command.commandType !== CommandType.ARCHIVE_CHORE && + command.commandType !== CommandType.UNARCHIVE_CHORE + ) { + return + } + + const cachedChore = await offlineDB.getChore(command.entityId) + if (!cachedChore) return + + const restoredChore = this._clearPendingFlags({ + ...cachedChore, + isActive: command.commandType === CommandType.ARCHIVE_CHORE, + }) + + await offlineDB.saveChores([restoredChore]) + } + // Enqueue a domain command async enqueue(type, entityId, payload) { if (!isOfflineFeatureEnabled()) { @@ -81,6 +109,11 @@ class CommandQueue { // Cancel/undo a pending command async cancel(commandId) { if (!isOfflineFeatureEnabled()) return + + const allCommands = await offlineDB.getCommands() + const command = allCommands.find(c => String(c.id) === String(commandId)) + + await this._rollbackCancelledCommand(command) return offlineDB.removeCommand(commandId) } diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 6a38d45..6f8de7a 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -536,6 +536,7 @@ const ChoreView = () => { try { const response = await UnArchiveChore(choreId) if (response.ok) { + await offlineDB.saveChores([{ ...chore, isActive: true }]) setChore({ ...chore, isActive: true }) queryClient.invalidateQueries(['chores']) } @@ -548,12 +549,16 @@ const ChoreView = () => { choreId, { id: choreId }, ) + await offlineDB.saveChores([ + { ...chore, isActive: true, _pending: 'unarchive' }, + ]) 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) + await offlineDB.saveChores([{ ...chore, isActive: false }]) setChore({ ...chore, isActive: false }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) }, diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index 27250e6..c292ad3 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -1,24 +1,24 @@ import { - Archive, - CheckBox, - CheckBoxOutlineBlank, - Close, - Delete, - SelectAll, - Unarchive, - ViewAgenda, - ViewModule, + Archive, + CheckBox, + CheckBoxOutlineBlank, + Close, + Delete, + SelectAll, + Unarchive, + ViewAgenda, + ViewModule, } from '@mui/icons-material' import { - Box, - Button, - Container, - Divider, - IconButton, - Input, - List, - Stack, - Typography, + Box, + Button, + Container, + Divider, + IconButton, + Input, + List, + Stack, + Typography, } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' @@ -31,6 +31,7 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' import { commandQueue, CommandType } from '../../utils/CommandQueue' import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' import LoadingComponent from '../components/Loading' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ChoreCard from './ChoreCard' @@ -38,6 +39,48 @@ import ChoreListView from './ChoreListView.jsx' import CompactChoreCard from './CompactChoreCard' import MultiSelectHelp from './MultiSelectHelp' +const sortByUpdatedAtDesc = chores => + (chores || []).sort((a, b) => { + const dateA = new Date(a.updatedAt || 0) + const dateB = new Date(b.updatedAt || 0) + return dateB - dateA + }) + +const applyPendingArchivedState = async chores => { + const pending = await commandQueue.getPending() + + const pendingArchiveIds = new Set( + pending + .filter(cmd => cmd.commandType === CommandType.ARCHIVE_CHORE) + .map(cmd => String(cmd.entityId)), + ) + const pendingUnarchiveIds = new Set( + pending + .filter(cmd => cmd.commandType === CommandType.UNARCHIVE_CHORE) + .map(cmd => String(cmd.entityId)), + ) + const pendingDeleteIds = new Set( + pending + .filter(cmd => cmd.commandType === CommandType.DELETE_CHORE) + .map(cmd => String(cmd.entityId)), + ) + + return (chores || []) + .filter(chore => !pendingDeleteIds.has(String(chore.id))) + .filter(chore => { + const id = String(chore.id) + if (pendingUnarchiveIds.has(id)) return false + return chore.isActive === false || pendingArchiveIds.has(id) + }) + .map(chore => { + const id = String(chore.id) + if (pendingArchiveIds.has(id)) { + return { ...chore, isActive: false, _pending: 'archive' } + } + return chore + }) +} + const ArchivedTasks = () => { const { data: userProfile, isLoading: isUserProfileLoading } = useUserProfile() @@ -71,19 +114,28 @@ const ArchivedTasks = () => { try { const response = await GetArchivedChores() const data = await response.json() - // Sort by updatedAt (most recent first) - const sortedChores = data.res.sort((a, b) => { - const dateA = new Date(a.updatedAt || 0) - const dateB = new Date(b.updatedAt || 0) - return dateB - dateA - }) + if (data?.res?.length) { + await offlineDB.saveChores(data.res) + } + const archivedWithPending = await applyPendingArchivedState( + data?.res || [], + ) + const sortedChores = sortByUpdatedAtDesc(archivedWithPending) setArchivedChores(sortedChores) setFilteredChores(sortedChores) } catch (error) { - showError({ - title: 'Failed to load archived tasks', - message: 'Please try again later.', - }) + try { + const cached = await offlineDB.getChores(true) + const archivedWithPending = await applyPendingArchivedState(cached) + const sortedChores = sortByUpdatedAtDesc(archivedWithPending) + setArchivedChores(sortedChores) + setFilteredChores(sortedChores) + } catch { + showError({ + title: 'Failed to load archived tasks', + message: 'Please try again later.', + }) + } } finally { setIsLoading(false) } @@ -337,6 +389,9 @@ const ArchivedTasks = () => { onError: async error => { if (isNetworkError(error)) { await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id }) + await offlineDB.saveChores([ + { ...chore, isActive: true, _pending: 'unarchive' }, + ]) queuedTasks.push(chore) resolve() } else { diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index d7b4d10..6bf279e 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -481,6 +481,9 @@ export const useChoreActions = ({ chore.id, { id: chore.id }, ) + await offlineDB.saveChores([ + { ...chore, isActive: false, _pending: 'archive' }, + ]) setChores(prev => prev.filter(c => c.id !== chore.id)) setFilteredChores(prev => prev.filter(c => c.id !== chore.id), @@ -493,6 +496,9 @@ export const useChoreActions = ({ "You're offline — archive will sync when back online", undoAction: async () => { await commandQueue.cancel(cmdId) + await offlineDB.saveChores([ + { ...chore, isActive: true }, + ]) queryClient.invalidateQueries({ queryKey: ['pendingCommands'], }) @@ -529,6 +535,9 @@ export const useChoreActions = ({ chore.id, { id: chore.id }, ) + await offlineDB.saveChores([ + { ...chore, isActive: true, _pending: 'unarchive' }, + ]) queryClient.invalidateQueries({ queryKey: ['pendingCommands'], }) @@ -537,6 +546,9 @@ export const useChoreActions = ({ "You're offline — restore will sync when back online", undoAction: async () => { await commandQueue.cancel(cmdId) + await offlineDB.saveChores([ + { ...chore, isActive: false }, + ]) queryClient.invalidateQueries({ queryKey: ['pendingCommands'], }) From eec3721e9d3be33a29eccb7dbeaafeeca475f2fd Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 26 May 2026 00:02:10 -0400 Subject: [PATCH 2/2] refactor: update offline support notifications and confirmation messages for clarity --- src/views/Settings/AdvancedSettings.jsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/views/Settings/AdvancedSettings.jsx b/src/views/Settings/AdvancedSettings.jsx index b3f723d..d97ad46 100644 --- a/src/views/Settings/AdvancedSettings.jsx +++ b/src/views/Settings/AdvancedSettings.jsx @@ -73,7 +73,7 @@ const AdvancedSettings = () => { queryClient.invalidateQueries() showNotification({ type: 'success', - message: 'Offline support disabled and local offline data cleared', + message: 'Offline mode turned off and local data was cleared', }) } catch { setOfflineFeatureEnabled(false) @@ -83,7 +83,7 @@ const AdvancedSettings = () => { showNotification({ type: 'warning', message: - 'Offline support disabled, but some local cache items may not have been cleared', + 'Offline mode was turned off, but some local data may still be stored', }) } finally { setOfflineLoading(false) @@ -93,10 +93,10 @@ const AdvancedSettings = () => { const showDisableOfflineConfirmation = () => { setConfirmModalConfig({ isOpen: true, - title: 'Disable Offline Support', + title: 'Turn Off Offline Mode', 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', + 'Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?', + confirmText: 'Turn Off & Clear Data', cancelText: 'Cancel', color: 'danger', onClose: isConfirmed => { @@ -117,7 +117,7 @@ const AdvancedSettings = () => { queryClient.invalidateQueries() showNotification({ type: 'success', - message: 'Offline support enabled for this device/browser', + message: 'Offline mode turned on for this device/browser', }) return } @@ -158,8 +158,8 @@ const AdvancedSettings = () => { - Enable offline queue and local cache for this device/browser. - Disabling removes pending offline actions and cached offline data. + Keep using Donetick when you're offline on this device/browser. Your + changes are saved locally and synced when you're back online. { overlay /> - When disabled, queued changes and offline cache are cleared from - this device/browser. + Turning this off removes unsynced offline changes and saved offline + data from this device/browser.