Merge branch 'develop' into advance-filtering

This commit is contained in:
Mohamad Tarbin
2026-07-04 02:06:18 -04:00
committed by GitHub
77 changed files with 8082 additions and 1829 deletions

View File

@@ -15,6 +15,7 @@ import './styles/safe-area.css'
import SSEProvider from './contexts/SSEContext'
import { useNotification } from './service/NotificationProvider'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
import NetworkBanner from './views/components/NetworkBanner'
const add = className => {
@@ -30,10 +31,12 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
const AppContent = () => {
const { showNotification } = useNotification()
useSyncOnReconnect()
// Initialize status bar with theme-aware configuration
useStatusBar()
const {
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
needRefresh: [needRefresh, setNeedRefresh],

View File

@@ -8,6 +8,35 @@ import { PushNotifications } from '@capacitor/push-notifications'
import { focusManager } from '@tanstack/react-query'
import { RegisterDeviceToken } from './utils/Fetcher'
// NFC chore deep link: donetick://chores/123?auto_complete=true
const handleNFCChoreDeepLink = url => {
try {
const urlObj = new URL(url)
// donetick://chores/123 → host='chores', pathname='/123'
const choreId = urlObj.pathname.slice(1)
const autoComplete = urlObj.searchParams.get('auto_complete')
const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}`
// getLaunchUrl() persists across every WebView reload caused by window.location.href.
// If we're already on the target page, skip to avoid an infinite reload loop.
if (window.location.pathname + window.location.search === path) return
console.log('[NFC] navigating to', path)
window.location.href = path
} catch (error) {
console.error('[NFC] Error handling chore deep link:', error)
}
}
const handleUrlOpen = url => {
console.log('[NFC] handleUrlOpen:', url)
if (url.startsWith('donetick://chores/')) {
handleNFCChoreDeepLink(url)
} else if (url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(url)
}
}
// OAuth callback handler for deep links
const handleOAuthDeepLink = async url => {
console.log('OAuth deep link received:', url)
@@ -215,16 +244,20 @@ const registerCapacitorListeners = () => {
return
}
localNotificationListenerRegistration()
// Register deep link handler for OAuth and other deep links
mobileApp.addListener('appUrlOpen', event => {
console.log('App URL opened:', event.url)
// Handle OAuth callback
if (event.url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(event.url)
// Cold-start: app was launched by tapping an NFC tag (or other deep link)
mobileApp.getLaunchUrl().then(result => {
if (result?.url) {
console.log('[NFC] getLaunchUrl:', result.url)
handleUrlOpen(result.url)
}
})
// Foreground / singleTask resume: app was already running when the tag was tapped
mobileApp.addListener('appUrlOpen', event => {
console.log('[NFC] appUrlOpen:', event.url)
handleUrlOpen(event.url)
})
mobileApp.addListener('appStateChange', ({ isActive }) => {
focusManager.setFocused(isActive)
@@ -233,6 +266,9 @@ const registerCapacitorListeners = () => {
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
} else if (window.location.pathname !== '/') {
// No history (e.g. app launched directly to a chore via NFC) — go home
window.location.href = '/'
} else {
mobileApp.exitApp()
}

View File

@@ -12,7 +12,7 @@ import Input from '@mui/joy/Input'
import Option from '@mui/joy/Option'
import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils'
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
top: 10,
left: 10,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',

View File

@@ -1,69 +1,81 @@
import { Network } from '@capacitor/network'
import { localStore } from '../utils/LocalStore'
import { syncManager } from '../utils/SyncManager.jsx' // Ensure you import syncManager if needed for syncing
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
class NetworkManager {
constructor() {
this.isOnline = true
this.isNetworkOn = null
this.init()
this.deviceOnline = true
this.serverReachable = true
this.offlineReason = null // 'device' | 'server' | null
this.connectionStatusListeners = []
this.queueSyncListeners = []
this.lastChecked = null
this.offlineSince = null
this.init()
}
// Effective online status: both device network AND server must be reachable
get isOnline() {
return this.deviceOnline && this.serverReachable
}
// Alias for backward compatibility (DeveloperSettings uses this)
get isNetworkOn() {
return this.deviceOnline
}
async init() {
const status = await Network.getStatus()
this.isNetworkOn = status.connected
this.deviceOnline = status.connected
this.lastChecked = Date.now()
if (!status.connected) {
this.offlineReason = 'device'
this.offlineSince = Date.now()
}
Network.addListener('networkStatusChange', status => {
if (this.isNetworkOn !== status.connected) {
this.isNetworkOn = status.connected
if (this.deviceOnline !== status.connected) {
this.deviceOnline = status.connected
this.lastChecked = Date.now()
this.isOnline = status.connected
}
})
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.')
if (!status.connected) {
this.offlineReason = 'device'
this.offlineSince = Date.now()
} else {
// Device came back online — update reason based on server state
this.offlineReason = this.serverReachable ? null : 'server'
}
this.notifyConnectionStatus()
}
})
syncQueue()
}
setOffline() {
if (this.isOnline === true) {
this.isOnline = false
// Called when a fetch() response is received (any HTTP status = server is up)
setServerReachable() {
if (!this.serverReachable) {
this.serverReachable = true
this.offlineReason = this.deviceOnline ? null : 'device'
this.notifyConnectionStatus()
this.offlineSince = Date.now() // Record the time when we went offline
}
}
// Called when fetch() throws a network error (server unreachable)
// Only takes effect when offline mode is enabled
setServerUnreachable() {
if (!isOfflineFeatureEnabled()) return
if (this.serverReachable) {
this.serverReachable = false
this.offlineReason = 'server'
this.offlineSince = Date.now()
this.notifyConnectionStatus()
}
}
// Legacy methods kept for compatibility
setOffline() {
this.setServerUnreachable()
}
setOnline() {
if (this.isOnline === false) {
this.isOnline = true
this.notifyConnectionStatus()
}
this.setServerReachable()
}
notifyConnectionStatus() {
@@ -81,8 +93,12 @@ class NetworkManager {
registerNetworkListener(callback) {
this.connectionStatusListeners.push(callback)
}
unregisterNetworkListener(callback) {
this.connectionStatusListeners = this.connectionStatusListeners.filter(
cb => cb !== callback,
)
}
registerBackendSyncListener(callback) {
// if callback is not in the list already, add it
if (!this.queueSyncListeners.includes(callback)) {
this.queueSyncListeners.push(callback)
}

View File

@@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => {
// Ensure apiClient is initialized with the correct URL
await apiClient.init()
const currentBaseURL = apiClient.getApiURL()
const isNative =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
@@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => {
const response = await fetch(`${currentBaseURL}/auth/login`, config)
if (!response.ok) {
const error = await response.json()
return { success: false, error: error.message || 'Login failed' }
const res = await response.json()
return { success: false, error: res?.error || 'Login failed' }
}
const data = await response.json()

View File

@@ -0,0 +1,91 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const uploadFile = useCallback(
async file => {
if (!isPlusAccount(userProfile)) {
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
})
return null
}
try {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
}
const compressedFile = await imageCompression(file, compressionOptions)
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', entityId)
const response = await apiClient.upload('/assets/chore', formData)
if (response.status === 507) {
showError({
title: 'Storage Quota Exceeded',
message: 'You have exceeded your quota for uploading files.',
})
return null
} else if (response.status === 413) {
showError({
title: 'File Too Large',
message: 'The file you are trying to upload is too large.',
})
return null
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
message: 'Image uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
})
return null
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
})
return null
}
const data = await response.json()
return resolvePhotoURL(data.url || data.sign)
} catch {
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
})
return null
}
},
[entityType, entityId, showError, userProfile],
)
return { uploadFile, isPlus: isPlusAccount(userProfile) }
}

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

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { networkManager } from '../hooks/NetworkManager'
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
import { commandQueue, CommandType } from '../utils/CommandQueue'
import {
ApproveChore,
ArchiveChore,
@@ -20,51 +20,91 @@ import {
UnArchiveChore,
UpdateChoreHistory,
} from '../utils/Fetcher'
import { localStore } from '../utils/LocalStore'
import { offlineDB } from '../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
export const useChores = includeArchive => {
const mergePendingCreates = async chores => {
const pending = await commandQueue.getPending()
const pendingCreates = pending.filter(
cmd => cmd.commandType === CommandType.CREATE_CHORE,
)
const deletedIds = new Set(
pending
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
.map(cmd => String(cmd.entityId)),
)
if (pendingCreates.length === 0) return chores
const existingIds = new Set((chores || []).map(chore => String(chore.id)))
const createdFromQueue = pendingCreates
.filter(
cmd =>
!existingIds.has(String(cmd.entityId)) &&
!deletedIds.has(String(cmd.entityId)),
)
.map(cmd => {
const payload = cmd.payload || {}
return {
...payload,
id: cmd.entityId,
nextDueDate: payload.nextDueDate || payload.dueDate || null,
_pendingCreate: true,
}
})
return [...(chores || []), ...createdFromQueue]
}
const isNetworkError = error =>
(error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError'
const buildOfflineChore = task => ({
...task,
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
nextDueDate: task.nextDueDate || task.dueDate || null,
_pendingCreate: true,
})
export const useChores = (includeArchive = false) => {
return useQuery({
queryKey: ['chores', includeArchive],
refetchOnWindowFocus: true,
queryFn: async () => {
const onlineChores = await GetChoresNew(includeArchive)
// Only handle offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChores
if (isOfflineFeatureEnabled()) {
// Sync from server first (no-op if already syncing or offline)
if (networkManager.isOnline) {
await syncEngine.sync()
}
const cursor = await offlineDB.getSyncCursor()
if (cursor > 0) {
const cached = await offlineDB.getChores(includeArchive)
const merged = await mergePendingCreates(cached || [])
return { res: merged }
}
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
var finalChores = []
if (onlineChores && onlineChores.res) {
finalChores = onlineChores.res.filter(
onlineChore =>
!offlineTasks.some(offlineTask => {
// Match by id or tempId
return (
String(onlineChore.id) === String(offlineTask.id) ||
(offlineTask.tempId &&
String(onlineChore.id) === String(offlineTask.tempId))
)
}),
// Offline feature disabled — fetch from API.
try {
const data = await GetChoresNew(includeArchive)
if (data?.res) {
syncEngine.cacheChores(data.res)
}
const merged = await mergePendingCreates(data?.res || [])
return { ...data, res: merged }
} catch {
// API failed — fall back to whatever is in the cache
const cached = await offlineDB.getChores(includeArchive)
const merged = await mergePendingCreates(cached || [])
if (merged && merged.length > 0) {
return { res: merged }
}
throw new Error(
'Unable to communicate with server and no data available',
)
}
// Combine online chores with offline tasks
if (offlineTasks.length > 0) {
// Merge the offline tasks with the online chores
finalChores = [
...finalChores,
...offlineTasks.map(task => ({
...task,
id: task.id || task.tempId, // Ensure we have an id for consistency
})),
]
}
return { res: finalChores }
// return { res: [...onlineChores.res, ...offlineTasks] }
},
})
}
@@ -73,21 +113,28 @@ export const useDeleteChores = () => {
return useMutation({
mutationFn: async choreIds => {
// If offline mode is enabled and we're offline, handle deletion locally
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updatedOfflineTasks = offlineTasks.filter(
task =>
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
if (!networkManager.isOnline) {
await offlineDB.deleteChores(choreIds)
await Promise.all(
choreIds.map(async id => {
await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id })
}),
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
// Force the chores query to refetch
queryClient.invalidateQueries(['chores'])
const removeDeletedChores = oldData => {
if (!oldData?.res) return oldData
const deletedIds = new Set(choreIds.map(id => String(id)))
return {
...oldData,
res: oldData.res.filter(chore => !deletedIds.has(String(chore.id))),
}
}
queryClient.setQueryData(['chores', false], removeDeletedChores)
queryClient.setQueryData(['chores', true], removeDeletedChores)
return
}
// If online, proceed with server-side deletion
await Promise.all(
choreIds.map(async id => {
const resp = await DeleteChore(id)
@@ -99,75 +146,63 @@ export const useDeleteChores = () => {
},
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
export const useCreateChore = () => {
const queryClient = useQueryClient()
const queueOfflineCreate = async newTask => {
const offlineChore = buildOfflineChore(newTask)
await commandQueue.enqueue(
CommandType.CREATE_CHORE,
offlineChore.id,
newTask,
)
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) {
return { res: [offlineChore] }
}
const alreadyExists = oldData.res.some(
chore => String(chore.id) === String(offlineChore.id),
)
if (alreadyExists) return oldData
return { ...oldData, res: [...oldData.res, offlineChore] }
})
return offlineChore
}
return useMutation({
mutationFn: async newTask => {
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')
}
return { ...newTask, id: createdChore.res }
} 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 +212,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 +245,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 =>
@@ -233,20 +253,18 @@ export const useUpdateChore = () => {
),
}
})
return updatedChoreRes?.res || updatedChoreRes
return updatedChoreRes?.res || updatedChore
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineUpdate()
}
throw error
}
},
onSuccess: (data, variables) => {
// Invalidate the chores query to refresh the data
onSuccess: (_, variables) => {
queryClient.invalidateQueries(['chores'])
// Invalidate history for the specific chore
queryClient.invalidateQueries(['choreHistory', variables.id])
},
onMutate: async updatedChore => {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// Handle offline case here if needed
return
}
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -257,8 +275,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
const { data, error, isLoading } = useQuery({
queryKey: ['choresHistory', limit],
queryFn: async () => {
const resp = await GetChoresHistory(limit, includeMembers)
return resp?.res || []
try {
const resp = await GetChoresHistory(limit, includeMembers)
const entries = resp?.res || []
// Cache for offline use — fire-and-forget so a cache failure never
// degrades the online experience
if (entries.length > 0) {
offlineDB
.saveHistory(entries)
.catch(err => console.error('Failed to cache chores history:', err))
}
return entries
} catch {
return offlineDB.getHistoryByDays(limit)
}
},
staleTime: 0,
})
@@ -275,30 +305,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 +332,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'])
@@ -374,11 +383,30 @@ export const useChoreHistory = choreId => {
if (!choreId) {
throw new Error('Chore ID is required to fetch history')
}
const response = await GetChoreHistory(choreId)
if (response && response.ok) {
return await response.json()
let json
try {
const response = await GetChoreHistory(choreId)
if (response && response.ok) {
json = await response.json()
} else {
throw new Error('Failed to fetch chore history')
}
} catch {
const cached = await offlineDB.getHistoryByChore(choreId)
return { res: cached }
}
throw new Error('Failed to fetch chore history')
// Cache for offline use — fire-and-forget so a cache failure never
// degrades the online view. Inject choreId since the single-chore
// endpoint may omit it from each entry.
const entries = (json?.res || []).map(e =>
e.choreId != null ? e : { ...e, choreId: Number(choreId) },
)
if (entries.length > 0) {
offlineDB
.saveHistory(entries)
.catch(err => console.error('Failed to cache chore history:', err))
}
return json
},
enabled: !!choreId,
staleTime: 0, // Always consider data stale
@@ -392,10 +420,62 @@ export const useUpdateChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId, historyData }) =>
UpdateChoreHistory(choreId, historyId, historyData),
mutationFn: async ({ choreId, historyId, historyData }) => {
const applyOptimisticUpdate = async () => {
queryClient.setQueryData(['choreHistory', choreId], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(entry =>
entry.id === historyId
? { ...entry, ...historyData, _pendingUpdate: true }
: entry,
),
}
})
await offlineDB.updateHistoryEntry(choreId, historyId, {
...historyData,
_pendingUpdate: true,
})
return { queued: true }
}
if (!networkManager.isOnline) {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId, historyData },
)
return applyOptimisticUpdate()
}
try {
const response = await UpdateChoreHistory(
choreId,
historyId,
historyData,
)
if (!response || !response.ok) {
throw new Error('Failed to update chore history')
}
return response
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(
CommandType.UPDATE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId, historyData },
)
return applyOptimisticUpdate()
}
throw error
}
},
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreHistory', choreId])
if (!data?.queued) {
queryClient.invalidateQueries(['choreHistory', choreId])
}
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -404,10 +484,57 @@ export const useDeleteChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId }) =>
DeleteChoreHistory(choreId, historyId),
mutationFn: async ({ choreId, historyId }) => {
const applyOptimisticDelete = async () => {
queryClient.setQueryData(['choreHistory', choreId], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(entry =>
entry.id === historyId
? { ...entry, _pendingDelete: true }
: entry,
),
}
})
await offlineDB.updateHistoryEntry(choreId, historyId, {
_pendingDelete: true,
})
return { queued: true }
}
if (!networkManager.isOnline) {
await commandQueue.enqueue(
CommandType.DELETE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId },
)
return applyOptimisticDelete()
}
try {
const response = await DeleteChoreHistory(choreId, historyId)
if (!response || !response.ok) {
throw new Error('Failed to delete chore history')
}
return response
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(
CommandType.DELETE_CHORE_HISTORY,
`${choreId}:${historyId}`,
{ choreId, historyId },
)
return applyOptimisticDelete()
}
throw error
}
},
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreHistory', choreId])
if (!data?.queued) {
queryClient.invalidateQueries(['choreHistory', choreId])
}
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -416,12 +543,80 @@ export const useMarkChoreComplete = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, body, completedDate, performer }) =>
MarkChoreComplete(choreId, body, completedDate, performer),
onSuccess: (data, { choreId }) => {
mutationFn: async ({ choreId, body, completedDate, performer }) => {
if (!networkManager.isOnline) {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
body,
completedDate,
performer,
})
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: body?.completedBy || 0,
performedAt: completedDate || new Date().toISOString(),
notes: body?.note || null,
status: 1,
points: 0,
pending: true,
})
// Optimistically update the cache to show pending state
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId ? { ...chore, _pending: 'complete' } : chore,
),
}
})
return { res: { _pending: 'complete' } }
}
const queueOfflineComplete = async () => {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
body,
completedDate,
performer,
})
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: body?.completedBy || 0,
performedAt: completedDate || new Date().toISOString(),
notes: body?.note || null,
status: 1,
points: 0,
pending: true,
})
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' } }
}
try {
return await MarkChoreComplete(choreId, body, completedDate, performer)
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineComplete()
}
throw error
}
},
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -430,11 +625,48 @@ 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' } }
}
try {
return await SkipChore(choreId)
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
id: choreId,
})
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' } }
}
throw error
}
},
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
queryClient.invalidateQueries(['pendingCommands'])
},
})
}
@@ -444,7 +676,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 +689,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,7 +31,7 @@ export const useStartChore = () => {
return useMutation({
mutationFn: StartChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -44,7 +44,7 @@ export const usePauseChore = () => {
return useMutation({
mutationFn: PauseChore,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -58,7 +58,7 @@ export const useUpdateTimeSession = () => {
return useMutation({
mutationFn: ({ choreId, sessionId, sessionData }) =>
UpdateTimeSession(choreId, sessionId, sessionData),
onSuccess: (data, { choreId }) => {
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -72,7 +72,7 @@ export const useDeleteTimeSession = () => {
return useMutation({
mutationFn: ({ choreId, sessionId }) =>
DeleteTimeSession(choreId, sessionId),
onSuccess: (data, { choreId }) => {
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -85,7 +85,7 @@ export const useResetChoreTimer = () => {
return useMutation({
mutationFn: ResetChoreTimer,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
@@ -98,7 +98,7 @@ export const useClearChoreTimer = () => {
return useMutation({
mutationFn: ClearChoreTimer,
onSuccess: (data, choreId) => {
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])

View File

@@ -6,6 +6,7 @@ import {
GetDeviceTokens,
GetUserProfile,
} from '../utils/Fetcher'
import { offlineDB } from '../utils/OfflineDB'
// Helper to check if we have a valid token
const isTokenValid = () => {
@@ -30,7 +31,20 @@ export const useCircleMembers = () => {
const { data, error, isLoading } = useQuery({
queryKey: ['allCircleMembers'],
queryFn: GetAllCircleMembers,
queryFn: async () => {
try {
const result = await GetAllCircleMembers()
// Cache for offline use
if (result?.res) {
offlineDB.saveKV('circle_members', result.res).catch(() => {})
}
return result
} catch {
const cached = await offlineDB.getKV('circle_members')
if (cached) return { res: cached }
return { res: [] }
}
},
})
const handleRefetch = () => {
@@ -42,19 +56,31 @@ export const useCircleMembers = () => {
export const useUserProfile = () => {
const queryClient = useQueryClient()
const token = localStorage.getItem('token')
const { data, error, isLoading } = useQuery({
queryKey: ['userProfile'],
queryKey: ['userProfile', token],
queryFn: async () => {
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

@@ -1,11 +1,136 @@
import { CapacitorNfc } from '@capgo/capacitor-nfc'
// Encodes a URL into an NDEF URI record (TNF=0x01, type='U')
const buildUriRecord = url => {
const encoder = new TextEncoder()
let prefixByte = 0x00
let uriStr = url
if (url.startsWith('https://')) {
prefixByte = 0x04
uriStr = url.slice(8)
} else if (url.startsWith('http://')) {
prefixByte = 0x03
uriStr = url.slice(7)
}
return {
tnf: 0x01,
type: [0x55],
id: [],
payload: [prefixByte, ...Array.from(encoder.encode(uriStr))],
}
}
// Decodes a URL from an NDEF URI record payload. Returns null if not a URI record.
export const decodeNdefUrl = record => {
if (!record || record.tnf !== 0x01) return null
if (record.type.length !== 1 || record.type[0] !== 0x55) return null
const payload = record.payload
if (!payload || payload.length === 0) return null
const prefixes = [
'',
'http://www.',
'https://www.',
'http://',
'https://',
'tel:',
'mailto:',
]
const prefix = prefixes[payload[0]] ?? ''
const uri = new TextDecoder().decode(new Uint8Array(payload.slice(1)))
return prefix + uri
}
// Starts a native NFC write session. Calls onWaiting once scanning is active,
// then onSuccess or onError when the write completes. Returns a cancel function.
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
let listener = null
let done = false
const cleanup = async () => {
if (listener) {
await listener.remove()
listener = null
}
await CapacitorNfc.stopScanning().catch(() => {})
}
try {
listener = await CapacitorNfc.addListener('nfcEvent', async () => {
if (done) return
done = true
try {
await CapacitorNfc.write({ records: [buildUriRecord(url)] })
await cleanup()
onSuccess()
} catch (err) {
await cleanup()
onError(err.message || 'Failed to write to NFC tag')
}
})
await CapacitorNfc.startScanning({
alertMessage: 'Hold your device near the NFC tag to write',
invalidateAfterFirstRead: true,
// Without FLAG_READER_SKIP_NDEF_CHECK (0x80), Android enumerates
// Ndef/NdefFormatable tech so the plugin can format blank tags on write.
androidReaderModeFlags: 0x0f, // NFC_A | NFC_B | NFC_F | NFC_V
})
onWaiting()
return cleanup
} catch (err) {
await cleanup()
onError(err.message || 'Failed to start NFC session')
return async () => {}
}
}
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
// NDEF record is found, or onError on failure. Returns a cancel function.
export const startNativeScan = async ({ onTag, onError }) => {
let listener = null
let done = false
const cleanup = async () => {
if (listener) {
await listener.remove()
listener = null
}
await CapacitorNfc.stopScanning().catch(() => {})
}
try {
listener = await CapacitorNfc.addListener('nfcEvent', async event => {
if (done) return
const records = event.tag?.ndefMessage ?? []
for (const record of records) {
const url = decodeNdefUrl(record)
if (url) {
done = true
await cleanup()
onTag(url)
return
}
}
})
await CapacitorNfc.startScanning({
alertMessage: 'Hold your device near the NFC tag',
invalidateAfterFirstRead: true,
})
return cleanup
} catch (err) {
await cleanup()
onError(err.message || 'Failed to start NFC session')
return async () => {}
}
}
// Legacy default export for web/PWA (NDEFReader API)
const writeToNFC = async url => {
if ('NDEFReader' in window) {
try {
const ndef = new window.NDEFReader()
await ndef.write({
records: [{ recordType: 'url', data: url }],
})
alert('URL written to NFC tag successfully!')
await ndef.write({ records: [{ recordType: 'url', data: url }] })
} catch (error) {
console.error('Error writing to NFC tag:', error)
alert('Error writing to NFC tag. Please try again.')

View File

@@ -1,5 +1,6 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
@@ -146,15 +147,22 @@ class ApiClient {
async request(endpoint, options = {}) {
await this.init()
const url = `${this.customServerURL}${endpoint}`
// Abort after 10s so a dead/unreachable server doesn't hang the UI
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10_000)
const config = {
// credentials: 'include',
...options,
headers: this.getHeaders(options.headers),
signal: options.signal ?? controller.signal,
}
try {
// 1. Initial Request
let response = await fetch(url, config)
clearTimeout(timeoutId)
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
@@ -222,6 +230,9 @@ class ApiClient {
return response
} catch (error) {
clearTimeout(timeoutId)
// fetch() threw = network-level failure or timeout — mark server unreachable
networkManager.setServerUnreachable()
console.error('Request failed', error)
throw error
}

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

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

View File

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

View File

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

1090
src/utils/OfflineDB.js Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
@@ -17,6 +19,8 @@ const AuthenticationLoading = () => {
const [message, setMessage] = useState('Authenticating')
const [subMessage, setSubMessage] = useState('Please wait')
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const { provider } = useParams()
useEffect(() => {
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
@@ -43,6 +47,29 @@ const AuthenticationLoading = () => {
})
})
}
const handleMFASuccess = async data => {
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
getUserProfileAndNavigateToHome()
}
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Authentication failed')
setSubMessage('Two-factor authentication was cancelled')
setStatus('error')
}
const handleOAuth2 = async () => {
// get provider from params:
const urlParams = new URLSearchParams(window.location.search)
@@ -64,37 +91,71 @@ const AuthenticationLoading = () => {
const redirectURI = Capacitor.isNativePlatform()
? 'donetick://auth/oauth2'
: `${window.location.origin}/auth/oauth2`
fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
localStorage.setItem('token', data.token)
localStorage.setItem('token_expiry', data.expire)
try {
const response = await fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
})
} else {
if (!response.ok) {
console.error('Authentication failed')
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
return
}
})
const data = await response.json()
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Authentication failed')
setSubMessage('MFA session is missing. Please try again')
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-Factor Authentication Required')
setSubMessage('Please verify your login to continue')
return
}
if (!data.token && !data.access_token) {
setMessage('Authentication failed')
setSubMessage('No valid authentication token returned')
setStatus('error')
return
}
await saveTokens({
accessToken: data.token || data.access_token,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
}
}
}
@@ -138,6 +199,17 @@ const AuthenticationLoading = () => {
<Link to='/login'>Go back Login</Link>
</Button>
)}
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Authentication failed')
setSubMessage('Two-factor authentication failed. Please try again')
}}
/>
</Box>
</Container>
)

View File

@@ -1,17 +1,32 @@
import { Preferences } from '@capacitor/preferences'
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import WifiIcon from '@mui/icons-material/Wifi'
import {
Alert,
Box,
Button,
CircularProgress,
Container,
Input,
Sheet,
Typography,
} from '@mui/joy'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
const CONNECTION_TIMEOUT_MS = 8000
const LoginSettings = () => {
const Navigate = useNavigate()
const { refetch: refetchResource } = useResource()
const [serverURL, setServerURL] = React.useState('')
const { showError } = useNotification()
const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error'
const [errorMessage, setErrorMessage] = React.useState('')
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -19,10 +34,95 @@ const LoginSettings = () => {
})
}, [])
const isValidServerURL = () => {
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
const isValidURL = url => {
return /^(http|https):\/\/[^ "]+$/.test(url.trim())
}
const testConnection = async url => {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
CONNECTION_TIMEOUT_MS,
)
try {
const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
const response = await fetch(testURL, {
method: 'GET',
signal: controller.signal,
})
clearTimeout(timeoutId)
// Any HTTP response (even 401/404) means the server is reachable
if (response.status < 500) {
return { ok: true }
}
return {
ok: false,
message: `Server responded with error ${response.status}. Please check your Donetick server.`,
}
} catch (err) {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
return {
ok: false,
message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`,
}
}
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
}
}
}
const handleSave = async () => {
const trimmedURL = serverURL.trim()
if (trimmedURL === '') {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
Navigate('/login')
return
}
if (!isValidURL(trimmedURL)) {
setStatus('error')
setErrorMessage(
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
)
return
}
setStatus('testing')
setErrorMessage('')
const result = await testConnection(trimmedURL)
if (!result.ok) {
setStatus('error')
setErrorMessage(result.message)
return
}
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
await apiClient.init(true)
refetchResource()
setStatus('success')
setTimeout(() => {
Navigate('/login')
}, 1200)
}
const handleURLChange = e => {
setServerURL(e.target.value)
if (status !== 'idle') {
setStatus('idle')
setErrorMessage('')
}
}
const isTesting = status === 'testing'
return (
<Container component='main' maxWidth='xs'>
<Box
@@ -38,7 +138,6 @@ const LoginSettings = () => {
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -51,13 +150,7 @@ const LoginSettings = () => {
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
@@ -71,9 +164,22 @@ const LoginSettings = () => {
name='serverURL'
autoFocus
value={serverURL}
onChange={e => {
setServerURL(e.target.value)
}}
onChange={handleURLChange}
disabled={isTesting}
color={
status === 'success'
? 'success'
: status === 'error'
? 'danger'
: 'neutral'
}
endDecorator={
status === 'success' ? (
<CheckCircleOutlineIcon color='success' fontSize='small' />
) : status === 'error' ? (
<ErrorOutlineIcon color='error' fontSize='small' />
) : null
}
/>
<Typography mt={1} level='body-xs'>
@@ -81,72 +187,68 @@ const LoginSettings = () => {
own self-hosted Donetick server.
</Typography>
<Typography mt={1} level='body-xs'>
Please ensure to include the protocol (http:// or https://) and the
port number if necessary (default Donetick port is 2021).
Include the protocol (http:// or https://) and port if necessary
(default Donetick port is 2021).
</Typography>
{status === 'error' && (
<Alert
color='danger'
variant='soft'
startDecorator={<ErrorOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
{errorMessage}
</Alert>
)}
{status === 'success' && (
<Alert
color='success'
variant='soft'
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
Connected! Redirecting to login...
</Alert>
)}
{status === 'testing' && (
<Alert
color='neutral'
variant='soft'
startDecorator={<WifiIcon />}
sx={{ mt: 2, width: '100%' }}
>
Testing connection to server...
</Alert>
)}
<Button
fullWidth
size='lg'
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
if (serverURL === '') {
Preferences.set({
key: 'customServerUrl',
value: API_URL,
}).then(() => {
Navigate('/login')
})
return
}
if (!isValidServerURL()) {
showError({
title: 'Invalid Server URL',
message:
'Please enter a valid server URL with protocol (http:// or https://)',
})
return
}
Preferences.set({
key: 'customServerUrl',
value: serverURL,
}).then(async () => {
// apiClient.customServerURL = serverURL + '/api/v1's
// Force re-initialization to reload from Preferences
await apiClient.init(true)
// refetch resource queries to update the API URL
refetchResource()
Navigate('/login')
})
}}
disabled={isTesting || status === 'success'}
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
onClick={handleSave}
startDecorator={
isTesting ? <CircularProgress size='sm' /> : undefined
}
>
Save
{isTesting ? 'Testing...' : 'Save & Connect'}
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='danger'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(
() => {
refetchResource()
Navigate('/login')
},
)
disabled={isTesting}
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
onClick={async () => {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
await apiClient.init(true)
refetchResource()
Navigate('/login')
}}
>
Cancel and Reset

View File

@@ -83,7 +83,8 @@ const ChoreEdit = () => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [assignees, setAssignees] = useState([])
const [anyone, setAnyone] = useState(false)
const [assignableTo, setAssignableTo] = useState([])
const [performers, setPerformers] = useState([])
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
const [dueDate, setDueDate] = useState(null)
@@ -158,6 +159,7 @@ const ChoreEdit = () => {
const Navigate = useNavigate()
const assignees = anyone ? performers : assignableTo
const HandleValidateChore = () => {
const errors = {}
@@ -330,12 +332,14 @@ const ChoreEdit = () => {
if (searchParams.get('clone') === 'true') {
newChoreId = null
}
const assignees = anyone ? [] : assignableTo
const chore = {
id: Number(newChoreId),
name: name,
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 +369,22 @@ const ChoreEdit = () => {
}
SaveFunction(chore)
.then(() => {
showSuccess({
title: 'Chore Saved',
message: 'Your task has been saved successfully!',
})
.then(result => {
if (
result?._pendingUpdate ||
result?._pendingCreate ||
result?.res?._pendingCreate
) {
showSuccess({
title: 'Saved Offline',
message: 'Your changes will sync when you are back online.',
})
} else {
showSuccess({
title: 'Chore Saved',
message: 'Your task has been saved successfully!',
})
}
Navigate('/chores')
})
.catch(error => {
@@ -407,15 +422,29 @@ const ChoreEdit = () => {
setIsNotificable(JSON.parse(defaultNotificationSetting))
}
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
if (defaultAnyoneSetting != null) {
const savedAnyone = JSON.parse(defaultAnyoneSetting)
setAnyone(savedAnyone)
}
const defaultAssigneeSetting = localStorage.getItem(
'defaultAssigneeSetting',
)
if (defaultAssigneeSetting !== null) {
const savedAssignees = JSON.parse(defaultAssigneeSetting)
setAssignees(savedAssignees)
setAssignableTo(savedAssignees)
}
}
}, [])
useEffect(() => {
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
const assigneeSetting = localStorage.getItem('defaultAssigneeSetting')
const assigneeDirty = assigneeSetting !== JSON.stringify(assignableTo)
const dirty = anyoneDirty || (!anyone && assigneeDirty)
setShowSaveAssigneeDefault(dirty)
}, [anyone, assignableTo])
// Keyboard shortcuts
useEffect(() => {
@@ -465,7 +494,8 @@ const ChoreEdit = () => {
setChore(data.res)
setName(data.res.name ? data.res.name : '')
setDescription(data.res.description ? data.res.description : '')
setAssignees(data.res.assignees ? data.res.assignees : [])
setAssignableTo(data.res.assignees ? data.res.assignees : [])
setAnyone((data.res.assignees?.length || 0) === 0)
setAssignedTo(data.res.assignedTo)
setFrequencyType(data.res.frequencyType ? data.res.frequencyType : 'once')
@@ -579,13 +609,15 @@ const ChoreEdit = () => {
if (assignees.length === 0) {
setAssignStrategy('no_assignee')
setAssignedTo(null)
} else if (assignees.length === 1) {
setAssignedTo(assignees[0].userId)
} else {
if (!assignees.some(a => a.userId === assignedTo)) {
setAssignedTo(assignees[0].userId)
}
if (assignStrategy === 'no_assignee') {
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
}
}
}, [assignees, assignStrategy])
}, [assignStrategy, assignedTo, assignees])
// useEffect(() => {
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
@@ -602,7 +634,7 @@ const ChoreEdit = () => {
if (attemptToSave) {
HandleValidateChore()
}
}, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
const handleDelete = () => {
setConfirmModelConfig({
@@ -929,9 +961,9 @@ const ChoreEdit = () => {
<ListItem key={'anyone'}>
<Checkbox
checked={assignees.length === 0}
checked={anyone}
onClick={() => {
setAssignees([])
setAnyone(!anyone)
setIsPrivate(false)
}}
overlay
@@ -944,19 +976,25 @@ const ChoreEdit = () => {
{performers?.map((item, index) => (
<ListItem key={item.id}>
<Checkbox
checked={
assignees.find(a => a.userId == item.userId) != null
}
checked={assignableTo.some(a => a.userId == item.userId)}
disabled={anyone}
onClick={() => {
if (anyone) {
setAnyone(false)
setAssignableTo([{ userId: item.userId }])
return
}
const assignees = assignableTo
const setAssignees = setAssignableTo
if (assignees.some(a => a.userId === item.userId)) {
const newAssignees = assignees.filter(
a => a.userId !== item.userId,
)
setAnyone(newAssignees.length === 0)
setAssignees(newAssignees)
} else {
setAssignees([...assignees, { userId: item.userId }])
}
setShowSaveAssigneeDefault(true)
}}
overlay
disableIcon
@@ -986,9 +1024,13 @@ const ChoreEdit = () => {
},
}}
onClick={() => {
localStorage.setItem(
'defaultAnyoneSetting',
JSON.stringify(anyone),
)
localStorage.setItem(
'defaultAssigneeSetting',
JSON.stringify(assignees),
JSON.stringify(assignableTo),
)
setShowSaveAssigneeDefault(false)
}}
@@ -1014,17 +1056,12 @@ const ChoreEdit = () => {
}
disabled={assignees.length === 0}
value={assignedTo > -1 ? assignedTo : null}
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
>
{performers
?.filter(p => assignees.find(a => a.userId == p.userId))
?.filter(p => assignees.some(a => a.userId == p.userId))
.map((item, index) => (
<Option
value={item.userId}
key={item.displayName}
onClick={() => {
setAssignedTo(item.userId)
}}
>
<Option value={item.userId} key={item.displayName}>
{item.displayName}
</Option>
))}

View File

@@ -44,7 +44,11 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreDetails,
useChoreHistory,
} from '../../queries/ChoreQueries.jsx'
import {
useChoreTimer,
useDeleteTimeSession,
@@ -54,8 +58,13 @@ import {
} from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import {
ChoreHistoryStatus,
ChoreStatus,
notInCompletionWindow,
} from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import {
ApproveChore,
GetChoreDetailById,
@@ -66,16 +75,34 @@ import {
UndoChoreAction,
UpdateChorePriority,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
const decodeHtmlEntities = value => {
if (typeof value !== 'string') return ''
return value
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&')
}
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
const ChoreView = () => {
const { t } = useTranslation('chores')
const { fmt } = useLocalization()
@@ -104,6 +131,17 @@ const ChoreView = () => {
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: choreHistoryData } = useChoreHistory(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
const historyCompletionCount = choreHistory.filter(historyEntry => {
const status = Number(historyEntry?.status)
return status === ChoreHistoryStatus.COMPLETED
}).length
const completionCount = choreHistoryData
? historyCompletionCount
: chore.totalCompletedCount || 0
const startChore = useStartChore()
const pauseChore = usePauseChore()
@@ -120,114 +158,99 @@ const ChoreView = () => {
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
const auto_complete = searchParams.get('auto_complete')
if (auto_complete === 'true') {
if (searchParams.get('auto_complete') === 'true') {
navigate({ search: '' }, { replace: true })
handleTaskCompletion()
}
}, [choreData, circleMembersData])
useEffect(() => {
if (chore && performers?.length > 0) {
generateInfoCards(chore)
const cards = [
{
size: 6,
icon: <PeopleAlt />,
title: t('choreView.assignment'),
text: `${t('choreView.assigned')}: ${
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
t('choreView.na')
}`,
subtext: ` ${t('choreView.last')}: ${
chore.lastCompletedDate
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: 'N/A'
}`,
},
{
size: 6,
icon: <CalendarMonth />,
title: t('choreView.schedule'),
text: `${t('choreView.due')}: ${
chore.nextDueDate
? moment(chore.nextDueDate).fromNow()
: t('choreView.na')
}`,
subtext: `${t('choreView.last')}: ${
chore.lastCompletedDate
? moment(chore.lastCompletedDate).fromNow()
: t('choreView.na')
}`,
subtext2:
chore.deadlineOffset > 0 && chore.nextDueDate
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
: null,
},
{
size: 6,
icon: <Checklist />,
title: t('choreView.statistics'),
text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
},
{
size: 6,
icon: <Person />,
title: t('choreView.details'),
subtext: `${t('choreView.createdBy')}: ${
performers.find(p => p.userId === chore.createdBy)?.displayName ||
t('choreView.na')
}`,
},
]
setInfoCards(cards)
}
}, [chore, performers])
}, [chore, performers, completionCount, t])
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
setChorePriority(priority)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const generateInfoCards = chore => {
const cards = [
{
size: 6,
icon: <PeopleAlt />,
title: t('choreView.assignment'),
text: `${t('choreView.assigned')}: ${
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
t('choreView.na')
}`,
subtext: ` ${t('choreView.last')}: ${
chore.lastCompletedDate
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: 'N/A'
}`,
},
{
size: 6,
icon: <CalendarMonth />,
title: t('choreView.schedule'),
text: `${t('choreView.due')}: ${
chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : t('choreView.na')
}`,
subtext: `${t('choreView.last')}: ${
chore.lastCompletedDate
? moment(chore.lastCompletedDate).fromNow()
: t('choreView.na')
}`,
subtext2:
chore.deadlineOffset > 0 && chore.nextDueDate
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
: null,
},
{
size: 6,
icon: <Checklist />,
title: t('choreView.statistics'),
text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
},
{
size: 6,
icon: <Person />,
title: t('choreView.details'),
subtext: `${t('choreView.createdBy')}: ${
performers.find(p => p.userId === chore.createdBy)?.displayName ||
t('choreView.na')
}`,
},
]
setInfoCards(cards)
}
const handleTaskCompletion = () => {
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 +258,6 @@ const ChoreView = () => {
try {
const undoResponse = await UndoChoreAction(choreId)
if (undoResponse.ok) {
// Refetch chore details after undo
const detailResponse = await GetChoreDetailById(choreId)
if (detailResponse.ok) {
const detailData = await detailResponse.json()
@@ -257,51 +279,108 @@ 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,
},
)
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
performedAt: completedDate || new Date().toISOString(),
dueDate: chore.nextDueDate || null,
notes: note || null,
status: 1,
points: chore.points || 0,
pending: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — completion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
},
})
} else {
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to complete task',
})
}
})
}
}
const handleSkippingTask = 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 = () => {
const startedChore = { ...chore, status: ChoreStatus.ACTIVE }
startChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
@@ -310,10 +389,37 @@ const ChoreView = () => {
}
setChore(newChore)
},
onError: async error => {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
choreId,
{ id: choreId },
)
setChore(startedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
setChore({ ...chore, status: previousStatus })
},
})
return
}
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to start task',
})
},
})
}
const handleChorePause = () => {
const pausedChore = { ...chore, status: ChoreStatus.PAUSED }
pauseChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
@@ -322,6 +428,32 @@ const ChoreView = () => {
}
setChore(newChore)
},
onError: async error => {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
choreId,
{ id: choreId },
)
setChore(pausedChore)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
setChore({ ...chore, status: previousStatus })
},
})
return
}
showError({
title: t('choreView.undoFailed'),
message: error?.message || 'Unable to pause task',
})
},
})
}
@@ -383,7 +515,6 @@ const ChoreView = () => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
@@ -395,23 +526,50 @@ 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'])
await offlineDB.saveChores([{ ...chore, isActive: true }])
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 },
)
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
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)
await offlineDB.saveChores([{ ...chore, isActive: false }])
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 +616,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 +908,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
<RichTextEditor value={chore.description} isEditable={false} />
{(() => {
const raw = chore.description || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
</Box>
</Sheet>
</>
@@ -792,7 +976,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
<RichTextEditor value={chore.notes} isEditable={false} />
{(() => {
const raw = chore.notes || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
/>
) : (
<Typography
level='body-md'
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
</Box>
</Sheet>
</>

View File

@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
return `Every ${dayNames} at ${timeStr}`
}
const RepeatOnSections = ({
export const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,

View File

@@ -111,6 +111,18 @@ const ActivityItem = ({ activity, members, onViewNote }) => {
text: 'Rejected',
icon: <ThumbDown />,
}
} else if (activity.status === 5) {
return {
color: 'danger',
text: 'Missed',
icon: <EventNote />,
}
} else if (activity.status === 6) {
return {
color: 'neutral',
text: 'Rescheduled',
icon: <Refresh />,
}
}
// Fallback for completed status

View File

@@ -13,16 +13,17 @@ import {
ViewModule,
} from '@mui/icons-material'
import {
Box,
Button,
Container,
Divider,
IconButton,
Input,
List,
Stack,
Typography,
Box,
Button,
Container,
Divider,
IconButton,
Input,
List,
Stack,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -33,8 +34,10 @@ import { useFilter } from '../../hooks/useFilter'
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 Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
@@ -42,11 +45,54 @@ import ChoreListView from './ChoreListView.jsx'
import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp'
const sortByUpdatedAtDesc = chores =>
(chores || []).sort((a, b) => {
const dateA = new Date(a.updatedAt || 0)
const dateB = new Date(b.updatedAt || 0)
return dateB - dateA
})
const applyPendingArchivedState = async chores => {
const pending = await commandQueue.getPending()
const pendingArchiveIds = new Set(
pending
.filter(cmd => cmd.commandType === CommandType.ARCHIVE_CHORE)
.map(cmd => String(cmd.entityId)),
)
const pendingUnarchiveIds = new Set(
pending
.filter(cmd => cmd.commandType === CommandType.UNARCHIVE_CHORE)
.map(cmd => String(cmd.entityId)),
)
const pendingDeleteIds = new Set(
pending
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
.map(cmd => String(cmd.entityId)),
)
return (chores || [])
.filter(chore => !pendingDeleteIds.has(String(chore.id)))
.filter(chore => {
const id = String(chore.id)
if (pendingUnarchiveIds.has(id)) return false
return chore.isActive === false || pendingArchiveIds.has(id)
})
.map(chore => {
const id = String(chore.id)
if (pendingArchiveIds.has(id)) {
return { ...chore, isActive: false, _pending: 'archive' }
}
return chore
})
}
const ArchivedTasks = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const { showSuccess, showError } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const queryClient = useQueryClient()
const unArchiveChore = useUnArchiveChore()
const [archivedChores, setArchivedChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
@@ -163,19 +209,28 @@ const ArchivedTasks = () => {
try {
const response = await GetArchivedChores()
const data = await response.json()
// Sort by updatedAt (most recent first)
const sortedChores = data.res.sort((a, b) => {
const dateA = new Date(a.updatedAt || 0)
const dateB = new Date(b.updatedAt || 0)
return dateB - dateA
})
if (data?.res?.length) {
await offlineDB.saveChores(data.res)
}
const archivedWithPending = await applyPendingArchivedState(
data?.res || [],
)
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
setArchivedChores(sortedChores)
setFilteredChores(sortedChores)
} catch (error) {
showError({
title: 'Failed to load archived tasks',
message: 'Please try again later.',
})
try {
const cached = await offlineDB.getChores(true)
const archivedWithPending = await applyPendingArchivedState(cached)
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
setArchivedChores(sortedChores)
setFilteredChores(sortedChores)
} catch {
showError({
title: 'Failed to load archived tasks',
message: 'Please try again later.',
})
}
} finally {
setIsLoading(false)
}
@@ -411,6 +466,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) => {
@@ -419,9 +478,18 @@ 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 })
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
queuedTasks.push(chore)
resolve()
} else {
failedTasks.push(chore)
reject(error)
}
},
})
})
@@ -430,22 +498,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

@@ -190,7 +190,11 @@ const scheduleChoreNotification = async (
for (let i = 0; i < chores.length; i++) {
const chore = chores[i]
try {
if (chore.notification === false || chore.nextDueDate === null) {
if (
chore.notification === false ||
chore.nextDueDate === null ||
chore.isActive === false
) {
continue
}
scheduleNotificationFromTemplate(

View File

@@ -7,6 +7,7 @@ import {
PriorityHigh,
Style,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
Accordion,
AccordionDetails,
@@ -352,18 +353,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(
@@ -383,10 +372,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
@@ -1100,20 +1087,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 */}
@@ -1296,87 +1282,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
@@ -1424,6 +1409,7 @@ const MyChores = () => {
/>
</IconButton>
<IconButton
data-testid='open-add-task-modal'
color='primary'
variant='soft'
sx={{

View File

@@ -1,9 +1,15 @@
import { Capacitor } from '@capacitor/core'
import DateModal from '../../Modals/Inputs/DateModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal'
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
const getNFCUrl = choreId =>
Capacitor.getPlatform() === 'android'
? `donetick://chores/${choreId}`
: `${window.location.origin}/chores/${choreId}`
const ChoreModals = ({
activeModal,
modalChore,
@@ -65,12 +71,13 @@ const ChoreModals = ({
<WriteNFCModal
config={{
isOpen: true,
url: `${window.location.origin}/chores/${modalChore.id}`,
url: getNFCUrl(modalChore.id),
onClose: onClose,
}}
/>
)}
{activeModal === 'nudge' && modalChore && (
<NudgeModal
config={{

View File

@@ -1,18 +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'
import { offlineDB } from '../../../utils/OfflineDB'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
export const useChoreActions = ({
chores,
@@ -35,11 +43,12 @@ export const useChoreActions = ({
}) => {
const queryClient = useQueryClient()
const archiveChore = useArchiveChore()
const unarchiveChore = useUnArchiveChore()
const startChore = useStartChore()
const pauseChore = usePauseChore()
const updateChoreInState = useCallback(
(updatedChore, event) => {
(updatedChore, event, { skipInvalidation = false } = {}) => {
let newChores = chores.map(c =>
c.id === updatedChore.id ? updatedChore : c,
)
@@ -61,7 +70,9 @@ export const useChoreActions = ({
setChores(newChores)
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries({ queryKey: ['chores'] })
if (!skipInvalidation) {
queryClient.invalidateQueries(['chores'])
}
const undoableActions = {
completed: 'Task completed',
@@ -77,7 +88,7 @@ export const useChoreActions = ({
try {
const undoResponse = await UndoChoreAction(updatedChore.id)
if (undoResponse.ok) {
refetchChores()
queryClient.invalidateQueries(['chores'])
const undoMessages = {
completed: 'Task completion has been undone.',
approved: 'Task approval has been undone.',
@@ -121,7 +132,8 @@ export const useChoreActions = ({
archive: {
type: 'success',
title: 'Task Archived',
message: 'The task has been archived and hidden from the active list.',
message:
'The task has been archived and hidden from the active list.',
},
started: {
type: 'success',
@@ -147,47 +159,56 @@ export const useChoreActions = ({
notifyFn({ title: notification.title, message: notification.message })
}
},
[chores, filteredChores, setChores, setFilteredChores, queryClient, showSuccess, showError, showWarning, showUndo, refetchChores],
[
chores,
filteredChores,
setChores,
setFilteredChores,
queryClient,
showSuccess,
showError,
showWarning,
showUndo,
],
)
const handleChoreAction = useCallback(
async (action, chore, extraData = {}) => {
switch (action) {
case 'complete':
// 1. Instantly hide the chore from the UI and Cache
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
if (!oldData || !oldData.res) return oldData;
return {
...oldData,
res: oldData.res.filter(c => c.id !== chore.id),
}
});
try {
const response = await MarkChoreComplete(
chore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
null,
null,
)
if (response.ok) {
// 2. Show the success notification with Undo
// Online: hide the chore and show undo
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
if (!oldData || !oldData.res) return oldData
return {
...oldData,
res: oldData.res.filter(c => c.id !== chore.id),
}
})
showSuccess({
message: 'Task completed',
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(chore.id)
if (undoResponse.ok) {
refetchChores()
queryClient.invalidateQueries(['chores'])
showUndo({
title: 'Undo Successful',
message: 'Task completion has been undone.',
})
} else throw new Error('Failed to undo')
} catch (error) {
} catch {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
@@ -195,60 +216,162 @@ export const useChoreActions = ({
}
},
})
// 3. Fetch the fresh active list from the server silently
// (This brings in the next occurrence if recurring, without showing the completed one)
queryClient.invalidateQueries({ queryKey: ['chores'] })
queryClient.invalidateQueries(['chores'])
} else {
refetchChores() // Network failed, revert to truth
refetchChores()
}
} catch (error) {
refetchChores() // Network failed, revert to truth
if (error?.queued) {
showError({
title: 'Update Failed',
message: 'Request will be reattempt when you are online',
if (isNetworkError(error)) {
// Offline — queue and show pending badge on the chore (don't hide it)
const cmdId = await commandQueue.enqueue(
CommandType.COMPLETE_CHORE,
chore.id,
{
id: chore.id,
body: impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
completedDate: null,
performer: null,
},
)
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: chore.id,
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
performedAt: new Date().toISOString(),
dueDate: chore.nextDueDate || null,
notes: null,
status: 1,
points: chore.points || 0,
pending: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
title: 'Task completion pending',
message:
"You're offline — completion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
} else {
showError({
title: 'Failed to update',
message: error,
title: 'Failed to complete',
message: error?.message || 'Unable to complete chore',
})
}
}
break
case 'start':
startChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'started')
},
onError: error => {
case 'start': {
const startedChore = { ...chore, status: 1 }
try {
await startChore.mutateAsync(chore.id)
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? startedChore : c,
),
}
})
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
} catch (error) {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
chore.id,
{ id: chore.id },
)
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
updateChoreInState(
{ ...chore, status: previousStatus },
previousStatus === 2 ? 'paused' : 'started',
{ skipInvalidation: true },
)
},
})
} else {
showError({
title: 'Failed to start',
message: error.message || 'Unable to start chore',
message: error?.message || 'Unable to start chore',
})
},
})
}
}
break
}
case 'pause':
pauseChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'paused')
},
onError: error => {
case 'pause': {
const pausedChore = { ...chore, status: 2 }
try {
await pauseChore.mutateAsync(chore.id)
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? pausedChore : c,
),
}
})
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
} catch (error) {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
chore.id,
{ id: chore.id },
)
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
updateChoreInState(
{ ...chore, status: previousStatus },
previousStatus === 2 ? 'paused' : 'started',
{ skipInvalidation: true },
)
},
})
} else {
showError({
title: 'Failed to pause',
message: error.message || 'Unable to pause chore',
message: error?.message || 'Unable to pause chore',
})
},
})
}
}
break
}
case 'approve':
try {
@@ -297,18 +420,45 @@ export const useChoreActions = ({
c => c.id !== chore.id,
)
setChores(newChores)
updateChoreInState(chore.id, 'deleted')
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
})
}
} 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({})
@@ -321,34 +471,137 @@ export const useChoreActions = ({
await new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState(data, 'archive')
updateChoreInState(chore, '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 },
)
await offlineDB.saveChores([
{ ...chore, isActive: false, _pending: 'archive' },
])
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)
await offlineDB.saveChores([
{ ...chore, isActive: true },
])
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 },
)
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — restore will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
await offlineDB.saveChores([
{ ...chore, isActive: false },
])
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 +616,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 +688,67 @@ export const useChoreActions = ({
setConfirmModelConfig,
openModal,
archiveChore,
unarchiveChore,
startChore,
pauseChore,
],
)
const handleChangeDueDate = useCallback(
newDate => {
async newDate => {
if (!modalChore) return
UpdateDueDate(modalChore.id, newDate).then(response => {
closeModal()
try {
const response = await UpdateDueDate(modalChore.id, newDate)
if (response.ok) {
response.json().then(data => {
const newChore = modalChore
newChore.nextDueDate = newDate
updateChoreInState(newChore, 'rescheduled')
updateChoreInState(
{ ...modalChore, nextDueDate: newDate },
'rescheduled',
)
}
} catch (error) {
if (isNetworkError(error)) {
const oldDueDate = modalChore.nextDueDate
const cmdId = await commandQueue.enqueue(
CommandType.RESCHEDULE_CHORE,
modalChore.id,
{
id: modalChore.id,
dueDate: newDate,
},
)
updateChoreInState(
{ ...modalChore, nextDueDate: newDate },
'rescheduled',
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — reschedule will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
updateChoreInState(
{ ...modalChore, nextDueDate: oldDueDate },
'rescheduled',
)
},
})
} else {
showError({
title: 'Failed to reschedule',
message: error.message || 'Unable to update due date',
})
}
})
closeModal()
}
},
[modalChore, updateChoreInState, closeModal],
[
modalChore,
updateChoreInState,
closeModal,
showSuccess,
showError,
queryClient,
],
)
const handleCompleteWithPastDate = useCallback(
@@ -568,7 +897,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 +940,7 @@ export const useChoreActions = ({
},
})
})
} catch (error) {
}
} catch (error) {}
}
if (archivedTasks.length > 0) {
showSuccess({
@@ -630,7 +966,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 +1036,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 +1083,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 +1117,15 @@ export const useChoreActions = ({
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
}, [
getSelectedChoresData,
showSuccess,
showError,
showUndo,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
return {
handleChoreAction,

View File

@@ -36,6 +36,7 @@ import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useFilter } from '../../hooks/useFilter'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreHistory,
useDeleteChoreHistory,
@@ -68,9 +69,27 @@ const ChoreHistory = () => {
const { data: circleMembersData } = useCircleMembers()
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
const performers = circleMembersData?.res || []
const pendingByHistoryId = useMemo(() => {
if (!pendingCmds?.length) return {}
return pendingCmds.reduce((acc, cmd) => {
if (
cmd.commandType !== 'update_chore_history' &&
cmd.commandType !== 'delete_chore_history'
) {
return acc
}
const historyId =
cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1])
if (!historyId) return acc
if (!acc[historyId]) acc[historyId] = []
acc[historyId].push(cmd)
return acc
}, {})
}, [pendingCmds])
const filterDefs = useMemo(
() => [
@@ -487,6 +506,7 @@ const ChoreHistory = () => {
},
})
}}
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
@@ -529,13 +549,21 @@ const ChoreHistory = () => {
},
},
{
onSuccess: () => {
onSuccess: data => {
setIsEditModalOpen(false)
setEditHistory(null)
showSuccess({
title: 'History Updated',
message: `The history record has been updated successfully.`,
})
if (data?.queued) {
showSuccess({
title: 'History Update Queued',
message:
'You are offline. The history update will sync when connection is restored.',
})
} else {
showSuccess({
title: 'History Updated',
message: `The history record has been updated successfully.`,
})
}
},
onError: error => {
console.error('Failed to update chore history:', error)
@@ -551,13 +579,21 @@ const ChoreHistory = () => {
historyId: editHistory.id,
},
{
onSuccess: () => {
onSuccess: data => {
setIsEditModalOpen(false)
setEditHistory(null)
showSuccess({
title: 'History Deleted',
message: `The history record has been deleted successfully.`,
})
if (data?.queued) {
showSuccess({
title: 'History Delete Queued',
message:
'You are offline. The history delete will sync when connection is restored.',
})
} else {
showSuccess({
title: 'History Deleted',
message: `The history record has been deleted successfully.`,
})
}
},
},
)

View File

@@ -13,7 +13,16 @@ import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import PendingBadge from '../components/PendingBadge'
const getCompletedChip = historyEntry => {
if (
historyEntry.status === 0 ||
historyEntry.status === 5 ||
historyEntry.status === 6
) {
return null
}
const formatTime = seconds => {
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
@@ -48,6 +57,7 @@ const HistoryCard = ({
performers,
historyEntry,
index,
pendingCommands,
onToggleActions,
onViewNote,
onViewDetails,
@@ -186,6 +196,18 @@ const HistoryCard = ({
</IconButton>
)}
</Box>
{pendingCommands?.length > 0 && (
<PendingBadge
commands={pendingCommands}
size='s'
sx={{
mr: 0.5,
position: 'absolute',
right: -3,
top: -3,
}}
/>
)}
</Box>
)
}

View File

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

View File

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

View File

@@ -1,114 +1,175 @@
import { CopyAll } from '@mui/icons-material'
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
import { useState } from 'react'
import {
Box,
Button,
Checkbox,
CircularProgress,
Input,
ListItem,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import { Capacitor } from '@capacitor/core'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter'
function WriteNFCModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
const [errorMessage, setErrorMessage] = useState('')
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
const cancelScanRef = useRef(null)
const isNative = Capacitor.isNativePlatform()
const requestNFCAccess = async () => {
if ('NDEFReader' in window) {
// Assuming permission request is implicit in 'write' or 'scan' methods
setNfcStatus('idle')
} else {
alert('NFC is not supported by this browser.')
}
const getURL = () => {
let url = config.url
if (isAutoCompleteWhenScan) url += '?auto_complete=true'
return url
}
const writeToNFC = async url => {
if ('NDEFReader' in window) {
try {
const ndef = new window.NDEFReader()
await ndef.write({
records: [{ recordType: 'url', data: url }],
})
setNfcStatus('success')
} catch (error) {
console.error('Error writing to NFC tag:', error)
setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.')
}
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
)
const handleClose = async () => {
if (cancelScanRef.current) {
await cancelScanRef.current()
cancelScanRef.current = null
}
}
const handleClose = () => {
config.onClose()
setNfcStatus('idle')
setErrorMessage('')
}
const getURL = () => {
let url = config.url
if (isAutoCompleteWhenScan) {
url = url + '?auto_complete=true'
const handleCancel = async () => {
if (cancelScanRef.current) {
await cancelScanRef.current()
cancelScanRef.current = null
}
setNfcStatus('idle')
}
const writeToNFC = async () => {
const url = getURL()
if (isNative) {
setNfcStatus('writing')
const cancel = await startNativeNFCWrite(url, {
onWaiting: () => setNfcStatus('waiting_for_tag'),
onSuccess: () => {
cancelScanRef.current = null
setNfcStatus('success')
},
onError: msg => {
cancelScanRef.current = null
setNfcStatus('error')
setErrorMessage(msg)
},
})
cancelScanRef.current = cancel
} else {
if ('NDEFReader' in window) {
try {
setNfcStatus('writing')
const ndef = new window.NDEFReader()
await ndef.write({ records: [{ recordType: 'url', data: url }] })
setNfcStatus('success')
} catch (error) {
console.error('Error writing to NFC tag:', error)
setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.')
}
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
)
}
}
}
const renderBody = () => {
if (nfcStatus === 'success') {
return (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
)
}
return url
if (nfcStatus === 'waiting_for_tag') {
return (
<>
<Box
display='flex'
flexDirection='column'
alignItems='center'
gap={2}
py={3}
>
<CircularProgress size='lg' />
<Typography level='body-md' textAlign='center'>
Hold your device near the NFC tag
</Typography>
</Box>
<Button
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
</>
)
}
return (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display='flex' justifyContent='space-around' mt={1}>
<Button
size='lg'
onClick={writeToNFC}
fullWidth
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
</Box>
</>
)
}
return (
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<Typography level='h4' mb={1}>
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
</Typography>
{nfcStatus === 'success' ? (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
) : (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
onClick={() => writeToNFC(getURL())}
fullWidth
sx={{ mr: 1 }}
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
<Button size='lg' onClick={requestNFCAccess} variant='outlined'>
Request Access
</Button>
</Box>
</>
)}
{renderBody()}
</ResponsiveModal>
)
}

View File

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

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 mode turned off and local data was cleared',
})
} catch {
setOfflineFeatureEnabled(false)
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
queryClient.removeQueries({ queryKey: ['chores'] })
queryClient.invalidateQueries()
showNotification({
type: 'warning',
message:
'Offline mode was turned off, but some local data may still be stored',
})
} finally {
setOfflineLoading(false)
}
}
const showDisableOfflineConfirmation = () => {
setConfirmModalConfig({
isOpen: true,
title: 'Turn Off Offline Mode',
message:
'Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?',
confirmText: 'Turn Off & Clear Data',
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 mode turned on 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}>
Keep using Donetick when you're offline on this device/browser. Your
changes are saved locally and synced when you're back online.
</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={offlineEnabled}
onChange={handleOfflineToggle}
variant='soft'
label='Enable Offline Support'
disabled={offlineLoading}
overlay
/>
<FormHelperText>
Turning this off removes unsynced offline changes and saved offline
data from this device/browser.
</FormHelperText>
</FormControl>
{/* Webhook Settings - Only show for admins */}
{isAdmin && (
<>
@@ -152,12 +271,17 @@ 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>
)
}
export default AdvancedSettings
export default AdvancedSettings

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,6 @@
import { Add, EditNotifications } from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
FormHelperText,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
import { FormControl } from '@mui/material'
import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -30,8 +21,15 @@ import {
import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate'
import { TASK_COLOR } from '../../utils/Colors'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore'
import NotificationPickerField from './NotificationPickerField'
import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor'
import SubTasks from './SubTask'
const getDefaultNotification = () => {
@@ -46,18 +44,20 @@ const getDefaultNotification = () => {
]
localStorage.setItem(
'defaultNotification',
'defaultNotificationTemplate',
JSON.stringify(defaultNotification),
)
return defaultNotification
}
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: circleMembers, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
const { isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore()
const { data: userProfile } = useUserProfile()
@@ -80,9 +80,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([])
const textareaRef = useRef(null)
const mainInputRef = useRef(null)
const richTextEditorRef = useRef(null)
const latestRef = useRef({})
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
@@ -92,20 +91,35 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [notificationMetadata, setNotificationMetadata] = useState({
templates: getDefaultNotification(),
})
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
const [subTasks, setSubTasks] = useState(null)
const [points, setPoints] = useState(-1)
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
const [hasDescription, setHasDescription] = useState(false)
const [hasSubTasks, setHasSubTasks] = useState(false)
const [hasNotifications, setHasNotifications] = useState(false)
const [hasDeadline, setHasDeadline] = useState(false)
const [deadlineOffset, setDeadlineOffset] = useState(-1)
const [dueDateOnly, setDueDateOnly] = useState(null)
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
const [attachments, setAttachments] = useState([])
// Priority colors
const priorityColors = {
0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1,
2: TASK_COLOR.PRIORITY_2,
3: TASK_COLOR.PRIORITY_3,
4: TASK_COLOR.PRIORITY_4,
}
const priorityLabels = {
0: '--',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => {
@@ -117,12 +131,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}
}, [hasDescription])
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => {
const handleKeyDown = event => {
const {
isModalOpen,
hasDescription,
dueDate,
createChore,
handleCloseModal,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
// event.preventDefault()
setShowKeyboardShortcuts(true)
}
if (
@@ -135,10 +154,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setShowKeyboardShortcuts(false)
}
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
// add subtask:
setHasSubTasks(true)
setShowKeyboardShortcuts(false)
// set focus on the first subtask input:
}
if (
isHoldingCmd &&
@@ -146,7 +163,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
isModalOpen &&
!dueDate
) {
// add due date:
const tomorrow = moment().add(1, 'day')
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
@@ -154,7 +170,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setDueTime(null)
setShowKeyboardShortcuts(false)
}
// Enter key to create task
if (
event.key === 'Enter' &&
(event.ctrlKey || event.metaKey) &&
@@ -164,7 +179,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
createChore()
return
}
// Escape key to cancel/close modal
if (event.key === 'Escape' && isModalOpen) {
event.preventDefault()
handleCloseModal()
@@ -185,22 +199,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}
}, [])
useEffect(() => {
if (isModalOpen && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.selectionStart = textareaRef.current.value?.length
textareaRef.current.selectionEnd = textareaRef.current.value?.length
}
}, [isModalOpen])
useEffect(() => {
if (autoFocus > 0 && mainInputRef.current) {
mainInputRef.current.focus()
mainInputRef.current.selectionStart = mainInputRef.current.value?.length
mainInputRef.current.selectionEnd = mainInputRef.current.value?.length
}
}, [autoFocus])
const renderHighlightedSentence = useCallback(
(
sentence,
@@ -262,20 +260,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
resolvedHighlights.push(current)
}
} else {
// No overlap, add the current highlight
resolvedHighlights.push(current)
}
}
for (const highlight of resolvedHighlights) {
// Add the text before the highlight
if (highlight.start > lastIndex) {
const textBefore = sentence.substring(lastIndex, highlight.start)
parts.push(textBefore)
plainText += textBefore
}
// Determine the class name based on the highlight type
let className = ''
switch (highlight.type) {
case 'repeat':
@@ -300,7 +295,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
break
}
// Add the highlighted span
const highlightedText = sentence.substring(
highlight.start,
highlight.end,
@@ -310,9 +304,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
key={highlight.start}
className={className}
style={{
// text underline:
textDecoration: 'underline',
// textDecorationColor: 'red',
textDecorationThickness: '2px',
textDecorationStyle: 'dashed',
}}
@@ -321,11 +313,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</span>,
)
// Update the last index to the end of the current highlight
lastIndex = highlight.end
}
// Add any remaining text after the last highlight
if (lastIndex < sentence.length) {
const remainingText = sentence.substring(lastIndex)
parts.push(remainingText)
@@ -342,12 +332,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const processText = useCallback(
sentence => {
// Parse everything from the original sentence to get correct highlight positions
const priority = parsePriority(sentence)
const pointsParsed = parsePoints(sentence)
const labels = parseLabels(sentence, userLabels || [])
// Parse assignees using circle members
const circleMembersList = circleMembers?.res || []
const assigneesForParsing = circleMembersList.map(member => ({
userId: member.userId,
@@ -364,9 +352,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const dueDateParsed = parseDueDate(sentence, chrono)
// Set all the parsed values
if (priority.result) setPriority(priority.result)
if (priority.result) setPriority(parseInt(priority.result, 10))
if (pointsParsed.result) setPoints(pointsParsed.result)
if (labels.result) setLabelsV2(labels.result)
if (labels.result) {
// parseLabels returns array of label objects, extract their IDs
const labelIds = labels.result
.filter(label => label.id) // Only labels with IDs (existing labels)
.map(label => label.id)
setLabelsV2(labelIds)
}
if (assigneesResult.isAnyone) {
// @Anyone was used - set empty assignees (anyone can do the task)
@@ -392,7 +386,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
if (repeat.result) {
setFrequency(repeat.result)
setFrequencyHumanReadable(repeat.name)
}
const syncDueDateStates = parsedDate => {
@@ -416,15 +409,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
dueDateHighlight = dueDateParsed.highlight[0]
}
if (repeat.result) {
// if repeat has result the cleaned sentence will remove the date related info which mean
// we need to reparse the date again to get the correct due date:
const dueDateParsedAgain = parseDueDate(sentence, chrono)
if (dueDateParsedAgain.result) {
syncDueDateStates(dueDateParsedAgain.result)
}
}
// Create the cleaned sentence by sequentially applying all cleanups
let cleanedSentence = sentence
if (priority.result) cleanedSentence = priority.cleanedSentence
@@ -509,6 +493,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
)
} else {
setUseCustomTime(false)
setDueTime(null)
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
}
}
@@ -517,9 +503,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const timeValue = e.target.value
setDueTime(timeValue)
if (dueDateOnly) {
setDueDate(
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
)
if (timeValue) {
setUseCustomTime(true)
setDueDate(
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
)
} else {
setUseCustomTime(false)
setDueDate(
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
)
}
}
}
@@ -527,13 +521,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setUseCustomTime(checked)
if (checked) {
const defaultTime = dueTime || '18:00'
setDueTime(defaultTime)
if (!dueTime) {
setDueTime(defaultTime)
}
if (dueDateOnly) {
setDueDate(
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
)
}
} else {
setDueTime(null)
if (dueDateOnly) {
setDueDate(
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
@@ -552,7 +549,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setTaskTitle('')
setDueDate(null)
setFrequency(null)
setFrequencyHumanReadable(null)
setPriority(0)
setPoints(-1)
setIsAnyoneTask(false)
@@ -563,7 +559,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setLabelsV2([])
setAssignees([])
setProjectId(getInitialProject())
setHasDeadline(false)
setDeadlineOffset(-1)
setDueDateOnly(null)
setDueTime(null)
@@ -614,6 +609,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
attachments: attachments.length > 0 ? attachments : null,
}
if (frequency) {
@@ -626,39 +622,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}
}
if (!frequency && dueDate) {
// use dueDate converted to UTC:
chore.nextDueDate = new Date(dueDate).toUTCString()
// Use RFC3339/ISO-8601 format expected by backend.
chore.nextDueDate = new Date(dueDate).toISOString()
chore.notificationMetadata = notificationMetadata
}
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
if (choreData?._pendingCreate) {
// Offline: task queued, add temp chore to UI immediately
onChoreUpdate(choreData)
} else {
// Online: choreData is the created chore object returned by the mutation
onChoreUpdate({
...chore,
...choreData,
id: 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) {
latestRef.current = {
isModalOpen,
hasDescription,
dueDate,
createChore,
handleCloseModal,
}
if (isCircleMembersLoading || isProjectsLoading) {
return <></>
}
@@ -698,6 +699,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
>
Create
@@ -761,7 +763,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
<SmartTaskTitleInput
autoFocus
value={taskText}
placeholder='Type your full text here...'
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
}}
@@ -806,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}}
/>
</Box>
{/* <Box>
<Typography level='body-sm'>Title:</Typography>
<Input
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</Box> */}
<Box
sx={{
paddingTop: 2,
paddingBottom: 1,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
<Box>
// scrollable horizontally but hide the scrollbar:
overflowX: 'auto',
'&::-webkit-scrollbar': {
display: 'none',
},
// if not mobile then go to next line if not enough space( show chip on next line):
flexWrap: isMobile ? 'nowrap' : 'wrap',
}}
>
<DueDatePickerField
emptyDisplay={pickerEmptyDisplay}
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={useCustomTime}
onDueDateChange={handleDueDateChange}
onDueTimeChange={handleDueTimeChange}
onUseCustomTimeChange={handleUseCustomTimeChange}
onClear={() => {
setDueDate(null)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
}}
/>
<RepeatPickerField
emptyDisplay={pickerEmptyDisplay}
value={frequency}
onChange={setFrequency}
onClear={() => setFrequency(null)}
/>
<PriorityPickerField
value={priority}
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
priorityColors={priorityColors}
priorityLabels={priorityLabels}
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
value={assignees?.[0]?.userId || null}
onChange={userId => {
if (!userId) {
setAssignees([])
} else {
setAssignees([{ userId }])
}
}}
onClear={() => setAssignees([])}
currentUserId={userProfile?.id}
members={circleMembers?.res || []}
/>
<LabelsPickerField
emptyDisplay={pickerEmptyDisplay}
values={labelsV2 || []}
onChange={setLabelsV2}
onClear={() => setLabelsV2([])}
labels={userLabels || []}
/>
<AttachmentPickerField
attachments={attachments}
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
entityType='chore_attachment'
/>
<NotificationPickerField
value={notificationMetadata}
onChange={setNotificationMetadata}
onClear={() => setNotificationMetadata({ templates: [] })}
emptyDisplay={pickerEmptyDisplay}
/>
</Box>
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
{!hasDescription && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
variant='outlined'
color='neutral'
size='md'
onClick={() => {
setHasDescription(true)
// Focus will be handled by the useEffect hook
@@ -836,8 +913,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
variant='outlined'
color='neutral'
size='md'
onClick={() => {
setHasSubTasks(true)
}}
@@ -848,52 +926,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
Subtasks
</Button>
)}
{!dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
const tomorrow = moment().add(1, 'day')
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
setUseCustomTime(false)
setDueTime(null)
}}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
}
>
Due Date
</Button>
)}
{!hasNotifications && dueDate && (
<Button
startDecorator={<EditNotifications />}
variant='plain'
size='sm'
onClick={() => {
setHasNotifications(true)
setFrequencyHumanReadable('Once')
setFrequency(null)
}}
>
Edit Notifications
</Button>
)}
{/* {!hasDeadline && dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
setHasDeadline(true)
setDeadlineOffset(86400)
}}
>
Set Deadline
</Button>
)} */}
</Box>
{hasDescription && (
@@ -919,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
/>
</Box>
)}
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 2,
}}
>
{priority > 0 && (
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
defaultValue={0}
value={priority}
onChange={(e, value) => setPriority(value)}
>
<Option value='0'>No Priority</Option>
<Option value='1'>P1</Option>
<Option value='2'>P2</Option>
<Option value='3'>P3</Option>
<Option value='4'>P4</Option>
</Select>
</FormControl>
)}
{dueDate && (
<FormControl>
<Typography level='body-sm'>Due Date</Typography>
<Input
type='date'
value={dueDateOnly || ''}
onChange={handleDueDateChange}
/>
<Checkbox
size='sm'
checked={useCustomTime}
onChange={e => handleUseCustomTimeChange(e.target.checked)}
label='Set a specific time'
sx={{ mt: 1 }}
/>
<FormHelperText>
{useCustomTime
? 'Task will be due at the specified time'
: 'Task will be due at the end of the day (11:59 PM)'}
</FormHelperText>
{useCustomTime && (
<Input
type='time'
value={dueTime || '18:00'}
onChange={handleDueTimeChange}
sx={{ maxWidth: 200, mt: 1 }}
/>
)}
</FormControl>
)}
</Box>
{/* {projects.length >= 1 && (
<FormControl>
<Typography level='body-sm'>Project</Typography>
<Select
value={projectId}
onChange={(event, newValue) => setProjectId(newValue)}
sx={{ minWidth: '15rem' }}
>
<Option key='default' value='default'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: '#1976d2',
}}
>
{(() => {
const IconComponent = getIconComponent('FolderOpen')
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor('#1976d2'),
}}
/>
)
})()}
</Avatar>
Default Project
</Box>
</Option>
{projects.map(project => (
<Option key={project.id} value={project.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: project.color || '#1976d2',
}}
>
{project.icon ? (
(() => {
const IconComponent = getIconComponent(project.icon)
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}}
/>
)
})()
) : (
<></>
)}
</Avatar>
{project.name}
</Box>
</Option>
))}
</Select>
</FormControl>
)} */}
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
>
{/* <FormControl>
<Typography level='body-sm'>Assignees</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{assignees.length > 0 ? (
assignees.map((assignee, index) => (
<Chip
key={assignee.userId || index}
variant='soft'
size='lg'
color='primary'
>
{assignee.displayName || assignee.username}
</Chip>
))
) : (
<Chip variant='soft' size='sm' color='neutral'>
{userProfile.displayName}
</Chip>
)}
</Box>
</FormControl> */}
{/* {hasDeadline && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'start',
}}
>
<Typography level='body-sm'>Deadline</Typography>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}
>
<DurationInput
value={deadlineOffset}
onChange={setDeadlineOffset}
size='sm'
minValue={0}
/>
<Typography level='body-sm'>after due date</Typography>
</Box>
</Box>
)} */}
{hasNotifications && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography level='body-sm'>Notification Schedule</Typography>
<Box sx={{ p: 0.5 }}>
<NotificationTemplate
onChange={metadata => {
if (
metadata.notifications !== notificationMetadata.templates
) {
const newNotificationMetadata = {
...notificationMetadata,
templates: metadata.notifications,
}
setNotificationMetadata(newNotificationMetadata)
}
}}
value={notificationMetadata}
showTimeline={false}
/>
</Box>
</Box>
)}
</Box>
</ResponsiveModal>
)
}

View File

@@ -0,0 +1,43 @@
import { Person } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const AssigneePickerField = ({
value = null,
onChange,
onClear,
members = [],
includeAnyone = true,
emptyDisplay,
currentUserId = null,
}) => {
const options = [
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
...members.map(member => ({
userId: member.userId,
displayName: member.displayName || member.username || 'Unknown',
})),
]
const displayValue = currentUserId && value === currentUserId ? null : value
return (
<BaseOptionPicker
items={options}
value={displayValue}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Assignee'
getItemValue={item => item.userId}
getItemLabel={item => item.displayName}
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Assignee' : selectedItems[0].displayName
}
menuMinWidth={220}
/>
)
}
export default AssigneePickerField

View File

@@ -0,0 +1,258 @@
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
IconButton,
Sheet,
Typography,
} from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
import { useFileUpload } from '../../hooks/useFileUpload'
const AttachmentPickerField = ({
attachments = [],
onChange,
onClear,
emptyDisplay = 'icon-text',
entityType = 'chore_attachment',
entityId,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const buttonRef = useRef(null)
const { uploadFile } = useFileUpload({ entityType, entityId })
useEffect(() => {
if (!isOpen) return
const handleEscape = e => {
if (e.key === 'Escape') setIsOpen(false)
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
const handleAddFile = () => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
setIsUploading(true)
try {
const url = await uploadFile(file)
if (url) {
onChange([...attachments, { url, name: file.name }])
}
} finally {
setIsUploading(false)
}
}
}
const handleRemove = index => {
const updated = attachments.filter((_, i) => i !== index)
onChange(updated)
if (updated.length === 0) setIsOpen(false)
}
const handleClear = e => {
e.stopPropagation()
onClear?.()
setIsOpen(false)
}
const isEmpty = attachments.length === 0
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
ref={buttonRef}
size='sm'
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
{isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
) : (
<AttachFile sx={{ fontSize: '20px' }} />
)}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{isEmpty
? 'Attachments'
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{ name: 'offset', options: { offset: [0, 8] } },
{
name: 'flip',
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 240,
maxWidth: 320,
p: 1,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{attachments.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{attachments.map((attachment, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 0.5,
borderRadius: 'sm',
'&:hover': { bgcolor: 'background.level1' },
}}
>
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
<Box
sx={{
display: 'none',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'sm',
bgcolor: 'background.level2',
flexShrink: 0,
}}
>
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
</Box>
<Typography
level='body-xs'
sx={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{attachment.name}
</Typography>
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={() => handleRemove(index)}
sx={{ flexShrink: 0 }}
>
<DeleteOutline sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
</Box>
)}
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
startDecorator={
isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
) : (
<AttachFile sx={{ fontSize: 16 }} />
)
}
onClick={handleAddFile}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Add image'}
</Button>
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default AttachmentPickerField

View File

@@ -0,0 +1,253 @@
import { Close } from '@mui/icons-material'
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BaseOptionPicker = ({
items = [],
value = null,
values = [],
multiple = false,
onChange,
onValuesChange,
emptyDisplay = 'icon',
emptyLabel = 'Select',
placement = 'top-start',
menuMinWidth = 180,
menuMaxHeight = 280,
getItemValue = item => item.id,
getItemLabel = item => item.label,
renderItemStart,
renderTriggerIcon,
getItemColor,
getTriggerText,
onClear,
}) => {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef(null)
useEffect(() => {
if (!isOpen) return
const handleEscape = event => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const selectedItems = useMemo(() => {
if (multiple) {
const selectedSet = new Set(values)
return items.filter(item => selectedSet.has(getItemValue(item)))
}
if (value === null || value === undefined) return []
return items.filter(item => getItemValue(item) === value)
}, [items, multiple, value, values, getItemValue])
const isEmpty = selectedItems.length === 0
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
const triggerText = getTriggerText
? getTriggerText({ selectedItems, isEmpty })
: isEmpty
? emptyLabel
: getItemLabel(selectedItems[0])
const triggerColor = isEmpty
? undefined
: getItemColor
? getItemColor(selectedItems[0])
: undefined
const handleSelect = selectedValue => {
if (multiple) {
const selectedSet = new Set(values)
if (selectedSet.has(selectedValue)) {
selectedSet.delete(selectedValue)
} else {
selectedSet.add(selectedValue)
}
onValuesChange?.(Array.from(selectedSet))
return
}
onChange?.(selectedValue)
setIsOpen(false)
}
const isSelected = item => {
const optionValue = getItemValue(item)
if (multiple) {
return values.includes(optionValue)
}
return value === optionValue
}
const handleClear = e => {
e.stopPropagation()
onClear?.()
}
return (
<>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
ref={buttonRef}
size={'sm'}
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
borderColor: triggerColor || undefined,
color: triggerColor || undefined,
'&:hover': {
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
borderColor: triggerColor || undefined,
},
}}
>
{renderTriggerIcon?.({ selectedItems, isEmpty })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{triggerText}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement={placement}
modifiers={[
{
name: 'offset',
options: {
offset: [0, 8],
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-start'],
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: menuMinWidth,
maxHeight: menuMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
p: 0.75,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{items.map((item, index) => {
const optionValue = getItemValue(item)
const selected = isSelected(item)
const itemColor = getItemColor ? getItemColor(item) : undefined
return (
<Button
key={optionValue ?? index}
variant={selected ? 'soft' : 'plain'}
color='neutral'
onClick={() => handleSelect(optionValue)}
sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start',
gap: 1,
whiteSpace: 'nowrap',
mb: index === items.length - 1 ? 0 : 0.5,
color: selected
? itemColor || 'text.primary'
: 'text.primary',
}}
>
{renderItemStart?.({ item, selected })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getItemLabel(item)}
</Typography>
</Button>
)
})}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default BaseOptionPicker

View File

@@ -45,6 +45,20 @@ const ALL_MONTHS = Object.values(VALID_MONTHS).filter(
(v, i, a) => a.indexOf(v) === i,
)
// Helper function to validate word boundaries for date/time matches
// Prevents matching partial words like 'wed' in 'wedding', 'fri' in 'friend', etc.
const isValidWordBoundary = (text, matchIndex, matchLength) => {
const charBefore = matchIndex > 0 ? text[matchIndex - 1] : ' '
const charAfter =
matchIndex + matchLength < text.length
? text[matchIndex + matchLength]
: ' '
// Valid boundaries: spaces, punctuation, but NOT alphanumeric or word-forming characters
return !(
/[\p{L}\p{N}_]/u.test(charBefore) || /[\p{L}\p{N}_]/u.test(charAfter)
)
}
export const parsePriority = inputSentence => {
let sentence = inputSentence.toLowerCase()
const priorityMap = {
@@ -292,7 +306,22 @@ export const parseRepeatV2 = inputSentence => {
.toLowerCase()
.split(/ and |,|\s/)
.map(day => day.trim())
.filter(day => VALID_DAYS[day])
.filter(day => {
// Validate that the day abbreviation is at proper word boundaries
// This prevents matches like 'wed' in 'wedding', 'fri' in 'friend'
if (!VALID_DAYS[day]) return false
// For short abbreviations (3 chars or less), validate word boundaries
if (day.length <= 3) {
const dayIndex = sentence.toLowerCase().indexOf(day)
if (dayIndex === -1) return false
return isValidWordBoundary(
sentence.toLowerCase(),
dayIndex,
day.length,
)
}
return true
})
.map(day => VALID_DAYS[day])
if (!result.frequencyMetadata.days.length)
return { result: null, name: null, cleanedSentence: inputSentence }
@@ -318,7 +347,20 @@ export const parseRepeatV2 = inputSentence => {
.toLowerCase()
.split(/ and |,|\s/)
.map(month => month.trim())
.filter(month => VALID_MONTHS[month])
.filter(month => {
if (!VALID_MONTHS[month]) return false
// For short abbreviations (3 chars or less), validate word boundaries
if (month.length <= 3) {
const monthIndex = sentence.toLowerCase().indexOf(month)
if (monthIndex === -1) return false
return isValidWordBoundary(
sentence.toLowerCase(),
monthIndex,
month.length,
)
}
return true
})
.map(month => VALID_MONTHS[month])
result.frequencyMetadata.unit = 'days'
return {
@@ -664,7 +706,7 @@ export const parseDueDate = (inputSentence, chrono) => {
forwardDate: true,
})
if (!parsedDueDate[0] || parsedDueDate[0].index === -1) {
if (!parsedDueDate.length) {
return {
result: null,
highlight: [],
@@ -672,7 +714,21 @@ export const parseDueDate = (inputSentence, chrono) => {
}
}
const dueDateMatch = parsedDueDate[0]
// Select the first valid word-bounded date match
// Prevents false positives like "wed" in "wedding" while still allowing later valid matches
const dueDateMatch = parsedDueDate.find(
match =>
match.index !== -1 &&
isValidWordBoundary(inputSentence, match.index, match.text.length),
)
if (!dueDateMatch) {
return {
result: null,
highlight: [],
cleanedSentence: inputSentence,
}
}
const dueDateText = dueDateMatch.text
const dueDateStartIndex = dueDateMatch.index
const dueDateEndIndex = dueDateStartIndex + dueDateText.length

View File

@@ -0,0 +1,628 @@
import {
Bedtime,
CalendarMonth,
Close,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
IconButton,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import Calendar from 'react-calendar'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const DueDatePickerField = ({
dueDateOnly,
dueTime,
useCustomTime,
onDueDateChange,
onDueTimeChange,
onUseCustomTimeChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (isOpen) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const handleQuickSchedule = option => {
const date = getQuickScheduleDate(option)
setLocalDueDateOnly(date.toISOString().split('T')[0])
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
onUseCustomTimeChange?.(localUseCustomTime)
if (localUseCustomTime && localDueTime) {
onDueTimeChange?.({ target: { value: localDueTime } })
} else {
onDueTimeChange?.({ target: { value: '' } })
}
setIsOpen(false)
}
const hasDueDate = Boolean(dueDateOnly)
const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text'
const dueDateLabel = useMemo(() => {
if (!dueDateOnly) {
return 'Due'
}
const formattedDate = moment(dueDateOnly).format('MMM D')
if (useCustomTime && dueTime) {
return `${formattedDate}, ${dueTime}`
}
return formattedDate
}, [dueDateOnly, dueTime, useCustomTime])
return (
<>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
size={size}
variant={hasDueDate ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<CalendarMonth sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{dueDateLabel}
</Typography>
</Button>
{hasDueDate && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Due Date'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasDueDate && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = getQuickScheduleDate(opt.key)
.toISOString()
.split('T')[0]
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly
? new Date(`${localDueDateOnly}T00:00:00`)
: null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
</>
)
}
export default DueDatePickerField

View File

@@ -0,0 +1,48 @@
import { Label } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const LabelsPickerField = ({
values = [],
onChange,
onClear,
labels = [],
emptyDisplay = 'icon-text',
}) => {
const options = labels.map(label => ({
id: label.id,
name: label.name,
color: label.color,
}))
return (
<BaseOptionPicker
items={options}
multiple
values={values}
onValuesChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Labels'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<Label
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) => {
if (isEmpty) return 'Labels'
if (selectedItems.length === 1) return selectedItems[0].name
return `${selectedItems.length} labels`
}}
menuMinWidth={220}
/>
)
}
export default LabelsPickerField

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

@@ -6,17 +6,47 @@ import { networkManager } from '../../hooks/NetworkManager'
const NetworkBanner = () => {
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
const [offlineReason, setOfflineReason] = useState(
networkManager.offlineReason,
)
const [isBannerVisible, setIsBannerVisible] = useState(
!networkManager.isOnline,
)
useEffect(() => {
const handleNetworkChange = isOnline => {
setIsOnline(isOnline)
setOfflineReason(networkManager.offlineReason)
if (!isOnline) {
setIsBannerVisible(true)
}
}
networkManager.registerNetworkListener(handleNetworkChange)
return () => networkManager.unregisterNetworkListener(handleNetworkChange)
}, [])
useEffect(() => {
if (isOnline || !isBannerVisible) {
return
}
const timerId = setTimeout(() => {
setIsBannerVisible(false)
}, 5000)
return () => clearTimeout(timerId)
}, [isOnline, isBannerVisible])
const message =
offlineReason === 'server'
? 'Server unreachable. Changes will sync when connection is restored.'
: 'No internet connection. Some features may not be available.'
return (
<Box sx={{}}>
{!isOnline && (
{!isOnline && isBannerVisible && (
<Alert
variant='soft'
color='warning'
@@ -36,7 +66,7 @@ const NetworkBanner = () => {
}}
startDecorator={<WifiOff />}
>
You are currently offline. Some features may not be available.
{message}
</Alert>
)}
</Box>

View File

@@ -0,0 +1,160 @@
import { Close, NotificationsNone } from '@mui/icons-material'
import { Box, Button, IconButton, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import NotificationTemplate from '../../components/NotificationTemplate'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const getDisplayLabel = templates => {
if (!templates || templates.length === 0) return 'Remind'
const count = templates.length
if (count === 1) {
const n = templates[0]
const numericValue = Number(n.value)
if (numericValue === 0) return 'On due date'
const unitName =
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
const absValue = Math.abs(numericValue)
const plural = absValue !== 1 ? 's' : ''
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
}
return `${count} reminders`
}
const NotificationPickerField = ({
value,
onChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const latestTemplatesRef = useRef(value?.templates || [])
const { ResponsiveModal } = useResponsiveModal()
useEffect(() => {
if (isOpen) {
latestTemplatesRef.current = value?.templates || []
}
}, [isOpen, value])
const templates = value?.templates || []
const hasNotifications = templates.length > 0
const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
const displayLabel = getDisplayLabel(templates)
const handleSave = () => {
onChange({ ...value, templates: latestTemplatesRef.current })
setIsOpen(false)
}
const footer = (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasNotifications && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove all
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
Apply
</Button>
</Box>
)
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
size={size}
variant={hasNotifications ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<NotificationsNone sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasNotifications && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Reminders'
footer={footer}
>
<NotificationTemplate
value={value}
onChange={({ notifications }) => {
latestTemplatesRef.current = notifications
}}
showTimeline
/>
</ResponsiveModal>
</>
)
}
export default NotificationPickerField

View File

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

View File

@@ -0,0 +1,74 @@
import { Flag } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const defaultPriorityColors = {
0: '#9CA3AF',
1: '#EF4444',
2: '#F97316',
3: '#FBBF24',
4: '#3B82F6',
}
const defaultPriorityLabels = {
0: 'No Priority',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
const PriorityPickerField = ({
value = 0,
onChange,
onClear,
emptyDisplay = 'icon-text',
priorityColors = defaultPriorityColors,
priorityLabels = defaultPriorityLabels,
size = 'sm',
}) => {
const options = [1, 2, 3, 4].map(priorityOption => ({
id: priorityOption,
label: priorityLabels[priorityOption],
color: priorityColors[priorityOption],
}))
// Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
return (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
size={size}
getItemValue={item => item.id}
getItemLabel={item => item.label}
getItemColor={item => item.color}
getTriggerText={({ selectedItems, isEmpty }) => {
// For priority 0 (no priority), show empty string (icon only)
if (value === 0 || isEmpty) return 'Priority'
return selectedItems[0]?.label || ''
}}
renderTriggerIcon={({ selectedItems, isEmpty }) => (
<Flag
sx={{
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
fontSize: '20px',
}}
/>
)}
renderItemStart={({ item }) => (
<Flag
sx={{
color: item.color,
fontSize: '18px',
}}
/>
)}
menuMinWidth={180}
/>
)
}
export default PriorityPickerField

View File

@@ -0,0 +1,48 @@
import { FolderOpen } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const ProjectPickerField = ({
value = 'default',
onChange,
onClear,
projects = [],
emptyDisplay = 'icon-text',
}) => {
const options = [
{ id: 'default', name: 'Default Project', color: '#9CA3AF' },
...projects.map(project => ({
id: project.id,
name: project.name,
color: project.color,
})),
]
return (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Project'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<FolderOpen
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Project' : selectedItems[0].name
}
menuMinWidth={240}
/>
)
}
export default ProjectPickerField

View File

@@ -0,0 +1,639 @@
import { Close, Repeat } from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
Divider,
IconButton,
Input,
List,
ListItem,
Radio,
RadioGroup,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const FREQUENCY_TYPES = [
'daily',
'weekly',
'monthly',
'yearly',
'adaptive',
'custom',
]
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
const DAYS = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]
const MONTHS = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
]
const OCCURRENCE_OPTIONS = [
{ value: 1, label: '1st' },
{ value: 2, label: '2nd' },
{ value: 3, label: '3rd' },
{ value: 4, label: '4th' },
{ value: -1, label: 'Last' },
]
const defaultMetadata = () => ({
unit: 'days',
time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
const initLocalState = value => {
if (!value) {
return {
frequencyType: 'daily',
frequency: 1,
frequencyMetadata: defaultMetadata(),
}
}
let { frequencyType, frequency, frequencyMetadata } = value
// Normalize parser output: interval/1/days → daily, etc.
if (frequencyType === 'interval' && frequency === 1) {
const unitTypeMap = {
days: 'daily',
weeks: 'weekly',
months: 'monthly',
years: 'yearly',
}
frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
}
return {
frequencyType,
frequency: frequency ?? 1,
frequencyMetadata: {
...defaultMetadata(),
...frequencyMetadata,
},
}
}
const getDisplayType = frequencyType =>
REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
// Shared section label
const SectionLabel = ({ children }) => (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
mb: 0.75,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{children}
</Typography>
)
// Shared time-of-day picker
const TimeRow = ({ metadata, onUpdate }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
<SectionLabel>Time of day</SectionLabel>
<Input
type='time'
size='sm'
value={moment(metadata?.time).format('HH:mm')}
onChange={e =>
onUpdate({
...metadata,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
time: moment(
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
).format(),
})
}
sx={{ width: 120 }}
/>
</Box>
)
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
// Interval section
const IntervalSection = ({
frequency,
frequencyMetadata,
onFrequencyUpdate,
onFrequencyMetadataUpdate,
}) => (
<Box>
<SectionLabel>Repeat every</SectionLabel>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Input
type='number'
size='sm'
value={frequency}
onChange={e =>
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
}
sx={{ width: 72 }}
slotProps={{ input: { min: 1, max: 999 } }}
/>
<List orientation='horizontal' wrap sx={pillListSx}>
{['days', 'weeks', 'months', 'years'].map(unit => (
<ListItem key={unit}>
<Checkbox
checked={frequencyMetadata?.unit === unit}
onClick={() =>
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
}
overlay
disableIcon
variant='soft'
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
/>
</ListItem>
))}
</List>
</Box>
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
// Days of week section
const DaysOfWeekSection = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
}) => {
const selectedDays = frequencyMetadata?.days || []
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
const selectedOccurrences = frequencyMetadata?.occurrences || []
const toggleDay = day => {
const next = selectedDays.includes(day)
? selectedDays.filter(d => d !== day)
: [...selectedDays, day]
onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
}
const toggleOccurrence = val => {
const next = selectedOccurrences.includes(val)
? selectedOccurrences.filter(v => v !== val)
: [...selectedOccurrences, val]
onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
}
return (
<Box>
<SectionLabel>Days</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{DAYS.map(day => (
<ListItem key={day}>
<Checkbox
checked={selectedDays.includes(day)}
onClick={() => toggleDay(day)}
overlay
disableIcon
variant='soft'
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
/>
</ListItem>
))}
</List>
<Box sx={{ mt: 2 }}>
<SectionLabel>Pattern</SectionLabel>
<RadioGroup
orientation='horizontal'
value={weekPattern}
onChange={e =>
onFrequencyMetadataUpdate({
...frequencyMetadata,
weekPattern: e.target.value,
occurrences:
e.target.value === 'every_week' ? [] : selectedOccurrences,
})
}
sx={{
padding: '3px',
borderRadius: '10px',
bgcolor: 'neutral.softBg',
'--RadioGroup-gap': '3px',
'--Radio-actionRadius': '7px',
display: 'inline-flex',
}}
>
{[
{ value: 'every_week', label: 'Every week' },
{ value: 'week_of_month', label: 'Specific weeks' },
].map(opt => (
<Radio
key={opt.value}
value={opt.value}
color='neutral'
disableIcon
label={opt.label}
variant='plain'
sx={{ px: 1.5, py: 0.5 }}
slotProps={{
action: ({ checked }) => ({
sx: checked
? {
bgcolor: 'background.surface',
boxShadow: 'sm',
'&:hover': { bgcolor: 'background.surface' },
}
: {},
}),
}}
/>
))}
</RadioGroup>
</Box>
{weekPattern === 'week_of_month' && (
<Box sx={{ mt: 1.5 }}>
<SectionLabel>Occurrences</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{OCCURRENCE_OPTIONS.map(opt => (
<ListItem key={opt.value}>
<Checkbox
checked={selectedOccurrences.includes(opt.value)}
onClick={() => toggleOccurrence(opt.value)}
overlay
disableIcon
variant='soft'
label={opt.label}
/>
</ListItem>
))}
</List>
</Box>
)}
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
}
// Day of month section
const DayOfMonthSection = ({
frequency,
frequencyMetadata,
onFrequencyUpdate,
onFrequencyMetadataUpdate,
}) => {
const selectedMonths = frequencyMetadata?.months || []
const toggleMonth = month => {
const next = selectedMonths.includes(month)
? selectedMonths.filter(m => m !== month)
: [...selectedMonths, month]
onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
}
return (
<Box>
<SectionLabel>Months</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{MONTHS.map(month => (
<ListItem key={month}>
<Checkbox
checked={selectedMonths.includes(month)}
onClick={() => toggleMonth(month)}
overlay
disableIcon
variant='soft'
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
/>
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
<SectionLabel>Day of month</SectionLabel>
<Input
type='number'
size='sm'
value={frequency}
onChange={e => {
const v = Math.min(
31,
Math.max(1, parseInt(e.target.value, 10) || 1),
)
onFrequencyUpdate(v)
}}
sx={{ width: 72 }}
slotProps={{ input: { min: 1, max: 31 } }}
/>
</Box>
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
}
const RepeatPickerField = ({
value,
onChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const [localFrequencyType, setLocalFrequencyType] = useState('daily')
const [localFrequency, setLocalFrequency] = useState(1)
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
useState(defaultMetadata)
const { ResponsiveModal } = useResponsiveModal()
useEffect(() => {
if (!isOpen) return
const init = initLocalState(value)
setLocalFrequencyType(init.frequencyType)
setLocalFrequency(init.frequency)
setLocalFrequencyMetadata(init.frequencyMetadata)
}, [isOpen, value])
const hasRepeat = Boolean(value)
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
const displayType = getDisplayType(localFrequencyType)
const handleTypeSelect = type => {
if (type === 'custom') {
setLocalFrequencyType('interval')
setLocalFrequency(1)
setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
} else {
setLocalFrequencyType(type)
setLocalFrequency(1)
}
}
const handleSubTypeSelect = newType => {
setLocalFrequencyType(newType)
if (newType === 'interval') {
setLocalFrequency(1)
setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
} else if (newType === 'days_of_the_week') {
setLocalFrequencyMetadata(prev => ({
...prev,
days: [],
weekPattern: 'every_week',
occurrences: [],
}))
} else if (newType === 'day_of_the_month') {
setLocalFrequency(1)
setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
}
}
const handleSave = () => {
onChange({
frequencyType: localFrequencyType,
frequency: localFrequency,
frequencyMetadata: localFrequencyMetadata,
})
setIsOpen(false)
}
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
size={size}
variant={hasRepeat ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<Repeat sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasRepeat && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Repeat Schedule'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasRepeat && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
}
>
{/* Frequency type selector */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<SectionLabel>Frequency</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{FREQUENCY_TYPES.map(type => (
<ListItem key={type}>
<Checkbox
checked={displayType === type}
onClick={() => handleTypeSelect(type)}
overlay
disableIcon
variant='soft'
label={type.charAt(0).toUpperCase() + type.slice(1)}
/>
</ListItem>
))}
</List>
</Box>
{/* Custom sub-type + detail panel */}
{displayType === 'custom' && (
<>
<Box>
<SectionLabel>Schedule type</SectionLabel>
<RadioGroup
orientation='horizontal'
value={localFrequencyType}
onChange={e => handleSubTypeSelect(e.target.value)}
sx={{
padding: '3px',
borderRadius: '10px',
bgcolor: 'neutral.softBg',
'--RadioGroup-gap': '3px',
'--Radio-actionRadius': '7px',
display: 'inline-flex',
}}
>
{REPEAT_ON_TYPE.map(type => (
<Radio
key={type}
value={type}
color='neutral'
disableIcon
label={type
.split('_')
.map((w, i, arr) =>
i === 0 || i === arr.length - 1
? w.charAt(0).toUpperCase() + w.slice(1)
: w,
)
.join(' ')}
variant='plain'
sx={{ px: 1.5, py: 0.5 }}
slotProps={{
action: ({ checked }) => ({
sx: checked
? {
bgcolor: 'background.surface',
boxShadow: 'sm',
'&:hover': { bgcolor: 'background.surface' },
}
: {},
}),
}}
/>
))}
</RadioGroup>
</Box>
<Divider />
{localFrequencyType === 'interval' && (
<IntervalSection
frequency={localFrequency}
frequencyMetadata={localFrequencyMetadata}
onFrequencyUpdate={setLocalFrequency}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
{localFrequencyType === 'days_of_the_week' && (
<DaysOfWeekSection
frequencyMetadata={localFrequencyMetadata}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
{localFrequencyType === 'day_of_the_month' && (
<DayOfMonthSection
frequency={localFrequency}
frequencyMetadata={localFrequencyMetadata}
onFrequencyUpdate={setLocalFrequency}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
</>
)}
</Box>
</ResponsiveModal>
</>
)
}
export default RepeatPickerField

View File

@@ -1,3 +1,18 @@
:root,
[data-joy-color-scheme='light'] {
--highlight-date-color: #b45309;
--highlight-repeat-color: #15803d;
--highlight-label-color: #1d4ed8;
--highlight-priority-color: #be123c;
}
[data-joy-color-scheme='dark'] {
--highlight-date-color: #fca5a5;
--highlight-repeat-color: #86efac;
--highlight-label-color: #93c5fd;
--highlight-priority-color: #f9a8d4;
}
.smart-task-display {
position: absolute;
width: 100%;
@@ -11,27 +26,36 @@
white-space: pre-wrap;
box-sizing: border-box;
}
.smart-task-common {
font-size: 1.2em;
line-height: 1.2em;
font-family: inherit;
caret-color: #f08080;
caret-color: var(--highlight-date-color);
}
.highlight-date {
color: #f08080;
color: var(--highlight-date-color);
}
.highlight-repeat {
color: #90ee90;
color: var(--highlight-repeat-color);
}
.highlight-label {
color: #add8e6;
color: var(--highlight-label-color);
}
.highlight-priority {
color: #ffb6c1;
color: var(--highlight-priority-color);
}
.highlight-assignee {
color: var(--highlight-repeat-color);
}
.highlight-points {
color: var(--highlight-label-color);
}
.task-input {
@@ -39,4 +63,6 @@
width: 100%;
border-radius: 8px;
box-sizing: border-box;
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd);
overflow: auto;
}

View File

@@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({
return (
<div>
<div
className='task-input overflow-auto rounded border'
style={{ minHeight: '2.4em' }}
>
<div className='task-input' style={{ minHeight: '2.4em' }}>
<textarea
ref={titleInputRef}
autoFocus={autoFocus}

View File

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