feat: implement offline support for labels and projects, enhance advanced settings with offline feature toggle, and add sync status indicator

- Added offline support for fetching and caching labels and projects using `offlineDB`.
- Enhanced `AdvancedSettings` to include an offline feature toggle with confirmation modal for disabling offline support.
- Introduced `SyncStatusIndicator` component to display sync status, pending commands, and failed commands.
- Created `PendingBadge` component to manage and display pending actions for synchronization.
- Removed deprecated offline mode toggle from `StorageSettings`.
- Improved error handling and user notifications for offline actions and sync processes.
This commit is contained in:
Mo Tarbin
2026-05-11 17:27:29 -04:00
parent 3d170d88fb
commit 2c353628a3
33 changed files with 3077 additions and 1067 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() {

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,66 @@
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(() => {
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)
networkManager.registerNetworkListener(async isOnline => {
if (isOnline) {
await runSync()
}
})
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
runSync()
}
})
// 3. Browser online event (fires when device network is restored)
window.addEventListener('online', () => runSync())
// 4. Retry pending commands every 30s (catches backend restart)
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)
setInterval(() => {
runSync()
}, CACHE_REFRESH_MS)
}
const runSync = async () => {
if (!isOfflineFeatureEnabled()) return
const didSync = await syncEngine.sync()
if (didSync) {
queryClient.invalidateQueries()
}
}
init()
}, [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,81 @@ 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,
)
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)))
.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()
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()
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 +103,14 @@ 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 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
}
// If online, proceed with server-side deletion
await Promise.all(
choreIds.map(async id => {
const resp = await DeleteChore(id)
@@ -99,75 +122,69 @@ 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 { res: offlineChore }
}
return useMutation({
mutationFn: async newTask => {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
if (!networkManager.isOnline) {
return queueOfflineCreate(newTask)
}
const createdChore = await resp.json()
if (!createdChore) {
throw new Error('Failed to get created chore data')
try {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
}
const createdChore = await resp.json()
if (!createdChore) {
throw new Error('Failed to get created chore data')
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineCreate(newTask)
}
throw error
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
},
// 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 +194,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,
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
return new Promise((resolve, reject) => {
resolve(updatedTask)
})
const queueOfflineUpdate = async () => {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE,
updatedChore.id,
updatedChore,
)
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,
}
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
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
if (!oldData) return { res: pendingChore }
return { ...oldData, res: pendingChore }
})
return pendingChore
}
try {
const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) {
throw new Error('Failed to save chore')
@@ -223,9 +227,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 +236,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'])
},
})
}
@@ -275,30 +275,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 }
}
throw new Error('Chore detail not available offline')
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
}
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 +302,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 }
}
throw new Error('Chore not available offline')
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
}
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'])
@@ -416,12 +395,32 @@ 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,
})
// 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 +429,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 +461,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 +474,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,73 +1,26 @@
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')
}
const updatedChoreRes = await resp.json()
if (!updatedChoreRes) {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
return updatedChoreRes?.res || updatedChoreRes
const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) {
throw new Error('Failed to save chore')
}
const updatedChoreRes = await resp.json()
if (!updatedChoreRes) {
throw new Error('Failed to get updated chore data')
}
// 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,9 +31,8 @@ export const useStartChore = () => {
return useMutation({
mutationFn: StartChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
@@ -44,9 +43,8 @@ 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 +56,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 +70,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 +83,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 +96,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)
}
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 () => {
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 (!token) {
return null
}
return result.res || 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
}

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

