From 07c31ebde1fa8ad1241da5c4a1bcd21511efb813 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 12 May 2026 01:10:49 -0400 Subject: [PATCH] feat: enhance chore management with history and sync improvements - Added support for starting and pausing chores with offline handling. - Implemented chore history updates and deletions with pending command management. - Enhanced sync engine to handle chore history changes and deletions. - Introduced a PendingBadge component to display pending actions for chore history. - Updated DeveloperSettings to include sync diagnostics and reset functionality. - Improved user notifications for offline actions and pending commands. - Refactored ChoreView to dynamically generate info cards based on chore history. - Added sync status indicator with cancel all functionality for pending commands. --- src/queries/ChoreQueries.jsx | 164 ++++++++- src/utils/CommandQueue.js | 16 +- src/utils/OfflineDB.js | 340 +++++++++++++++++- src/utils/SyncEngine.js | 37 +- src/views/ChoreEdit/ChoreView.jsx | 201 ++++++++--- src/views/Chores/hooks/useChoreActions.js | 130 +++++-- src/views/History/ChoreHistory.jsx | 60 +++- src/views/History/HistoryCard.jsx | 20 +- src/views/Modals/Inputs/ConfirmationModal.jsx | 2 +- src/views/Settings/DeveloperSettings.jsx | 287 ++++++++++++++- src/views/Timer/TimerDetails.jsx | 44 ++- src/views/components/PendingBadge.jsx | 15 +- src/views/components/SyncStatusIndicator.jsx | 47 ++- 13 files changed, 1218 insertions(+), 145 deletions(-) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 3670dd9..86dc132 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -257,8 +257,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => { const { data, error, isLoading } = useQuery({ queryKey: ['choresHistory', limit], queryFn: async () => { - const resp = await GetChoresHistory(limit, includeMembers) - return resp?.res || [] + try { + const resp = await GetChoresHistory(limit, includeMembers) + const entries = resp?.res || [] + // Cache for offline use — fire-and-forget so a cache failure never + // degrades the online experience + if (entries.length > 0) { + offlineDB + .saveHistory(entries) + .catch(err => console.error('Failed to cache chores history:', err)) + } + return entries + } catch { + return offlineDB.getHistoryByDays(limit) + } }, staleTime: 0, }) @@ -353,11 +365,30 @@ export const useChoreHistory = choreId => { if (!choreId) { throw new Error('Chore ID is required to fetch history') } - const response = await GetChoreHistory(choreId) - if (response && response.ok) { - return await response.json() + let json + try { + const response = await GetChoreHistory(choreId) + if (response && response.ok) { + json = await response.json() + } else { + throw new Error('Failed to fetch chore history') + } + } catch { + const cached = await offlineDB.getHistoryByChore(choreId) + return { res: cached } } - throw new Error('Failed to fetch chore history') + // Cache for offline use — fire-and-forget so a cache failure never + // degrades the online view. Inject choreId since the single-chore + // endpoint may omit it from each entry. + const entries = (json?.res || []).map(e => + e.choreId != null ? e : { ...e, choreId: Number(choreId) }, + ) + if (entries.length > 0) { + offlineDB + .saveHistory(entries) + .catch(err => console.error('Failed to cache chore history:', err)) + } + return json }, enabled: !!choreId, staleTime: 0, // Always consider data stale @@ -371,10 +402,62 @@ export const useUpdateChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, historyId, historyData }) => - UpdateChoreHistory(choreId, historyId, historyData), + mutationFn: async ({ choreId, historyId, historyData }) => { + const applyOptimisticUpdate = async () => { + queryClient.setQueryData(['choreHistory', choreId], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(entry => + entry.id === historyId + ? { ...entry, ...historyData, _pendingUpdate: true } + : entry, + ), + } + }) + await offlineDB.updateHistoryEntry(choreId, historyId, { + ...historyData, + _pendingUpdate: true, + }) + return { queued: true } + } + + if (!networkManager.isOnline) { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId, historyData }, + ) + return applyOptimisticUpdate() + } + + try { + const response = await UpdateChoreHistory( + choreId, + historyId, + historyData, + ) + if (!response || !response.ok) { + throw new Error('Failed to update chore history') + } + return response + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue( + CommandType.UPDATE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId, historyData }, + ) + return applyOptimisticUpdate() + } + throw error + } + }, onSuccess: (data, { choreId }) => { - queryClient.invalidateQueries(['choreHistory', choreId]) + if (!data?.queued) { + queryClient.invalidateQueries(['choreHistory', choreId]) + } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -383,10 +466,57 @@ export const useDeleteChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ choreId, historyId }) => - DeleteChoreHistory(choreId, historyId), + mutationFn: async ({ choreId, historyId }) => { + const applyOptimisticDelete = async () => { + queryClient.setQueryData(['choreHistory', choreId], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(entry => + entry.id === historyId + ? { ...entry, _pendingDelete: true } + : entry, + ), + } + }) + await offlineDB.updateHistoryEntry(choreId, historyId, { + _pendingDelete: true, + }) + return { queued: true } + } + + if (!networkManager.isOnline) { + await commandQueue.enqueue( + CommandType.DELETE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId }, + ) + return applyOptimisticDelete() + } + + try { + const response = await DeleteChoreHistory(choreId, historyId) + if (!response || !response.ok) { + throw new Error('Failed to delete chore history') + } + return response + } catch (error) { + if (isNetworkError(error)) { + await commandQueue.enqueue( + CommandType.DELETE_CHORE_HISTORY, + `${choreId}:${historyId}`, + { choreId, historyId }, + ) + return applyOptimisticDelete() + } + throw error + } + }, onSuccess: (data, { choreId }) => { - queryClient.invalidateQueries(['choreHistory', choreId]) + if (!data?.queued) { + queryClient.invalidateQueries(['choreHistory', choreId]) + } + queryClient.invalidateQueries(['pendingCommands']) }, }) } @@ -403,6 +533,16 @@ export const useMarkChoreComplete = () => { completedDate, performer, }) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: body?.completedBy || 0, + performedAt: completedDate || new Date().toISOString(), + notes: body?.note || null, + status: 1, + points: 0, + pending: true, + }) // Optimistically update the cache to show pending state queryClient.setQueryData(['chores'], oldData => { if (!oldData) return oldData diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index ecb33c6..58566b2 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -5,9 +5,13 @@ import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' export const CommandType = { CREATE_CHORE: 'create_chore', UPDATE_CHORE: 'update_chore', + UPDATE_CHORE_HISTORY: 'update_chore_history', COMPLETE_CHORE: 'complete_chore', SKIP_CHORE: 'skip_chore', + START_CHORE: 'start_chore', + PAUSE_CHORE: 'pause_chore', DELETE_CHORE: 'delete_chore', + DELETE_CHORE_HISTORY: 'delete_chore_history', RESCHEDULE_CHORE: 'reschedule_chore', ARCHIVE_CHORE: 'archive_chore', UNARCHIVE_CHORE: 'unarchive_chore', @@ -52,9 +56,17 @@ class CommandQueue { // Get pending commands for a specific entity (for undo/UI) async getPendingForEntity(entityId) { if (!isOfflineFeatureEnabled()) return [] - const commands = await offlineDB.getCommandsByEntity(String(entityId)) + const allCommands = await offlineDB.getCommands() + const key = String(entityId) + const commands = allCommands + .filter( + c => + c.entityId === key || + (typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)), + ) + .sort((a, b) => a.createdAt - b.createdAt) return commands - .filter(c => c.status === 'pending') + .filter(c => c.status === 'pending' || c.status === 'syncing') .map(c => ({ ...c, payload: JSON.parse(c.payload) })) } diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 08f28c1..29be31d 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -3,9 +3,9 @@ import { Capacitor } from '@capacitor/core' import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' const DB_NAME = 'donetick_offline' -const DB_VERSION = 1 +const DB_VERSION = 2 const IDB_NAME = 'donetick_offline' -const IDB_VERSION = 1 +const IDB_VERSION = 2 // Cache platform detection let _isNative = null @@ -63,6 +63,19 @@ class SQLiteBackend { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS cached_history ( + id INTEGER PRIMARY KEY, + chore_id INTEGER NOT NULL, + data TEXT NOT NULL, + performed_at INTEGER NOT NULL, + pending INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_history_chore_id ON cached_history(chore_id); + CREATE INDEX IF NOT EXISTS idx_history_performed_at ON cached_history(performed_at DESC); + CREATE INDEX IF NOT EXISTS idx_history_pending ON cached_history(pending); `, }) @@ -134,6 +147,131 @@ class SQLiteBackend { }) } + // ── History cache ── + + async saveHistory(entries) { + if (!entries.length) return + // Delete pending entries for the affected chore IDs first + const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] + if (choreIds.length) { + const placeholders = choreIds.map(() => '?').join(', ') + await CapacitorSQLite.run({ + database: DB_NAME, + statement: `DELETE FROM cached_history WHERE pending = 1 AND chore_id IN (${placeholders})`, + values: choreIds, + }) + } + const statements = entries.map(entry => ({ + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + entry.id, + Number(entry.choreId), + JSON.stringify(entry), + new Date(entry.performedAt).getTime(), + 0, + Date.now(), + ], + })) + await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements }) + } + + async savePendingHistory(entry) { + await CapacitorSQLite.run({ + database: DB_NAME, + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + entry.id, + Number(entry.choreId), + JSON.stringify(entry), + new Date(entry.performedAt).getTime(), + 1, + Date.now(), + ], + }) + } + + async getHistoryByChore(choreId) { + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: + 'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC', + values: [Number(choreId)], + }) + return (result.values || []).map(row => JSON.parse(row.data)) + } + + async getHistoryByDays(days) { + const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000 + const result = await CapacitorSQLite.query({ + database: DB_NAME, + statement: + since === 0 + ? 'SELECT data FROM cached_history ORDER BY performed_at DESC' + : 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC', + values: since === 0 ? [] : [since], + }) + return (result.values || []).map(row => JSON.parse(row.data)) + } + + async deleteHistory(ids) { + if (!ids.length) return + const statements = ids.map(id => ({ + statement: 'DELETE FROM cached_history WHERE id = ?', + values: [id], + })) + await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements }) + } + + async updateHistoryEntry(choreId, historyId, updates) { + const existing = await CapacitorSQLite.query({ + database: DB_NAME, + statement: 'SELECT data, pending FROM cached_history WHERE id = ?', + values: [historyId], + }) + + if (!existing.values?.length) return + + const row = existing.values[0] + const current = JSON.parse(row.data) + const merged = { + ...current, + ...updates, + id: historyId, + choreId: Number(choreId), + } + + await CapacitorSQLite.run({ + database: DB_NAME, + statement: + 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)', + values: [ + historyId, + Number(choreId), + JSON.stringify(merged), + new Date(merged.performedAt).getTime(), + row.pending || 0, + Date.now(), + ], + }) + } + + async deleteHistoryEntry(historyId) { + await CapacitorSQLite.run({ + database: DB_NAME, + statement: 'DELETE FROM cached_history WHERE id = ?', + values: [historyId], + }) + } + + async clearHistory() { + await CapacitorSQLite.execute({ + database: DB_NAME, + statements: 'DELETE FROM cached_history', + }) + } + // ── Command queue ── async enqueueCommand(command) { @@ -286,6 +424,7 @@ class SQLiteBackend { DELETE FROM cached_chores; DELETE FROM command_queue; DELETE FROM sync_meta; + DELETE FROM cached_history; `, }) } @@ -323,6 +462,17 @@ class IndexedDBBackend { if (!db.objectStoreNames.contains('sync_meta')) { db.createObjectStore('sync_meta', { keyPath: 'key' }) } + + if (!db.objectStoreNames.contains('cached_history')) { + const histStore = db.createObjectStore('cached_history', { + keyPath: 'id', + }) + histStore.createIndex('chore_id', 'choreId', { unique: false }) + histStore.createIndex('performed_at', 'performedAt', { + unique: false, + }) + histStore.createIndex('pending', 'pending', { unique: false }) + } } request.onsuccess = event => resolve(event.target.result) @@ -428,6 +578,135 @@ class IndexedDBBackend { await this._request(store.clear()) } + // ── History cache ── + + async saveHistory(entries) { + if (!entries.length) return + // Delete pending entries for the affected chore IDs first + const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] + await this._deletePendingHistoryByChoreIds(choreIds) + // Upsert real entries + const { tx, store } = await this._tx('cached_history', 'readwrite') + for (const entry of entries) { + store.put({ + id: entry.id, + choreId: Number(entry.choreId), + data: entry, + performedAt: new Date(entry.performedAt).getTime(), + pending: 0, + cachedAt: Date.now(), + }) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async savePendingHistory(entry) { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request( + store.put({ + id: entry.id, + choreId: Number(entry.choreId), + data: entry, + performedAt: new Date(entry.performedAt).getTime(), + pending: 1, + cachedAt: Date.now(), + }), + ) + } + + async _deletePendingHistoryByChoreIds(choreIds) { + if (!choreIds.length) return + const choreIdSet = new Set(choreIds) + // Tx 1: read all pending entries + const { store: readStore } = await this._tx('cached_history') + const index = readStore.index('pending') + const rows = await this._request(index.getAll(1)) + const toDelete = rows + .filter(row => choreIdSet.has(Number(row.choreId))) + .map(row => row.id) + if (!toDelete.length) return + // Tx 2: delete them + const { tx, store: writeStore } = await this._tx( + 'cached_history', + 'readwrite', + ) + for (const id of toDelete) { + writeStore.delete(id) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async getHistoryByChore(choreId) { + const { store } = await this._tx('cached_history') + const index = store.index('chore_id') + const rows = await this._request(index.getAll(Number(choreId))) + return rows + .map(row => row.data) + .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt)) + } + + async getHistoryByDays(days) { + const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000 + const { store } = await this._tx('cached_history') + const rows = await this._request(store.getAll()) + return rows + .map(row => row.data) + .filter(entry => + since === 0 ? true : new Date(entry.performedAt).getTime() >= since, + ) + .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt)) + } + + async deleteHistory(ids) { + if (!ids.length) return + const { tx, store } = await this._tx('cached_history', 'readwrite') + for (const id of ids) { + store.delete(id) + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async updateHistoryEntry(choreId, historyId, updates) { + const { store } = await this._tx('cached_history') + const existing = await this._request(store.get(historyId)) + if (!existing) return + + const merged = { + ...existing, + choreId: Number(choreId), + data: { + ...existing.data, + ...updates, + id: historyId, + choreId: Number(choreId), + }, + } + merged.performedAt = new Date(merged.data.performedAt).getTime() + merged.cachedAt = Date.now() + + const { store: writeStore } = await this._tx('cached_history', 'readwrite') + await this._request(writeStore.put(merged)) + } + + async deleteHistoryEntry(historyId) { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request(store.delete(historyId)) + } + + async clearHistory() { + const { store } = await this._tx('cached_history', 'readwrite') + await this._request(store.clear()) + } + // ── Command queue ── async enqueueCommand(command) { @@ -526,7 +805,12 @@ class IndexedDBBackend { } async clearAll() { - const storeNames = ['cached_chores', 'command_queue', 'sync_meta'] + const storeNames = [ + 'cached_chores', + 'command_queue', + 'sync_meta', + 'cached_history', + ] for (const storeName of storeNames) { const { store } = await this._tx(storeName, 'readwrite') await this._request(store.clear()) @@ -666,6 +950,56 @@ class OfflineDB { return this.backend.setLastSyncTime(time) } + // History cache + async saveHistory(entries) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.saveHistory(entries) + } + + async savePendingHistory(entry) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.savePendingHistory(entry) + } + + async getHistoryByChore(choreId) { + if (!isOfflineFeatureEnabled()) return [] + await this._ensureInit() + console.log('MO: Fetching history for chore', choreId) + return this.backend.getHistoryByChore(choreId) + } + + async getHistoryByDays(days) { + if (!isOfflineFeatureEnabled()) return [] + await this._ensureInit() + return this.backend.getHistoryByDays(days) + } + + async deleteHistory(ids) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.deleteHistory(ids) + } + + async updateHistoryEntry(choreId, historyId, updates) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.updateHistoryEntry(choreId, historyId, updates) + } + + async deleteHistoryEntry(historyId) { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.deleteHistoryEntry(historyId) + } + + async clearHistory() { + if (!isOfflineFeatureEnabled()) return + await this._ensureInit() + return this.backend.clearHistory() + } + // General key-value cache (uses sync_meta store) async saveKV(key, value) { if (!isOfflineFeatureEnabled()) return diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js index 56fc455..0caf2ef 100644 --- a/src/utils/SyncEngine.js +++ b/src/utils/SyncEngine.js @@ -5,10 +5,14 @@ import { ArchiveChore, CreateChore, DeleteChore, + DeleteChoreHistory, MarkChoreComplete, + PauseChore, SaveChore, SkipChore, + StartChore, UnArchiveChore, + UpdateChoreHistory, UpdateDueDate, } from './Fetcher' import { offlineDB } from './OfflineDB' @@ -121,10 +125,30 @@ class SyncEngine { response = await SkipChore(cmd.payload.id || cmd.entityId) break + case CommandType.START_CHORE: + response = await StartChore(cmd.payload.id || cmd.entityId) + break + + case CommandType.PAUSE_CHORE: + response = await PauseChore(cmd.payload.id || cmd.entityId) + break + case CommandType.DELETE_CHORE: response = await DeleteChore(cmd.payload.id || cmd.entityId) break + case CommandType.UPDATE_CHORE_HISTORY: { + const { choreId, historyId, historyData } = cmd.payload + response = await UpdateChoreHistory(choreId, historyId, historyData) + break + } + + case CommandType.DELETE_CHORE_HISTORY: { + const { choreId, historyId } = cmd.payload + response = await DeleteChoreHistory(choreId, historyId) + break + } + case CommandType.RESCHEDULE_CHORE: { const { id, dueDate } = cmd.payload response = await UpdateDueDate(id, dueDate) @@ -153,7 +177,7 @@ class SyncEngine { } async _deltaSync() { - const cursor = (await offlineDB.getSyncCursor()) || 0 + const cursor = (await offlineDB.getSyncCursor()) || -1 let hasMore = true let currentCursor = cursor @@ -182,12 +206,23 @@ class SyncEngine { await offlineDB.saveChores(changedChores) } + // Upsert changed history entries (also clears any pending entries for the same chore IDs) + const changedHistory = data.changes?.choreHistories ?? [] + if (changedHistory.length > 0) { + await offlineDB.saveHistory(changedHistory) + } + // Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both) const deletedIds = data.deletions?.chores ?? [] if (deletedIds.length > 0) { await offlineDB.deleteChores(deletedIds) } + const deletedHistoryIds = data.deletions?.choreHistories ?? [] + if (deletedHistoryIds.length > 0) { + await offlineDB.deleteHistory(deletedHistoryIds) + } + // Always advance the cursor, even when there are no changes if (data.cursor) { currentCursor = data.cursor diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 1917ff0..7373559 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -45,7 +45,10 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useLocalization } from '../../contexts/LocalizationContext' import { usePendingCommands } from '../../hooks/usePendingCommands' -import { useChoreDetails } from '../../queries/ChoreQueries.jsx' +import { + useChoreDetails, + useChoreHistory, +} from '../../queries/ChoreQueries.jsx' import { useChoreTimer, useDeleteTimeSession, @@ -55,7 +58,11 @@ import { } from '../../queries/TimeQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' -import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx' +import { + ChoreHistoryStatus, + ChoreStatus, + notInCompletionWindow, +} from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { commandQueue, CommandType } from '../../utils/CommandQueue' import { @@ -68,6 +75,7 @@ import { UndoChoreAction, UpdateChorePriority, } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' import Priorities from '../../utils/Priorities' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -123,8 +131,21 @@ const ChoreView = () => { const { data: choreData, isLoading: isChoreLoading } = useChoreDetails(choreId) + const { data: choreHistoryData } = useChoreHistory(choreId) const { data: pendingCmds } = usePendingCommands(choreId) + const choreHistory = choreHistoryData?.res || [] + const historyCompletionCount = choreHistory.filter(historyEntry => { + const status = Number(historyEntry?.status) + return ( + status === ChoreHistoryStatus.COMPLETED || + status === ChoreHistoryStatus.SKIPPED + ) + }).length + const completionCount = choreHistoryData + ? historyCompletionCount + : chore.totalCompletedCount || 0 + const startChore = useStartChore() const pauseChore = usePauseChore() const deleteTimeSession = useDeleteTimeSession() @@ -148,9 +169,61 @@ const ChoreView = () => { useEffect(() => { if (chore && performers?.length > 0) { - generateInfoCards(chore) + const cards = [ + { + size: 6, + icon: , + title: t('choreView.assignment'), + text: `${t('choreView.assigned')}: ${ + performers.find(p => p.userId === chore.assignedTo)?.displayName || + t('choreView.na') + }`, + subtext: ` ${t('choreView.last')}: ${ + chore.lastCompletedDate + ? performers.find(p => p.userId === chore.lastCompletedBy) + ?.displayName + : 'N/A' + }`, + }, + { + size: 6, + icon: , + title: t('choreView.schedule'), + text: `${t('choreView.due')}: ${ + chore.nextDueDate + ? moment(chore.nextDueDate).fromNow() + : t('choreView.na') + }`, + subtext: `${t('choreView.last')}: ${ + chore.lastCompletedDate + ? moment(chore.lastCompletedDate).fromNow() + : t('choreView.na') + }`, + + subtext2: + chore.deadlineOffset > 0 && chore.nextDueDate + ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` + : null, + }, + { + size: 6, + icon: , + title: t('choreView.statistics'), + text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`, + }, + { + size: 6, + icon: , + title: t('choreView.details'), + subtext: `${t('choreView.createdBy')}: ${ + performers.find(p => p.userId === chore.createdBy)?.displayName || + t('choreView.na') + }`, + }, + ] + setInfoCards(cards) } - }, [chore, performers]) + }, [chore, performers, completionCount, t]) const handleUpdatePriority = priority => { UpdateChorePriority(choreId, priority.value).then(response => { if (response.ok) { @@ -161,61 +234,6 @@ const ChoreView = () => { } }) } - const generateInfoCards = chore => { - const cards = [ - { - size: 6, - icon: , - title: t('choreView.assignment'), - text: `${t('choreView.assigned')}: ${ - performers.find(p => p.userId === chore.assignedTo)?.displayName || - t('choreView.na') - }`, - subtext: ` ${t('choreView.last')}: ${ - chore.lastCompletedDate - ? performers.find(p => p.userId === chore.lastCompletedBy) - ?.displayName - : 'N/A' - }`, - }, - { - size: 6, - icon: , - title: t('choreView.schedule'), - text: `${t('choreView.due')}: ${ - chore.nextDueDate - ? moment(chore.nextDueDate).fromNow() - : t('choreView.na') - }`, - subtext: `${t('choreView.last')}: ${ - chore.lastCompletedDate - ? moment(chore.lastCompletedDate).fromNow() - : t('choreView.na') - }`, - - subtext2: - chore.deadlineOffset > 0 && chore.nextDueDate - ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` - : null, - }, - { - size: 6, - icon: , - title: t('choreView.statistics'), - text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`, - }, - { - size: 6, - icon: , - title: t('choreView.details'), - subtext: `${t('choreView.createdBy')}: ${ - performers.find(p => p.userId === chore.createdBy)?.displayName || - t('choreView.na') - }`, - }, - ] - setInfoCards(cards) - } const handleTaskCompletion = async () => { try { const resp = await MarkChoreComplete( @@ -279,6 +297,17 @@ const ChoreView = () => { performer: null, }, ) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: Number(choreId), + completedBy: impersonatedUser?.userId || userProfile?.id || 0, + performedAt: completedDate || new Date().toISOString(), + dueDate: chore.nextDueDate || null, + notes: note || null, + status: 1, + points: chore.points || 0, + pending: true, + }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ message: "You're offline — completion will sync when back online", @@ -354,6 +383,7 @@ const ChoreView = () => { } } const handleChoreStart = () => { + const startedChore = { ...chore, status: ChoreStatus.ACTIVE } startChore.mutate(choreId, { onSuccess: data => { const newChore = { @@ -362,10 +392,37 @@ const ChoreView = () => { } setChore(newChore) }, + onError: async error => { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + choreId, + { id: choreId }, + ) + setChore(startedChore) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + setChore({ ...chore, status: previousStatus }) + }, + }) + return + } + + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to start task', + }) + }, }) } const handleChorePause = () => { + const pausedChore = { ...chore, status: ChoreStatus.PAUSED } pauseChore.mutate(choreId, { onSuccess: data => { const newChore = { @@ -374,6 +431,32 @@ const ChoreView = () => { } setChore(newChore) }, + onError: async error => { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + choreId, + { id: choreId }, + ) + setChore(pausedChore) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + setChore({ ...chore, status: previousStatus }) + }, + }) + return + } + + showError({ + title: t('choreView.undoFailed'), + message: error?.message || 'Unable to pause task', + }) + }, }) } diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 39119f3..1c4e3cf 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -17,6 +17,7 @@ import { UpdateChoreAssignee, UpdateDueDate, } from '../../../utils/Fetcher' +import { offlineDB } from '../../../utils/OfflineDB' const isNetworkError = err => err instanceof TypeError && err.message === 'Failed to fetch' @@ -234,6 +235,17 @@ export const useChoreActions = ({ performer: null, }, ) + await offlineDB.savePendingHistory({ + id: -Date.now(), + choreId: chore.id, + completedBy: impersonatedUser?.userId || userProfile?.id || 0, + performedAt: new Date().toISOString(), + dueDate: chore.nextDueDate || null, + notes: null, + status: 1, + points: chore.points || 0, + pending: true, + }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ title: 'Task completion pending', @@ -257,57 +269,107 @@ export const useChoreActions = ({ case 'start': { const startedChore = { ...chore, status: 1 } - startChore.mutate(chore.id, { - onSuccess: () => { - queryClient.cancelQueries(['chores']) - queryClient.setQueryData(['chores', false], oldData => { - if (!oldData?.res) return oldData - return { - ...oldData, - res: oldData.res.map(c => - c.id === chore.id ? startedChore : c, - ), - } - }) + try { + await startChore.mutateAsync(chore.id) + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? startedChore : c, + ), + } + }) + updateChoreInState(startedChore, 'started', { + skipInvalidation: true, + }) + } catch (error) { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + chore.id, + { id: chore.id }, + ) updateChoreInState(startedChore, 'started', { skipInvalidation: true, }) - }, - onError: error => { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + updateChoreInState( + { ...chore, status: previousStatus }, + previousStatus === 2 ? 'paused' : 'started', + { skipInvalidation: true }, + ) + }, + }) + } else { showError({ title: 'Failed to start', - message: error.message || 'Unable to start chore', + message: error?.message || 'Unable to start chore', }) - }, - }) + } + } break } case 'pause': { const pausedChore = { ...chore, status: 2 } - pauseChore.mutate(chore.id, { - onSuccess: () => { - queryClient.cancelQueries(['chores']) - queryClient.setQueryData(['chores', false], oldData => { - if (!oldData?.res) return oldData - return { - ...oldData, - res: oldData.res.map(c => - c.id === chore.id ? pausedChore : c, - ), - } - }) + try { + await pauseChore.mutateAsync(chore.id) + queryClient.cancelQueries(['chores']) + queryClient.setQueryData(['chores', false], oldData => { + if (!oldData?.res) return oldData + return { + ...oldData, + res: oldData.res.map(c => + c.id === chore.id ? pausedChore : c, + ), + } + }) + updateChoreInState(pausedChore, 'paused', { + skipInvalidation: true, + }) + } catch (error) { + if (isNetworkError(error)) { + const previousStatus = chore.status + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + chore.id, + { id: chore.id }, + ) updateChoreInState(pausedChore, 'paused', { skipInvalidation: true, }) - }, - onError: error => { + queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) + showSuccess({ + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + queryClient.invalidateQueries({ + queryKey: ['pendingCommands'], + }) + updateChoreInState( + { ...chore, status: previousStatus }, + previousStatus === 2 ? 'paused' : 'started', + { skipInvalidation: true }, + ) + }, + }) + } else { showError({ title: 'Failed to pause', - message: error.message || 'Unable to pause chore', + message: error?.message || 'Unable to pause chore', }) - }, - }) + } + } break } diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 17ed410..b7d8543 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -20,10 +20,11 @@ import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy' import moment from 'moment' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useLocalization } from '../../contexts/LocalizationContext' import useConfirmationModal from '../../hooks/useConfirmationModal' +import { usePendingCommands } from '../../hooks/usePendingCommands' import { useChoreHistory, useDeleteChoreHistory, @@ -48,15 +49,33 @@ const ChoreHistory = () => { const { fmt } = useLocalization() const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) - const { showSuccess, showError } = useNotification() + const { showSuccess } = useNotification() // React Query hooks const { data: choreHistoryData, isLoading } = useChoreHistory(choreId) const { data: circleMembersData } = useCircleMembers() const updateChoreHistory = useUpdateChoreHistory() const deleteChoreHistory = useDeleteChoreHistory() + const { data: pendingCmds } = usePendingCommands(choreId) const choreHistory = choreHistoryData?.res || [] const performers = circleMembersData?.res || [] + const pendingByHistoryId = useMemo(() => { + if (!pendingCmds?.length) return {} + return pendingCmds.reduce((acc, cmd) => { + if ( + cmd.commandType !== 'update_chore_history' && + cmd.commandType !== 'delete_chore_history' + ) { + return acc + } + const historyId = + cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1]) + if (!historyId) return acc + if (!acc[historyId]) acc[historyId] = [] + acc[historyId].push(cmd) + return acc + }, {}) + }, [pendingCmds]) const handleDelete = historyEntry => { showConfirmation( @@ -366,6 +385,7 @@ const ChoreHistory = () => { performers={performers} allHistory={choreHistory} index={index} + pendingCommands={pendingByHistoryId[historyEntry.id] || []} onViewNote={notes => { setNoteViewerConfig({ isOpen: true, @@ -407,13 +427,21 @@ const ChoreHistory = () => { }, }, { - onSuccess: () => { + onSuccess: data => { setIsEditModalOpen(false) setEditHistory(null) - showSuccess({ - title: 'History Updated', - message: `The history record has been updated successfully.`, - }) + if (data?.queued) { + showSuccess({ + title: 'History Update Queued', + message: + 'You are offline. The history update will sync when connection is restored.', + }) + } else { + showSuccess({ + title: 'History Updated', + message: `The history record has been updated successfully.`, + }) + } }, onError: error => { console.error('Failed to update chore history:', error) @@ -429,13 +457,21 @@ const ChoreHistory = () => { historyId: editHistory.id, }, { - onSuccess: () => { + onSuccess: data => { setIsEditModalOpen(false) setEditHistory(null) - showSuccess({ - title: 'History Deleted', - message: `The history record has been deleted successfully.`, - }) + if (data?.queued) { + showSuccess({ + title: 'History Delete Queued', + message: + 'You are offline. The history delete will sync when connection is restored.', + }) + } else { + showSuccess({ + title: 'History Deleted', + message: `The history record has been deleted successfully.`, + }) + } }, }, ) diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index cd81f81..81b94e6 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -17,9 +17,14 @@ import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy' import moment from 'moment' import { useLocalization } from '../../contexts/LocalizationContext' import { TASK_COLOR } from '../../utils/Colors.jsx' +import PendingBadge from '../components/PendingBadge' const getCompletedChip = historyEntry => { - if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) { + if ( + historyEntry.status === 0 || + historyEntry.status === 5 || + historyEntry.status === 6 + ) { return null } @@ -94,6 +99,7 @@ const HistoryCard = ({ performers, historyEntry, index, + pendingCommands, onToggleActions, onViewNote, }) => { @@ -330,6 +336,18 @@ const HistoryCard = ({ )} + {pendingCommands?.length > 0 && ( + + )} ) } diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx index f86668e..e2ae0b8 100644 --- a/src/views/Modals/Inputs/ConfirmationModal.jsx +++ b/src/views/Modals/Inputs/ConfirmationModal.jsx @@ -73,7 +73,7 @@ function ConfirmationModal({ config }) { return ( handleAction(false)} size='sm' unmountDelay={250} > diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx index 35ef183..df72c41 100644 --- a/src/views/Settings/DeveloperSettings.jsx +++ b/src/views/Settings/DeveloperSettings.jsx @@ -1,14 +1,23 @@ +import { LocalNotifications } from '@capacitor/local-notifications' import { Refresh, Token } from '@mui/icons-material' import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' -import { useEffect, useState } from 'react' -import { LocalNotifications } from '@capacitor/local-notifications' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { networkManager } from '../../hooks/NetworkManager' +import useConfirmationModal from '../../hooks/useConfirmationModal' import { useSSEContext } from '../../hooks/useSSEContext' import { useNotification } from '../../service/NotificationProvider' import { apiClient } from '../../utils/ApiClient' +import { commandQueue } from '../../utils/CommandQueue' import { RefreshToken } from '../../utils/Fetcher' +import { offlineDB } from '../../utils/OfflineDB' +import { syncEngine } from '../../utils/SyncEngine' import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' const DeveloperSettings = () => { + const queryClient = useQueryClient() + const { confirmModalConfig, showConfirmation } = useConfirmationModal() const { isConnected, isConnecting, @@ -31,9 +40,48 @@ const DeveloperSettings = () => { const [isRefreshingDirect, setIsRefreshingDirect] = useState(false) const [scheduledNotifications, setScheduledNotifications] = useState([]) const [isLoadingNotifications, setIsLoadingNotifications] = useState(false) + const [isResettingSync, setIsResettingSync] = useState(false) + const [syncDiagnostics, setSyncDiagnostics] = useState({ + cursor: null, + lastSync: null, + pendingCount: 0, + failedCount: 0, + syncing: false, + syncError: null, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + }) const { showNotification } = useNotification() + const refreshSyncDiagnostics = useCallback(async () => { + try { + const [cursor, lastSync, pendingCommands, failedCommands] = + await Promise.all([ + offlineDB.getSyncCursor(), + offlineDB.getLastSyncTime(), + commandQueue.getPending(), + commandQueue.getFailed(), + ]) + + setSyncDiagnostics(prev => ({ + ...prev, + cursor, + lastSync, + pendingCount: pendingCommands.length, + failedCount: failedCommands.length, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + })) + } catch (error) { + console.error('Failed to load sync diagnostics:', error) + } + }, []) + useEffect(() => { setIsNativePlatform(isNative()) @@ -54,12 +102,8 @@ const DeveloperSettings = () => { const pending = await LocalNotifications.getPending() // Sort by schedule time (earliest first) const sorted = pending.notifications.sort((a, b) => { - const timeA = a.schedule?.at - ? new Date(a.schedule.at).getTime() - : 0 - const timeB = b.schedule?.at - ? new Date(b.schedule.at).getTime() - : 0 + const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0 + const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0 return timeA - timeB }) setScheduledNotifications(sorted) @@ -73,7 +117,39 @@ const DeveloperSettings = () => { loadTokenData() loadScheduledNotifications() - }, []) + refreshSyncDiagnostics() + }, [refreshSyncDiagnostics]) + + useEffect(() => { + const unsubscribeSync = syncEngine.onSyncStateChange(state => { + setSyncDiagnostics(prev => ({ + ...prev, + syncing: + typeof state.syncing === 'boolean' ? state.syncing : prev.syncing, + syncError: state.error ?? prev.syncError, + lastSync: state.lastSync ?? prev.lastSync, + })) + }) + + networkManager.registerNetworkListener(() => { + setSyncDiagnostics(prev => ({ + ...prev, + isOnline: networkManager.isOnline, + isNetworkOn: networkManager.isNetworkOn, + offlineSince: networkManager.offlineSince, + lastChecked: networkManager.lastChecked, + })) + }) + + const interval = setInterval(() => { + refreshSyncDiagnostics() + }, 5000) + + return () => { + unsubscribeSync() + clearInterval(interval) + } + }, [refreshSyncDiagnostics]) useEffect(() => { const calculateTimeLeft = () => { @@ -239,6 +315,51 @@ const DeveloperSettings = () => { } } + const handleResetDatabaseAndResync = async () => { + showConfirmation( + 'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?', + 'Clear Local DB & Re-Sync', + async () => { + setIsResettingSync(true) + try { + await offlineDB.clearAll() + + showNotification({ + type: 'success', + message: 'Local offline database cleared. Starting full sync...', + }) + + const didSync = await syncEngine.sync() + if (didSync) { + await queryClient.invalidateQueries() + showNotification({ + type: 'success', + message: 'Full sync completed from the beginning', + }) + } else { + showNotification({ + type: 'warning', + message: + 'Database cleared. Full sync did not run (likely offline or already syncing).', + }) + } + } catch (error) { + console.error('Failed to reset database and resync:', error) + showNotification({ + type: 'error', + message: `Reset/resync failed: ${error.message}`, + }) + } finally { + await refreshSyncDiagnostics() + setIsResettingSync(false) + } + }, + 'Clear & Re-Sync', + 'Cancel', + 'danger', + ) + } + const getNotificationStatusColor = scheduleTime => { if (!scheduleTime) return 'neutral' @@ -252,6 +373,11 @@ const DeveloperSettings = () => { return 'success' // More than 1 hour } + const formatDateTime = timestamp => { + if (!timestamp) return 'N/A' + return new Date(timestamp).toLocaleString() + } + return (
Developer Settings @@ -377,6 +503,143 @@ const DeveloperSettings = () => { + + + + Sync & Network Diagnostics + + + + + + + Network Status + + Connection:{' '} + + {syncDiagnostics.isOnline ? 'Online' : 'Offline'} + + + + Device Network:{' '} + + {syncDiagnostics.isNetworkOn === false + ? 'Disconnected' + : syncDiagnostics.isNetworkOn === true + ? 'Connected' + : 'Unknown'} + + + + Offline Since: {formatDateTime(syncDiagnostics.offlineSince)} + + + Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)} + + + + + + + Sync Offset Information + + Sync Cursor:{' '} + + {syncDiagnostics.cursor ?? 'N/A'} + + + + Last Sync:{' '} + + {formatDateTime(syncDiagnostics.lastSync)} + + + + Sync State:{' '} + + {syncDiagnostics.syncing ? 'Syncing' : 'Idle'} + + + + Pending Commands:{' '} + + {syncDiagnostics.pendingCount} + + + + Failed Commands:{' '} + 0 ? 'danger' : 'success'} + > + {syncDiagnostics.failedCount} + + + {syncDiagnostics.syncError && ( + + Sync Error: {syncDiagnostics.syncError} + + )} + + + + + + Recovery Actions + + Clears local offline cache, sync cursor, and queued commands, then + re-syncs from the beginning. + + + + + + + + {isNativePlatform && ( @@ -436,9 +699,7 @@ const DeveloperSettings = () => { ? new Date(scheduleTime) : null const now = new Date() - const timeUntil = scheduledDate - ? scheduledDate - now - : null + const timeUntil = scheduledDate ? scheduledDate - now : null return ( { + +
) } diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index 349e15b..5cd8d9e 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -47,10 +47,14 @@ import { } from '../../queries/TimeQueries' import { useCircleMembers } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' +import { commandQueue, CommandType } from '../../utils/CommandQueue' import { resolvePhotoURL } from '../../utils/Helpers' import { getSafeBottom } from '../../utils/SafeAreaUtils' import LoadingComponent from '../components/Loading' +const isNetworkError = err => + err instanceof TypeError && err.message === 'Failed to fetch' + const TimerDetails = () => { const { choreId } = useParams() const { fmt } = useLocalization() @@ -256,7 +260,23 @@ const TimerDetails = () => { }) refetchTimer() }, - onError: () => { + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.START_CHORE, + choreId, + { id: choreId }, + ) + showSuccess({ + title: 'Start queued', + message: "You're offline — start will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + }, + }) + return + } + showError({ title: 'Failed to start timer', message: 'Please try again.', @@ -278,7 +298,23 @@ const TimerDetails = () => { }) refetchTimer() }, - onError: () => { + onError: async error => { + if (isNetworkError(error)) { + const cmdId = await commandQueue.enqueue( + CommandType.PAUSE_CHORE, + choreId, + { id: choreId }, + ) + showSuccess({ + title: 'Pause queued', + message: "You're offline — pause will sync when back online", + undoAction: async () => { + await commandQueue.cancel(cmdId) + }, + }) + return + } + showError({ title: 'Failed to pause timer', message: 'Please try again.', @@ -928,9 +964,7 @@ const TimerDetails = () => { 'MMM DD', ) const startTime = fmt.time(pause.start) - const endTime = pause.end - ? fmt.time(pause.end) - : null + const endTime = pause.end ? fmt.time(pause.end) : null const realTimeDuration = isOngoing ? Math.max( diff --git a/src/views/components/PendingBadge.jsx b/src/views/components/PendingBadge.jsx index 8b659ce..aba2ace 100644 --- a/src/views/components/PendingBadge.jsx +++ b/src/views/components/PendingBadge.jsx @@ -20,6 +20,8 @@ const LABELS = { update_chore: 'Update pending', create_chore: 'Create pending', delete_chore: 'Delete pending', + update_chore_history: 'Edit history pending', + delete_chore_history: 'Delete history pending', reschedule_chore: 'Reschedule pending', archive_chore: 'Archive pending', unarchive_chore: 'Restore pending', @@ -27,6 +29,16 @@ const LABELS = { pause_chore: 'Pause pending', } +const formatCommandLabel = commandType => { + return ( + LABELS[commandType] || + commandType + ?.replace(/_/g, ' ') + ?.replace(/\b\w/g, letter => letter.toUpperCase()) || + 'Pending action' + ) +} + function PendingBadge({ commands, size = 'sm', sx = {} }) { const { ResponsiveModal } = useResponsiveModal() const queryClient = useQueryClient() @@ -45,6 +57,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) { const invalidatePending = async () => { await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) await queryClient.invalidateQueries({ queryKey: ['chores'] }) + await queryClient.invalidateQueries({ queryKey: ['choreHistory'] }) } const handleUndo = async (e, cmdId) => { @@ -150,7 +163,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) { > - {LABELS[cmd.commandType] || 'Pending action'} + {formatCommandLabel(cmd.commandType)} {new Date(cmd.createdAt).toLocaleString()} diff --git a/src/views/components/SyncStatusIndicator.jsx b/src/views/components/SyncStatusIndicator.jsx index aaf5645..f8ef7e7 100644 --- a/src/views/components/SyncStatusIndicator.jsx +++ b/src/views/components/SyncStatusIndicator.jsx @@ -1,5 +1,6 @@ import { CheckCircleOutline, + ClearAll, CloudDone, CloudQueue, CloudSync, @@ -34,14 +35,28 @@ import { syncEngine } from '../../utils/SyncEngine' const COMMAND_LABELS = { create_chore: 'Create chore', update_chore: 'Update chore', + update_chore_history: 'Edit history', complete_chore: 'Complete chore', skip_chore: 'Skip chore', + start_chore: 'Start chore', + pause_chore: 'Pause chore', delete_chore: 'Delete chore', + delete_chore_history: 'Delete history', reschedule_chore: 'Reschedule chore', archive_chore: 'Archive chore', unarchive_chore: 'Restore chore', } +const formatCommandLabel = commandType => { + return ( + COMMAND_LABELS[commandType] || + commandType + ?.replace(/_/g, ' ') + ?.replace(/\b\w/g, letter => letter.toUpperCase()) || + 'Pending action' + ) +} + const RETRY_INTERVAL = 30 function SyncStatusIndicator() { @@ -131,6 +146,16 @@ function SyncStatusIndicator() { await refreshCommands() } + const handleCancelAll = async () => { + const [pending, failed] = await Promise.all([ + commandQueue.getPending(), + commandQueue.getFailed(), + ]) + const allCommands = [...pending, ...failed] + await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.id))) + await refreshCommands() + } + const formatTime = timestamp => { if (!timestamp) return 'Never' const seconds = Math.floor((Date.now() - timestamp) / 1000) @@ -323,7 +348,7 @@ function SyncStatusIndicator() { }} > - {COMMAND_LABELS[type] || type} + {formatCommandLabel(type)} {count} @@ -375,7 +400,7 @@ function SyncStatusIndicator() { fontWeight: 500, }} > - {COMMAND_LABELS[cmd.commandType] || cmd.commandType} + {formatCommandLabel(cmd.commandType)}