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 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 // Enqueue a domain command
async enqueue(type, entityId, payload) { async enqueue(type, entityId, payload) {
if (!isOfflineFeatureEnabled()) { if (!isOfflineFeatureEnabled()) {
@@ -81,6 +109,11 @@ class CommandQueue {
// Cancel/undo a pending command // Cancel/undo a pending command
async cancel(commandId) { async cancel(commandId) {
if (!isOfflineFeatureEnabled()) return 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) return offlineDB.removeCommand(commandId)
} }

View File

@@ -536,6 +536,7 @@ const ChoreView = () => {
try { try {
const response = await UnArchiveChore(choreId) const response = await UnArchiveChore(choreId)
if (response.ok) { if (response.ok) {
await offlineDB.saveChores([{ ...chore, isActive: true }])
setChore({ ...chore, isActive: true }) setChore({ ...chore, isActive: true })
queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['chores'])
} }
@@ -548,12 +549,16 @@ const ChoreView = () => {
choreId, choreId,
{ id: choreId }, { id: choreId },
) )
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
setChore({ ...chore, isActive: true }) setChore({ ...chore, isActive: true })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({ showSuccess({
message: "You're offline — restore will sync when back online", message: "You're offline — restore will sync when back online",
undoAction: async () => { undoAction: async () => {
await commandQueue.cancel(cmdId) await commandQueue.cancel(cmdId)
await offlineDB.saveChores([{ ...chore, isActive: false }])
setChore({ ...chore, isActive: false }) setChore({ ...chore, isActive: false })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
}, },

View File

@@ -31,6 +31,7 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue' import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher' import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import LoadingComponent from '../components/Loading' import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard' import ChoreCard from './ChoreCard'
@@ -38,6 +39,48 @@ import ChoreListView from './ChoreListView.jsx'
import CompactChoreCard from './CompactChoreCard' import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp' 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 ArchivedTasks = () => {
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile() useUserProfile()
@@ -71,19 +114,28 @@ const ArchivedTasks = () => {
try { try {
const response = await GetArchivedChores() const response = await GetArchivedChores()
const data = await response.json() const data = await response.json()
// Sort by updatedAt (most recent first) if (data?.res?.length) {
const sortedChores = data.res.sort((a, b) => { await offlineDB.saveChores(data.res)
const dateA = new Date(a.updatedAt || 0) }
const dateB = new Date(b.updatedAt || 0) const archivedWithPending = await applyPendingArchivedState(
return dateB - dateA data?.res || [],
}) )
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
setArchivedChores(sortedChores) setArchivedChores(sortedChores)
setFilteredChores(sortedChores) setFilteredChores(sortedChores)
} catch (error) { } catch (error) {
try {
const cached = await offlineDB.getChores(true)
const archivedWithPending = await applyPendingArchivedState(cached)
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
setArchivedChores(sortedChores)
setFilteredChores(sortedChores)
} catch {
showError({ showError({
title: 'Failed to load archived tasks', title: 'Failed to load archived tasks',
message: 'Please try again later.', message: 'Please try again later.',
}) })
}
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -337,6 +389,9 @@ const ArchivedTasks = () => {
onError: async error => { onError: async error => {
if (isNetworkError(error)) { if (isNetworkError(error)) {
await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id }) await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id })
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
queuedTasks.push(chore) queuedTasks.push(chore)
resolve() resolve()
} else { } else {

View File

@@ -481,6 +481,9 @@ export const useChoreActions = ({
chore.id, chore.id,
{ id: chore.id }, { id: chore.id },
) )
await offlineDB.saveChores([
{ ...chore, isActive: false, _pending: 'archive' },
])
setChores(prev => prev.filter(c => c.id !== chore.id)) setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => setFilteredChores(prev =>
prev.filter(c => c.id !== chore.id), prev.filter(c => c.id !== chore.id),
@@ -493,6 +496,9 @@ export const useChoreActions = ({
"You're offline — archive will sync when back online", "You're offline — archive will sync when back online",
undoAction: async () => { undoAction: async () => {
await commandQueue.cancel(cmdId) await commandQueue.cancel(cmdId)
await offlineDB.saveChores([
{ ...chore, isActive: true },
])
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['pendingCommands'], queryKey: ['pendingCommands'],
}) })
@@ -529,6 +535,9 @@ export const useChoreActions = ({
chore.id, chore.id,
{ id: chore.id }, { id: chore.id },
) )
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['pendingCommands'], queryKey: ['pendingCommands'],
}) })
@@ -537,6 +546,9 @@ export const useChoreActions = ({
"You're offline — restore will sync when back online", "You're offline — restore will sync when back online",
undoAction: async () => { undoAction: async () => {
await commandQueue.cancel(cmdId) await commandQueue.cancel(cmdId)
await offlineDB.saveChores([
{ ...chore, isActive: false },
])
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['pendingCommands'], queryKey: ['pendingCommands'],
}) })

View File

@@ -73,7 +73,7 @@ const AdvancedSettings = () => {
queryClient.invalidateQueries() queryClient.invalidateQueries()
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Offline support disabled and local offline data cleared', message: 'Offline mode turned off and local data was cleared',
}) })
} catch { } catch {
setOfflineFeatureEnabled(false) setOfflineFeatureEnabled(false)
@@ -83,7 +83,7 @@ const AdvancedSettings = () => {
showNotification({ showNotification({
type: 'warning', type: 'warning',
message: 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 { } finally {
setOfflineLoading(false) setOfflineLoading(false)
@@ -93,10 +93,10 @@ const AdvancedSettings = () => {
const showDisableOfflineConfirmation = () => { const showDisableOfflineConfirmation = () => {
setConfirmModalConfig({ setConfirmModalConfig({
isOpen: true, isOpen: true,
title: 'Disable Offline Support', title: 'Turn Off Offline Mode',
message: message:
'Disabling offline support will remove queued offline actions and local cached offline data on this device/browser. Do you want to continue?', 'Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?',
confirmText: 'Disable & Clear', confirmText: 'Turn Off & Clear Data',
cancelText: 'Cancel', cancelText: 'Cancel',
color: 'danger', color: 'danger',
onClose: isConfirmed => { onClose: isConfirmed => {
@@ -117,7 +117,7 @@ const AdvancedSettings = () => {
queryClient.invalidateQueries() queryClient.invalidateQueries()
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Offline support enabled for this device/browser', message: 'Offline mode turned on for this device/browser',
}) })
return return
} }
@@ -158,8 +158,8 @@ const AdvancedSettings = () => {
</Chip> </Chip>
</Box> </Box>
<Typography level='body-md' mt={-1}> <Typography level='body-md' mt={-1}>
Enable offline queue and local cache for this device/browser. Keep using Donetick when you're offline on this device/browser. Your
Disabling removes pending offline actions and cached offline data. changes are saved locally and synced when you're back online.
</Typography> </Typography>
<FormControl sx={{ mt: 1 }}> <FormControl sx={{ mt: 1 }}>
<Checkbox <Checkbox
@@ -171,8 +171,8 @@ const AdvancedSettings = () => {
overlay overlay
/> />
<FormHelperText> <FormHelperText>
When disabled, queued changes and offline cache are cleared from Turning this off removes unsynced offline changes and saved offline
this device/browser. data from this device/browser.
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>