feat: implement offline support for chore archiving and unarchiving, enhance state management for pending commands

This commit is contained in:
Mo Tarbin
2026-05-25 16:51:34 -04:00
parent b54f4a846c
commit 8058f8f376
4 changed files with 133 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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