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
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'] })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user