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({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ['choresHistory', limit],
|
queryKey: ['choresHistory', limit],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const resp = await GetChoresHistory(limit, includeMembers)
|
try {
|
||||||
return resp?.res || []
|
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,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
@@ -353,11 +365,30 @@ export const useChoreHistory = choreId => {
|
|||||||
if (!choreId) {
|
if (!choreId) {
|
||||||
throw new Error('Chore ID is required to fetch history')
|
throw new Error('Chore ID is required to fetch history')
|
||||||
}
|
}
|
||||||
const response = await GetChoreHistory(choreId)
|
let json
|
||||||
if (response && response.ok) {
|
try {
|
||||||
return await response.json()
|
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,
|
enabled: !!choreId,
|
||||||
staleTime: 0, // Always consider data stale
|
staleTime: 0, // Always consider data stale
|
||||||
@@ -371,10 +402,62 @@ export const useUpdateChoreHistory = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ choreId, historyId, historyData }) =>
|
mutationFn: async ({ choreId, historyId, historyData }) => {
|
||||||
UpdateChoreHistory(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 }) => {
|
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()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ choreId, historyId }) =>
|
mutationFn: async ({ choreId, historyId }) => {
|
||||||
DeleteChoreHistory(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 }) => {
|
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,
|
completedDate,
|
||||||
performer,
|
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
|
// Optimistically update the cache to show pending state
|
||||||
queryClient.setQueryData(['chores'], oldData => {
|
queryClient.setQueryData(['chores'], oldData => {
|
||||||
if (!oldData) return oldData
|
if (!oldData) return oldData
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
|||||||
export const CommandType = {
|
export const CommandType = {
|
||||||
CREATE_CHORE: 'create_chore',
|
CREATE_CHORE: 'create_chore',
|
||||||
UPDATE_CHORE: 'update_chore',
|
UPDATE_CHORE: 'update_chore',
|
||||||
|
UPDATE_CHORE_HISTORY: 'update_chore_history',
|
||||||
COMPLETE_CHORE: 'complete_chore',
|
COMPLETE_CHORE: 'complete_chore',
|
||||||
SKIP_CHORE: 'skip_chore',
|
SKIP_CHORE: 'skip_chore',
|
||||||
|
START_CHORE: 'start_chore',
|
||||||
|
PAUSE_CHORE: 'pause_chore',
|
||||||
DELETE_CHORE: 'delete_chore',
|
DELETE_CHORE: 'delete_chore',
|
||||||
|
DELETE_CHORE_HISTORY: 'delete_chore_history',
|
||||||
RESCHEDULE_CHORE: 'reschedule_chore',
|
RESCHEDULE_CHORE: 'reschedule_chore',
|
||||||
ARCHIVE_CHORE: 'archive_chore',
|
ARCHIVE_CHORE: 'archive_chore',
|
||||||
UNARCHIVE_CHORE: 'unarchive_chore',
|
UNARCHIVE_CHORE: 'unarchive_chore',
|
||||||
@@ -52,9 +56,17 @@ class CommandQueue {
|
|||||||
// Get pending commands for a specific entity (for undo/UI)
|
// Get pending commands for a specific entity (for undo/UI)
|
||||||
async getPendingForEntity(entityId) {
|
async getPendingForEntity(entityId) {
|
||||||
if (!isOfflineFeatureEnabled()) return []
|
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
|
return commands
|
||||||
.filter(c => c.status === 'pending')
|
.filter(c => c.status === 'pending' || c.status === 'syncing')
|
||||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Capacitor } from '@capacitor/core'
|
|||||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||||
|
|
||||||
const DB_NAME = 'donetick_offline'
|
const DB_NAME = 'donetick_offline'
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 2
|
||||||
const IDB_NAME = 'donetick_offline'
|
const IDB_NAME = 'donetick_offline'
|
||||||
const IDB_VERSION = 1
|
const IDB_VERSION = 2
|
||||||
|
|
||||||
// Cache platform detection
|
// Cache platform detection
|
||||||
let _isNative = null
|
let _isNative = null
|
||||||
@@ -63,6 +63,19 @@ class SQLiteBackend {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
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 ──
|
// ── Command queue ──
|
||||||
|
|
||||||
async enqueueCommand(command) {
|
async enqueueCommand(command) {
|
||||||
@@ -286,6 +424,7 @@ class SQLiteBackend {
|
|||||||
DELETE FROM cached_chores;
|
DELETE FROM cached_chores;
|
||||||
DELETE FROM command_queue;
|
DELETE FROM command_queue;
|
||||||
DELETE FROM sync_meta;
|
DELETE FROM sync_meta;
|
||||||
|
DELETE FROM cached_history;
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -323,6 +462,17 @@ class IndexedDBBackend {
|
|||||||
if (!db.objectStoreNames.contains('sync_meta')) {
|
if (!db.objectStoreNames.contains('sync_meta')) {
|
||||||
db.createObjectStore('sync_meta', { keyPath: 'key' })
|
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)
|
request.onsuccess = event => resolve(event.target.result)
|
||||||
@@ -428,6 +578,135 @@ class IndexedDBBackend {
|
|||||||
await this._request(store.clear())
|
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 ──
|
// ── Command queue ──
|
||||||
|
|
||||||
async enqueueCommand(command) {
|
async enqueueCommand(command) {
|
||||||
@@ -526,7 +805,12 @@ class IndexedDBBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clearAll() {
|
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) {
|
for (const storeName of storeNames) {
|
||||||
const { store } = await this._tx(storeName, 'readwrite')
|
const { store } = await this._tx(storeName, 'readwrite')
|
||||||
await this._request(store.clear())
|
await this._request(store.clear())
|
||||||
@@ -666,6 +950,56 @@ class OfflineDB {
|
|||||||
return this.backend.setLastSyncTime(time)
|
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)
|
// General key-value cache (uses sync_meta store)
|
||||||
async saveKV(key, value) {
|
async saveKV(key, value) {
|
||||||
if (!isOfflineFeatureEnabled()) return
|
if (!isOfflineFeatureEnabled()) return
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import {
|
|||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
CreateChore,
|
CreateChore,
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
|
DeleteChoreHistory,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
|
PauseChore,
|
||||||
SaveChore,
|
SaveChore,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
|
StartChore,
|
||||||
UnArchiveChore,
|
UnArchiveChore,
|
||||||
|
UpdateChoreHistory,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from './Fetcher'
|
} from './Fetcher'
|
||||||
import { offlineDB } from './OfflineDB'
|
import { offlineDB } from './OfflineDB'
|
||||||
@@ -121,10 +125,30 @@ class SyncEngine {
|
|||||||
response = await SkipChore(cmd.payload.id || cmd.entityId)
|
response = await SkipChore(cmd.payload.id || cmd.entityId)
|
||||||
break
|
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:
|
case CommandType.DELETE_CHORE:
|
||||||
response = await DeleteChore(cmd.payload.id || cmd.entityId)
|
response = await DeleteChore(cmd.payload.id || cmd.entityId)
|
||||||
break
|
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: {
|
case CommandType.RESCHEDULE_CHORE: {
|
||||||
const { id, dueDate } = cmd.payload
|
const { id, dueDate } = cmd.payload
|
||||||
response = await UpdateDueDate(id, dueDate)
|
response = await UpdateDueDate(id, dueDate)
|
||||||
@@ -153,7 +177,7 @@ class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _deltaSync() {
|
async _deltaSync() {
|
||||||
const cursor = (await offlineDB.getSyncCursor()) || 0
|
const cursor = (await offlineDB.getSyncCursor()) || -1
|
||||||
|
|
||||||
let hasMore = true
|
let hasMore = true
|
||||||
let currentCursor = cursor
|
let currentCursor = cursor
|
||||||
@@ -182,12 +206,23 @@ class SyncEngine {
|
|||||||
await offlineDB.saveChores(changedChores)
|
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)
|
// Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both)
|
||||||
const deletedIds = data.deletions?.chores ?? []
|
const deletedIds = data.deletions?.chores ?? []
|
||||||
if (deletedIds.length > 0) {
|
if (deletedIds.length > 0) {
|
||||||
await offlineDB.deleteChores(deletedIds)
|
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
|
// Always advance the cursor, even when there are no changes
|
||||||
if (data.cursor) {
|
if (data.cursor) {
|
||||||
currentCursor = data.cursor
|
currentCursor = data.cursor
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
import {
|
||||||
|
useChoreDetails,
|
||||||
|
useChoreHistory,
|
||||||
|
} from '../../queries/ChoreQueries.jsx'
|
||||||
import {
|
import {
|
||||||
useChoreTimer,
|
useChoreTimer,
|
||||||
useDeleteTimeSession,
|
useDeleteTimeSession,
|
||||||
@@ -55,7 +58,11 @@ import {
|
|||||||
} from '../../queries/TimeQueries'
|
} from '../../queries/TimeQueries'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
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 { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||||
import {
|
import {
|
||||||
@@ -68,6 +75,7 @@ import {
|
|||||||
UndoChoreAction,
|
UndoChoreAction,
|
||||||
UpdateChorePriority,
|
UpdateChorePriority,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
import Priorities from '../../utils/Priorities'
|
import Priorities from '../../utils/Priorities'
|
||||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
@@ -123,8 +131,21 @@ const ChoreView = () => {
|
|||||||
|
|
||||||
const { data: choreData, isLoading: isChoreLoading } =
|
const { data: choreData, isLoading: isChoreLoading } =
|
||||||
useChoreDetails(choreId)
|
useChoreDetails(choreId)
|
||||||
|
const { data: choreHistoryData } = useChoreHistory(choreId)
|
||||||
const { data: pendingCmds } = usePendingCommands(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 startChore = useStartChore()
|
||||||
const pauseChore = usePauseChore()
|
const pauseChore = usePauseChore()
|
||||||
const deleteTimeSession = useDeleteTimeSession()
|
const deleteTimeSession = useDeleteTimeSession()
|
||||||
@@ -148,9 +169,61 @@ const ChoreView = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chore && performers?.length > 0) {
|
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 => {
|
const handleUpdatePriority = priority => {
|
||||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||||
if (response.ok) {
|
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 () => {
|
const handleTaskCompletion = async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await MarkChoreComplete(
|
const resp = await MarkChoreComplete(
|
||||||
@@ -279,6 +297,17 @@ const ChoreView = () => {
|
|||||||
performer: null,
|
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'] })
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
showSuccess({
|
showSuccess({
|
||||||
message: "You're offline — completion will sync when back online",
|
message: "You're offline — completion will sync when back online",
|
||||||
@@ -354,6 +383,7 @@ const ChoreView = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleChoreStart = () => {
|
const handleChoreStart = () => {
|
||||||
|
const startedChore = { ...chore, status: ChoreStatus.ACTIVE }
|
||||||
startChore.mutate(choreId, {
|
startChore.mutate(choreId, {
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
const newChore = {
|
const newChore = {
|
||||||
@@ -362,10 +392,37 @@ const ChoreView = () => {
|
|||||||
}
|
}
|
||||||
setChore(newChore)
|
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 handleChorePause = () => {
|
||||||
|
const pausedChore = { ...chore, status: ChoreStatus.PAUSED }
|
||||||
pauseChore.mutate(choreId, {
|
pauseChore.mutate(choreId, {
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
const newChore = {
|
const newChore = {
|
||||||
@@ -374,6 +431,32 @@ const ChoreView = () => {
|
|||||||
}
|
}
|
||||||
setChore(newChore)
|
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,
|
UpdateChoreAssignee,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from '../../../utils/Fetcher'
|
} from '../../../utils/Fetcher'
|
||||||
|
import { offlineDB } from '../../../utils/OfflineDB'
|
||||||
|
|
||||||
const isNetworkError = err =>
|
const isNetworkError = err =>
|
||||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||||
@@ -234,6 +235,17 @@ export const useChoreActions = ({
|
|||||||
performer: null,
|
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'] })
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: 'Task completion pending',
|
title: 'Task completion pending',
|
||||||
@@ -257,57 +269,107 @@ export const useChoreActions = ({
|
|||||||
|
|
||||||
case 'start': {
|
case 'start': {
|
||||||
const startedChore = { ...chore, status: 1 }
|
const startedChore = { ...chore, status: 1 }
|
||||||
startChore.mutate(chore.id, {
|
try {
|
||||||
onSuccess: () => {
|
await startChore.mutateAsync(chore.id)
|
||||||
queryClient.cancelQueries(['chores'])
|
queryClient.cancelQueries(['chores'])
|
||||||
queryClient.setQueryData(['chores', false], oldData => {
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
if (!oldData?.res) return oldData
|
if (!oldData?.res) return oldData
|
||||||
return {
|
return {
|
||||||
...oldData,
|
...oldData,
|
||||||
res: oldData.res.map(c =>
|
res: oldData.res.map(c =>
|
||||||
c.id === chore.id ? startedChore : 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', {
|
updateChoreInState(startedChore, 'started', {
|
||||||
skipInvalidation: true,
|
skipInvalidation: true,
|
||||||
})
|
})
|
||||||
},
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
onError: error => {
|
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({
|
showError({
|
||||||
title: 'Failed to start',
|
title: 'Failed to start',
|
||||||
message: error.message || 'Unable to start chore',
|
message: error?.message || 'Unable to start chore',
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'pause': {
|
case 'pause': {
|
||||||
const pausedChore = { ...chore, status: 2 }
|
const pausedChore = { ...chore, status: 2 }
|
||||||
pauseChore.mutate(chore.id, {
|
try {
|
||||||
onSuccess: () => {
|
await pauseChore.mutateAsync(chore.id)
|
||||||
queryClient.cancelQueries(['chores'])
|
queryClient.cancelQueries(['chores'])
|
||||||
queryClient.setQueryData(['chores', false], oldData => {
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
if (!oldData?.res) return oldData
|
if (!oldData?.res) return oldData
|
||||||
return {
|
return {
|
||||||
...oldData,
|
...oldData,
|
||||||
res: oldData.res.map(c =>
|
res: oldData.res.map(c =>
|
||||||
c.id === chore.id ? pausedChore : 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', {
|
updateChoreInState(pausedChore, 'paused', {
|
||||||
skipInvalidation: true,
|
skipInvalidation: true,
|
||||||
})
|
})
|
||||||
},
|
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
onError: error => {
|
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({
|
showError({
|
||||||
title: 'Failed to pause',
|
title: 'Failed to pause',
|
||||||
message: error.message || 'Unable to pause chore',
|
message: error?.message || 'Unable to pause chore',
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ import DeleteIcon from '@mui/icons-material/Delete'
|
|||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||||
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
import {
|
import {
|
||||||
useChoreHistory,
|
useChoreHistory,
|
||||||
useDeleteChoreHistory,
|
useDeleteChoreHistory,
|
||||||
@@ -48,15 +49,33 @@ const ChoreHistory = () => {
|
|||||||
const { fmt } = useLocalization()
|
const { fmt } = useLocalization()
|
||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||||
const { showSuccess, showError } = useNotification()
|
const { showSuccess } = useNotification()
|
||||||
// React Query hooks
|
// React Query hooks
|
||||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||||
const { data: circleMembersData } = useCircleMembers()
|
const { data: circleMembersData } = useCircleMembers()
|
||||||
const updateChoreHistory = useUpdateChoreHistory()
|
const updateChoreHistory = useUpdateChoreHistory()
|
||||||
const deleteChoreHistory = useDeleteChoreHistory()
|
const deleteChoreHistory = useDeleteChoreHistory()
|
||||||
|
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||||
|
|
||||||
const choreHistory = choreHistoryData?.res || []
|
const choreHistory = choreHistoryData?.res || []
|
||||||
const performers = circleMembersData?.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 => {
|
const handleDelete = historyEntry => {
|
||||||
showConfirmation(
|
showConfirmation(
|
||||||
@@ -366,6 +385,7 @@ const ChoreHistory = () => {
|
|||||||
performers={performers}
|
performers={performers}
|
||||||
allHistory={choreHistory}
|
allHistory={choreHistory}
|
||||||
index={index}
|
index={index}
|
||||||
|
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
|
||||||
onViewNote={notes => {
|
onViewNote={notes => {
|
||||||
setNoteViewerConfig({
|
setNoteViewerConfig({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -407,13 +427,21 @@ const ChoreHistory = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: data => {
|
||||||
setIsEditModalOpen(false)
|
setIsEditModalOpen(false)
|
||||||
setEditHistory(null)
|
setEditHistory(null)
|
||||||
showSuccess({
|
if (data?.queued) {
|
||||||
title: 'History Updated',
|
showSuccess({
|
||||||
message: `The history record has been updated successfully.`,
|
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 => {
|
onError: error => {
|
||||||
console.error('Failed to update chore history:', error)
|
console.error('Failed to update chore history:', error)
|
||||||
@@ -429,13 +457,21 @@ const ChoreHistory = () => {
|
|||||||
historyId: editHistory.id,
|
historyId: editHistory.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: data => {
|
||||||
setIsEditModalOpen(false)
|
setIsEditModalOpen(false)
|
||||||
setEditHistory(null)
|
setEditHistory(null)
|
||||||
showSuccess({
|
if (data?.queued) {
|
||||||
title: 'History Deleted',
|
showSuccess({
|
||||||
message: `The history record has been deleted successfully.`,
|
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 moment from 'moment'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
|
import PendingBadge from '../components/PendingBadge'
|
||||||
|
|
||||||
const getCompletedChip = historyEntry => {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +99,7 @@ const HistoryCard = ({
|
|||||||
performers,
|
performers,
|
||||||
historyEntry,
|
historyEntry,
|
||||||
index,
|
index,
|
||||||
|
pendingCommands,
|
||||||
onToggleActions,
|
onToggleActions,
|
||||||
onViewNote,
|
onViewNote,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -330,6 +336,18 @@ const HistoryCard = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{pendingCommands?.length > 0 && (
|
||||||
|
<PendingBadge
|
||||||
|
commands={pendingCommands}
|
||||||
|
size='s'
|
||||||
|
sx={{
|
||||||
|
mr: 0.5,
|
||||||
|
position: 'absolute',
|
||||||
|
right: -3,
|
||||||
|
top: -3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function ConfirmationModal({ config }) {
|
|||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={config?.onClose}
|
onClose={() => handleAction(false)}
|
||||||
size='sm'
|
size='sm'
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { Refresh, Token } from '@mui/icons-material'
|
import { Refresh, Token } from '@mui/icons-material'
|
||||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { networkManager } from '../../hooks/NetworkManager'
|
||||||
|
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
|
import { commandQueue } from '../../utils/CommandQueue'
|
||||||
import { RefreshToken } from '../../utils/Fetcher'
|
import { RefreshToken } from '../../utils/Fetcher'
|
||||||
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
|
import { syncEngine } from '../../utils/SyncEngine'
|
||||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
|
||||||
const DeveloperSettings = () => {
|
const DeveloperSettings = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
@@ -31,9 +40,48 @@ const DeveloperSettings = () => {
|
|||||||
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
||||||
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
||||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
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 { 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(() => {
|
useEffect(() => {
|
||||||
setIsNativePlatform(isNative())
|
setIsNativePlatform(isNative())
|
||||||
|
|
||||||
@@ -54,12 +102,8 @@ const DeveloperSettings = () => {
|
|||||||
const pending = await LocalNotifications.getPending()
|
const pending = await LocalNotifications.getPending()
|
||||||
// Sort by schedule time (earliest first)
|
// Sort by schedule time (earliest first)
|
||||||
const sorted = pending.notifications.sort((a, b) => {
|
const sorted = pending.notifications.sort((a, b) => {
|
||||||
const timeA = a.schedule?.at
|
const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0
|
||||||
? new Date(a.schedule.at).getTime()
|
const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0
|
||||||
: 0
|
|
||||||
const timeB = b.schedule?.at
|
|
||||||
? new Date(b.schedule.at).getTime()
|
|
||||||
: 0
|
|
||||||
return timeA - timeB
|
return timeA - timeB
|
||||||
})
|
})
|
||||||
setScheduledNotifications(sorted)
|
setScheduledNotifications(sorted)
|
||||||
@@ -73,7 +117,39 @@ const DeveloperSettings = () => {
|
|||||||
|
|
||||||
loadTokenData()
|
loadTokenData()
|
||||||
loadScheduledNotifications()
|
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(() => {
|
useEffect(() => {
|
||||||
const calculateTimeLeft = () => {
|
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 => {
|
const getNotificationStatusColor = scheduleTime => {
|
||||||
if (!scheduleTime) return 'neutral'
|
if (!scheduleTime) return 'neutral'
|
||||||
|
|
||||||
@@ -252,6 +373,11 @@ const DeveloperSettings = () => {
|
|||||||
return 'success' // More than 1 hour
|
return 'success' // More than 1 hour
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDateTime = timestamp => {
|
||||||
|
if (!timestamp) return 'N/A'
|
||||||
|
return new Date(timestamp).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='grid gap-4 py-4' id='developer'>
|
<div className='grid gap-4 py-4' id='developer'>
|
||||||
<Typography level='h3'>Developer Settings</Typography>
|
<Typography level='h3'>Developer Settings</Typography>
|
||||||
@@ -377,6 +503,143 @@ const DeveloperSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</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 && (
|
{isNativePlatform && (
|
||||||
<Card variant='outlined'>
|
<Card variant='outlined'>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
@@ -436,9 +699,7 @@ const DeveloperSettings = () => {
|
|||||||
? new Date(scheduleTime)
|
? new Date(scheduleTime)
|
||||||
: null
|
: null
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeUntil = scheduledDate
|
const timeUntil = scheduledDate ? scheduledDate - now : null
|
||||||
? scheduledDate - now
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -726,6 +987,8 @@ const DeveloperSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmationModal config={confirmModalConfig} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,14 @@ import {
|
|||||||
} from '../../queries/TimeQueries'
|
} from '../../queries/TimeQueries'
|
||||||
import { useCircleMembers } from '../../queries/UserQueries'
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
|
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { getSafeBottom } from '../../utils/SafeAreaUtils'
|
import { getSafeBottom } from '../../utils/SafeAreaUtils'
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
|
|
||||||
|
const isNetworkError = err =>
|
||||||
|
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||||
|
|
||||||
const TimerDetails = () => {
|
const TimerDetails = () => {
|
||||||
const { choreId } = useParams()
|
const { choreId } = useParams()
|
||||||
const { fmt } = useLocalization()
|
const { fmt } = useLocalization()
|
||||||
@@ -256,7 +260,23 @@ const TimerDetails = () => {
|
|||||||
})
|
})
|
||||||
refetchTimer()
|
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({
|
showError({
|
||||||
title: 'Failed to start timer',
|
title: 'Failed to start timer',
|
||||||
message: 'Please try again.',
|
message: 'Please try again.',
|
||||||
@@ -278,7 +298,23 @@ const TimerDetails = () => {
|
|||||||
})
|
})
|
||||||
refetchTimer()
|
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({
|
showError({
|
||||||
title: 'Failed to pause timer',
|
title: 'Failed to pause timer',
|
||||||
message: 'Please try again.',
|
message: 'Please try again.',
|
||||||
@@ -928,9 +964,7 @@ const TimerDetails = () => {
|
|||||||
'MMM DD',
|
'MMM DD',
|
||||||
)
|
)
|
||||||
const startTime = fmt.time(pause.start)
|
const startTime = fmt.time(pause.start)
|
||||||
const endTime = pause.end
|
const endTime = pause.end ? fmt.time(pause.end) : null
|
||||||
? fmt.time(pause.end)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const realTimeDuration = isOngoing
|
const realTimeDuration = isOngoing
|
||||||
? Math.max(
|
? Math.max(
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const LABELS = {
|
|||||||
update_chore: 'Update pending',
|
update_chore: 'Update pending',
|
||||||
create_chore: 'Create pending',
|
create_chore: 'Create pending',
|
||||||
delete_chore: 'Delete pending',
|
delete_chore: 'Delete pending',
|
||||||
|
update_chore_history: 'Edit history pending',
|
||||||
|
delete_chore_history: 'Delete history pending',
|
||||||
reschedule_chore: 'Reschedule pending',
|
reschedule_chore: 'Reschedule pending',
|
||||||
archive_chore: 'Archive pending',
|
archive_chore: 'Archive pending',
|
||||||
unarchive_chore: 'Restore pending',
|
unarchive_chore: 'Restore pending',
|
||||||
@@ -27,6 +29,16 @@ const LABELS = {
|
|||||||
pause_chore: 'Pause pending',
|
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 = {} }) {
|
function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -45,6 +57,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
|||||||
const invalidatePending = async () => {
|
const invalidatePending = async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||||
await queryClient.invalidateQueries({ queryKey: ['chores'] })
|
await queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['choreHistory'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUndo = async (e, cmdId) => {
|
const handleUndo = async (e, cmdId) => {
|
||||||
@@ -150,7 +163,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
|||||||
>
|
>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
|
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
|
||||||
{LABELS[cmd.commandType] || 'Pending action'}
|
{formatCommandLabel(cmd.commandType)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||||
{new Date(cmd.createdAt).toLocaleString()}
|
{new Date(cmd.createdAt).toLocaleString()}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CheckCircleOutline,
|
CheckCircleOutline,
|
||||||
|
ClearAll,
|
||||||
CloudDone,
|
CloudDone,
|
||||||
CloudQueue,
|
CloudQueue,
|
||||||
CloudSync,
|
CloudSync,
|
||||||
@@ -34,14 +35,28 @@ import { syncEngine } from '../../utils/SyncEngine'
|
|||||||
const COMMAND_LABELS = {
|
const COMMAND_LABELS = {
|
||||||
create_chore: 'Create chore',
|
create_chore: 'Create chore',
|
||||||
update_chore: 'Update chore',
|
update_chore: 'Update chore',
|
||||||
|
update_chore_history: 'Edit history',
|
||||||
complete_chore: 'Complete chore',
|
complete_chore: 'Complete chore',
|
||||||
skip_chore: 'Skip chore',
|
skip_chore: 'Skip chore',
|
||||||
|
start_chore: 'Start chore',
|
||||||
|
pause_chore: 'Pause chore',
|
||||||
delete_chore: 'Delete chore',
|
delete_chore: 'Delete chore',
|
||||||
|
delete_chore_history: 'Delete history',
|
||||||
reschedule_chore: 'Reschedule chore',
|
reschedule_chore: 'Reschedule chore',
|
||||||
archive_chore: 'Archive chore',
|
archive_chore: 'Archive chore',
|
||||||
unarchive_chore: 'Restore 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
|
const RETRY_INTERVAL = 30
|
||||||
|
|
||||||
function SyncStatusIndicator() {
|
function SyncStatusIndicator() {
|
||||||
@@ -131,6 +146,16 @@ function SyncStatusIndicator() {
|
|||||||
await refreshCommands()
|
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 => {
|
const formatTime = timestamp => {
|
||||||
if (!timestamp) return 'Never'
|
if (!timestamp) return 'Never'
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||||
@@ -323,7 +348,7 @@ function SyncStatusIndicator() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
{COMMAND_LABELS[type] || type}
|
{formatCommandLabel(type)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip size='sm' color='warning' variant='soft'>
|
<Chip size='sm' color='warning' variant='soft'>
|
||||||
{count}
|
{count}
|
||||||
@@ -375,7 +400,7 @@ function SyncStatusIndicator() {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{COMMAND_LABELS[cmd.commandType] || cmd.commandType}
|
{formatCommandLabel(cmd.commandType)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -448,6 +473,24 @@ function SyncStatusIndicator() {
|
|||||||
|
|
||||||
<Divider sx={{ my: 0.5 }} />
|
<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 */}
|
{/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
disabled={syncState.syncing}
|
disabled={syncState.syncing}
|
||||||
|
|||||||
Reference in New Issue
Block a user