including improved command handling and sync status updates

This commit is contained in:
Mo Tarbin
2026-05-25 01:56:14 -04:00
parent 45e667abf7
commit aece93d63d
10 changed files with 183 additions and 23 deletions

View File

@@ -29,12 +29,21 @@ const mergePendingCreates = async chores => {
const pendingCreates = pending.filter( const pendingCreates = pending.filter(
cmd => cmd.commandType === CommandType.CREATE_CHORE, 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 if (pendingCreates.length === 0) return chores
const existingIds = new Set((chores || []).map(chore => String(chore.id))) const existingIds = new Set((chores || []).map(chore => String(chore.id)))
const createdFromQueue = pendingCreates const createdFromQueue = pendingCreates
.filter(cmd => !existingIds.has(String(cmd.entityId))) .filter(
cmd =>
!existingIds.has(String(cmd.entityId)) &&
!deletedIds.has(String(cmd.entityId)),
)
.map(cmd => { .map(cmd => {
const payload = cmd.payload || {} const payload = cmd.payload || {}
return { return {
@@ -70,7 +79,7 @@ export const useChores = (includeArchive = false) => {
} }
const cursor = await offlineDB.getSyncCursor() const cursor = await offlineDB.getSyncCursor()
if (cursor > 0) { if (cursor > 0) {
const cached = await offlineDB.getChores() const cached = await offlineDB.getChores(includeArchive)
const merged = await mergePendingCreates(cached || []) const merged = await mergePendingCreates(cached || [])
return { res: merged } return { res: merged }
} }
@@ -86,7 +95,7 @@ export const useChores = (includeArchive = false) => {
return { ...data, res: merged } return { ...data, res: merged }
} catch { } catch {
// API failed — fall back to whatever is in the cache // 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 || []) const merged = await mergePendingCreates(cached || [])
if (merged && merged.length > 0) { if (merged && merged.length > 0) {
return { res: merged } return { res: merged }
@@ -104,11 +113,25 @@ export const useDeleteChores = () => {
return useMutation({ return useMutation({
mutationFn: async choreIds => { mutationFn: async choreIds => {
if (!networkManager.isOnline) { if (!networkManager.isOnline) {
await offlineDB.deleteChores(choreIds)
await Promise.all( await Promise.all(
choreIds.map(async id => { choreIds.map(async id => {
await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { 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 return
} }
await Promise.all( await Promise.all(

View File

@@ -33,6 +33,7 @@ export const useStartChore = () => {
mutationFn: StartChore, mutationFn: StartChore,
onSuccess: (_, choreId) => { onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId]) queryClient.invalidateQueries(['choreHistory', choreId])
}, },
}) })
@@ -45,6 +46,7 @@ export const usePauseChore = () => {
mutationFn: PauseChore, mutationFn: PauseChore,
onSuccess: (_, choreId) => { onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId]) queryClient.invalidateQueries(['choreHistory', choreId])
}, },
}) })

View File

@@ -18,6 +18,14 @@ export const CommandType = {
} }
class CommandQueue { class CommandQueue {
_sanitizeCreatePayload(payload = {}) {
const sanitized = { ...payload }
delete sanitized.id
delete sanitized._pendingCreate
delete sanitized._pendingUpdate
return sanitized
}
// Enqueue a domain command // Enqueue a domain command
async enqueue(type, entityId, payload) { async enqueue(type, entityId, payload) {
if (!isOfflineFeatureEnabled()) { if (!isOfflineFeatureEnabled()) {
@@ -121,17 +129,82 @@ class CommandQueue {
const toRemove = [] const toRemove = []
for (const cmd of pending) { 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) { if (cmd.commandType === CommandType.UPDATE_CHORE) {
const prev = seen.get(cmd.entityId)
if (prev && prev.commandType === CommandType.UPDATE_CHORE) { if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
// Merge: keep latest payload, remove older // Merge: keep latest payload, remove older
toRemove.push(prev.id) 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) seen.set(cmd.entityId, cmd)
} }
for (const id of toRemove) { for (const id of [...new Set(toRemove)]) {
await offlineDB.removeCommand(id) await offlineDB.removeCommand(id)
} }
} }

View File

@@ -104,15 +104,17 @@ class SQLiteBackend {
}) })
} }
async getChores() { async getChores(includeArchive = false) {
const result = await CapacitorSQLite.query({ const result = await CapacitorSQLite.query({
database: DB_NAME, database: DB_NAME,
statement: 'SELECT data FROM cached_chores', statement: 'SELECT data FROM cached_chores',
values: [], values: [],
}) })
return (result.values || []) const chores = (result.values || []).map(row => JSON.parse(row.data))
.map(row => JSON.parse(row.data)) if (includeArchive) {
.filter(chore => chore.isActive !== false) return chores
}
return chores.filter(chore => chore.isActive !== false)
} }
async getChore(id) { 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) { async removeCommand(id) {
await CapacitorSQLite.run({ await CapacitorSQLite.run({
database: DB_NAME, database: DB_NAME,
@@ -544,10 +575,14 @@ class IndexedDBBackend {
}) })
} }
async getChores() { async getChores(includeArchive = false) {
const { store } = await this._tx('cached_chores') const { store } = await this._tx('cached_chores')
const rows = await this._request(store.getAll()) 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) { 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) { async removeCommand(id) {
const { store } = await this._tx('command_queue', 'readwrite') const { store } = await this._tx('command_queue', 'readwrite')
await this._request(store.delete(id)) await this._request(store.delete(id))
@@ -864,10 +912,10 @@ class OfflineDB {
return this.backend.saveChores(chores) return this.backend.saveChores(chores)
} }
async getChores() { async getChores(includeArchive = false) {
if (!isOfflineFeatureEnabled()) return [] if (!isOfflineFeatureEnabled()) return []
await this._ensureInit() await this._ensureInit()
return this.backend.getChores() return this.backend.getChores(includeArchive)
} }
async getChore(id) { async getChore(id) {
@@ -913,6 +961,12 @@ class OfflineDB {
return this.backend.updateCommandStatus(id, status, error) 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) { async removeCommand(id) {
if (!isOfflineFeatureEnabled()) return if (!isOfflineFeatureEnabled()) return
await this._ensureInit() await this._ensureInit()

View File

@@ -92,7 +92,7 @@ class SyncEngine {
} else { } else {
// Transient network/server error - reset to pending so it retries // Transient network/server error - reset to pending so it retries
await commandQueue.resetPending(cmd.id) await commandQueue.resetPending(cmd.id)
break throw err
} }
} }
} }

View File

@@ -137,10 +137,7 @@ const ChoreView = () => {
const choreHistory = choreHistoryData?.res || [] const choreHistory = choreHistoryData?.res || []
const historyCompletionCount = choreHistory.filter(historyEntry => { const historyCompletionCount = choreHistory.filter(historyEntry => {
const status = Number(historyEntry?.status) const status = Number(historyEntry?.status)
return ( return status === ChoreHistoryStatus.COMPLETED
status === ChoreHistoryStatus.COMPLETED ||
status === ChoreHistoryStatus.SKIPPED
)
}).length }).length
const completionCount = choreHistoryData const completionCount = choreHistoryData
? historyCompletionCount ? historyCompletionCount

View File

@@ -729,7 +729,14 @@ export const useChoreActions = ({
} }
} }
}, },
[modalChore, updateChoreInState, closeModal, showSuccess, showError], [
modalChore,
updateChoreInState,
closeModal,
showSuccess,
showError,
queryClient,
],
) )
const handleCompleteWithPastDate = useCallback( const handleCompleteWithPastDate = useCallback(

View File

@@ -126,7 +126,9 @@ const DeveloperSettings = () => {
...prev, ...prev,
syncing: syncing:
typeof state.syncing === 'boolean' ? state.syncing : 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, lastSync: state.lastSync ?? prev.lastSync,
})) }))
}) })

View File

@@ -634,15 +634,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
createChoreMutation createChoreMutation
.mutateAsync(chore) .mutateAsync(chore)
.then(result => { .then(result => {
const choreData = result?.res const choreData = result
if (choreData?._pendingCreate) { if (choreData?._pendingCreate) {
// Offline: task queued, add temp chore to UI immediately // Offline: task queued, add temp chore to UI immediately
onChoreUpdate(choreData) onChoreUpdate(choreData)
} else { } else {
// Online: choreData is the server's parsed response ({ res: id }) // Online: choreData is the created chore object returned by the mutation
onChoreUpdate({ onChoreUpdate({
...chore, ...chore,
id: choreData?.res || choreData?.id, ...choreData,
id: choreData?.id,
nextDueDate: chore.dueDate, nextDueDate: chore.dueDate,
}) })
} }

View File

@@ -201,6 +201,7 @@ function SyncStatusIndicator() {
return ( return (
<Dropdown> <Dropdown>
<MenuButton <MenuButton
aria-label='Open sync and network status'
variant='plain' variant='plain'
sx={{ sx={{
p: 0.5, p: 0.5,