including improved command handling and sync status updates
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -729,7 +729,14 @@ export const useChoreActions = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[modalChore, updateChoreInState, closeModal, showSuccess, showError],
|
[
|
||||||
|
modalChore,
|
||||||
|
updateChoreInState,
|
||||||
|
closeModal,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
queryClient,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCompleteWithPastDate = useCallback(
|
const handleCompleteWithPastDate = useCallback(
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user