Merge pull request #153 from donetick/upload-and-attachment
Improve Offline mode and stop temp id position, stuck commands can no…
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user