diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index d4d2bfe..85f7daf 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -29,12 +29,21 @@ const mergePendingCreates = async chores => { const pendingCreates = pending.filter( cmd => cmd.commandType === CommandType.CREATE_CHORE, ) + const deletedIds = new Set( + pending + .filter(cmd => cmd.commandType === CommandType.DELETE_CHORE) + .map(cmd => String(cmd.entityId)), + ) if (pendingCreates.length === 0) return chores const existingIds = new Set((chores || []).map(chore => String(chore.id))) const createdFromQueue = pendingCreates - .filter(cmd => !existingIds.has(String(cmd.entityId))) + .filter( + cmd => + !existingIds.has(String(cmd.entityId)) && + !deletedIds.has(String(cmd.entityId)), + ) .map(cmd => { const payload = cmd.payload || {} return { @@ -70,7 +79,7 @@ export const useChores = (includeArchive = false) => { } const cursor = await offlineDB.getSyncCursor() if (cursor > 0) { - const cached = await offlineDB.getChores() + const cached = await offlineDB.getChores(includeArchive) const merged = await mergePendingCreates(cached || []) return { res: merged } } @@ -86,7 +95,7 @@ export const useChores = (includeArchive = false) => { return { ...data, res: merged } } catch { // API failed — fall back to whatever is in the cache - const cached = await offlineDB.getChores() + const cached = await offlineDB.getChores(includeArchive) const merged = await mergePendingCreates(cached || []) if (merged && merged.length > 0) { return { res: merged } @@ -104,11 +113,25 @@ export const useDeleteChores = () => { return useMutation({ mutationFn: async choreIds => { if (!networkManager.isOnline) { + await offlineDB.deleteChores(choreIds) await Promise.all( choreIds.map(async id => { await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id }) }), ) + + const removeDeletedChores = oldData => { + if (!oldData?.res) return oldData + + const deletedIds = new Set(choreIds.map(id => String(id))) + return { + ...oldData, + res: oldData.res.filter(chore => !deletedIds.has(String(chore.id))), + } + } + + queryClient.setQueryData(['chores', false], removeDeletedChores) + queryClient.setQueryData(['chores', true], removeDeletedChores) return } await Promise.all( diff --git a/src/queries/TimeQueries.jsx b/src/queries/TimeQueries.jsx index 9cc9155..074b258 100644 --- a/src/queries/TimeQueries.jsx +++ b/src/queries/TimeQueries.jsx @@ -33,6 +33,7 @@ export const useStartChore = () => { mutationFn: StartChore, onSuccess: (_, choreId) => { queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) }, }) @@ -45,6 +46,7 @@ export const usePauseChore = () => { mutationFn: PauseChore, onSuccess: (_, choreId) => { queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['choreHistory', choreId]) }, }) diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index 58566b2..44eb303 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -18,6 +18,14 @@ export const CommandType = { } class CommandQueue { + _sanitizeCreatePayload(payload = {}) { + const sanitized = { ...payload } + delete sanitized.id + delete sanitized._pendingCreate + delete sanitized._pendingUpdate + return sanitized + } + // Enqueue a domain command async enqueue(type, entityId, payload) { if (!isOfflineFeatureEnabled()) { @@ -121,17 +129,82 @@ class CommandQueue { const toRemove = [] for (const cmd of pending) { + const prev = seen.get(cmd.entityId) + + if (prev?.commandType === CommandType.CREATE_CHORE) { + if (cmd.commandType === CommandType.UPDATE_CHORE) { + const mergedPayload = this._sanitizeCreatePayload({ + ...prev.payload, + ...cmd.payload, + }) + await offlineDB.updateCommand(prev.id, { + payload: JSON.stringify(mergedPayload), + }) + toRemove.push(cmd.id) + continue + } + + if (cmd.commandType === CommandType.RESCHEDULE_CHORE) { + const mergedPayload = this._sanitizeCreatePayload({ + ...prev.payload, + dueDate: cmd.payload?.dueDate ?? prev.payload?.dueDate, + nextDueDate: cmd.payload?.dueDate ?? prev.payload?.nextDueDate, + }) + await offlineDB.updateCommand(prev.id, { + payload: JSON.stringify(mergedPayload), + }) + toRemove.push(cmd.id) + continue + } + + if (cmd.commandType === CommandType.ARCHIVE_CHORE) { + const mergedPayload = this._sanitizeCreatePayload({ + ...prev.payload, + isActive: false, + }) + await offlineDB.updateCommand(prev.id, { + payload: JSON.stringify(mergedPayload), + }) + toRemove.push(cmd.id) + continue + } + + if (cmd.commandType === CommandType.UNARCHIVE_CHORE) { + const mergedPayload = this._sanitizeCreatePayload({ + ...prev.payload, + isActive: true, + }) + await offlineDB.updateCommand(prev.id, { + payload: JSON.stringify(mergedPayload), + }) + toRemove.push(cmd.id) + continue + } + + if (cmd.commandType === CommandType.DELETE_CHORE) { + toRemove.push(prev.id, cmd.id) + seen.delete(cmd.entityId) + continue + } + } + if (cmd.commandType === CommandType.UPDATE_CHORE) { - const prev = seen.get(cmd.entityId) if (prev && prev.commandType === CommandType.UPDATE_CHORE) { // Merge: keep latest payload, remove older toRemove.push(prev.id) } } + + if (cmd.commandType === CommandType.DELETE_CHORE) { + if (prev?.commandType === CommandType.UPDATE_CHORE) { + toRemove.push(prev.id) + } + } + seen.set(cmd.entityId, cmd) } - for (const id of toRemove) { + for (const id of [...new Set(toRemove)]) { await offlineDB.removeCommand(id) } } diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 0bba249..00e45ec 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -104,15 +104,17 @@ class SQLiteBackend { }) } - async getChores() { + async getChores(includeArchive = false) { const result = await CapacitorSQLite.query({ database: DB_NAME, statement: 'SELECT data FROM cached_chores', values: [], }) - return (result.values || []) - .map(row => JSON.parse(row.data)) - .filter(chore => chore.isActive !== false) + const chores = (result.values || []).map(row => JSON.parse(row.data)) + if (includeArchive) { + return chores + } + return chores.filter(chore => chore.isActive !== false) } async getChore(id) { @@ -334,6 +336,35 @@ class SQLiteBackend { }) } + async updateCommand(id, updates) { + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: 'SELECT * FROM command_queue WHERE id = ?', + values: [id], + }) + + if (!result.values?.length) return + + const row = result.values[0] + await CapacitorSQLite.run({ + database: DB_NAME, + statement: `UPDATE command_queue + SET command_type = ?, entity_id = ?, payload = ?, created_at = ?, status = ?, error = ? + WHERE id = ?`, + values: [ + updates.commandType ?? row.command_type, + updates.entityId ?? row.entity_id, + updates.payload ?? row.payload, + updates.createdAt ?? row.created_at, + updates.status ?? row.status, + Object.prototype.hasOwnProperty.call(updates, 'error') + ? updates.error + : row.error, + id, + ], + }) + } + async removeCommand(id) { await CapacitorSQLite.run({ database: DB_NAME, @@ -544,10 +575,14 @@ class IndexedDBBackend { }) } - async getChores() { + async getChores(includeArchive = false) { const { store } = await this._tx('cached_chores') const rows = await this._request(store.getAll()) - return rows.map(row => row.data).filter(chore => chore.isActive !== false) + const chores = rows.map(row => row.data) + if (includeArchive) { + return chores + } + return chores.filter(chore => chore.isActive !== false) } async getChore(id) { @@ -748,6 +783,19 @@ class IndexedDBBackend { } } + async updateCommand(id, updates) { + const { store } = await this._tx('command_queue', 'readwrite') + const row = await this._request(store.get(id)) + if (row) { + await this._request( + store.put({ + ...row, + ...updates, + }), + ) + } + } + async removeCommand(id) { const { store } = await this._tx('command_queue', 'readwrite') await this._request(store.delete(id)) @@ -864,10 +912,10 @@ class OfflineDB { return this.backend.saveChores(chores) } - async getChores() { + async getChores(includeArchive = false) { if (!isOfflineFeatureEnabled()) return [] await this._ensureInit() - return this.backend.getChores() + return this.backend.getChores(includeArchive) } async getChore(id) { @@ -913,6 +961,12 @@ class OfflineDB { return this.backend.updateCommandStatus(id, status, error) } + async updateCommand(id, updates) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.updateCommand(id, updates) + } + async removeCommand(id) { if (!isOfflineFeatureEnabled()) return await this._ensureInit() diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 0caf2ef..6f1b5ff 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -92,7 +92,7 @@ class SyncEngine { } else { // Transient network/server error - reset to pending so it retries await commandQueue.resetPending(cmd.id) - break + throw err } } } diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 9280e09..6a38d45 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -137,10 +137,7 @@ const ChoreView = () => { const choreHistory = choreHistoryData?.res || [] const historyCompletionCount = choreHistory.filter(historyEntry => { const status = Number(historyEntry?.status) - return ( - status === ChoreHistoryStatus.COMPLETED || - status === ChoreHistoryStatus.SKIPPED - ) + return status === ChoreHistoryStatus.COMPLETED }).length const completionCount = choreHistoryData ? historyCompletionCount diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 1c4e3cf..d7b4d10 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -729,7 +729,14 @@ export const useChoreActions = ({ } } }, - [modalChore, updateChoreInState, closeModal, showSuccess, showError], + [ + modalChore, + updateChoreInState, + closeModal, + showSuccess, + showError, + queryClient, + ], ) const handleCompleteWithPastDate = useCallback( diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx index df72c41..b400f88 100644 --- a/src/views/Settings/DeveloperSettings.jsx +++ b/src/views/Settings/DeveloperSettings.jsx @@ -126,7 +126,9 @@ const DeveloperSettings = () => { ...prev, syncing: typeof state.syncing === 'boolean' ? state.syncing : prev.syncing, - syncError: state.error ?? prev.syncError, + syncError: Object.prototype.hasOwnProperty.call(state, 'error') + ? state.error + : prev.syncError, lastSync: state.lastSync ?? prev.lastSync, })) }) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 66987c8..f886be5 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -634,15 +634,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { createChoreMutation .mutateAsync(chore) .then(result => { - const choreData = result?.res + const choreData = result if (choreData?._pendingCreate) { // Offline: task queued, add temp chore to UI immediately onChoreUpdate(choreData) } else { - // Online: choreData is the server's parsed response ({ res: id }) + // Online: choreData is the created chore object returned by the mutation onChoreUpdate({ ...chore, - id: choreData?.res || choreData?.id, + ...choreData, + id: choreData?.id, nextDueDate: chore.dueDate, }) } diff --git a/src/views/components/SyncStatusIndicator.jsx b/src/views/components/SyncStatusIndicator.jsx index f8ef7e7..a12d250 100644 --- a/src/views/components/SyncStatusIndicator.jsx +++ b/src/views/components/SyncStatusIndicator.jsx @@ -201,6 +201,7 @@ function SyncStatusIndicator() { return (