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:
Mo Tarbin
2026-05-12 01:10:49 -04:00
parent d981d57a8f
commit 5869b39035
13 changed files with 1218 additions and 145 deletions

View File

@@ -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

View File

@@ -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) }))
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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',
})
},
}) })
} }

View File

@@ -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
} }

View File

@@ -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.`,
})
}
}, },
}, },
) )

View File

@@ -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>
) )
} }

View File

@@ -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}
> >

View File

@@ -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>
) )
} }

View File

@@ -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(

View File

@@ -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()}

View File

@@ -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}