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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <PeopleAlt />,
|
||||
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: <CalendarMonth />,
|
||||
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: <Checklist />,
|
||||
title: t('choreView.statistics'),
|
||||
text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <Person />,
|
||||
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: <PeopleAlt />,
|
||||
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: <CalendarMonth />,
|
||||
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: <Checklist />,
|
||||
title: t('choreView.statistics'),
|
||||
text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <Person />,
|
||||
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',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 = ({
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{pendingCommands?.length > 0 && (
|
||||
<PendingBadge
|
||||
commands={pendingCommands}
|
||||
size='s'
|
||||
sx={{
|
||||
mr: 0.5,
|
||||
position: 'absolute',
|
||||
right: -3,
|
||||
top: -3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function ConfirmationModal({ config }) {
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
unmountDelay={250}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<div className='grid gap-4 py-4' id='developer'>
|
||||
<Typography level='h3'>Developer Settings</Typography>
|
||||
@@ -377,6 +503,143 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-lg'>Sync & Network Diagnostics</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Refresh />}
|
||||
onClick={refreshSyncDiagnostics}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Network Status</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Connection:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.isOnline ? 'success' : 'danger'}
|
||||
>
|
||||
{syncDiagnostics.isOnline ? 'Online' : 'Offline'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Device Network:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={
|
||||
syncDiagnostics.isNetworkOn === false
|
||||
? 'danger'
|
||||
: syncDiagnostics.isNetworkOn === true
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{syncDiagnostics.isNetworkOn === false
|
||||
? 'Disconnected'
|
||||
: syncDiagnostics.isNetworkOn === true
|
||||
? 'Connected'
|
||||
: 'Unknown'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Offline Since: {formatDateTime(syncDiagnostics.offlineSince)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Sync Offset Information</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Sync Cursor:{' '}
|
||||
<Chip size='sm' variant='soft'>
|
||||
{syncDiagnostics.cursor ?? 'N/A'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Last Sync:{' '}
|
||||
<Chip size='sm' variant='soft' color='primary'>
|
||||
{formatDateTime(syncDiagnostics.lastSync)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Sync State:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.syncing ? 'warning' : 'success'}
|
||||
>
|
||||
{syncDiagnostics.syncing ? 'Syncing' : 'Idle'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Pending Commands:{' '}
|
||||
<Chip size='sm' variant='soft' color='warning'>
|
||||
{syncDiagnostics.pendingCount}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Failed Commands:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.failedCount > 0 ? 'danger' : 'success'}
|
||||
>
|
||||
{syncDiagnostics.failedCount}
|
||||
</Chip>
|
||||
</Typography>
|
||||
{syncDiagnostics.syncError && (
|
||||
<Typography level='body-sm' color='danger'>
|
||||
Sync Error: {syncDiagnostics.syncError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Recovery Actions</Typography>
|
||||
<Typography level='body-xs' color='warning'>
|
||||
Clears local offline cache, sync cursor, and queued commands, then
|
||||
re-syncs from the beginning.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='soft'
|
||||
onClick={handleResetDatabaseAndResync}
|
||||
loading={isResettingSync}
|
||||
disabled={isResettingSync || syncDiagnostics.syncing}
|
||||
>
|
||||
Clear DB & Full Re-Sync
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{isNativePlatform && (
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -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 (
|
||||
<Card
|
||||
@@ -726,6 +987,8 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {} }) {
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
|
||||
{LABELS[cmd.commandType] || 'Pending action'}
|
||||
{formatCommandLabel(cmd.commandType)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{new Date(cmd.createdAt).toLocaleString()}
|
||||
|
||||
@@ -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() {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>
|
||||
{COMMAND_LABELS[type] || type}
|
||||
{formatCommandLabel(type)}
|
||||
</Typography>
|
||||
<Chip size='sm' color='warning' variant='soft'>
|
||||
{count}
|
||||
@@ -375,7 +400,7 @@ function SyncStatusIndicator() {
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{COMMAND_LABELS[cmd.commandType] || cmd.commandType}
|
||||
{formatCommandLabel(cmd.commandType)}
|
||||
</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -448,6 +473,24 @@ function SyncStatusIndicator() {
|
||||
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
|
||||
<MenuItem
|
||||
disabled={syncState.syncing || totalBadge === 0}
|
||||
onClick={handleCancelAll}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<ClearAll sx={{ fontSize: 18 }} />
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Cancel All
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */}
|
||||
<MenuItem
|
||||
disabled={syncState.syncing}
|
||||
|
||||
Reference in New Issue
Block a user