From 25d3f76f9eda0a51ce96b7823430355f146bbd45 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 25 May 2026 16:51:34 -0400 Subject: [PATCH] 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'], })