Merge pull request #99 from donetick/offline-support-v2

Offline support v2
This commit is contained in:
Mohamad Tarbin
2026-05-25 01:57:27 -04:00
committed by GitHub
38 changed files with 4487 additions and 1188 deletions

View File

@@ -11,6 +11,7 @@ import SSEProvider from './contexts/SSEContext'
import { AuthProvider } from './hooks/useAuth.jsx'
import { useNotification } from './service/NotificationProvider'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
import NetworkBanner from './views/components/NetworkBanner'
const add = className => {
@@ -34,6 +35,7 @@ const startOpenReplay = () => {
const AppContent = () => {
const { showNotification } = useNotification()
useSyncOnReconnect()
const {
needRefresh: [needRefresh, setNeedRefresh],

View File

@@ -1,6 +1,5 @@
import { Network } from '@capacitor/network'
import { localStore } from '../utils/LocalStore'
import { syncManager } from '../utils/SyncManager.jsx' // Ensure you import syncManager if needed for syncing
class NetworkManager {
constructor() {
this.isOnline = true
@@ -21,35 +20,12 @@ class NetworkManager {
this.isNetworkOn = status.connected
this.lastChecked = Date.now()
this.isOnline = status.connected
if (!status.connected) {
this.offlineSince = Date.now()
}
this.notifyConnectionStatus()
}
})
const syncQueue = () => {
localStore
.syncQueuedRequests()
.then(hasMessages => {
console.log(
'Queued requests synced successfully. Queue has messaage is: ',
hasMessages,
)
if (hasMessages) {
this.notifyBackendSync()
}
})
.catch(error => {
console.error('Error syncing queued requests:', error)
})
}
this.registerNetworkListener(async isOnline => {
if (isOnline && this.isNetworkOn) {
// TODO: Delete when Sync manager Implemented
syncQueue()
console.log('NetworkManager: Network is back online. with SYNCMANAGER')
await syncManager.syncTasks()
console.log('Finished syncing queued requests.')
}
})
syncQueue()
}
setOffline() {
@@ -81,6 +57,11 @@ class NetworkManager {
registerNetworkListener(callback) {
this.connectionStatusListeners.push(callback)
}
unregisterNetworkListener(callback) {
this.connectionStatusListeners = this.connectionStatusListeners.filter(
cb => cb !== callback,
)
}
registerBackendSyncListener(callback) {
// if callback is not in the list already, add it
if (!this.queueSyncListeners.includes(callback)) {

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import { commandQueue } from '../utils/CommandQueue'
// Hook to get pending commands for a specific chore (for showing pending badges/undo)
export const usePendingCommands = choreId => {
return useQuery({
queryKey: ['pendingCommands', choreId],
queryFn: () => commandQueue.getPendingForEntity(String(choreId)),
refetchInterval: 2000, // Poll since commands change outside React
staleTime: 0,
})
}
// Hook to get all pending command count (for sync indicator)
export const usePendingCommandCount = () => {
return useQuery({
queryKey: ['pendingCommands', 'all'],
queryFn: async () => {
const cmds = await commandQueue.getPending()
return cmds.length
},
refetchInterval: 3000,
staleTime: 0,
})
}

View File

@@ -0,0 +1,109 @@
import { App as capacitorApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { commandQueue } from '../utils/CommandQueue'
import { offlineDB } from '../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
import { networkManager } from './NetworkManager'
const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online
export function useSyncOnReconnect() {
const queryClient = useQueryClient()
const initialized = useRef(false)
useEffect(() => {
let pendingPollInterval
let cacheRefreshInterval
let resumeListener
let networkListener
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
runSync()
}
}
const handleOnline = () => runSync()
const init = async () => {
if (initialized.current) return
initialized.current = true
if (isOfflineFeatureEnabled()) {
await offlineDB.init()
}
// 1. Device network change (works on native + real network drops)
networkListener = async isOnline => {
if (isOnline) {
await runSync()
}
}
networkManager.registerNetworkListener(networkListener)
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
document.addEventListener('visibilitychange', handleVisibilityChange)
// 3. Browser online event (fires when device network is restored)
window.addEventListener('online', handleOnline)
// 3.5 Native app resume (fires when returning to the foreground)
if (Capacitor.isNativePlatform()) {
resumeListener = await capacitorApp.addListener(
'appStateChange',
({ isActive }) => {
if (isActive) {
console.log(
'App resumed, checking connectivity and syncing if online...',
)
runSync()
}
},
)
}
// 4. Retry pending commands every 30s (catches backend restart)
pendingPollInterval = setInterval(async () => {
const pending = await commandQueue.getPending()
if (pending.length > 0) {
runSync()
}
}, PENDING_POLL_MS)
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
cacheRefreshInterval = setInterval(() => {
runSync()
}, CACHE_REFRESH_MS)
}
const runSync = async () => {
if (!isOfflineFeatureEnabled()) return
const didSync = await syncEngine.sync()
if (didSync) {
queryClient.invalidateQueries()
}
}
init()
return () => {
if (pendingPollInterval) {
clearInterval(pendingPollInterval)
}
if (cacheRefreshInterval) {
clearInterval(cacheRefreshInterval)
}
if (networkListener) {
networkManager.unregisterNetworkListener(networkListener)
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('online', handleOnline)
resumeListener?.remove()
}
}, [queryClient])
}

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { networkManager } from '../hooks/NetworkManager'
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
import { commandQueue, CommandType } from '../utils/CommandQueue'
import {
ApproveChore,
ArchiveChore,
@@ -20,51 +20,90 @@ import {
UnArchiveChore,
UpdateChoreHistory,
} from '../utils/Fetcher'
import { localStore } from '../utils/LocalStore'
import { offlineDB } from '../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
export const useChores = includeArchive => {
const mergePendingCreates = async chores => {
const pending = await commandQueue.getPending()
const pendingCreates = pending.filter(
cmd => cmd.commandType === CommandType.CREATE_CHORE,
)
const deletedIds = new Set(
pending
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
.map(cmd => String(cmd.entityId)),
)
if (pendingCreates.length === 0) return chores
const existingIds = new Set((chores || []).map(chore => String(chore.id)))
const createdFromQueue = pendingCreates
.filter(
cmd =>
!existingIds.has(String(cmd.entityId)) &&
!deletedIds.has(String(cmd.entityId)),
)
.map(cmd => {
const payload = cmd.payload || {}
return {
...payload,
id: cmd.entityId,
nextDueDate: payload.nextDueDate || payload.dueDate || null,
_pendingCreate: true,
}
})
return [...(chores || []), ...createdFromQueue]
}
const isNetworkError = error =>
error instanceof TypeError && error.message === 'Failed to fetch'
const buildOfflineChore = task => ({
...task,
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
nextDueDate: task.nextDueDate || task.dueDate || null,
_pendingCreate: true,
})
export const useChores = (includeArchive = false) => {
return useQuery({
queryKey: ['chores', includeArchive],
refetchOnWindowFocus: true,
queryFn: async () => {
const onlineChores = await GetChoresNew(includeArchive)
// Only handle offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChores
if (isOfflineFeatureEnabled()) {
// Sync from server first (no-op if already syncing or offline)
if (networkManager.isOnline) {
await syncEngine.sync()
}
const cursor = await offlineDB.getSyncCursor()
if (cursor > 0) {
const cached = await offlineDB.getChores(includeArchive)
const merged = await mergePendingCreates(cached || [])
return { res: merged }
}
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
var finalChores = []
if (onlineChores && onlineChores.res) {
finalChores = onlineChores.res.filter(
onlineChore =>
!offlineTasks.some(offlineTask => {
// Match by id or tempId
return (
String(onlineChore.id) === String(offlineTask.id) ||
(offlineTask.tempId &&
String(onlineChore.id) === String(offlineTask.tempId))
)
}),
// Offline feature disabled — fetch from API.
try {
const data = await GetChoresNew(includeArchive)
if (data?.res) {
syncEngine.cacheChores(data.res)
}
const merged = await mergePendingCreates(data?.res || [])
return { ...data, res: merged }
} catch {
// API failed — fall back to whatever is in the cache
const cached = await offlineDB.getChores(includeArchive)
const merged = await mergePendingCreates(cached || [])
if (merged && merged.length > 0) {
return { res: merged }
}
throw new Error(
'Unable to communicate with server and no data available',
)
}
// Combine online chores with offline tasks
if (offlineTasks.length > 0) {
// Merge the offline tasks with the online chores
finalChores = [
...finalChores,
...offlineTasks.map(task => ({
...task,
id: task.id || task.tempId, // Ensure we have an id for consistency
})),
]
}
return { res: finalChores }
// return { res: [...onlineChores.res, ...offlineTasks] }
},
})
}
@@ -73,21 +112,28 @@ export const useDeleteChores = () => {
return useMutation({
mutationFn: async choreIds => {
// If offline mode is enabled and we're offline, handle deletion locally
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updatedOfflineTasks = offlineTasks.filter(
task =>
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
if (!networkManager.isOnline) {
await offlineDB.deleteChores(choreIds)
await Promise.all(
choreIds.map(async id => {
await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id })
}),
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
// Force the chores query to refetch
queryClient.invalidateQueries(['chores'])
return
const removeDeletedChores = oldData => {
if (!oldData?.res) return oldData
const deletedIds = new Set(choreIds.map(id => String(id)))
return {
...oldData,
res: oldData.res.filter(chore => !deletedIds.has(String(chore.id))),
}
}
// If online, proceed with server-side deletion
queryClient.setQueryData(['chores', false], removeDeletedChores)
queryClient.setQueryData(['chores', true], removeDeletedChores)
return
}
await Promise.all(
choreIds.map(async id => {
const resp = await DeleteChore(id)
@@ -99,14 +145,44 @@ export const useDeleteChores = () => {
},
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
export const useCreateChore = () => {
const queryClient = useQueryClient()
const queueOfflineCreate = async newTask => {
const offlineChore = buildOfflineChore(newTask)
await commandQueue.enqueue(
CommandType.CREATE_CHORE,
offlineChore.id,
newTask,
)
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) {
return { res: [offlineChore] }
}
const alreadyExists = oldData.res.some(
chore => String(chore.id) === String(offlineChore.id),
)
if (alreadyExists) return oldData
return { ...oldData, res: [...oldData.res, offlineChore] }
})
return offlineChore
}
return useMutation({
mutationFn: async newTask => {
if (!networkManager.isOnline) {
return queueOfflineCreate(newTask)
}
try {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
@@ -117,57 +193,21 @@ export const useCreateChore = () => {
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
return createdChore.res
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineCreate(newTask)
}
throw error
}
},
// onMutate: async newTask => {
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// const tempId = crypto.randomUUID() // Generate temp ID
// const offlineTasks =
// (await localStore.getFromCache('offlineTasks')) || []
// const updateOfflineTasks = [
// ...offlineTasks,
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
// ]
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// // force useChores to refetch:
// queryClient.invalidateQueries(['chores'])
// // Force the chores query to refetch
// queryClient.refetchQueries(['chores'])
// // Update the chores query cache immediately
// // queryClient.setQueryData(['chores'], oldData => {
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// // if (!oldData)
// // return {
// // res: [{ ...newTask, id: tempId, tempId }],
// // } // If no data, return offline tasks
// // return {
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// // }
// // })
// return { tempId }
// }
// const tempId = crypto.randomUUID() // Generate temp ID
// // Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
// return { tempId: null }
// },
onSuccess: () => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -177,44 +217,31 @@ export const useUpdateChore = () => {
return useMutation({
mutationFn: async updatedChore => {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
updatedChore['updatedAt'] = new Date().toISOString()
if (!updatedChore['nextDueDate']) {
updatedChore['nextDueDate'] = updatedChore['dueDate']
}
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
for (const task of offlineTasks) {
// Find the task with the same id or tempId and update it
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
// Update the task in local storage
const updatedTask = { ...task, ...updatedChore }
const updatedOfflineTasks = offlineTasks.map(t =>
t.id === task.id ? updatedTask : t,
const queueOfflineUpdate = async () => {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE,
updatedChore.id,
updatedChore,
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
return new Promise((resolve, reject) => {
resolve(updatedTask)
const pendingChore = { ...updatedChore, _pendingUpdate: true }
// Persist to offline DB so cache fallback reads the updated data
await offlineDB.saveChores([pendingChore])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [pendingChore] }
return {
res: oldData.res.map(chore =>
chore.id === updatedChore.id ? pendingChore : chore,
),
}
})
}
}
const newTaskId = crypto.randomUUID()
const updatedChoreWithNewId = {
...updatedChore,
tempId: newTaskId,
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
if (!oldData) return { res: pendingChore }
return { ...oldData, res: pendingChore }
})
return pendingChore
}
await localStore.saveToCache('offlineTasks', [
...offlineTasks,
updatedChoreWithNewId,
])
return new Promise((resolve, reject) => {
// Resolve with the updated task
resolve(updatedChoreWithNewId)
})
} else {
// Call the API to update the chore
try {
const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) {
throw new Error('Failed to save chore')
@@ -223,9 +250,7 @@ export const useUpdateChore = () => {
if (!updatedChoreRes) {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
// update the local chores cache with the updated chore:
queryClient.setQueryData(['chores'], oldData => {
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [updatedChore] }
return {
res: oldData.res.map(chore =>
@@ -234,19 +259,17 @@ export const useUpdateChore = () => {
}
})
return updatedChoreRes?.res || updatedChoreRes
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineUpdate()
}
throw error
}
},
onSuccess: (data, variables) => {
// Invalidate the chores query to refresh the data
onSuccess: (_, variables) => {
queryClient.invalidateQueries(['chores'])
// Invalidate history for the specific chore
queryClient.invalidateQueries(['choreHistory', variables.id])
},
onMutate: async updatedChore => {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// Handle offline case here if needed
return
}
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -257,8 +280,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
const { data, error, isLoading } = useQuery({
queryKey: ['choresHistory', limit],
queryFn: async () => {
try {
const resp = await GetChoresHistory(limit, includeMembers)
return resp?.res || []
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,
})
@@ -275,30 +310,20 @@ export const useChoreDetails = choreId => {
queryKey: ['choreDetails', choreId],
refetchOnWindowFocus: true,
queryFn: async () => {
var onlineChore = null
try {
const response = await GetChoreDetailById(choreId)
if (response && response.ok) {
onlineChore = await response.json()
return await response.json()
}
} catch (error) {
console.error('Error fetching chore detail:', error)
throw new Error('Failed to fetch chore detail')
} catch {
// Fall back to cached chore (without timer details)
const cached = await offlineDB.getChore(choreId)
if (cached) {
return { res: cached }
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
throw new Error('Chore detail not available offline')
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
const offline = offlineTasks.find(task => {
// Match by tempId or id if it was created offline
return task.id === choreId || (task.tempId && task.tempId === choreId)
})
return { res: offline ? { ...offline } : onlineChore.res }
},
})
}
@@ -312,32 +337,21 @@ export const useChore = choreId => {
if (!choreId) {
throw new Error('Chore ID is required to fetch chore details')
}
var onlineChore = null
try {
const response = await GetChoreByID(choreId)
if (response && response.ok) {
onlineChore = await response.json()
return await response.json()
}
} catch (error) {
console.error('Error fetching chore detail:', error)
throw new Error('Failed to fetch chore')
} catch {
// API failed — try offline cache
const cached = await offlineDB.getChore(choreId)
if (cached) {
return { res: cached }
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
throw new Error('Chore not available offline')
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
const offline = offlineTasks.find(task => {
return (
String(task.id) === choreId ||
(task.tempId && task.tempId === choreId)
)
})
return { res: offline ? { ...offline } : onlineChore.res }
},
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
@@ -374,11 +388,30 @@ export const useChoreHistory = choreId => {
if (!choreId) {
throw new Error('Chore ID is required to fetch history')
}
let json
try {
const response = await GetChoreHistory(choreId)
if (response && response.ok) {
return await response.json()
}
json = await response.json()
} else {
throw new Error('Failed to fetch chore history')
}
} catch {
const cached = await offlineDB.getHistoryByChore(choreId)
return { res: cached }
}
// Cache for offline use — fire-and-forget so a cache failure never
// degrades the online view. Inject choreId since the single-chore
// endpoint may omit it from each entry.
const entries = (json?.res || []).map(e =>
e.choreId != null ? e : { ...e, choreId: Number(choreId) },
)
if (entries.length > 0) {
offlineDB
.saveHistory(entries)
.catch(err => console.error('Failed to cache chore history:', err))
}
return json
},
enabled: !!choreId,
staleTime: 0, // Always consider data stale
@@ -392,10 +425,62 @@ export const useUpdateChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId, historyData }) =>
UpdateChoreHistory(choreId, historyId, historyData),
mutationFn: async ({ choreId, historyId, historyData }) => {
const applyOptimisticUpdate = async () => {
queryClient.setQueryData(['choreHistory', choreId], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(entry =>
entry.id === historyId
? { ...entry, ...historyData, _pendingUpdate: true }
: entry,
),
}
})
await offlineDB.updateHistoryEntry(choreId, historyId, {
...historyData,
_pendingUpdate: true,
})
return { queued: true }
}
if (!networkManager.isOnline) {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId, historyData },
)
return applyOptimisticUpdate()
}
try {
const response = await UpdateChoreHistory(
choreId,
historyId,
historyData,
)
if (!response || !response.ok) {
throw new Error('Failed to update chore history')
}
return response
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId, historyData },
)
return applyOptimisticUpdate()
}
throw error
}
},
onSuccess: (data, { choreId }) => {
if (!data?.queued) {
queryClient.invalidateQueries(['choreHistory', choreId])
}
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -404,10 +489,57 @@ export const useDeleteChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId }) =>
DeleteChoreHistory(choreId, historyId),
mutationFn: async ({ choreId, historyId }) => {
const applyOptimisticDelete = async () => {
queryClient.setQueryData(['choreHistory', choreId], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(entry =>
entry.id === historyId
? { ...entry, _pendingDelete: true }
: entry,
),
}
})
await offlineDB.updateHistoryEntry(choreId, historyId, {
_pendingDelete: true,
})
return { queued: true }
}
if (!networkManager.isOnline) {
await commandQueue.enqueue(
CommandType.DELETE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId },
)
return applyOptimisticDelete()
}
try {
const response = await DeleteChoreHistory(choreId, historyId)
if (!response || !response.ok) {
throw new Error('Failed to delete chore history')
}
return response
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(
CommandType.DELETE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId },
)
return applyOptimisticDelete()
}
throw error
}
},
onSuccess: (data, { choreId }) => {
if (!data?.queued) {
queryClient.invalidateQueries(['choreHistory', choreId])
}
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -416,12 +548,42 @@ export const useMarkChoreComplete = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, body, completedDate, performer }) =>
MarkChoreComplete(choreId, body, completedDate, performer),
onSuccess: (data, { choreId }) => {
mutationFn: async ({ choreId, body, completedDate, performer }) => {
if (!networkManager.isOnline) {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
body,
completedDate,
performer,
})
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: body?.completedBy || 0,
performedAt: completedDate || new Date().toISOString(),
notes: body?.note || null,
status: 1,
points: 0,
pending: true,
})
// Optimistically update the cache to show pending state
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId ? { ...chore, _pending: 'complete' } : chore,
),
}
})
return { res: { _pending: 'complete' } }
}
return MarkChoreComplete(choreId, body, completedDate, performer)
},
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -430,11 +592,29 @@ export const useSkipChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: SkipChore,
onSuccess: (data, choreId) => {
mutationFn: async choreId => {
if (!networkManager.isOnline) {
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
id: choreId,
})
// Optimistically update the cache to show pending state
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId ? { ...chore, _pending: 'skip' } : chore,
),
}
})
return { res: { _pending: 'skip' } }
}
return SkipChore(choreId)
},
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -444,7 +624,7 @@ export const useApproveChore = () => {
return useMutation({
mutationFn: ApproveChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
@@ -457,7 +637,7 @@ export const useRejectChore = () => {
return useMutation({
mutationFn: RejectChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])

View File

@@ -1,51 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { networkManager } from '../hooks/NetworkManager'
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
import { localStore } from '../utils/LocalStore'
export const useUpdate = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async updatedChore => {
if (!networkManager.isOnline) {
updatedChore['updatedAt'] = new Date().toISOString()
if (!updatedChore['nextDueDate']) {
updatedChore['nextDueDate'] = updatedChore['dueDate']
}
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
for (const task of offlineTasks) {
// Find the task with the same id or tempId and update it
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
// Update the task in local storage
const updatedTask = { ...task, ...updatedChore }
const updatedOfflineTasks = offlineTasks.map(t =>
t.id === task.id ? updatedTask : t,
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
return new Promise((resolve, reject) => {
resolve(updatedTask)
})
}
}
const newTaskId = crypto.randomUUID()
const updatedChoreWithNewId = {
...updatedChore,
tempId: newTaskId,
}
await localStore.saveToCache('offlineTasks', [
...offlineTasks,
updatedChoreWithNewId,
])
return new Promise((resolve, reject) => {
// Resolve with the updated task
resolve(updatedChoreWithNewId)
})
} else {
// Call the API to update the chore
const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) {
throw new Error('Failed to save chore')
@@ -56,18 +17,10 @@ export const useUpdate = () => {
}
// Successfully updated the chore on the server, return the updated chore
return updatedChoreRes?.res || updatedChoreRes
}
},
onSuccess: (data, variables) => {
// Invalidate the chores query to refresh the data
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
onMutate: async updatedChore => {
if (!networkManager.isOnline) {
// Handle offline case here if needed
return
}
},
})
}

View File

@@ -31,7 +31,7 @@ export const useStartChore = () => {
return useMutation({
mutationFn: StartChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -44,7 +44,7 @@ export const usePauseChore = () => {
return useMutation({
mutationFn: PauseChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -58,7 +58,7 @@ export const useUpdateTimeSession = () => {
return useMutation({
mutationFn: ({ choreId, sessionId, sessionData }) =>
UpdateTimeSession(choreId, sessionId, sessionData),
onSuccess: (data, { choreId }) => {
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -72,7 +72,7 @@ export const useDeleteTimeSession = () => {
return useMutation({
mutationFn: ({ choreId, sessionId }) =>
DeleteTimeSession(choreId, sessionId),
onSuccess: (data, { choreId }) => {
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -85,7 +85,7 @@ export const useResetChoreTimer = () => {
return useMutation({
mutationFn: ResetChoreTimer,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -98,7 +98,7 @@ export const useClearChoreTimer = () => {
return useMutation({
mutationFn: ClearChoreTimer,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])

View File

@@ -6,6 +6,7 @@ import {
GetDeviceTokens,
GetUserProfile,
} from '../utils/Fetcher'
import { offlineDB } from '../utils/OfflineDB'
// Helper to check if we have a valid token
const isTokenValid = () => {
@@ -30,7 +31,20 @@ export const useCircleMembers = () => {
const { data, error, isLoading } = useQuery({
queryKey: ['allCircleMembers'],
queryFn: GetAllCircleMembers,
queryFn: async () => {
try {
const result = await GetAllCircleMembers()
// Cache for offline use
if (result?.res) {
offlineDB.saveKV('circle_members', result.res).catch(() => {})
}
return result
} catch {
const cached = await offlineDB.getKV('circle_members')
if (cached) return { res: cached }
return { res: [] }
}
},
})
const handleRefetch = () => {
@@ -42,19 +56,31 @@ export const useCircleMembers = () => {
export const useUserProfile = () => {
const queryClient = useQueryClient()
const token = localStorage.getItem('token')
const { data, error, isLoading } = useQuery({
queryKey: ['userProfile'],
queryKey: ['userProfile', token],
queryFn: async () => {
if (!token) {
return null
}
try {
const resp = await GetUserProfile()
const result = await resp.json()
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
if (result?.res) {
await offlineDB.saveKV('user_profile', result.res)
}
return result.res || null
} catch {
// API unreachable — only serve cached profile for authenticated sessions
return await offlineDB.getKV('user_profile')
}
},
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
enabled: isTokenValid(), // Only run query when we have a valid token
staleTime: 30 * 60 * 1000,
gcTime: 30 * 60 * 1000,
enabled: !!token,
})
return {
data,

View File

@@ -17,7 +17,7 @@ class ApiClient {
}
async init(force = false) {
if (this.initPromise) {
if (!force && this.initPromise) {
return this.initPromise
}

213
src/utils/CommandQueue.js Normal file
View File

@@ -0,0 +1,213 @@
import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
// Domain command types
export const CommandType = {
CREATE_CHORE: 'create_chore',
UPDATE_CHORE: 'update_chore',
UPDATE_CHORE_HISTORY: 'update_chore_history',
COMPLETE_CHORE: 'complete_chore',
SKIP_CHORE: 'skip_chore',
START_CHORE: 'start_chore',
PAUSE_CHORE: 'pause_chore',
DELETE_CHORE: 'delete_chore',
DELETE_CHORE_HISTORY: 'delete_chore_history',
RESCHEDULE_CHORE: 'reschedule_chore',
ARCHIVE_CHORE: 'archive_chore',
UNARCHIVE_CHORE: 'unarchive_chore',
}
class CommandQueue {
_sanitizeCreatePayload(payload = {}) {
const sanitized = { ...payload }
delete sanitized.id
delete sanitized._pendingCreate
delete sanitized._pendingUpdate
return sanitized
}
// Enqueue a domain command
async enqueue(type, entityId, payload) {
if (!isOfflineFeatureEnabled()) {
throw new Error('Offline support is disabled on this device')
}
const command = {
commandType: type,
entityId: String(entityId),
payload: JSON.stringify(payload),
createdAt: Date.now(),
status: 'pending',
error: null,
}
return offlineDB.enqueueCommand(command)
}
// Get all pending commands in order
async getPending() {
if (!isOfflineFeatureEnabled()) return []
const commands = await offlineDB.getCommands()
return commands
.filter(c => c.status === 'pending' || c.status === 'syncing')
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
}
// Get all failed commands
async getFailed() {
if (!isOfflineFeatureEnabled()) return []
const commands = await offlineDB.getCommands()
return commands
.filter(c => c.status === 'failed')
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
}
// Get pending commands for a specific entity (for undo/UI)
async getPendingForEntity(entityId) {
if (!isOfflineFeatureEnabled()) return []
const allCommands = await offlineDB.getCommands()
const key = String(entityId)
const commands = allCommands
.filter(
c =>
c.entityId === key ||
(typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)),
)
.sort((a, b) => a.createdAt - b.createdAt)
return commands
.filter(c => c.status === 'pending' || c.status === 'syncing')
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
}
// Cancel/undo a pending command
async cancel(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.removeCommand(commandId)
}
// Mark as syncing
async markSyncing(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.updateCommandStatus(commandId, 'syncing', null)
}
// Mark as failed (only for unrecoverable errors like conflicts)
async markFailed(commandId, error) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.updateCommandStatus(commandId, 'failed', error)
}
// Reset back to pending (for transient network/server errors so it retries)
async resetPending(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.updateCommandStatus(commandId, 'pending', null)
}
// Reset any in-flight commands so they remain retryable after aborted syncs
async resetSyncing() {
if (!isOfflineFeatureEnabled()) return
const commands = await offlineDB.getCommands()
const syncingCommands = commands.filter(c => c.status === 'syncing')
await Promise.all(
syncingCommands.map(cmd =>
offlineDB.updateCommandStatus(cmd.id, 'pending', null),
),
)
}
// Remove after successful sync
async markDone(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.removeCommand(commandId)
}
// Compact: merge consecutive updates to same entity
async compact() {
if (!isOfflineFeatureEnabled()) return
const pending = await this.getPending()
const seen = new Map() // entityId -> last command
const toRemove = []
for (const cmd of pending) {
const prev = seen.get(cmd.entityId)
if (prev?.commandType === CommandType.CREATE_CHORE) {
if (cmd.commandType === CommandType.UPDATE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
...cmd.payload,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.RESCHEDULE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
dueDate: cmd.payload?.dueDate ?? prev.payload?.dueDate,
nextDueDate: cmd.payload?.dueDate ?? prev.payload?.nextDueDate,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.ARCHIVE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
isActive: false,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.UNARCHIVE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
isActive: true,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.DELETE_CHORE) {
toRemove.push(prev.id, cmd.id)
seen.delete(cmd.entityId)
continue
}
}
if (cmd.commandType === CommandType.UPDATE_CHORE) {
if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
// Merge: keep latest payload, remove older
toRemove.push(prev.id)
}
}
if (cmd.commandType === CommandType.DELETE_CHORE) {
if (prev?.commandType === CommandType.UPDATE_CHORE) {
toRemove.push(prev.id)
}
}
seen.set(cmd.entityId, cmd)
}
for (const id of [...new Set(toRemove)]) {
await offlineDB.removeCommand(id)
}
}
}
export const commandQueue = new CommandQueue()

View File

@@ -1,70 +1,3 @@
export const FEATURES = {
OFFLINE_MODE: 'experimental_feature_offline_mode',
}
/**
* Get the current state of a feature flag from localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
* @returns {boolean} - Whether the feature is enabled
*/
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
try {
const value = localStorage.getItem(featureKey)
if (value === 'true') return true
if (value === 'false') return false
if (value === null || value === undefined) return defaultValue
return Boolean(value)
} catch (error) {
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
return defaultValue
}
}
/**
* Set the state of a feature flag in localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} enabled - Whether to enable the feature
*/
export const setFeatureEnabled = (featureKey, enabled) => {
try {
localStorage.setItem(featureKey, enabled.toString())
} catch (error) {
console.error(
`FeatureToggle: Error setting feature "${featureKey}":`,
error,
)
}
}
export const toggleFeature = featureKey => {
const currentState = isFeatureEnabled(featureKey)
const newState = !currentState
setFeatureEnabled(featureKey, newState)
return newState
}
export const getAllFeatureStates = () => {
const states = {}
Object.entries(FEATURES).forEach(([name, key]) => {
states[name] = isFeatureEnabled(key)
})
return states
}
export const clearAllFeatures = () => {
try {
Object.values(FEATURES).forEach(featureKey => {
localStorage.removeItem(featureKey)
})
} catch (error) {
console.error('FeatureToggle: Error clearing features:', error)
}
}
/**
* Check if the current instance is the official donetick.com service
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
@@ -102,7 +35,11 @@ export const isOfficialDonetickInstanceSync = () => {
// Dynamic import to avoid circular dependencies
return import('./ApiClient')
.then(({ apiClient }) => {
const currentApiUrl = apiClient.baseURL
const currentApiUrl =
apiClient.baseURL || apiClient.customServerURL || ''
if (!currentApiUrl || typeof currentApiUrl !== 'string') {
return false
}
// Check if the API URL contains donetick.com
return currentApiUrl.toLowerCase().includes('donetick.com')
})
@@ -123,12 +60,6 @@ export const isOfficialDonetickInstanceSync = () => {
// Export default object for easier imports
export default {
FEATURES,
isFeatureEnabled,
setFeatureEnabled,
toggleFeature,
getAllFeatureStates,
clearAllFeatures,
isOfficialDonetickInstance,
isOfficialDonetickInstanceSync,
}

View File

@@ -1,223 +0,0 @@
import { CapacitorSQLite } from '@capacitor-community/sqlite'
const CACHE_TABLE = 'offline_cache'
const QUEUE_TABLE = 'offline_request_queue'
const OFFLINE_TASK = 'offlineTasks' // For storing offline tasks
class LocalStore {
constructor() {
this.db = null
// this.useLocalStorage = !Capacitor.isNativePlatform()
this.useLocalStorage = true // default to localStorage for now.
}
async initDatabase() {
if (this.useLocalStorage) return null
if (this.db) return this.db
const db = await CapacitorSQLite.createConnection({
database: 'offline_data',
version: 1,
})
await db.open()
// Create tables if they don't exist
await db.execute(`
CREATE TABLE IF NOT EXISTS ${CACHE_TABLE} (
key TEXT PRIMARY KEY,
value TEXT,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS ${QUEUE_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT,
requestBody TEXT
);
`)
this.db = db
return db
}
async saveToCache(key, data) {
const timestamp = Date.now() // Current timestamp in milliseconds
if (this.useLocalStorage) {
localStorage.setItem(key, JSON.stringify({ value: data, timestamp }))
return
}
const db = await this.initDatabase()
await db.run(
`
INSERT OR REPLACE INTO ${CACHE_TABLE} (key, value, timestamp)
VALUES (?, ?, ?);
`,
[key, JSON.stringify(data), timestamp],
)
}
// async saveTemporaryTask(task) {
// const baseURL = apiManager.getApiURL()
// const fullURL = `${baseURL}/chores/${task.tempId}`
// const options = {
// method: 'GET',
// headers: HEADERS(),
// url: fullURL,
// }
// const respond = { res: task }
// const requestId = murmurhash.v3(JSON.stringify({ fullURL, options }))
// if (this.useLocalStorage) {
// this.saveToCache(requestId, respond)
// return
// }
// const db = await this.initDatabase()
// await db.run(
// `
// INSERT INTO ${CACHE_TABLE} (url, requestBody)
// VALUES (?, ?);
// `,
// [
// requestId,
// JSON.stringify({
// url: fullURL,
// options: { method: 'GET', headers: HEADERS() },
// }),
// ],
// )
// console.log('Saved temporary task to queue:', task)
// return
// }
async getFromCache(key, ttl = 0) {
const now = Date.now()
if (this.useLocalStorage) {
const cachedItem = localStorage.getItem(key)
if (!cachedItem) return null
const { value, timestamp } = JSON.parse(cachedItem)
if (ttl > 0 && now - timestamp > ttl) {
localStorage.removeItem(key) // Remove expired item
return null
}
return value
}
const db = await this.initDatabase()
const result = await db.query(
`
SELECT value, timestamp FROM ${CACHE_TABLE} WHERE key = ?;
`,
[key],
)
if (result.values.length === 0) return null
const { value, timestamp } = result.values[0]
if (ttl > 0 && now - timestamp > ttl) {
// Remove expired item
await db.run(`DELETE FROM ${CACHE_TABLE} WHERE key = ?;`, [key])
return null
}
return JSON.parse(value)
}
async cleanExpiredCache(ttl) {
const now = Date.now()
if (this.useLocalStorage) {
const keys = Object.keys(localStorage)
for (const key of keys) {
const cachedItem = localStorage.getItem(key)
if (!cachedItem) continue
const { timestamp } = JSON.parse(cachedItem)
if (now - timestamp > ttl) {
localStorage.removeItem(key) // Remove expired item
}
}
return
}
const db = await this.initDatabase()
await db.run(
`
DELETE FROM ${CACHE_TABLE} WHERE ? - timestamp > ?;
`,
[now, ttl],
)
}
async queueRequest(requestId, requestPayload) {
if (this.useLocalStorage) {
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
console.log('requestPayload', requestPayload)
if (typeof requestPayload?.options?.body['id'] === 'string') {
requestPayload['id'] = null
}
queue.push({ requestId, requestBody: requestPayload })
localStorage.setItem(QUEUE_TABLE, JSON.stringify(queue))
return
}
const db = await this.initDatabase()
await db.run(
`
INSERT INTO ${QUEUE_TABLE} (url, requestBody)
VALUES (?, ?);
`,
[requestId, JSON.stringify(requestPayload)],
)
}
async syncQueuedRequests() {
console.log('Syncing queued requests...')
var queueSize = 0
if (this.useLocalStorage) {
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
console.log('LocalStore: queue: ', queue)
queueSize = queue.length
for (const request of queue) {
try {
await fetch(request.requestBody.url, request.requestBody.options)
console.log('LocalStore: Synced request:', request)
} catch (error) {
console.error('LocalStore: Failed to sync request:', request, error)
}
}
// Clear the queue after syncing
localStorage.removeItem(QUEUE_TABLE)
localStorage.removeItem(OFFLINE_TASK)
return queueSize > 0
}
const db = await this.initDatabase()
const result = await db.query(`SELECT * FROM ${QUEUE_TABLE};`)
queueSize = result.values.length
for (const request of result.values) {
try {
await fetch(
request.requestBody.url,
JSON.parse(request.requestBody.options),
)
console.log('Synced request:', request)
} catch (error) {
console.error('Failed to sync request:', request, error)
}
}
// Clear the queue after syncing
await db.run(`DELETE FROM ${QUEUE_TABLE};`)
return queueSize > 0
}
}
export const localStore = new LocalStore()

1075
src/utils/OfflineDB.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
const OFFLINE_FEATURE_KEY = 'offline_feature_enabled'
const OFFLINE_FEATURE_EVENT = 'donetick:offline-feature-changed'
const parseBoolean = value => {
if (value === null || typeof value === 'undefined') return true
try {
return JSON.parse(value) !== false
} catch {
return true
}
}
export const isOfflineFeatureEnabled = () => {
if (typeof window === 'undefined' || !window.localStorage) return true
return parseBoolean(window.localStorage.getItem(OFFLINE_FEATURE_KEY))
}
export const setOfflineFeatureEnabled = enabled => {
if (typeof window === 'undefined' || !window.localStorage) return
window.localStorage.setItem(OFFLINE_FEATURE_KEY, JSON.stringify(!!enabled))
window.dispatchEvent(
new CustomEvent(OFFLINE_FEATURE_EVENT, {
detail: { enabled: !!enabled },
}),
)
}
export const subscribeToOfflineFeature = callback => {
if (typeof window === 'undefined') return () => {}
const handleToggle = event => {
if (event?.type === 'storage') {
if (event.key !== OFFLINE_FEATURE_KEY) return
callback(parseBoolean(event.newValue))
return
}
callback(!!event?.detail?.enabled)
}
window.addEventListener(OFFLINE_FEATURE_EVENT, handleToggle)
window.addEventListener('storage', handleToggle)
return () => {
window.removeEventListener(OFFLINE_FEATURE_EVENT, handleToggle)
window.removeEventListener('storage', handleToggle)
}
}
export const clearBrowserCacheStorage = async () => {
if (typeof window === 'undefined' || !('caches' in window)) return
try {
const cacheKeys = await window.caches.keys()
await Promise.all(cacheKeys.map(key => window.caches.delete(key)))
} catch {
// Ignore cache clear failures and continue with offline cleanup
}
}

246
src/utils/SyncEngine.js Normal file
View File

@@ -0,0 +1,246 @@
import { networkManager } from '../hooks/NetworkManager'
import { apiClient } from './ApiClient'
import { commandQueue, CommandType } from './CommandQueue'
import {
ArchiveChore,
CreateChore,
DeleteChore,
DeleteChoreHistory,
MarkChoreComplete,
PauseChore,
SaveChore,
SkipChore,
StartChore,
UnArchiveChore,
UpdateChoreHistory,
UpdateDueDate,
} from './Fetcher'
import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
class SyncEngine {
constructor() {
this.isSyncing = false
this.listeners = []
}
// Register listener for sync state changes
onSyncStateChange(callback) {
this.listeners.push(callback)
return () => {
this.listeners = this.listeners.filter(l => l !== callback)
}
}
_notify(state) {
this.listeners.forEach(cb => cb(state))
}
// Main sync entry point — returns true if sync succeeded, false otherwise
async sync() {
if (!isOfflineFeatureEnabled()) return false
if (this.isSyncing) return false
this.isSyncing = true
this._notify({ syncing: true, error: null })
try {
await commandQueue.resetSyncing()
// Step 1: Compact the queue (merge consecutive updates)
await commandQueue.compact()
// Step 2: Replay pending commands
await this._replayCommands()
// Step 3: Delta sync from server
await this._deltaSync()
this._notify({ syncing: false, lastSync: Date.now() })
return true
} catch (err) {
await commandQueue.resetSyncing()
console.error('Sync failed:', err)
this._notify({ syncing: false, error: err.message })
return false
} finally {
this.isSyncing = false
}
}
async _replayCommands() {
const commands = await commandQueue.getPending()
for (const cmd of commands) {
if (!networkManager.isOnline) break
await commandQueue.markSyncing(cmd.id)
try {
await this._executeCommand(cmd)
await commandQueue.markDone(cmd.id)
} catch (err) {
const status = err.status || err.statusCode
if (status === 409) {
// Conflict - mark for user attention but continue with other commands
await commandQueue.markFailed(
cmd.id,
'Conflict: modified by another user',
)
} else if (status === 404) {
// Entity no longer exists - discard command
await commandQueue.markDone(cmd.id)
} else {
// Transient network/server error - reset to pending so it retries
await commandQueue.resetPending(cmd.id)
throw err
}
}
}
}
async _executeCommand(cmd) {
let response
switch (cmd.commandType) {
case CommandType.CREATE_CHORE:
response = await CreateChore(cmd.payload)
break
case CommandType.UPDATE_CHORE:
response = await SaveChore(cmd.payload)
break
case CommandType.COMPLETE_CHORE: {
const { id, body, completedDate, performer } = cmd.payload
response = await MarkChoreComplete(
id,
body || {},
completedDate || null,
performer || null,
)
break
}
case CommandType.SKIP_CHORE:
response = await SkipChore(cmd.payload.id || cmd.entityId)
break
case CommandType.START_CHORE:
response = await StartChore(cmd.payload.id || cmd.entityId)
break
case CommandType.PAUSE_CHORE:
response = await PauseChore(cmd.payload.id || cmd.entityId)
break
case CommandType.DELETE_CHORE:
response = await DeleteChore(cmd.payload.id || cmd.entityId)
break
case CommandType.UPDATE_CHORE_HISTORY: {
const { choreId, historyId, historyData } = cmd.payload
response = await UpdateChoreHistory(choreId, historyId, historyData)
break
}
case CommandType.DELETE_CHORE_HISTORY: {
const { choreId, historyId } = cmd.payload
response = await DeleteChoreHistory(choreId, historyId)
break
}
case CommandType.RESCHEDULE_CHORE: {
const { id, dueDate } = cmd.payload
response = await UpdateDueDate(id, dueDate)
break
}
case CommandType.ARCHIVE_CHORE:
response = await ArchiveChore(cmd.payload.id || cmd.entityId)
break
case CommandType.UNARCHIVE_CHORE:
response = await UnArchiveChore(cmd.payload.id || cmd.entityId)
break
default:
console.warn('Unknown command type:', cmd.commandType)
return
}
// Check if the response indicates an error and throw so the caller can handle it
if (response && typeof response.ok !== 'undefined' && !response.ok) {
const err = new Error(`API error: ${response.status}`)
err.status = response.status
throw err
}
}
async _deltaSync() {
const cursor = (await offlineDB.getSyncCursor()) || -1
let hasMore = true
let currentCursor = cursor
while (hasMore && networkManager.isOnline) {
// Use apiClient.get which handles auth and returns a fetch Response
const response = await apiClient.get(
`/sync/changes?since=${currentCursor}`,
)
if (!response || !response.ok) {
const error = new Error(
response
? `Delta sync failed: ${response.status}`
: 'Delta sync failed: no response from server',
)
error.status = response?.status
throw error
}
const data = await response.json()
// Upsert changed chores first
const changedChores = data.changes?.chores ?? []
if (changedChores.length > 0) {
await offlineDB.saveChores(changedChores)
}
// Upsert changed history entries (also clears any pending entries for the same chore IDs)
const changedHistory = data.changes?.choreHistories ?? []
if (changedHistory.length > 0) {
await offlineDB.saveHistory(changedHistory)
}
// Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both)
const deletedIds = data.deletions?.chores ?? []
if (deletedIds.length > 0) {
await offlineDB.deleteChores(deletedIds)
}
const deletedHistoryIds = data.deletions?.choreHistories ?? []
if (deletedHistoryIds.length > 0) {
await offlineDB.deleteHistory(deletedHistoryIds)
}
// Always advance the cursor, even when there are no changes
if (data.cursor) {
currentCursor = data.cursor
}
hasMore = !!data.hasMore
}
await offlineDB.setSyncCursor(currentCursor)
await offlineDB.setLastSyncTime(Date.now())
}
// Cache current chores (call after a successful online fetch)
async cacheChores(chores) {
if (!isOfflineFeatureEnabled()) return
if (!chores || chores.length === 0) return
await offlineDB.saveChores(chores)
}
}
export const syncEngine = new SyncEngine()

View File

@@ -1,33 +0,0 @@
import { CreateChore, SaveChore } from './Fetcher'
import { localStore } from './LocalStore'
class SyncManager {
async syncTasks() {
console.log('SYNCMANAGER: Starting sync process for offline tasks.')
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
for (const task of offlineTasks) {
// if task.needSync then it's need to be created:
var resp
if (task.needSync) {
resp = await CreateChore(task)
} else {
resp = await SaveChore(task)
}
if (!resp.ok) {
console.log(
`SYNCMANAGER: Failed to sync task with id: ${task.id}. Error: ${resp.statusText}`,
)
} else {
console.log(
`SYNCMANAGER: Successfully synced task with id: ${task.id}.`,
)
console.log(`SYNCMANAGER: Response:`, resp)
}
}
// Clear the offline tasks cache after syncing
await localStore.saveToCache('offlineTasks', [])
return true
}
}
export const syncManager = new SyncManager()

View File

@@ -163,6 +163,7 @@ export const clearAllTokens = async () => {
// Clear localStorage
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
localStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY)
// Clean up legacy keys
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')

View File

@@ -336,6 +336,7 @@ const ChoreEdit = () => {
description: description,
assignees: assignees,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
nextDueDate: dueDate ? new Date(dueDate).toISOString() : null,
frequencyType: frequencyType,
frequency: Number(frequency),
frequencyMetadata: frequencyMetadata,
@@ -365,11 +366,22 @@ const ChoreEdit = () => {
}
SaveFunction(chore)
.then(() => {
.then(result => {
if (
result?._pendingUpdate ||
result?._pendingCreate ||
result?.res?._pendingCreate
) {
showSuccess({
title: 'Saved Offline',
message: 'Your changes will sync when you are back online.',
})
} else {
showSuccess({
title: 'Chore Saved',
message: 'Your task has been saved successfully!',
})
}
Navigate('/chores')
})
.catch(error => {

View File

@@ -44,7 +44,11 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreDetails,
useChoreHistory,
} from '../../queries/ChoreQueries.jsx'
import {
useChoreTimer,
useDeleteTimeSession,
@@ -54,8 +58,13 @@ import {
} from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import {
ChoreHistoryStatus,
ChoreStatus,
notInCompletionWindow,
} from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import {
ApproveChore,
GetChoreDetailById,
@@ -66,16 +75,34 @@ import {
UndoChoreAction,
UpdateChorePriority,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
const decodeHtmlEntities = value => {
if (typeof value !== 'string') return ''
return value
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&')
}
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
const ChoreView = () => {
const { t } = useTranslation('chores')
const { fmt } = useLocalization()
@@ -104,6 +131,17 @@ const ChoreView = () => {
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: choreHistoryData } = useChoreHistory(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
const historyCompletionCount = choreHistory.filter(historyEntry => {
const status = Number(historyEntry?.status)
return status === ChoreHistoryStatus.COMPLETED
}).length
const completionCount = choreHistoryData
? historyCompletionCount
: chore.totalCompletedCount || 0
const startChore = useStartChore()
const pauseChore = usePauseChore()
@@ -128,21 +166,6 @@ const ChoreView = () => {
useEffect(() => {
if (chore && performers?.length > 0) {
generateInfoCards(chore)
}
}, [chore, performers])
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
setChorePriority(priority)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const generateInfoCards = chore => {
const cards = [
{
size: 6,
@@ -164,7 +187,9 @@ const ChoreView = () => {
icon: <CalendarMonth />,
title: t('choreView.schedule'),
text: `${t('choreView.due')}: ${
chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : t('choreView.na')
chore.nextDueDate
? moment(chore.nextDueDate).fromNow()
: t('choreView.na')
}`,
subtext: `${t('choreView.last')}: ${
chore.lastCompletedDate
@@ -181,7 +206,7 @@ const ChoreView = () => {
size: 6,
icon: <Checklist />,
title: t('choreView.statistics'),
text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
},
{
size: 6,
@@ -195,8 +220,20 @@ const ChoreView = () => {
]
setInfoCards(cards)
}
const handleTaskCompletion = () => {
MarkChoreComplete(
}, [chore, performers, completionCount, t])
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
setChorePriority(priority)
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleTaskCompletion = async () => {
try {
const resp = await MarkChoreComplete(
choreId,
impersonatedUser
? { completedBy: impersonatedUser.userId, note }
@@ -204,30 +241,16 @@ const ChoreView = () => {
completedDate,
null,
)
.then(resp => {
if (resp.ok) {
return resp.json().then(data => {
const data = await resp.json()
setNote(null)
setChore(data.res)
})
}
})
.then(() => {
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
.then(() => {
// refetch the chore details
GetChoreDetailById(choreId).then(resp => {
if (resp.ok) {
return resp.json().then(data => {
setChore(data.res)
})
const detailResp = await GetChoreDetailById(choreId)
if (detailResp.ok) {
const detailData = await detailResp.json()
setChore(detailData.res)
}
})
})
.then(() => {
// Show undo notification
showSuccess({
title: t('choreView.taskCompleted'),
message: t('choreView.taskCompletedMessage'),
@@ -235,7 +258,6 @@ const ChoreView = () => {
try {
const undoResponse = await UndoChoreAction(choreId)
if (undoResponse.ok) {
// Refetch chore details after undo
const detailResponse = await GetChoreDetailById(choreId)
if (detailResponse.ok) {
const detailData = await detailResponse.json()
@@ -257,25 +279,61 @@ const ChoreView = () => {
}
},
})
}
} catch (error) {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.COMPLETE_CHORE,
choreId,
{
id: choreId,
body: impersonatedUser
? { completedBy: impersonatedUser.userId, note }
: { note },
completedDate: completedDate || 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'] })
showSuccess({
message: "You're offline — completion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to complete task',
})
}
const handleSkippingTask = () => {
SkipChore(choreId).then(response => {
}
}
const handleSkippingTask = async () => {
try {
const response = await SkipChore(choreId)
if (response.ok) {
response.json().then(data => {
const newChore = data.res
setChore(newChore)
// Invalidate chores cache to refetch data
const data = await response.json()
setChore(data.res)
queryClient.invalidateQueries(['chores'])
// Show undo notification
showSuccess({
message: t('choreView.skipTask'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(choreId)
if (undoResponse.ok) {
// Refetch chore details after undo
const detailResponse = await GetChoreDetailById(choreId)
if (detailResponse.ok) {
const detailData = await detailResponse.json()
@@ -297,11 +355,32 @@ const ChoreView = () => {
}
},
})
}
} catch (error) {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.SKIP_CHORE,
choreId,
{ id: choreId },
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — skip will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to skip task',
})
}
})
}
}
const handleChoreStart = () => {
const startedChore = { ...chore, status: ChoreStatus.ACTIVE }
startChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
@@ -310,10 +389,37 @@ const ChoreView = () => {
}
setChore(newChore)
},
onError: async error => {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
choreId,
{ id: choreId },
)
setChore(startedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
setChore({ ...chore, status: previousStatus })
},
})
return
}
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to start task',
})
},
})
}
const handleChorePause = () => {
const pausedChore = { ...chore, status: ChoreStatus.PAUSED }
pauseChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
@@ -322,6 +428,32 @@ const ChoreView = () => {
}
setChore(newChore)
},
onError: async error => {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
choreId,
{ id: choreId },
)
setChore(pausedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
setChore({ ...chore, status: previousStatus })
},
})
return
}
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to pause task',
})
},
})
}
@@ -383,7 +515,6 @@ const ChoreView = () => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
@@ -395,23 +526,45 @@ const ChoreView = () => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleUnarchiveChore = () => {
UnArchiveChore(choreId).then(response => {
const handleUnarchiveChore = async () => {
try {
const response = await UnArchiveChore(choreId)
if (response.ok) {
response.json().then(data => {
setChore({ ...chore, isActive: true })
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.UNARCHIVE_CHORE,
choreId,
{ id: choreId },
)
setChore({ ...chore, isActive: true })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — restore will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
setChore({ ...chore, isActive: false })
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: 'Failed to restore',
message: error.message || 'Unable to restore task',
})
}
})
}
}
// Check if the current user can approve/reject (admin, manager, or task owner)
@@ -458,16 +611,19 @@ const ChoreView = () => {
mb: 1,
}}
>
<Typography
level='h3'
// textAlign={'center'}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
mt: 1,
mb: 0.5,
}}
>
{chore.name}
</Typography>
<Typography level='h3'>{chore.name}</Typography>
<PendingBadge commands={pendingCmds} />
</Box>
{chore.isActive === false && (
<Chip
startDecorator={<Archive />}
@@ -747,7 +903,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
<RichTextEditor value={chore.description} isEditable={false} />
{(() => {
const raw = chore.description || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
</Box>
</Sheet>
</>
@@ -792,7 +971,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
<RichTextEditor value={chore.notes} isEditable={false} />
{(() => {
const raw = chore.notes || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
</Box>
</Sheet>
</>

View File

@@ -20,6 +20,7 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -28,6 +29,7 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -41,6 +43,7 @@ const ArchivedTasks = () => {
useUserProfile()
const { showSuccess, showError } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const queryClient = useQueryClient()
const unArchiveChore = useUnArchiveChore()
const [archivedChores, setArchivedChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
@@ -319,6 +322,10 @@ const ArchivedTasks = () => {
const restoredTasks = []
const failedTasks = []
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
const queuedTasks = []
for (const chore of selectedData) {
try {
await new Promise((resolve, reject) => {
@@ -327,9 +334,15 @@ const ArchivedTasks = () => {
restoredTasks.push(chore)
resolve(data)
},
onError: error => {
onError: async error => {
if (isNetworkError(error)) {
await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id })
queuedTasks.push(chore)
resolve()
} else {
failedTasks.push(chore)
reject(error)
}
},
})
})
@@ -338,22 +351,22 @@ const ArchivedTasks = () => {
}
}
if (restoredTasks.length > 0) {
showSuccess({
title: '📤 Tasks Restored',
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
})
// Remove restored tasks from archived list
const restoredIds = new Set(restoredTasks.map(c => c.id))
const newArchivedChores = archivedChores.filter(
c => !restoredIds.has(c.id),
)
const newFilteredChores = filteredChores.filter(
c => !restoredIds.has(c.id),
)
const allRestored = [...restoredTasks, ...queuedTasks]
if (allRestored.length > 0) {
const offlineNote = queuedTasks.length > 0 ? " (queued — will sync when back online)" : ''
// Remove from archived view optimistically for both online and queued
const restoredIds = new Set(allRestored.map(c => c.id))
const newArchivedChores = archivedChores.filter(c => !restoredIds.has(c.id))
const newFilteredChores = filteredChores.filter(c => !restoredIds.has(c.id))
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
if (queuedTasks.length > 0) {
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
}
showSuccess({
title: '📤 Tasks Restored',
message: `Restored ${allRestored.length} task${allRestored.length > 1 ? 's' : ''}${offlineNote}.`,
})
}
if (failedTasks.length > 0) {

View File

@@ -5,6 +5,7 @@ import {
Pause,
PlayArrow,
Repeat,
Schedule,
ThumbUp,
TimesOneMobiledata,
Toll,
@@ -22,6 +23,7 @@ import {
} from '@mui/joy'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import {
getDueDateChipColor,
@@ -32,6 +34,7 @@ import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import Priorities from '../../utils/Priorities'
import ChoreActionMenu from '../components/ChoreActionMenu'
import PendingBadge from '../components/PendingBadge'
const ChoreCard = ({
chore,
performers,
@@ -47,6 +50,7 @@ const ChoreCard = ({
}) => {
const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization()
const { data: pendingCmds } = usePendingCommands(chore.id)
const { impersonatedUser } = useImpersonateUser()
@@ -86,7 +90,11 @@ const ChoreCard = ({
return name
}
return (
<Box key={chore.id + '-box'} minWidth={'100%'}>
<Box
key={chore.id + '-box'}
minWidth={'100%'}
sx={{ position: 'relative' }}
>
<Chip
variant='soft'
sx={{
@@ -122,6 +130,9 @@ const ChoreCard = ({
</div>
</Chip>
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
<PendingBadge commands={pendingCmds} />
</Box>
<Box
sx={{
position: 'relative',
@@ -342,9 +353,31 @@ const ChoreCard = ({
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
justifyContent: 'center',
}}
>
{chore.status === 3 && (
<Chip
variant='soft'
color='neutral'
size='sm'
sx={{
mb: 1,
px: 0.75,
py: 0.5,
minHeight: 56,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 0.25,
}}
>
<Schedule sx={{ fontSize: 16 }} />
<Typography level='body-xs'>Pending</Typography>
</Chip>
)}
{showActions && (
<Box
display='flex'

View File

@@ -12,6 +12,7 @@ import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import {
getDueDateChipColor,
@@ -24,6 +25,7 @@ import {
getTextColorFromBackgroundColor,
} from '../../utils/Colors.jsx'
import ChoreActionMenu from '../components/ChoreActionMenu'
import PendingBadge from '../components/PendingBadge'
const CompactChoreCard = ({
chore,
@@ -44,6 +46,7 @@ const CompactChoreCard = ({
const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization()
const { data: circleMembersData } = useCircleMembers()
const { data: pendingCmds } = usePendingCommands(chore.id)
const { impersonatedUser } = useImpersonateUser()
@@ -383,7 +386,9 @@ const CompactChoreCard = ({
>
{chore.name}
</Typography>
{(chore._pending || (pendingCmds && pendingCmds.length > 0)) && (
<PendingBadge commands={pendingCmds} size='xs' sx={{ mr: -0.5 }} />
)}
{/* Due Date - Inline with name */}
<Chip
variant='soft'

View File

@@ -1,6 +1,6 @@
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
import IconButton from '@mui/joy/IconButton'
import React, { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
const IconButtonWithMenu = ({
@@ -17,6 +17,7 @@ const IconButtonWithMenu = ({
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
const menuOptions = Array.isArray(options) ? options : []
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
@@ -85,7 +86,7 @@ const IconButtonWithMenu = ({
</Typography>
</MenuItem>
)}
{options?.map(item => (
{menuOptions.map(item => (
<MenuItem
key={`${k}-${item?.id}`}
onClick={() => {

View File

@@ -272,18 +272,6 @@ const MyChores = () => {
// Don't set choreSections here - let the dedicated effect handle it
// This prevents caching issues when switching between projects
if (localStorage.getItem('openChoreSections') === null) {
setSelectedChoreSectionWithCache(selectedChoreSection)
const openSections = processedSections.reduce(
(acc, _section, index) => {
acc[index] = true
return acc
},
{},
)
setOpenChoreSections(openSections)
}
if (await canScheduleNotification()) {
console.log('Scheduling chore notifications...')
scheduleChoreNotification(
@@ -303,10 +291,8 @@ const MyChores = () => {
choresData?.res,
membersData?.res,
processedChores, // Added to ensure local state syncs when query data updates
processedSections,
userProfile,
impersonatedUser?.userId,
selectedChoreSection,
])
// Auto-update sections when processedSections changes
@@ -1296,8 +1282,7 @@ const MyChores = () => {
)}
</Box>
)}
{searchTerm?.length > 0 &&
viewMode !== 'calendar' && (
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
<ChoreListView
chores={getFilteredChores}
viewMode={viewMode}
@@ -1492,8 +1477,7 @@ const MyChores = () => {
)}
</>
)}
{searchTerm.length === 0 &&
viewMode !== 'calendar' && (
{searchTerm.length === 0 && viewMode !== 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null

View File

@@ -1,7 +1,11 @@
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useArchiveChore } from '../../../queries/ChoreQueries'
import {
useArchiveChore,
useUnArchiveChore,
} from '../../../queries/ChoreQueries'
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import { commandQueue, CommandType } from '../../../utils/CommandQueue'
import {
ApproveChore,
DeleteChore,
@@ -13,6 +17,10 @@ import {
UpdateChoreAssignee,
UpdateDueDate,
} from '../../../utils/Fetcher'
import { offlineDB } from '../../../utils/OfflineDB'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
export const useChoreActions = ({
chores,
@@ -35,11 +43,12 @@ export const useChoreActions = ({
}) => {
const queryClient = useQueryClient()
const archiveChore = useArchiveChore()
const unarchiveChore = useUnArchiveChore()
const startChore = useStartChore()
const pauseChore = usePauseChore()
const updateChoreInState = useCallback(
(updatedChore, event) => {
(updatedChore, event, { skipInvalidation = false } = {}) => {
let newChores = chores.map(c =>
c.id === updatedChore.id ? updatedChore : c,
)
@@ -61,7 +70,9 @@ export const useChoreActions = ({
setChores(newChores)
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries({ queryKey: ['chores'] })
if (!skipInvalidation) {
queryClient.invalidateQueries(['chores'])
}
const undoableActions = {
completed: 'Task completed',
@@ -77,7 +88,7 @@ export const useChoreActions = ({
try {
const undoResponse = await UndoChoreAction(updatedChore.id)
if (undoResponse.ok) {
refetchChores()
queryClient.invalidateQueries(['chores'])
const undoMessages = {
completed: 'Task completion has been undone.',
approved: 'Task approval has been undone.',
@@ -121,7 +132,8 @@ export const useChoreActions = ({
archive: {
type: 'success',
title: 'Task Archived',
message: 'The task has been archived and hidden from the active list.',
message:
'The task has been archived and hidden from the active list.',
},
started: {
type: 'success',
@@ -147,47 +159,56 @@ export const useChoreActions = ({
notifyFn({ title: notification.title, message: notification.message })
}
},
[chores, filteredChores, setChores, setFilteredChores, queryClient, showSuccess, showError, showWarning, showUndo, refetchChores],
[
chores,
filteredChores,
setChores,
setFilteredChores,
queryClient,
showSuccess,
showError,
showWarning,
showUndo,
],
)
const handleChoreAction = useCallback(
async (action, chore, extraData = {}) => {
switch (action) {
case 'complete':
// 1. Instantly hide the chore from the UI and Cache
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
if (!oldData || !oldData.res) return oldData;
return {
...oldData,
res: oldData.res.filter(c => c.id !== chore.id),
}
});
try {
const response = await MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
null,
null,
)
if (response.ok) {
// 2. Show the success notification with Undo
// Online: hide the chore and show undo
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
if (!oldData || !oldData.res) return oldData
return {
...oldData,
res: oldData.res.filter(c => c.id !== chore.id),
}
})
showSuccess({
message: 'Task completed',
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(chore.id)
if (undoResponse.ok) {
refetchChores()
queryClient.invalidateQueries(['chores'])
showUndo({
title: 'Undo Successful',
message: 'Task completion has been undone.',
})
} else throw new Error('Failed to undo')
} catch (error) {
} catch {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
@@ -195,60 +216,162 @@ export const useChoreActions = ({
}
},
})
// 3. Fetch the fresh active list from the server silently
// (This brings in the next occurrence if recurring, without showing the completed one)
queryClient.invalidateQueries({ queryKey: ['chores'] })
queryClient.invalidateQueries(['chores'])
} else {
refetchChores() // Network failed, revert to truth
refetchChores()
}
} catch (error) {
refetchChores() // Network failed, revert to truth
if (error?.queued) {
showError({
title: 'Update Failed',
message: 'Request will be reattempt when you are online',
if (isNetworkError(error)) {
// Offline — queue and show pending badge on the chore (don't hide it)
const cmdId = await commandQueue.enqueue(
CommandType.COMPLETE_CHORE,
chore.id,
{
id: chore.id,
body: impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
completedDate: 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'] })
showSuccess({
title: 'Task completion pending',
message:
"You're offline — completion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
} else {
showError({
title: 'Failed to update',
message: error,
title: 'Failed to complete',
message: error?.message || 'Unable to complete chore',
})
}
}
break
case 'start':
startChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'started')
case 'start': {
const startedChore = { ...chore, status: 1 }
try {
await startChore.mutateAsync(chore.id)
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? startedChore : c,
),
}
})
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
} catch (error) {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
chore.id,
{ id: chore.id },
)
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
updateChoreInState(
{ ...chore, status: previousStatus },
previousStatus === 2 ? 'paused' : 'started',
{ skipInvalidation: true },
)
},
onError: error => {
})
} else {
showError({
title: 'Failed to start',
message: error.message || 'Unable to start chore',
})
},
message: error?.message || 'Unable to start chore',
})
}
}
break
}
case 'pause':
pauseChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'paused')
case 'pause': {
const pausedChore = { ...chore, status: 2 }
try {
await pauseChore.mutateAsync(chore.id)
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? pausedChore : c,
),
}
})
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
} catch (error) {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
chore.id,
{ id: chore.id },
)
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
updateChoreInState(
{ ...chore, status: previousStatus },
previousStatus === 2 ? 'paused' : 'started',
{ skipInvalidation: true },
)
},
onError: error => {
})
} else {
showError({
title: 'Failed to pause',
message: error.message || 'Unable to pause chore',
})
},
message: error?.message || 'Unable to pause chore',
})
}
}
break
}
case 'approve':
try {
@@ -305,12 +428,39 @@ export const useChoreActions = ({
})
}
} catch (error) {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.DELETE_CHORE,
chore.id,
{ id: chore.id },
)
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev =>
prev.filter(c => c.id !== chore.id),
)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — deletion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
setChores(prev => [...prev, chore])
setFilteredChores(prev => [...prev, chore])
},
})
} else {
showError({
title: 'Failed to delete',
message: error,
message: error?.message || 'Unable to delete chore',
})
}
}
}
setConfirmModelConfig({})
},
})
@@ -324,32 +474,123 @@ export const useChoreActions = ({
updateChoreInState(data, 'archive')
resolve(data)
},
onError: error => {
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.ARCHIVE_CHORE,
chore.id,
{ id: chore.id },
)
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev =>
prev.filter(c => c.id !== chore.id),
)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — archive will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
setChores(prev => [...prev, chore])
setFilteredChores(prev => [...prev, chore])
},
})
resolve()
} else {
showError({
title: 'Failed to archive',
message: error.message || 'Unable to archive chore',
})
reject(error)
}
},
})
})
} catch (error) {
} catch (error) {}
break
case 'unarchive':
try {
await new Promise((resolve, reject) => {
unarchiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState({ ...chore, isActive: true }, 'unarchive')
resolve(data)
},
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.UNARCHIVE_CHORE,
chore.id,
{ id: chore.id },
)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — restore will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
resolve()
} else {
showError({
title: 'Failed to restore',
message: error.message || 'Unable to restore chore',
})
reject(error)
}
},
})
})
} catch (error) {}
break
case 'skip':
try {
const response = await SkipChore(chore.id)
if (response.ok) {
// Online: update in place (chore gets new due date)
const data = await response.json()
updateChoreInState(data.res, 'skipped')
} else {
refetchChores()
}
} catch (error) {
if (isNetworkError(error)) {
// Offline — queue and show pending badge on the chore
const cmdId = await commandQueue.enqueue(
CommandType.SKIP_CHORE,
chore.id,
{ id: chore.id },
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — skip will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
} else {
showError({
title: 'Failed to skip',
message: error,
message: error?.message || 'Unable to skip chore',
})
}
}
break
case 'changeDueDate':
@@ -363,6 +604,40 @@ export const useChoreActions = ({
updateChoreInState(chore, eventType)
}
} catch (error) {
if (isNetworkError(error)) {
const oldDueDate = chore.nextDueDate
const cmdId = await commandQueue.enqueue(
CommandType.RESCHEDULE_CHORE,
chore.id,
{
id: chore.id,
dueDate: extraData.date,
},
)
const eventType =
extraData.date === null ? 'due-date-removed' : 'rescheduled'
updateChoreInState(
{ ...chore, nextDueDate: extraData.date },
eventType,
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message:
"You're offline — reschedule will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
const undoEventType =
oldDueDate === null ? 'due-date-removed' : 'rescheduled'
updateChoreInState(
{ ...chore, nextDueDate: oldDueDate },
undoEventType,
)
},
})
} else {
showError({
title:
extraData.date === null
@@ -371,6 +646,7 @@ export const useChoreActions = ({
message: error.message || 'Unable to update due date',
})
}
}
} else {
openModal(action, chore, extraData)
}
@@ -400,26 +676,67 @@ export const useChoreActions = ({
setConfirmModelConfig,
openModal,
archiveChore,
unarchiveChore,
startChore,
pauseChore,
],
)
const handleChangeDueDate = useCallback(
newDate => {
async newDate => {
if (!modalChore) return
UpdateDueDate(modalChore.id, newDate).then(response => {
closeModal()
try {
const response = await UpdateDueDate(modalChore.id, newDate)
if (response.ok) {
response.json().then(data => {
const newChore = modalChore
newChore.nextDueDate = newDate
updateChoreInState(newChore, 'rescheduled')
updateChoreInState(
{ ...modalChore, nextDueDate: newDate },
'rescheduled',
)
}
} catch (error) {
if (isNetworkError(error)) {
const oldDueDate = modalChore.nextDueDate
const cmdId = await commandQueue.enqueue(
CommandType.RESCHEDULE_CHORE,
modalChore.id,
{
id: modalChore.id,
dueDate: newDate,
},
)
updateChoreInState(
{ ...modalChore, nextDueDate: newDate },
'rescheduled',
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — reschedule will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
updateChoreInState(
{ ...modalChore, nextDueDate: oldDueDate },
'rescheduled',
)
},
})
} else {
showError({
title: 'Failed to reschedule',
message: error.message || 'Unable to update due date',
})
}
})
closeModal()
}
},
[modalChore, updateChoreInState, closeModal],
[
modalChore,
updateChoreInState,
closeModal,
showSuccess,
showError,
queryClient,
],
)
const handleCompleteWithPastDate = useCallback(
@@ -568,7 +885,15 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
}, [
getSelectedChoresData,
impersonatedUser,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkArchive = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
@@ -603,8 +928,7 @@ export const useChoreActions = ({
},
})
})
} catch (error) {
}
} catch (error) {}
}
if (archivedTasks.length > 0) {
showSuccess({
@@ -630,7 +954,17 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
}, [
getSelectedChoresData,
archiveChore,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkDelete = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
@@ -690,7 +1024,18 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
}, [
getSelectedChoresData,
chores,
filteredChores,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkSkip = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
@@ -726,7 +1071,7 @@ export const useChoreActions = ({
for (const chore of skippedTasks) {
await UndoChoreAction(chore.id)
}
refetchChores()
queryClient.invalidateQueries(['chores'])
showUndo({
title: 'Undo Successful',
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
@@ -760,7 +1105,15 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
}, [
getSelectedChoresData,
showSuccess,
showError,
showUndo,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
return {
handleChoreAction,

View File

@@ -20,10 +20,11 @@ import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreHistory,
useDeleteChoreHistory,
@@ -48,15 +49,33 @@ const ChoreHistory = () => {
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
const { showSuccess } = useNotification()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers()
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
const performers = circleMembersData?.res || []
const pendingByHistoryId = useMemo(() => {
if (!pendingCmds?.length) return {}
return pendingCmds.reduce((acc, cmd) => {
if (
cmd.commandType !== 'update_chore_history' &&
cmd.commandType !== 'delete_chore_history'
) {
return acc
}
const historyId =
cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1])
if (!historyId) return acc
if (!acc[historyId]) acc[historyId] = []
acc[historyId].push(cmd)
return acc
}, {})
}, [pendingCmds])
const handleDelete = historyEntry => {
showConfirmation(
@@ -366,6 +385,7 @@ const ChoreHistory = () => {
performers={performers}
allHistory={choreHistory}
index={index}
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
@@ -407,13 +427,21 @@ const ChoreHistory = () => {
},
},
{
onSuccess: () => {
onSuccess: data => {
setIsEditModalOpen(false)
setEditHistory(null)
if (data?.queued) {
showSuccess({
title: 'History Update Queued',
message:
'You are offline. The history update will sync when connection is restored.',
})
} else {
showSuccess({
title: 'History Updated',
message: `The history record has been updated successfully.`,
})
}
},
onError: error => {
console.error('Failed to update chore history:', error)
@@ -429,13 +457,21 @@ const ChoreHistory = () => {
historyId: editHistory.id,
},
{
onSuccess: () => {
onSuccess: data => {
setIsEditModalOpen(false)
setEditHistory(null)
if (data?.queued) {
showSuccess({
title: 'History Delete Queued',
message:
'You are offline. The history delete will sync when connection is restored.',
})
} else {
showSuccess({
title: 'History Deleted',
message: `The history record has been deleted successfully.`,
})
}
},
},
)

View File

@@ -17,9 +17,14 @@ import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import PendingBadge from '../components/PendingBadge'
const getCompletedChip = historyEntry => {
if (historyEntry.status === 0 || historyEntry.status === 5 || historyEntry.status === 6) {
if (
historyEntry.status === 0 ||
historyEntry.status === 5 ||
historyEntry.status === 6
) {
return null
}
@@ -94,6 +99,7 @@ const HistoryCard = ({
performers,
historyEntry,
index,
pendingCommands,
onToggleActions,
onViewNote,
}) => {
@@ -330,6 +336,18 @@ const HistoryCard = ({
</IconButton>
)}
</Box>
{pendingCommands?.length > 0 && (
<PendingBadge
commands={pendingCommands}
size='s'
sx={{
mr: 0.5,
position: 'absolute',
right: -3,
top: -3,
}}
/>
)}
</Box>
)
}

View File

@@ -1,10 +1,29 @@
import { useQuery } from '@tanstack/react-query'
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
export const useLabels = () => {
return useQuery({
queryKey: ['labels'],
queryFn: GetLabels,
queryFn: async () => {
try {
const data = await GetLabels()
const labels = Array.isArray(data?.res)
? data.res
: Array.isArray(data)
? data
: []
if (labels.length > 0) {
offlineDB.saveKV('labels', labels)
}
return labels
} catch {
const cached = await offlineDB.getKV('labels')
if (Array.isArray(cached)) return cached
return []
}
},
})
}

View File

@@ -73,7 +73,7 @@ function ConfirmationModal({ config }) {
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
onClose={() => handleAction(false)}
size='sm'
unmountDelay={250}
>

View File

@@ -1,5 +1,11 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
CreateProject,
DeleteProject,
GetProjects,
UpdateProject,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
// Query hook for fetching all projects
export const useProjects = () => {
@@ -10,22 +16,15 @@ export const useProjects = () => {
const response = await GetProjects()
if (response.ok) {
const data = await response.json()
return data.res || data
const projects = data.res || data
offlineDB.saveKV('projects', projects)
return projects
}
throw new Error('Failed to fetch projects')
} catch (error) {
console.error('Error fetching projects:', error)
// Return default project if API fails
return [
{
id: 'default',
name: 'Default Project',
description: 'Your default project workspace',
color: '#1976d2',
created_by: 'system',
created_at: new Date().toISOString(),
}
]
} catch {
const cached = await offlineDB.getKV('projects')
if (cached) return cached
return []
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
@@ -39,7 +38,7 @@ export const useCreateProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (projectData) => {
mutationFn: async projectData => {
try {
const response = await CreateProject(projectData)
if (response.ok) {
@@ -59,7 +58,7 @@ export const useCreateProject = () => {
return localProject
}
},
onSuccess: (newProject) => {
onSuccess: newProject => {
// Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
const updatedProjects = [...oldProjects, newProject]
@@ -69,7 +68,7 @@ export const useCreateProject = () => {
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
onError: error => {
console.error('Create project mutation failed:', error)
},
})
@@ -98,18 +97,18 @@ export const useUpdateProject = () => {
}
}
},
onSuccess: (updatedProject) => {
onSuccess: updatedProject => {
// Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
return oldProjects.map(project =>
project.id === updatedProject.id ? updatedProject : project
project.id === updatedProject.id ? updatedProject : project,
)
})
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
onError: error => {
console.error('Update project mutation failed:', error)
},
})
@@ -120,7 +119,7 @@ export const useDeleteProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (projectId) => {
mutationFn: async projectId => {
try {
// Prevent deletion of default project
if (projectId === 'default') {
@@ -147,14 +146,14 @@ export const useDeleteProject = () => {
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
onError: error => {
console.error('Delete project mutation failed:', error)
},
})
}
// Hook to get a specific project by ID
export const useProject = (projectId) => {
export const useProject = projectId => {
return useQuery({
queryKey: ['projects', projectId],
queryFn: async () => {

View File

@@ -1,7 +1,6 @@
import {
Box,
Button,
Card,
Checkbox,
Chip,
FormControl,
@@ -9,22 +8,43 @@ import {
Input,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import RealTimeSettings from '../../components/RealTimeSettings'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import { offlineDB } from '../../utils/OfflineDB'
import {
clearBrowserCacheStorage,
isOfflineFeatureEnabled,
setOfflineFeatureEnabled,
subscribeToOfflineFeature,
} from '../../utils/OfflineFeatureToggle'
import { syncEngine } from '../../utils/SyncEngine'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const AdvancedSettings = () => {
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const [userCircles, setUserCircles] = useState([])
const [webhookURL, setWebhookURL] = useState(null)
const [webhookError, setWebhookError] = useState(null)
const [isAdmin, setIsAdmin] = useState(false)
const [offlineEnabled, setOfflineEnabled] = useState(
isOfflineFeatureEnabled(),
)
const [offlineLoading, setOfflineLoading] = useState(false)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
useEffect(() => {
const unsubscribe = subscribeToOfflineFeature(setOfflineEnabled)
return unsubscribe
}, [])
useEffect(() => {
GetUserCircle().then(resp => {
@@ -42,21 +62,120 @@ const AdvancedSettings = () => {
}
}, [userCircles])
if (!userProfile) {
return (
<SettingsLayout title="Advanced Settings">
<div>Loading...</div>
</SettingsLayout>
)
const disableOfflineSupport = async () => {
setOfflineLoading(true)
try {
await offlineDB.clearAll()
await clearBrowserCacheStorage()
setOfflineFeatureEnabled(false)
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
queryClient.removeQueries({ queryKey: ['chores'] })
queryClient.invalidateQueries()
showNotification({
type: 'success',
message: 'Offline support disabled and local offline data cleared',
})
} catch {
setOfflineFeatureEnabled(false)
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
queryClient.removeQueries({ queryKey: ['chores'] })
queryClient.invalidateQueries()
showNotification({
type: 'warning',
message:
'Offline support disabled, but some local cache items may not have been cleared',
})
} finally {
setOfflineLoading(false)
}
}
const showDisableOfflineConfirmation = () => {
setConfirmModalConfig({
isOpen: true,
title: 'Disable Offline Support',
message:
'Disabling offline support will remove queued offline actions and local cached offline data on this device/browser. Do you want to continue?',
confirmText: 'Disable & Clear',
cancelText: 'Cancel',
color: 'danger',
onClose: isConfirmed => {
setConfirmModalConfig({})
if (isConfirmed) {
disableOfflineSupport()
}
},
})
}
const handleOfflineToggle = async event => {
const nextEnabled = !!event.target.checked
if (nextEnabled) {
setOfflineFeatureEnabled(true)
await syncEngine.sync()
queryClient.invalidateQueries()
showNotification({
type: 'success',
message: 'Offline support enabled for this device/browser',
})
return
}
showDisableOfflineConfirmation()
}
// if (!userProfile) {
// return (
// <SettingsLayout title="Advanced Settings">
// <div>Loading...</div>
// </SettingsLayout>
// )
// }
return (
<SettingsLayout title="Advanced Settings">
<SettingsLayout title='Advanced Settings'>
<div className='grid gap-4'>
<Typography level='body-md'>
Configure advanced features like webhooks and real-time updates for enhanced productivity.
Configure advanced features like webhooks and real-time updates for
enhanced productivity.
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<Typography level='title-lg'>Offline Support</Typography>
<Chip
variant='outlined'
size='sm'
sx={{
height: '20px',
fontSize: '0.65rem',
fontWeight: 'bold',
color: 'warning.main',
borderColor: 'warning.main',
}}
>
Early Access
</Chip>
</Box>
<Typography level='body-md' mt={-1}>
Enable offline queue and local cache for this device/browser.
Disabling removes pending offline actions and cached offline data.
</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={offlineEnabled}
onChange={handleOfflineToggle}
variant='soft'
label='Enable Offline Support'
disabled={offlineLoading}
overlay
/>
<FormHelperText>
When disabled, queued changes and offline cache are cleared from
this device/browser.
</FormHelperText>
</FormControl>
{/* Webhook Settings - Only show for admins */}
{isAdmin && (
<>
@@ -152,9 +271,14 @@ const AdvancedSettings = () => {
Real-time Updates
</Typography>
<Typography level='body-md' mt={-1}>
Configure how you receive live updates when tasks and activities change in your circle.
Configure how you receive live updates when tasks and activities
change in your circle.
</Typography>
<RealTimeSettings />
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
</SettingsLayout>
)

View File

@@ -1,14 +1,23 @@
import { LocalNotifications } from '@capacitor/local-notifications'
import { Refresh, Token } from '@mui/icons-material'
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { LocalNotifications } from '@capacitor/local-notifications'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { networkManager } from '../../hooks/NetworkManager'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useSSEContext } from '../../hooks/useSSEContext'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { commandQueue } from '../../utils/CommandQueue'
import { RefreshToken } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import { syncEngine } from '../../utils/SyncEngine'
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const DeveloperSettings = () => {
const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const {
isConnected,
isConnecting,
@@ -31,9 +40,48 @@ const DeveloperSettings = () => {
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
const [scheduledNotifications, setScheduledNotifications] = useState([])
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
const [isResettingSync, setIsResettingSync] = useState(false)
const [syncDiagnostics, setSyncDiagnostics] = useState({
cursor: null,
lastSync: null,
pendingCount: 0,
failedCount: 0,
syncing: false,
syncError: null,
isOnline: networkManager.isOnline,
isNetworkOn: networkManager.isNetworkOn,
offlineSince: networkManager.offlineSince,
lastChecked: networkManager.lastChecked,
})
const { showNotification } = useNotification()
const refreshSyncDiagnostics = useCallback(async () => {
try {
const [cursor, lastSync, pendingCommands, failedCommands] =
await Promise.all([
offlineDB.getSyncCursor(),
offlineDB.getLastSyncTime(),
commandQueue.getPending(),
commandQueue.getFailed(),
])
setSyncDiagnostics(prev => ({
...prev,
cursor,
lastSync,
pendingCount: pendingCommands.length,
failedCount: failedCommands.length,
isOnline: networkManager.isOnline,
isNetworkOn: networkManager.isNetworkOn,
offlineSince: networkManager.offlineSince,
lastChecked: networkManager.lastChecked,
}))
} catch (error) {
console.error('Failed to load sync diagnostics:', error)
}
}, [])
useEffect(() => {
setIsNativePlatform(isNative())
@@ -54,12 +102,8 @@ const DeveloperSettings = () => {
const pending = await LocalNotifications.getPending()
// Sort by schedule time (earliest first)
const sorted = pending.notifications.sort((a, b) => {
const timeA = a.schedule?.at
? new Date(a.schedule.at).getTime()
: 0
const timeB = b.schedule?.at
? new Date(b.schedule.at).getTime()
: 0
const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0
const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0
return timeA - timeB
})
setScheduledNotifications(sorted)
@@ -73,7 +117,41 @@ const DeveloperSettings = () => {
loadTokenData()
loadScheduledNotifications()
}, [])
refreshSyncDiagnostics()
}, [refreshSyncDiagnostics])
useEffect(() => {
const unsubscribeSync = syncEngine.onSyncStateChange(state => {
setSyncDiagnostics(prev => ({
...prev,
syncing:
typeof state.syncing === 'boolean' ? state.syncing : prev.syncing,
syncError: Object.prototype.hasOwnProperty.call(state, 'error')
? state.error
: prev.syncError,
lastSync: state.lastSync ?? prev.lastSync,
}))
})
networkManager.registerNetworkListener(() => {
setSyncDiagnostics(prev => ({
...prev,
isOnline: networkManager.isOnline,
isNetworkOn: networkManager.isNetworkOn,
offlineSince: networkManager.offlineSince,
lastChecked: networkManager.lastChecked,
}))
})
const interval = setInterval(() => {
refreshSyncDiagnostics()
}, 5000)
return () => {
unsubscribeSync()
clearInterval(interval)
}
}, [refreshSyncDiagnostics])
useEffect(() => {
const calculateTimeLeft = () => {
@@ -239,6 +317,51 @@ const DeveloperSettings = () => {
}
}
const handleResetDatabaseAndResync = async () => {
showConfirmation(
'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?',
'Clear Local DB & Re-Sync',
async () => {
setIsResettingSync(true)
try {
await offlineDB.clearAll()
showNotification({
type: 'success',
message: 'Local offline database cleared. Starting full sync...',
})
const didSync = await syncEngine.sync()
if (didSync) {
await queryClient.invalidateQueries()
showNotification({
type: 'success',
message: 'Full sync completed from the beginning',
})
} else {
showNotification({
type: 'warning',
message:
'Database cleared. Full sync did not run (likely offline or already syncing).',
})
}
} catch (error) {
console.error('Failed to reset database and resync:', error)
showNotification({
type: 'error',
message: `Reset/resync failed: ${error.message}`,
})
} finally {
await refreshSyncDiagnostics()
setIsResettingSync(false)
}
},
'Clear & Re-Sync',
'Cancel',
'danger',
)
}
const getNotificationStatusColor = scheduleTime => {
if (!scheduleTime) return 'neutral'
@@ -252,6 +375,11 @@ const DeveloperSettings = () => {
return 'success' // More than 1 hour
}
const formatDateTime = timestamp => {
if (!timestamp) return 'N/A'
return new Date(timestamp).toLocaleString()
}
return (
<div className='grid gap-4 py-4' id='developer'>
<Typography level='h3'>Developer Settings</Typography>
@@ -377,6 +505,143 @@ const DeveloperSettings = () => {
</Box>
</Card>
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
}}
>
<Typography level='title-lg'>Sync & Network Diagnostics</Typography>
<Button
size='sm'
variant='soft'
startDecorator={<Refresh />}
onClick={refreshSyncDiagnostics}
>
Refresh
</Button>
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Network Status</Typography>
<Typography level='body-sm'>
Connection:{' '}
<Chip
size='sm'
variant='soft'
color={syncDiagnostics.isOnline ? 'success' : 'danger'}
>
{syncDiagnostics.isOnline ? 'Online' : 'Offline'}
</Chip>
</Typography>
<Typography level='body-sm'>
Device Network:{' '}
<Chip
size='sm'
variant='soft'
color={
syncDiagnostics.isNetworkOn === false
? 'danger'
: syncDiagnostics.isNetworkOn === true
? 'success'
: 'neutral'
}
>
{syncDiagnostics.isNetworkOn === false
? 'Disconnected'
: syncDiagnostics.isNetworkOn === true
? 'Connected'
: 'Unknown'}
</Chip>
</Typography>
<Typography level='body-xs' color='neutral'>
Offline Since: {formatDateTime(syncDiagnostics.offlineSince)}
</Typography>
<Typography level='body-xs' color='neutral'>
Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)}
</Typography>
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Sync Offset Information</Typography>
<Typography level='body-sm'>
Sync Cursor:{' '}
<Chip size='sm' variant='soft'>
{syncDiagnostics.cursor ?? 'N/A'}
</Chip>
</Typography>
<Typography level='body-sm'>
Last Sync:{' '}
<Chip size='sm' variant='soft' color='primary'>
{formatDateTime(syncDiagnostics.lastSync)}
</Chip>
</Typography>
<Typography level='body-sm'>
Sync State:{' '}
<Chip
size='sm'
variant='soft'
color={syncDiagnostics.syncing ? 'warning' : 'success'}
>
{syncDiagnostics.syncing ? 'Syncing' : 'Idle'}
</Chip>
</Typography>
<Typography level='body-sm'>
Pending Commands:{' '}
<Chip size='sm' variant='soft' color='warning'>
{syncDiagnostics.pendingCount}
</Chip>
</Typography>
<Typography level='body-sm'>
Failed Commands:{' '}
<Chip
size='sm'
variant='soft'
color={syncDiagnostics.failedCount > 0 ? 'danger' : 'success'}
>
{syncDiagnostics.failedCount}
</Chip>
</Typography>
{syncDiagnostics.syncError && (
<Typography level='body-sm' color='danger'>
Sync Error: {syncDiagnostics.syncError}
</Typography>
)}
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Recovery Actions</Typography>
<Typography level='body-xs' color='warning'>
Clears local offline cache, sync cursor, and queued commands, then
re-syncs from the beginning.
</Typography>
<Box>
<Button
size='sm'
color='danger'
variant='soft'
onClick={handleResetDatabaseAndResync}
loading={isResettingSync}
disabled={isResettingSync || syncDiagnostics.syncing}
>
Clear DB & Full Re-Sync
</Button>
</Box>
</Box>
</Box>
</Card>
{isNativePlatform && (
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -436,9 +701,7 @@ const DeveloperSettings = () => {
? new Date(scheduleTime)
: null
const now = new Date()
const timeUntil = scheduledDate
? scheduledDate - now
: null
const timeUntil = scheduledDate ? scheduledDate - now : null
return (
<Card
@@ -726,6 +989,8 @@ const DeveloperSettings = () => {
</Box>
</Box>
</Card>
<ConfirmationModal config={confirmModalConfig} />
</div>
)
}

View File

@@ -4,17 +4,11 @@ import {
Card,
Chip,
LinearProgress,
Switch,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import {
FEATURES,
isFeatureEnabled,
setFeatureEnabled,
} from '../../utils/FeatureToggle'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -26,9 +20,6 @@ const StorageSettings = () => {
const [usage, setUsage] = useState({ used: 0, total: 0 })
const [loading, setLoading] = useState(true)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const [offlineModeEnabled, setOfflineModeEnabledState] = useState(
isFeatureEnabled(FEATURES.OFFLINE_MODE),
)
const showConfirmation = (
message,
@@ -54,11 +45,6 @@ const StorageSettings = () => {
})
}
const handleOfflineModeToggle = enabled => {
setOfflineModeEnabledState(enabled)
setFeatureEnabled(FEATURES.OFFLINE_MODE, enabled)
}
useEffect(() => {
if (isPlusAccount(userProfile)) {
GetStorageUsage().then(resp => {
@@ -127,48 +113,14 @@ const StorageSettings = () => {
)}
</Card>
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
Experimental Features
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Coming Soon
</Chip>
</Typography>
<div className='mb-2 flex items-center justify-between'>
<div className='flex-1'>
<Typography level='body-md' sx={{ mb: 0.5 }}>
Enable Offline Mode
</Typography>
<Typography level='body-sm' color='neutral'>
Allows the app to work offline by caching data locally. This is
experimental and may cause some slowness. If you experience
performance issues, we recommend turning this off.
</Typography>
</div>
<Switch
checked={offlineModeEnabled}
disabled={true}
onChange={event => handleOfflineModeToggle(event.target.checked)}
sx={{ ml: 2 }}
/>
</div>
{offlineModeEnabled && (
<Typography level='body-xs' color='warning' sx={{ mt: 1 }}>
Offline mode is enabled. If you experience slowness, disable
this setting.
</Typography>
)}
</Card>
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
Cache
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is data stored locally in your browser for faster access and
offline use. Clearing this will not affect your server data, but may
log you out or remove offline tasks.
This is data stored locally in your browser for faster access.
Clearing this will not affect your server data, but may log you out.
</Typography>
<Button
variant='soft'
@@ -189,27 +141,6 @@ const StorageSettings = () => {
>
Clear All Local Storage and Cache
</Button>
<Button
variant='outlined'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear only the offline cache and tasks?',
'Clear Offline Cache',
() => {
localStorage.removeItem('offline_cache')
localStorage.removeItem('offline_request_queue')
localStorage.removeItem('offlineTasks')
},
'Clear Cache',
'Cancel',
'danger',
)
}}
sx={{ mt: 1 }}
>
Clear Offline Cache and Offline Tasks
</Button>
</Card>
{Capacitor.isNativePlatform() && (

View File

@@ -47,10 +47,14 @@ import {
} from '../../queries/TimeQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getSafeBottom } from '../../utils/SafeAreaUtils'
import LoadingComponent from '../components/Loading'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
const TimerDetails = () => {
const { choreId } = useParams()
const { fmt } = useLocalization()
@@ -256,7 +260,23 @@ const TimerDetails = () => {
})
refetchTimer()
},
onError: () => {
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
choreId,
{ id: choreId },
)
showSuccess({
title: 'Start queued',
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
},
})
return
}
showError({
title: 'Failed to start timer',
message: 'Please try again.',
@@ -278,7 +298,23 @@ const TimerDetails = () => {
})
refetchTimer()
},
onError: () => {
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
choreId,
{ id: choreId },
)
showSuccess({
title: 'Pause queued',
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
},
})
return
}
showError({
title: 'Failed to pause timer',
message: 'Please try again.',
@@ -928,9 +964,7 @@ const TimerDetails = () => {
'MMM DD',
)
const startTime = fmt.time(pause.start)
const endTime = pause.end
? fmt.time(pause.end)
: null
const endTime = pause.end ? fmt.time(pause.end) : null
const realTimeDuration = isOngoing
? Math.max(

View File

@@ -633,32 +633,28 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
createChoreMutation
.mutateAsync(chore)
.then(resp => {
resp.json().then(data => {
if (resp.status !== 200) {
console.error('Error creating chore:', data)
return
.then(result => {
const choreData = result
if (choreData?._pendingCreate) {
// Offline: task queued, add temp chore to UI immediately
onChoreUpdate(choreData)
} else {
// Online: choreData is the created chore object returned by the mutation
onChoreUpdate({
...chore,
id: data.res,
...choreData,
id: choreData?.id,
nextDueDate: chore.dueDate,
})
handleCloseModal(false)
}
handleCloseModal()
setTaskText('')
})
})
.catch(error => {
if (error?.queued) {
handleCloseModal(true)
}
console.error('Error creating chore:', error)
})
handleCloseModal(false)
}
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
if (isCircleMembersLoading || isProjectsLoading) {
return <></>
}

View File

@@ -31,6 +31,7 @@ import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
import { useLocalization } from '../../contexts/LocalizationContext'
import NavBarLink from './NavBarLink'
import SyncStatusIndicator from './SyncStatusIndicator'
import { SafeArea } from 'capacitor-plugin-safe-area'
import Z_INDEX from '../../constants/zIndex'
@@ -199,6 +200,7 @@ const NavBar = () => {
{getMenuIcon()}
<Box className='flex-1' />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SyncStatusIndicator />
<UserProfileAvatar />
{/* <ThemeToggleButton /> */}
</Box>

View File

@@ -0,0 +1,206 @@
import { Close, CloudSync } from '@mui/icons-material'
import {
Box,
Button,
Divider,
IconButton,
List,
ListItem,
ListItemContent,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { commandQueue } from '../../utils/CommandQueue'
const LABELS = {
complete_chore: 'Complete pending',
skip_chore: 'Skip pending',
update_chore: 'Update pending',
create_chore: 'Create pending',
delete_chore: 'Delete pending',
update_chore_history: 'Edit history pending',
delete_chore_history: 'Delete history pending',
reschedule_chore: 'Reschedule pending',
archive_chore: 'Archive pending',
unarchive_chore: 'Restore pending',
start_chore: 'Start 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 = {} }) {
const { ResponsiveModal } = useResponsiveModal()
const queryClient = useQueryClient()
const [isOpen, setIsOpen] = useState(false)
const [cancelingIds, setCancelingIds] = useState({})
const [isCancelingAll, setIsCancelingAll] = useState(false)
const pendingSyncLabel = `${commands?.length || 0} pending action${commands?.length === 1 ? '' : 's'} to sync`
if (!commands || commands.length === 0) return null
const stopEvent = e => {
e.stopPropagation()
}
const invalidatePending = async () => {
await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
await queryClient.invalidateQueries({ queryKey: ['chores'] })
await queryClient.invalidateQueries({ queryKey: ['choreHistory'] })
}
const handleUndo = async (e, cmdId) => {
e.stopPropagation()
setCancelingIds(prev => ({ ...prev, [cmdId]: true }))
try {
await commandQueue.cancel(cmdId)
await invalidatePending()
} finally {
setCancelingIds(prev => {
const next = { ...prev }
delete next[cmdId]
return next
})
}
}
const handleCancelAll = async e => {
e.stopPropagation()
if (commands.length === 0) return
setIsCancelingAll(true)
try {
await Promise.all(commands.map(cmd => commandQueue.cancel(cmd.id)))
await invalidatePending()
setIsOpen(false)
} finally {
setIsCancelingAll(false)
setCancelingIds({})
}
}
const handleOpen = e => {
e.stopPropagation()
setIsOpen(true)
}
const handleClose = e => {
if (e?.stopPropagation) {
e.stopPropagation()
}
setIsOpen(false)
}
const isXs = size === 'xs'
return (
<Box data-no-chore-nav='true' sx={{ mt: isXs ? 0 : 0.5, ...sx }}>
<IconButton
variant='soft'
color='warning'
size='sm'
onClick={handleOpen}
onMouseDown={stopEvent}
onPointerDown={stopEvent}
aria-label={pendingSyncLabel}
title={pendingSyncLabel}
sx={{
borderRadius: '50%',
...(isXs && {
width: 18,
height: 18,
minWidth: 18,
minHeight: 18,
p: 0.25,
}),
}}
>
{/* <Badge
badgeContent={commands.length}
size='sm'
color='warning'
sx={{
'& .MuiBadge-badge': { fontSize: 10, minWidth: 16, height: 16 },
}}
> */}
<CloudSync sx={{ fontSize: isXs ? 14 : 16 }} />
{/* </Badge> */}
</IconButton>
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
<Typography level='title-lg' mb={0.5}>
Pending actions
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
synced.
</Typography>
<List sx={{ '--List-gap': '8px', p: 0, mb: 1 }}>
{commands.map(cmd => (
<ListItem
key={cmd.id}
sx={{
alignItems: 'center',
justifyContent: 'space-between',
p: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'md',
}}
>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
{formatCommandLabel(cmd.commandType)}
</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{new Date(cmd.createdAt).toLocaleString()}
</Typography>
</ListItemContent>
<IconButton
variant='plain'
color='danger'
size='sm'
onClick={e => handleUndo(e, cmd.id)}
disabled={Boolean(cancelingIds[cmd.id]) || isCancelingAll}
>
<Close sx={{ fontSize: 14 }} />
</IconButton>
</ListItem>
))}
</List>
<Divider sx={{ mb: 1 }} />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' onClick={handleClose}>
Close
</Button>
<Button
color='danger'
onClick={handleCancelAll}
loading={isCancelingAll}
disabled={commands.length === 0}
>
Cancel all
</Button>
</Box>
</ResponsiveModal>
</Box>
)
}
export default PendingBadge

View File

@@ -0,0 +1,522 @@
import {
CheckCircleOutline,
ClearAll,
CloudDone,
CloudQueue,
CloudSync,
Refresh,
WifiOff,
} from '@mui/icons-material'
import {
Badge,
Box,
Button,
Chip,
CircularProgress,
Divider,
Dropdown,
ListItemDecorator,
Menu,
MenuButton,
MenuItem,
Sheet,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { networkManager } from '../../hooks/NetworkManager'
import { commandQueue } from '../../utils/CommandQueue'
import {
isOfflineFeatureEnabled,
subscribeToOfflineFeature,
} from '../../utils/OfflineFeatureToggle'
import { syncEngine } from '../../utils/SyncEngine'
const COMMAND_LABELS = {
create_chore: 'Create chore',
update_chore: 'Update chore',
update_chore_history: 'Edit history',
complete_chore: 'Complete chore',
skip_chore: 'Skip chore',
start_chore: 'Start chore',
pause_chore: 'Pause chore',
delete_chore: 'Delete chore',
delete_chore_history: 'Delete history',
reschedule_chore: 'Reschedule chore',
archive_chore: 'Archive chore',
unarchive_chore: 'Restore chore',
}
const formatCommandLabel = commandType => {
return (
COMMAND_LABELS[commandType] ||
commandType
?.replace(/_/g, ' ')
?.replace(/\b\w/g, letter => letter.toUpperCase()) ||
'Pending action'
)
}
const RETRY_INTERVAL = 30
function SyncStatusIndicator() {
const queryClient = useQueryClient()
const [pendingCommands, setPendingCommands] = useState([])
const [failedCommands, setFailedCommands] = useState([])
const [syncState, setSyncState] = useState({
syncing: false,
lastSync: null,
error: null,
})
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
const [retryIn, setRetryIn] = useState(RETRY_INTERVAL)
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
isOfflineFeatureEnabled(),
)
useEffect(() => {
const unsubscribe = subscribeToOfflineFeature(setOfflineFeatureEnabled)
return unsubscribe
}, [])
useEffect(() => {
const unsubscribe = syncEngine.onSyncStateChange(state => {
setSyncState(prev => ({ ...prev, ...state }))
})
return unsubscribe
}, [])
useEffect(() => {
if (!syncState.syncing) {
setRetryIn(RETRY_INTERVAL)
}
}, [syncState.syncing, syncState.lastSync])
useEffect(() => {
networkManager.registerNetworkListener(online => {
setIsOnline(online)
if (!online) setOfflineSince(networkManager.offlineSince)
})
}, [])
useEffect(() => {
if (!isOnline || syncState.syncing) return
const interval = setInterval(() => {
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
}, 1000)
return () => clearInterval(interval)
}, [isOnline, syncState.syncing, syncState.lastSync])
useEffect(() => {
const update = async () => {
try {
const [pending, failed] = await Promise.all([
commandQueue.getPending(),
commandQueue.getFailed(),
])
setPendingCommands(pending)
setFailedCommands(failed)
} catch {
// OfflineDB may not be initialized yet
}
}
update()
const interval = setInterval(update, 5000)
return () => clearInterval(interval)
}, [syncState])
const refreshCommands = async () => {
const [pending, failed] = await Promise.all([
commandQueue.getPending(),
commandQueue.getFailed(),
])
setPendingCommands(pending)
setFailedCommands(failed)
}
const handleForceSync = async () => {
const didSync = await syncEngine.sync()
if (didSync) queryClient.invalidateQueries()
await refreshCommands()
}
const handleDismissFailed = async id => {
await commandQueue.cancel(id)
await refreshCommands()
}
const handleCancelAll = async () => {
const [pending, failed] = await Promise.all([
commandQueue.getPending(),
commandQueue.getFailed(),
])
const allCommands = [...pending, ...failed]
await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.id)))
await refreshCommands()
}
const formatTime = timestamp => {
if (!timestamp) return 'Never'
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 10) return 'Just now'
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
return `${Math.floor(minutes / 60)}h ago`
}
const formatOfflineDuration = timestamp => {
if (!timestamp) return ''
const minutes = Math.floor((Date.now() - timestamp) / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
return `${Math.floor(minutes / 60)}h ago`
}
const groupedPending = Object.entries(
pendingCommands.reduce((acc, cmd) => {
acc[cmd.commandType] = (acc[cmd.commandType] || 0) + 1
return acc
}, {}),
)
const pendingCount = pendingCommands.length
const failedCount = failedCommands.length
const totalBadge = pendingCount + failedCount
const getStatusIcon = () => {
if (syncState.syncing)
return <CloudSync sx={{ fontSize: 20, color: 'primary.500' }} />
if (!isOnline) return <WifiOff sx={{ fontSize: 20, color: 'danger.500' }} />
if (failedCount > 0)
return <CloudQueue sx={{ fontSize: 20, color: 'danger.400' }} />
if (pendingCount > 0)
return <CloudQueue sx={{ fontSize: 20, color: 'warning.500' }} />
return <CloudDone sx={{ fontSize: 20, color: 'success.500' }} />
}
if (!offlineFeatureEnabled) return null
return (
<Dropdown>
<MenuButton
aria-label='Open sync and network status'
variant='plain'
sx={{
p: 0.5,
border: 'none',
backgroundColor: 'transparent',
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<Box
sx={{ display: 'flex', alignItems: 'center', position: 'relative' }}
>
{syncState.syncing && (
<CircularProgress
size='sm'
sx={{
position: 'absolute',
'--CircularProgress-size': '28px',
'--CircularProgress-trackThickness': '2px',
'--CircularProgress-progressThickness': '2px',
}}
/>
)}
{totalBadge > 0 ? (
<Badge
badgeContent={totalBadge}
size='sm'
color={failedCount > 0 ? 'danger' : 'warning'}
sx={{
'& .MuiBadge-badge': {
fontSize: 9,
minWidth: 16,
height: 16,
},
}}
>
{getStatusIcon()}
</Badge>
) : (
getStatusIcon()
)}
</Box>
</MenuButton>
<Menu
placement='bottom-end'
sx={{
minWidth: 280,
p: 1,
'--List-gap': '4px',
boxShadow: 'var(--joy-shadow-lg)',
border: '1px solid var(--joy-palette-divider)',
borderRadius: 'var(--joy-radius-md)',
}}
>
{/* Header */}
<Sheet sx={{ p: 1.5, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: isOnline
? 'var(--joy-palette-success-500)'
: 'var(--joy-palette-danger-500)',
}}
/>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{isOnline ? 'Online' : 'Offline'}
</Typography>
</Box>
{syncState.syncing && (
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-primary-500)' }}
>
Syncing...
</Typography>
)}
</Box>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Last sync: {formatTime(syncState.lastSync)}
</Typography>
{!isOnline && offlineSince && (
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-danger-400)', mt: 0.25 }}
>
Offline since {formatOfflineDuration(offlineSince)}
</Typography>
)}
{syncState.error && (
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
>
Error: {syncState.error}
</Typography>
)}
</Sheet>
{/* Pending actions */}
{groupedPending.length > 0 && (
<>
<Box sx={{ px: 1, py: 0.5 }}>
<Typography
level='body-xs'
sx={{
fontWeight: 600,
color: 'var(--joy-palette-text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Pending ({pendingCount})
</Typography>
</Box>
{groupedPending.map(([type, count]) => (
<Box
key={type}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.5,
borderRadius: 'var(--joy-radius-sm)',
}}
>
<Typography level='body-sm'>
{formatCommandLabel(type)}
</Typography>
<Chip size='sm' color='warning' variant='soft'>
{count}
</Chip>
</Box>
))}
<Divider sx={{ my: 0.5 }} />
</>
)}
{/* Failed actions */}
{failedCommands.length > 0 && (
<>
<Box sx={{ px: 1, py: 0.5 }}>
<Typography
level='body-xs'
sx={{
fontWeight: 600,
color: 'var(--joy-palette-danger-500)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Failed ({failedCount})
</Typography>
</Box>
{failedCommands.map(cmd => (
<Box
key={cmd.id}
sx={{
px: 1,
py: 0.75,
mb: 0.5,
borderRadius: 'var(--joy-radius-sm)',
backgroundColor: 'var(--joy-palette-danger-softBg)',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
color: 'var(--joy-palette-danger-700)',
fontWeight: 500,
}}
>
{formatCommandLabel(cmd.commandType)}
</Typography>
<Button
size='sm'
variant='plain'
color='danger'
sx={{ fontSize: 11, py: 0, minHeight: 'unset', px: 0.5 }}
onClick={() => handleDismissFailed(cmd.id)}
>
Dismiss
</Button>
</Box>
{cmd.error && (
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
>
{cmd.error}
</Typography>
)}
</Box>
))}
<Divider sx={{ my: 0.5 }} />
</>
)}
{/* All clear */}
{pendingCount === 0 && failedCount === 0 && (
<Box
sx={{
px: 1,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<CheckCircleOutline
sx={{ fontSize: 16, color: 'var(--joy-palette-success-500)' }}
/>
<Typography
level='body-sm'
sx={{ color: 'var(--joy-palette-text-secondary)' }}
>
All changes synced
</Typography>
</Box>
)}
{/* Next retry / offline hint */}
{isOnline && !syncState.syncing && pendingCount > 0 && (
<Box sx={{ px: 1, pb: 0.5 }}>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Next auto-sync in {retryIn}s
</Typography>
</Box>
)}
{!isOnline && (
<Box sx={{ px: 1, pb: 0.5 }}>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Will sync when back online
</Typography>
</Box>
)}
<Divider sx={{ my: 0.5 }} />
<MenuItem
disabled={syncState.syncing || totalBadge === 0}
onClick={handleCancelAll}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
<ClearAll sx={{ fontSize: 18 }} />
</ListItemDecorator>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Cancel All
</Typography>
</MenuItem>
{/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */}
<MenuItem
disabled={syncState.syncing}
onClick={handleForceSync}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator>
{syncState.syncing ? (
<CircularProgress size='sm' />
) : (
<Refresh sx={{ fontSize: 18 }} />
)}
</ListItemDecorator>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{syncState.syncing ? 'Syncing...' : 'Sync Now'}
</Typography>
</MenuItem>
</Menu>
</Dropdown>
)
}
export default SyncStatusIndicator