@@ -0,0 +1,128 @@
import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
// Domain command types
export const CommandType = {
CREATE_CHORE: 'create_chore',
UPDATE_CHORE: 'update_chore',
COMPLETE_CHORE: 'complete_chore',
SKIP_CHORE: 'skip_chore',
DELETE_CHORE: 'delete_chore',
RESCHEDULE_CHORE: 'reschedule_chore',
ARCHIVE_CHORE: 'archive_chore',
UNARCHIVE_CHORE: 'unarchive_chore',
}
class CommandQueue {
// 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 commands = await offlineDB.getCommandsByEntity(String(entityId))
return commands
.filter(c => c.status === 'pending')
.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) {
if (cmd.commandType === CommandType.UPDATE_CHORE) {
const prev = seen.get(cmd.entityId)
if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
// Merge: keep latest payload, remove older
toRemove.push(prev.id)
}
}
seen.set(cmd.entityId, cmd)
}
for (const id of 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()

688
src/utils/OfflineDB.js Normal file
View File

@@ -0,0 +1,688 @@
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { Capacitor } from '@capacitor/core'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
const DB_NAME = 'donetick_offline'
const DB_VERSION = 1
const IDB_NAME = 'donetick_offline'
const IDB_VERSION = 1
// Cache platform detection
let _isNative = null
const isNative = () => {
if (_isNative === null) {
try {
_isNative = Capacitor.isNativePlatform()
} catch {
_isNative = false
}
}
return _isNative
}
// ── SQLite backend (iOS/Android) ──
class SQLiteBackend {
constructor() {
this.db = null
this.initialized = false
}
async init() {
if (this.initialized) return
this.db = await CapacitorSQLite.createConnection({
database: DB_NAME,
version: DB_VERSION,
encrypted: false,
mode: 'no-encryption',
})
await CapacitorSQLite.open({ database: DB_NAME })
await CapacitorSQLite.execute({
database: DB_NAME,
statements: `
CREATE TABLE IF NOT EXISTS cached_chores (
id INTEGER PRIMARY KEY,
data TEXT NOT NULL,
sync_version INTEGER NOT NULL DEFAULT 0,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS command_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT
);
CREATE TABLE IF NOT EXISTS sync_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`,
})
this.initialized = true
}
// ── Chore cache ──
async saveChores(chores) {
if (!chores.length) return
const statements = chores.map(chore => ({
statement:
'INSERT OR REPLACE INTO cached_chores (id, data, sync_version, cached_at) VALUES (?, ?, ?, ?)',
values: [
chore.id,
JSON.stringify(chore),
chore.syncVersion || 0,
Date.now(),
],
}))
await CapacitorSQLite.executeSet({
database: DB_NAME,
set: statements,
})
}
async getChores() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT data FROM cached_chores',
values: [],
})
return (result.values || [])
.map(row => JSON.parse(row.data))
.filter(chore => chore.isActive !== false)
}
async getChore(id) {
const numericId = Number(id)
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT data FROM cached_chores WHERE id = ?',
values: [isNaN(numericId) ? id : numericId],
})
if (result.values && result.values.length > 0) {
return JSON.parse(result.values[0].data)
}
return null
}
async deleteChores(ids) {
if (!ids.length) return
const statements = ids.map(id => ({
statement: 'DELETE FROM cached_chores WHERE id = ?',
values: [id],
}))
await CapacitorSQLite.executeSet({
database: DB_NAME,
set: statements,
})
}
async clearChores() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: 'DELETE FROM cached_chores',
})
}
// ── Command queue ──
async enqueueCommand(command) {
const result = await CapacitorSQLite.run({
database: DB_NAME,
statement: `INSERT INTO command_queue (command_type, entity_id, payload, created_at, status, error)
VALUES (?, ?, ?, ?, ?, ?)`,
values: [
command.commandType,
command.entityId,
command.payload,
command.createdAt,
command.status,
command.error,
],
})
return result.changes?.lastId
}
async getCommands() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT * FROM command_queue ORDER BY created_at ASC',
values: [],
})
return (result.values || []).map(row => ({
id: row.id,
commandType: row.command_type,
entityId: row.entity_id,
payload: row.payload,
createdAt: row.created_at,
status: row.status,
error: row.error,
}))
}
async getCommandsByEntity(entityId) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement:
'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC',
values: [entityId],
})
return (result.values || []).map(row => ({
id: row.id,
commandType: row.command_type,
entityId: row.entity_id,
payload: row.payload,
createdAt: row.created_at,
status: row.status,
error: row.error,
}))
}
async updateCommandStatus(id, status, error) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'UPDATE command_queue SET status = ?, error = ? WHERE id = ?',
values: [status, error, id],
})
}
async removeCommand(id) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'DELETE FROM command_queue WHERE id = ?',
values: [id],
})
}
async clearCommands() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: 'DELETE FROM command_queue',
})
}
// ── Sync metadata ──
async getSyncCursor() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'",
values: [],
})
if (result.values && result.values.length > 0) {
return Number(result.values[0].value)
}
return 0
}
async setSyncCursor(cursor) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('sync_cursor', ?)",
values: [String(cursor)],
})
}
async getLastSyncTime() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'",
values: [],
})
if (result.values && result.values.length > 0) {
return Number(result.values[0].value)
}
return null
}
async setLastSyncTime(time) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync_time', ?)",
values: [String(time)],
})
}
async saveKV(key, value) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)',
values: [key, JSON.stringify(value)],
})
}
async getKV(key) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT value FROM sync_meta WHERE key = ?',
values: [key],
})
if (result.values && result.values.length > 0) {
try {
return JSON.parse(result.values[0].value)
} catch {
return null
}
}
return null
}
async clearAll() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: `
DELETE FROM cached_chores;
DELETE FROM command_queue;
DELETE FROM sync_meta;
`,
})
}
}
// ── IndexedDB backend (Web) ──
class IndexedDBBackend {
constructor() {
this.db = null
this.initialized = false
}
_open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IDB_NAME, IDB_VERSION)
request.onupgradeneeded = event => {
const db = event.target.result
if (!db.objectStoreNames.contains('cached_chores')) {
db.createObjectStore('cached_chores', { keyPath: 'id' })
}
if (!db.objectStoreNames.contains('command_queue')) {
const cmdStore = db.createObjectStore('command_queue', {
keyPath: 'id',
autoIncrement: true,
})
cmdStore.createIndex('entity_id', 'entityId', { unique: false })
cmdStore.createIndex('created_at', 'createdAt', { unique: false })
cmdStore.createIndex('status', 'status', { unique: false })
}
if (!db.objectStoreNames.contains('sync_meta')) {
db.createObjectStore('sync_meta', { keyPath: 'key' })
}
}
request.onsuccess = event => resolve(event.target.result)
request.onerror = event => reject(event.target.error)
})
}
async init() {
if (this.initialized) return
this.db = await this._open()
// Re-open if browser closes the connection (e.g. after device sleep)
this.db.onclose = () => {
this.initialized = false
}
this.initialized = true
}
async _tx(storeName, mode = 'readonly') {
// If the connection was closed (e.g. laptop sleep), re-open transparently
if (!this.initialized || !this.db) {
await this.init()
}
try {
const tx = this.db.transaction(storeName, mode)
const store = tx.objectStore(storeName)
return { tx, store }
} catch (err) {
// InvalidStateError = connection closed; re-open once and retry
if (
err.name === 'InvalidStateError' ||
err.name === 'TransactionInactiveError'
) {
this.initialized = false
await this.init()
const tx = this.db.transaction(storeName, mode)
const store = tx.objectStore(storeName)
return { tx, store }
}
throw err
}
}
_request(idbRequest) {
return new Promise((resolve, reject) => {
idbRequest.onsuccess = () => resolve(idbRequest.result)
idbRequest.onerror = () => reject(idbRequest.error)
})
}
// ── Chore cache ──
async saveChores(chores) {
if (!chores.length) return
const { tx, store } = await this._tx('cached_chores', 'readwrite')
for (const chore of chores) {
store.put({
id: chore.id,
data: chore,
syncVersion: chore.syncVersion || 0,
cachedAt: Date.now(),
})
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async getChores() {
const { store } = await this._tx('cached_chores')
const rows = await this._request(store.getAll())
return rows.map(row => row.data).filter(chore => chore.isActive !== false)
}
async getChore(id) {
const { store } = await this._tx('cached_chores')
// Try numeric ID first (chores are stored with numeric keys from the server)
// URL params are strings so we need to coerce
const numericId = Number(id)
const row = await this._request(
store.get(isNaN(numericId) ? id : numericId),
)
return row ? row.data : null
}
async deleteChores(ids) {
if (!ids.length) return
const { tx, store } = await this._tx('cached_chores', 'readwrite')
for (const id of ids) {
store.delete(id)
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async clearChores() {
const { store } = await this._tx('cached_chores', 'readwrite')
await this._request(store.clear())
}
// ── Command queue ──
async enqueueCommand(command) {
const { store } = await this._tx('command_queue', 'readwrite')
const id = await this._request(
store.add({
commandType: command.commandType,
entityId: command.entityId,
payload: command.payload,
createdAt: command.createdAt,
status: command.status,
error: command.error,
}),
)
return id
}
async getCommands() {
const { store } = await this._tx('command_queue')
const index = store.index('created_at')
const rows = await this._request(index.getAll())
return rows
}
async getCommandsByEntity(entityId) {
const { store } = await this._tx('command_queue')
const index = store.index('entity_id')
const rows = await this._request(index.getAll(entityId))
return rows.sort((a, b) => a.createdAt - b.createdAt)
}
async updateCommandStatus(id, status, error) {
const { store } = await this._tx('command_queue', 'readwrite')
const row = await this._request(store.get(id))
if (row) {
row.status = status
row.error = error
await this._request(store.put(row))
}
}
async removeCommand(id) {
const { store } = await this._tx('command_queue', 'readwrite')
await this._request(store.delete(id))
}
async clearCommands() {
const { store } = await this._tx('command_queue', 'readwrite')
await this._request(store.clear())
}
// ── Sync metadata ──
async getSyncCursor() {
const { store } = await this._tx('sync_meta')
const row = await this._request(store.get('sync_cursor'))
return row ? Number(row.value) : 0
}
async setSyncCursor(cursor) {
const { store } = await this._tx('sync_meta', 'readwrite')
await this._request(
store.put({ key: 'sync_cursor', value: String(cursor) }),
)
}
async getLastSyncTime() {
const { store } = await this._tx('sync_meta')
const row = await this._request(store.get('last_sync_time'))
return row ? Number(row.value) : null
}
async setLastSyncTime(time) {
const { store } = await this._tx('sync_meta', 'readwrite')
await this._request(
store.put({ key: 'last_sync_time', value: String(time) }),
)
}
async saveKV(key, value) {
const { store } = await this._tx('sync_meta', 'readwrite')
await this._request(store.put({ key, value: JSON.stringify(value) }))
}
async getKV(key) {
const { store } = await this._tx('sync_meta')
const row = await this._request(store.get(key))
if (row) {
try {
return JSON.parse(row.value)
} catch {
return null
}
}
return null
}
async clearAll() {
const storeNames = ['cached_chores', 'command_queue', 'sync_meta']
for (const storeName of storeNames) {
const { store } = await this._tx(storeName, 'readwrite')
await this._request(store.clear())
}
}
}
// ── OfflineDB facade ──
class OfflineDB {
constructor() {
this.backend = null
this.initialized = false
// When the tab becomes visible after being hidden (laptop wake/tab switch),
// reset so the next operation re-validates the IDB connection.
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.backend) {
// Signal the IDB backend to re-open on next use
this.backend.initialized = false
}
})
}
}
async init() {
if (this.initialized) return
if (this._initPromise) return this._initPromise
this._initPromise = (async () => {
this.backend = isNative() ? new SQLiteBackend() : new IndexedDBBackend()
await this.backend.init()
this.initialized = true
this._initPromise = null
})()
return this._initPromise
}
async _ensureInit() {
if (!this.initialized) {
await this.init()
}
}
// Chore cache
async saveChores(chores) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.saveChores(chores)
}
async getChores() {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getChores()
}
async getChore(id) {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.getChore(id)
}
async deleteChores(ids) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.deleteChores(ids)
}
async clearChores() {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.clearChores()
}
// Command queue
async enqueueCommand(command) {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.enqueueCommand(command)
}
async getCommands() {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getCommands()
}
async getCommandsByEntity(entityId) {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getCommandsByEntity(entityId)
}
async updateCommandStatus(id, status, error) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.updateCommandStatus(id, status, error)
}
async removeCommand(id) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.removeCommand(id)
}
async clearCommands() {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.clearCommands()
}
// Sync metadata
async getSyncCursor() {
if (!isOfflineFeatureEnabled()) return 0
await this._ensureInit()
return this.backend.getSyncCursor()
}
async setSyncCursor(cursor) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.setSyncCursor(cursor)
}
async getLastSyncTime() {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.getLastSyncTime()
}
async setLastSyncTime(time) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.setLastSyncTime(time)
}
// General key-value cache (uses sync_meta store)
async saveKV(key, value) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.saveKV(key, value)
}
async getKV(key) {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.getKV(key)
}
async clearAll() {
await this._ensureInit()
return this.backend.clearAll()
}
}
export const offlineDB = new OfflineDB()

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

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

