diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 61d567e..38cdb1f 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -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, }) diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 24a54d1..e5cb1d1 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -241,8 +241,14 @@ class ApiClient { return response } catch (error) { clearTimeout(timeoutId) - // fetch() threw = network-level failure or timeout — mark server unreachable - networkManager.setServerUnreachable() + // 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 } diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index 44df434..23c32d6 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -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 diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 39b511a..34b12c1 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -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() diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 8ae2004..1ce4172 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -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. diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index cf118f3..de2e375 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -13,15 +13,15 @@ import { ViewModule, } from '@mui/icons-material' import { - Box, - Button, - Container, - Divider, - IconButton, - Input, - List, - Stack, - Typography, + Box, + Button, + Container, + Divider, + IconButton, + Input, + List, + Stack, + Typography, } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' @@ -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) && ( {searchTerm && ( - )} diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index adca5a3..faa3bac 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -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, diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index 5cd8d9e..e41aac7 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -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()