Merge pull request #110 from donetick/offline-support-v2.2
Implement offline support for chore archiving and unarchiving
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user