Improve Offline mode and stop temp id position, stuck commands can no longer block the pipeline, Fix Spurious Offline flip, Ghost Chore when switching accounts

This commit is contained in:
Mo Tarbin
2026-07-18 02:31:00 -04:00
parent 277e10daf1
commit 80099635be
8 changed files with 227 additions and 34 deletions

View File

@@ -59,9 +59,13 @@ const mergePendingCreates = async chores => {
return [...(chores || []), ...createdFromQueue]
}
// Effectively "can this action be queued offline?" — the offline feature must
// be enabled, otherwise there is no command queue to replay it later and the
// failure should surface to the user instead.
const isNetworkError = error =>
(error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError'
isOfflineFeatureEnabled() &&
((error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError')
const buildOfflineChore = task => ({
...task,
@@ -97,7 +101,11 @@ export const useChores = (includeArchive = false) => {
try {
const data = await GetChoresNew(includeArchive)
if (data?.res) {
syncEngine.cacheChores(data.res)
// Only the archived-inclusive fetch is the complete list, which
// allows cacheChores to reconcile server-side deletions
syncEngine.cacheChores(data.res, {
complete: includeArchive === true,
})
}
const merged = await mergePendingCreates(data?.res || [])
return { ...data, res: merged }
@@ -120,7 +128,7 @@ export const useDeleteChores = () => {
return useMutation({
mutationFn: async choreIds => {
if (!networkManager.isOnline) {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
await offlineDB.deleteChores(choreIds)
await Promise.all(
choreIds.map(async id => {
@@ -186,7 +194,7 @@ export const useCreateChore = () => {
return useMutation({
mutationFn: async newTask => {
if (!networkManager.isOnline) {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
return queueOfflineCreate(newTask)
}
@@ -451,7 +459,7 @@ export const useUpdateChoreHistory = () => {
return { queued: true }
}
if (!networkManager.isOnline) {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE_HISTORY,
`${choreId}:${historyId}`,
@@ -514,7 +522,7 @@ export const useDeleteChoreHistory = () => {
return { queued: true }
}
if (!networkManager.isOnline) {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
await commandQueue.enqueue(
CommandType.DELETE_CHORE_HISTORY,
`${choreId}:${historyId}`,
@@ -555,7 +563,7 @@ export const useMarkChoreComplete = () => {
return useMutation({
mutationFn: async ({ choreId, body, completedDate, performer }) => {
if (!networkManager.isOnline) {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
body,
@@ -635,7 +643,7 @@ export const useSkipChore = () => {
return useMutation({
mutationFn: async choreId => {
if (!networkManager.isOnline) {
if (isOfflineFeatureEnabled() && !networkManager.isOnline) {
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
id: choreId,
})

View File

@@ -241,8 +241,14 @@ class ApiClient {
return response
} catch (error) {
clearTimeout(timeoutId)
// fetch() threw = network-level failure or timeout — mark server unreachable
// fetch() threw = network-level failure or timeout — mark server
// unreachable. Caller-initiated aborts (component unmount, query
// cancellation) say nothing about server health, so skip those.
const externalAbort =
error?.name === 'AbortError' && options.signal?.aborted
if (!externalAbort) {
networkManager.setServerUnreachable()
}
console.error('Request failed', error)
throw error
}

View File

@@ -131,6 +131,50 @@ class CommandQueue {
return offlineDB.removeCommand(commandId)
}
// Rewrite queued commands after an offline-created entity gets its real
// server id: commands queued against the temp id (complete, skip, history
// edits, …) would otherwise replay against an id the server doesn't know.
async remapEntityId(tempId, realId) {
if (!isOfflineFeatureEnabled()) return
const tempKey = String(tempId)
const realKey = String(realId)
const commands = await offlineDB.getCommands()
for (const cmd of commands) {
const entityId = String(cmd.entityId)
const matches = entityId === tempKey || entityId.startsWith(`${tempKey}:`)
if (!matches) continue
const newEntityId =
entityId === tempKey
? realKey
: `${realKey}:${entityId.slice(tempKey.length + 1)}`
let newPayload = cmd.payload
try {
const parsed = JSON.parse(cmd.payload)
if (parsed && typeof parsed === 'object') {
if (String(parsed.id) === tempKey) parsed.id = realId
if (String(parsed.choreId) === tempKey) parsed.choreId = realId
newPayload = JSON.stringify(parsed)
}
} catch {
// unparseable payload — remap the entity id only
}
await offlineDB.updateCommand(cmd.id, {
entityId: newEntityId,
payload: newPayload,
})
}
}
// Track a transient failure so replay can give up after repeated attempts
async incrementRetry(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.incrementCommandRetry(commandId)
}
// Mark as syncing
async markSyncing(commandId) {
if (!isOfflineFeatureEnabled()) return

View File

@@ -94,7 +94,8 @@ class SQLiteBackend {
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT
error TEXT,
retry_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sync_meta (
@@ -117,6 +118,18 @@ class SQLiteBackend {
`,
})
// Migration for databases created before retry tracking existed.
// ALTER TABLE fails harmlessly when the column is already there.
try {
await CapacitorSQLite.execute({
database: DB_NAME,
statements:
'ALTER TABLE command_queue ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0;',
})
} catch {
// column already exists
}
this.initialized = true
}
@@ -348,6 +361,7 @@ class SQLiteBackend {
createdAt: row.created_at,
status: row.status,
error: row.error,
retryCount: row.retry_count ?? 0,
}))
}
@@ -366,6 +380,7 @@ class SQLiteBackend {
createdAt: row.created_at,
status: row.status,
error: row.error,
retryCount: row.retry_count ?? 0,
}))
}
@@ -391,7 +406,7 @@ class SQLiteBackend {
await CapacitorSQLite.run({
database: DB_NAME,
statement: `UPDATE command_queue
SET command_type = ?, entity_id = ?, payload = ?, created_at = ?, status = ?, error = ?
SET command_type = ?, entity_id = ?, payload = ?, created_at = ?, status = ?, error = ?, retry_count = ?
WHERE id = ?`,
values: [
updates.commandType ?? row.command_type,
@@ -402,11 +417,21 @@ class SQLiteBackend {
Object.prototype.hasOwnProperty.call(updates, 'error')
? updates.error
: row.error,
updates.retryCount ?? row.retry_count ?? 0,
id,
],
})
}
async incrementCommandRetry(id) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
'UPDATE command_queue SET retry_count = COALESCE(retry_count, 0) + 1 WHERE id = ?',
values: [id],
})
}
async removeCommand(id) {
await CapacitorSQLite.run({
database: DB_NAME,
@@ -801,6 +826,7 @@ class IndexedDBBackend {
createdAt: command.createdAt,
status: command.status,
error: command.error,
retryCount: 0,
}),
)
return id
@@ -843,6 +869,15 @@ class IndexedDBBackend {
}
}
async incrementCommandRetry(id) {
const { store } = await this._tx('command_queue', 'readwrite')
const row = await this._request(store.get(id))
if (row) {
row.retryCount = (row.retryCount ?? 0) + 1
await this._request(store.put(row))
}
}
async removeCommand(id) {
const { store } = await this._tx('command_queue', 'readwrite')
await this._request(store.delete(id))
@@ -1014,6 +1049,12 @@ class OfflineDB {
return this.backend.updateCommand(id, updates)
}
async incrementCommandRetry(id) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.incrementCommandRetry(id)
}
async removeCommand(id) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()

View File

@@ -19,6 +19,11 @@ import { syncOfflineImages } from './ImageCache'
import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
// Give up on a command after this many transient failures so one stuck
// command can't starve the queue and delta sync forever. Failed commands
// stay visible via commandQueue.getFailed().
const MAX_COMMAND_RETRIES = 8
class SyncEngine {
constructor() {
this.isSyncing = false
@@ -90,6 +95,12 @@ class SyncEngine {
await commandQueue.markDone(cmd.id)
} catch (err) {
const status = err.status || err.statusCode
const isPermanentRejection =
status >= 400 &&
status < 500 &&
status !== 401 &&
status !== 408 &&
status !== 429
if (status === 409) {
// Conflict - mark for user attention but continue with other commands
await commandQueue.markFailed(
@@ -99,8 +110,21 @@ class SyncEngine {
} else if (status === 404) {
// Entity no longer exists - discard command
await commandQueue.markDone(cmd.id)
} else if (isPermanentRejection) {
// The server rejected the command outright — retrying can never
// succeed, so park it as failed instead of blocking the queue.
await commandQueue.markFailed(
cmd.id,
`Rejected by server (${status})`,
)
} else if ((cmd.retryCount ?? 0) + 1 >= MAX_COMMAND_RETRIES) {
await commandQueue.markFailed(
cmd.id,
`Gave up after ${MAX_COMMAND_RETRIES} attempts: ${err.message}`,
)
} else {
// Transient network/server error - reset to pending so it retries
await commandQueue.incrementRetry(cmd.id)
await commandQueue.resetPending(cmd.id)
throw err
}
@@ -114,6 +138,23 @@ class SyncEngine {
switch (cmd.commandType) {
case CommandType.CREATE_CHORE:
response = await CreateChore(cmd.payload)
// Offline-created chores are queued under a temp id. Once the server
// assigns the real id, rewrite queued follow-up commands (complete,
// skip, history edits, …) so they don't replay against the temp id.
// Never throw past this point: the chore WAS created, and a retry of
// this command would create a duplicate.
if (response?.ok && String(cmd.entityId).startsWith('temp_')) {
try {
const created = await response.json().catch(() => null)
const realId = created?.res
if (realId != null) {
await commandQueue.remapEntityId(cmd.entityId, realId)
await offlineDB.deleteChores([cmd.entityId])
}
} catch (err) {
console.error('Failed to remap temp chore id after create', err)
}
}
break
case CommandType.UPDATE_CHORE:
@@ -234,7 +275,7 @@ class SyncEngine {
}
// Always advance the cursor, even when there are no changes
if (data.cursor) {
if (data.cursor != null) {
currentCursor = data.cursor
}
@@ -245,11 +286,34 @@ class SyncEngine {
await offlineDB.setLastSyncTime(Date.now())
}
// Cache current chores (call after a successful online fetch)
async cacheChores(chores) {
// Cache current chores (call after a successful online fetch).
// Pass complete: true only when `chores` is the *full* list (including
// archived) — then cached rows missing from it are server-side deletions
// and get removed, so the offline cache doesn't keep ghost chores.
async cacheChores(chores, { complete = false } = {}) {
if (!isOfflineFeatureEnabled()) return
if (!chores || chores.length === 0) return
await offlineDB.saveChores(chores)
if (complete) {
try {
const fetchedIds = new Set(chores.map(chore => String(chore.id)))
const cached = await offlineDB.getChores(true)
const staleIds = (cached || [])
.filter(
chore =>
chore?.id != null &&
!String(chore.id).startsWith('temp_') &&
!fetchedIds.has(String(chore.id)),
)
.map(chore => chore.id)
if (staleIds.length > 0) {
await offlineDB.deleteChores(staleIds)
}
} catch (err) {
console.error('Failed to reconcile cached chores', err)
}
}
// Fire-and-forget: keep the offline image store in step with the data.
// Reconcile against the *full* cached list — the passed list may exclude
// archived chores, and eviction must only run against everything we have.

View File

@@ -38,6 +38,7 @@ import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
@@ -117,7 +118,9 @@ const ArchivedTasks = () => {
const availableLabels = useMemo(() => {
const seen = {}
archivedChores.forEach(c => {
c.labelsV2?.forEach(l => { seen[l.id] = l })
c.labelsV2?.forEach(l => {
seen[l.id] = l
})
})
return Object.values(seen)
}, [archivedChores])
@@ -467,7 +470,9 @@ const ArchivedTasks = () => {
const failedTasks = []
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
isOfflineFeatureEnabled() &&
err instanceof TypeError &&
err.message === 'Failed to fetch'
const queuedTasks = []
for (const chore of selectedData) {
@@ -480,7 +485,11 @@ const ArchivedTasks = () => {
},
onError: async 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' },
])
@@ -500,11 +509,18 @@ const ArchivedTasks = () => {
const allRestored = [...restoredTasks, ...queuedTasks]
if (allRestored.length > 0) {
const offlineNote = queuedTasks.length > 0 ? " (queued — will sync when back online)" : ''
const offlineNote =
queuedTasks.length > 0
? ' (queued — will sync when back online)'
: ''
// Remove from archived view optimistically for both online and queued
const restoredIds = new Set(allRestored.map(c => c.id))
const newArchivedChores = archivedChores.filter(c => !restoredIds.has(c.id))
const newFilteredChores = filteredChores.filter(c => !restoredIds.has(c.id))
const newArchivedChores = archivedChores.filter(
c => !restoredIds.has(c.id),
)
const newFilteredChores = filteredChores.filter(
c => !restoredIds.has(c.id),
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
if (queuedTasks.length > 0) {
@@ -1001,7 +1017,11 @@ const ArchivedTasks = () => {
{(searchTerm || hasActiveFilters) && (
<Box sx={{ display: 'flex', gap: 1 }}>
{searchTerm && (
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
<Button
onClick={handleSearchClose}
variant='outlined'
color='neutral'
>
Clear search
</Button>
)}

View File

@@ -19,9 +19,14 @@ import {
UpdateDueDate,
} from '../../../utils/Fetcher'
import { offlineDB } from '../../../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
// Effectively "can this action be queued offline?" — requires the offline
// feature, otherwise there is no command queue to replay it later.
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
isOfflineFeatureEnabled() &&
err instanceof TypeError &&
err.message === 'Failed to fetch'
export const useChoreActions = ({
chores,

View File

@@ -48,12 +48,17 @@ import {
import { useCircleMembers } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getSafeBottom } from '../../utils/SafeAreaUtils'
import LoadingComponent from '../components/Loading'
// Effectively "can this action be queued offline?" — requires the offline
// feature, otherwise there is no command queue to replay it later.
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
isOfflineFeatureEnabled() &&
err instanceof TypeError &&
err.message === 'Failed to fetch'
const TimerDetails = () => {
const { choreId } = useParams()