@@ -0,0 +1,211 @@
import { networkManager } from '../hooks/NetworkManager'
import { apiClient } from './ApiClient'
import { commandQueue, CommandType } from './CommandQueue'
import {
ArchiveChore,
CreateChore,
DeleteChore,
MarkChoreComplete,
SaveChore,
SkipChore,
UnArchiveChore,
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)
break
}
}
}
}
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.DELETE_CHORE:
response = await DeleteChore(cmd.payload.id || cmd.entityId)
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()) || 0
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)
}
// 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)
}
// 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,18 @@ const ChoreEdit = () => {
}
SaveFunction(chore)
.then(() => {
showSuccess({
title: 'Chore Saved',
message: 'Your task has been saved successfully!',
})
.then(result => {
if (result?._pendingUpdate || result?._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,6 +44,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import {
useChoreTimer,
@@ -56,6 +57,7 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import {
ApproveChore,
GetChoreDetailById,
@@ -71,11 +73,28 @@ 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 +123,7 @@ const ChoreView = () => {
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const startChore = useStartChore()
const pauseChore = usePauseChore()
@@ -136,7 +156,6 @@ const ChoreView = () => {
if (response.ok) {
response.json().then(() => {
setChorePriority(priority)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
@@ -164,7 +183,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
@@ -195,39 +216,26 @@ const ChoreView = () => {
]
setInfoCards(cards)
}
const handleTaskCompletion = () => {
MarkChoreComplete(
choreId,
impersonatedUser
? { completedBy: impersonatedUser.userId, note }
: { note },
completedDate,
null,
)
.then(resp => {
if (resp.ok) {
return resp.json().then(data => {
setNote(null)
setChore(data.res)
})
}
})
.then(() => {
// Invalidate chores cache to refetch data
const handleTaskCompletion = async () => {
try {
const resp = await MarkChoreComplete(
choreId,
impersonatedUser
? { completedBy: impersonatedUser.userId, note }
: { note },
completedDate,
null,
)
if (resp.ok) {
const data = await resp.json()
setNote(null)
setChore(data.res)
queryClient.invalidateQueries(['chores'])
})
.then(() => {
// refetch the chore details
GetChoreDetailById(choreId).then(resp => {
if (resp.ok) {
return resp.json().then(data => {
setChore(data.res)
})
}
})
})
.then(() => {
// Show undo notification
const detailResp = await GetChoreDetailById(choreId)
if (detailResp.ok) {
const detailData = await detailResp.json()
setChore(detailData.res)
}
showSuccess({
title: t('choreView.taskCompleted'),
message: t('choreView.taskCompletedMessage'),
@@ -235,7 +243,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,49 +264,94 @@ const ChoreView = () => {
}
},
})
})
}
const handleSkippingTask = () => {
SkipChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
setChore(newChore)
// Invalidate chores cache to refetch data
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()
setChore(detailData.res)
queryClient.invalidateQueries(['chores'])
}
showUndo({
title: t('choreView.undoSuccessful'),
message: t('choreView.taskSkipUndone'),
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}
} 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,
},
)
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 = async () => {
try {
const response = await SkipChore(choreId)
if (response.ok) {
const data = await response.json()
setChore(data.res)
queryClient.invalidateQueries(['chores'])
showSuccess({
message: t('choreView.skipTask'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(choreId)
if (undoResponse.ok) {
const detailResponse = await GetChoreDetailById(choreId)
if (detailResponse.ok) {
const detailData = await detailResponse.json()
setChore(detailData.res)
queryClient.invalidateQueries(['chores'])
}
showUndo({
title: t('choreView.undoSuccessful'),
message: t('choreView.taskSkipUndone'),
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}
} 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 = () => {
startChore.mutate(choreId, {
@@ -383,7 +435,6 @@ const ChoreView = () => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
@@ -395,23 +446,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'])
setChore({ ...chore, isActive: true })
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 +531,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 +823,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
<RichTextEditor value={chore.description} isEditable={false} />
{(() => {
const content = decodeHtmlEntities(chore.description || '')
const shouldRenderHtml = hasHtmlTags(content)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: content }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{content}
</Typography>
)
})()}
</Box>
</Sheet>
</>
@@ -792,7 +891,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
<RichTextEditor value={chore.notes} isEditable={false} />
{(() => {
const content = decodeHtmlEntities(chore.notes || '')
const shouldRenderHtml = hasHtmlTags(content)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: content }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{content}
</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 => {
failedTasks.push(chore)
reject(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,20 +1282,19 @@ const MyChores = () => {
)}
</Box>
)}
{searchTerm?.length > 0 &&
viewMode !== 'calendar' && (
<ChoreListView
chores={getFilteredChores}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
)}
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
<ChoreListView
chores={getFilteredChores}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
)}
{viewMode === 'calendar' && (
<>
{/* Summary Chips when no date selected */}
@@ -1492,87 +1477,86 @@ const MyChores = () => {
)}
</>
)}
{searchTerm.length === 0 &&
viewMode !== 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null
return (
<Accordion
key={section.name + index}
sx={{
my: 0,
px: 0,
}}
expanded={Boolean(openChoreSections[index])}
>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='neutral'
size='md'
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = {
...openChoreSections,
}
delete newOpenChoreSections[index]
setOpenChoreSectionsWithCache(newOpenChoreSections)
} else {
setOpenChoreSectionsWithCache({
...openChoreSections,
[index]: true,
})
{searchTerm.length === 0 && viewMode !== 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null
return (
<Accordion
key={section.name + index}
sx={{
my: 0,
px: 0,
}}
expanded={Boolean(openChoreSections[index])}
>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='neutral'
size='md'
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = {
...openChoreSections,
}
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
delete newOpenChoreSections[index]
setOpenChoreSectionsWithCache(newOpenChoreSections)
} else {
setOpenChoreSectionsWithCache({
...openChoreSections,
[index]: true,
})
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
['& > *']: {
// px: 0.5,
px: 0.5,
// pr: 0,
},
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
<ChoreListView
chores={section.content}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
</AccordionDetails>
</Accordion>
)
})}
</AccordionGroup>
)}
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
['& > *']: {
// px: 0.5,
px: 0.5,
// pr: 0,
},
}}
>
<ChoreListView
chores={section.content}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
</AccordionDetails>
</Accordion>
)
})}
</AccordionGroup>
)}
<Box
sx={{
// center the button

View File

@@ -1,19 +1,26 @@
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useArchiveChore } from '../../../queries/ChoreQueries'
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import {
ApproveChore,
DeleteChore,
MarkChoreComplete,
NudgeChore,
RejectChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
UpdateDueDate,
useArchiveChore,
useUnArchiveChore,
} from '../../../queries/ChoreQueries'
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import { commandQueue, CommandType } from '../../../utils/CommandQueue'
import {
ApproveChore,
DeleteChore,
MarkChoreComplete,
NudgeChore,
RejectChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../../utils/Fetcher'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
export const useChoreActions = ({
chores,
filteredChores,
@@ -35,11 +42,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 +69,9 @@ export const useChoreActions = ({
setChores(newChores)
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries({ queryKey: ['chores'] })
if (!skipInvalidation) {
queryClient.invalidateQueries(['chores'])
}
const undoableActions = {
completed: 'Task completed',
@@ -77,7 +87,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 +131,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 +158,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,35 +215,63 @@ 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,
},
)
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':
case 'start': {
const startedChore = { ...chore, status: 1 }
startChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'started')
onSuccess: () => {
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? startedChore : c,
),
}
})
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
},
onError: error => {
showError({
@@ -233,13 +281,25 @@ export const useChoreActions = ({
},
})
break
}
case 'pause':
case 'pause': {
const pausedChore = { ...chore, status: 2 }
pauseChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'paused')
onSuccess: () => {
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? pausedChore : c,
),
}
})
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
},
onError: error => {
showError({
@@ -249,6 +309,7 @@ export const useChoreActions = ({
},
})
break
}
case 'approve':
try {
@@ -305,10 +366,37 @@ export const useChoreActions = ({
})
}
} catch (error) {
showError({
title: 'Failed to delete',
message: 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 || 'Unable to delete chore',
})
}
}
}
setConfirmModelConfig({})
@@ -324,31 +412,122 @@ export const useChoreActions = ({
updateChoreInState(data, 'archive')
resolve(data)
},
onError: error => {
showError({
title: 'Failed to archive',
message: error.message || 'Unable to archive chore',
})
reject(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) {
showError({
title: 'Failed to skip',
message: 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 || 'Unable to skip chore',
})
}
}
break
@@ -363,13 +542,48 @@ export const useChoreActions = ({
updateChoreInState(chore, eventType)
}
} catch (error) {
showError({
title:
extraData.date === null
? 'Failed to remove due date'
: 'Failed to reschedule',
message: error.message || 'Unable to update due date',
})
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
? 'Failed to remove due date'
: 'Failed to reschedule',
message: error.message || 'Unable to update due date',
})
}
}
} else {
openModal(action, chore, extraData)
@@ -400,26 +614,60 @@ 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],
)
const handleCompleteWithPastDate = useCallback(
@@ -568,7 +816,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 +859,7 @@ export const useChoreActions = ({
},
})
})
} catch (error) {
}
} catch (error) {}
}
if (archivedTasks.length > 0) {
showSuccess({
@@ -630,7 +885,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 +955,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 +1002,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 +1036,15 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
}, [
getSelectedChoresData,
showSuccess,
showError,
showUndo,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
return {
handleChoreAction,

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

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
// Query hook for fetching all projects
export const useProjects = () => {
@@ -10,22 +11,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

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

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

@@ -633,32 +633,27 @@ 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
} else {
onChoreUpdate({
...chore,
id: data.res,
nextDueDate: chore.dueDate,
})
handleCloseModal(false)
}
handleCloseModal()
setTaskText('')
})
.then(result => {
const choreData = result?.res
if (choreData?._pendingCreate) {
// Offline: task queued, add temp chore to UI immediately
onChoreUpdate(choreData)
} else {
// Online: choreData is the server's parsed response ({ res: id })
onChoreUpdate({
...chore,
id: choreData?.res || choreData?.id,
nextDueDate: chore.dueDate,
})
}
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,193 @@
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',
reschedule_chore: 'Reschedule pending',
archive_chore: 'Archive pending',
unarchive_chore: 'Restore pending',
start_chore: 'Start pending',
pause_chore: 'Pause pending',
}
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'] })
}
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 }}>
{LABELS[cmd.commandType] || 'Pending action'}
</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,478 @@
import {
CheckCircleOutline,
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',
complete_chore: 'Complete chore',
skip_chore: 'Skip chore',
delete_chore: 'Delete chore',
reschedule_chore: 'Reschedule chore',
archive_chore: 'Archive chore',
unarchive_chore: 'Restore chore',
}
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 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
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'>
{COMMAND_LABELS[type] || 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,
}}
>
{COMMAND_LABELS[cmd.commandType] || 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 }} />
{/* 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