Merge pull request #110 from donetick/offline-support-v2.2

Implement offline support for chore archiving and unarchiving
This commit is contained in:
Mohamad Tarbin
2026-05-26 01:07:38 -04:00
committed by GitHub
5 changed files with 143 additions and 38 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'],
})

View File

@@ -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 = () => {
</Chip>
</Box>
<Typography level='body-md' mt={-1}>
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.
</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
@@ -171,8 +171,8 @@ const AdvancedSettings = () => {
overlay
/>
<FormHelperText>
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.
</FormHelperText>
</FormControl>