diff --git a/package.json b/package.json
index 253a62c..64307d3 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"chrono-node": "^2.7.7",
"dotenv": "^16.4.5",
"esm": "^3.2.25",
+ "event-source-polyfill": "^1.0.31",
"farmhash": "^4.0.1",
"fuse.js": "^7.0.0",
"js-cookie": "^3.0.5",
diff --git a/src/App.jsx b/src/App.jsx
index cbad980..314097a 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,15 +1,18 @@
import NavBar from '@/views/components/NavBar'
-import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
+import { Button, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
import { Outlet, useNavigate } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { useResource } from './queries/ResourceQueries'
import { AuthenticationProvider } from './service/AuthenticationService'
-import { ErrorProvider } from './service/ErrorProvider'
+import {
+ NotificationProvider,
+ useNotification,
+} from './service/NotificationProvider'
import { apiManager } from './utils/TokenManager'
import NetworkBanner from './views/components/NetworkBanner'
const add = className => {
@@ -22,22 +25,15 @@ const remove = className => {
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
const queryClient = new QueryClient({})
-function App() {
- const resource = useResource()
- const navigate = useNavigate()
- startApiManager(navigate)
- startOpenReplay()
- const { mode, systemMode } = useColorScheme()
- const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
+const AppContent = () => {
+ const { showNotification } = useNotification()
const {
- offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
- // eslint-disable-next-line prefer-template
console.log('SW Registered: ' + r)
r &&
setInterval(() => {
@@ -48,10 +44,53 @@ function App() {
console.log('SW registration error', error)
},
})
- const close = () => {
- setOfflineReady(false)
- setNeedRefresh(false)
- }
+
+ useEffect(() => {
+ if (needRefresh) {
+ showNotification({
+ type: 'custom',
+ component: (
+
+
+ A new version is now available. Click on reload button to update.
+
+ {
+ updateServiceWorker(true)
+ setNeedRefresh(false)
+ }}
+ sx={{ ml: 2 }}
+ >
+ Refresh
+
+
+ ),
+ snackbarProps: {
+ autoHideDuration: null, // Persistent until user action
+ },
+ })
+ }
+ }, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh])
+
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+function App() {
+ const resource = useResource()
+ const navigate = useNavigate()
+ startApiManager(navigate)
+ startOpenReplay()
+
+ const { mode, systemMode } = useColorScheme()
const setThemeClass = () => {
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
@@ -73,6 +112,7 @@ function App() {
useEffect(() => {
setThemeClass()
}, [mode, systemMode])
+
useEffect(() => {
registerCapacitorListeners()
}, [])
@@ -83,30 +123,9 @@ function App() {
-
-
-
-
-
-
-
- {needRefresh && (
-
-
- A new version is now available.Click on reload button to update.
-
- {
- updateServiceWorker(true)
- setShowUpdateSnackbar(false)
- }}
- >
- Refresh
-
-
- )}
+
+
+
)
diff --git a/src/assets/screenshot-my-chore-dark.png b/src/assets/screenshot-my-chore-dark.png
new file mode 100644
index 0000000..a92c87c
Binary files /dev/null and b/src/assets/screenshot-my-chore-dark.png differ
diff --git a/src/assets/screenshot-my-chore.png b/src/assets/screenshot-my-chore.png
index 62880cb..498d72a 100644
Binary files a/src/assets/screenshot-my-chore.png and b/src/assets/screenshot-my-chore.png differ
diff --git a/src/components/RealTimeSettings.jsx b/src/components/RealTimeSettings.jsx
new file mode 100644
index 0000000..1a24ad3
--- /dev/null
+++ b/src/components/RealTimeSettings.jsx
@@ -0,0 +1,190 @@
+import { Sync, SyncDisabled } from '@mui/icons-material'
+import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
+import { useState } from 'react'
+import { useSSEContext } from '../hooks/useSSEContext'
+import { useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+import SSEConnectionStatus from './SSEConnectionStatus'
+
+const REALTIME_TYPES = {
+ DISABLED: 'disabled',
+ SSE: 'sse',
+}
+
+const RealTimeSettings = () => {
+ const { data: userProfile } = useUserProfile()
+
+ // SSE context
+ const sseContext = useSSEContext()
+
+ // Get current realtime type from localStorage
+ const getCurrentRealtimeType = () => {
+ const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
+ return sseEnabled ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED
+ }
+
+ const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
+
+ const handleRealtimeTypeChange = (event, newValue) => {
+ if (!isPlusAccount(userProfile)) {
+ return // Don't allow changes for non-Plus users
+ }
+
+ setRealtimeType(newValue)
+
+ // Update localStorage and toggle connections
+ switch (newValue) {
+ case REALTIME_TYPES.DISABLED:
+ localStorage.setItem('sse_enabled', 'false')
+ sseContext.disconnect()
+ break
+ case REALTIME_TYPES.SSE:
+ localStorage.setItem('sse_enabled', 'true')
+ sseContext.connect()
+ break
+ }
+ }
+
+ const getCurrentContext = () => {
+ switch (realtimeType) {
+ case REALTIME_TYPES.SSE:
+ return sseContext
+ default:
+ return {
+ isConnected: false,
+ isConnecting: false,
+ error: null,
+ getConnectionStatus: () => 'disabled',
+ }
+ }
+ }
+
+ const context = getCurrentContext()
+
+ const getStatusDescription = () => {
+ if (!isPlusAccount(userProfile)) {
+ return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
+ }
+
+ if (realtimeType === REALTIME_TYPES.DISABLED) {
+ return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
+ }
+
+ if (context.isConnected) {
+ return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
+ }
+
+ if (context.isConnecting) {
+ return 'Connecting to real-time updates...'
+ }
+
+ if (context.error) {
+ return `Real-time updates are enabled but not working: ${context.error}`
+ }
+
+ return 'Real-time updates are enabled but not currently connected.'
+ }
+
+ const getConnectionStatusComponent = () => {
+ switch (realtimeType) {
+ case REALTIME_TYPES.SSE:
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+ {
+ handleRealtimeTypeChange(
+ null,
+ e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED,
+ )
+ }}
+ disabled={!isPlusAccount(userProfile)}
+ inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
+ />
+
+
+
+ Real-time Updates
+ {!isPlusAccount(userProfile) && (
+
+ Plus Feature
+
+ )}
+
+
+ {realtimeType !== REALTIME_TYPES.DISABLED &&
+ isPlusAccount(userProfile) ? (
+
+ ) : (
+
+ )}
+
+
+ Get instant notifications when tasks are updated
+
+
+
+
+ {/*
+
+ Real-time Connection Type
+
+ Choose how to receive real-time updates
+
+
+
+ Disabled
+ WebSocket
+ SSE
+
+ */}
+
+ {getStatusDescription()}
+
+ {realtimeType !== REALTIME_TYPES.DISABLED &&
+ isPlusAccount(userProfile) && (
+
+
+ Status:
+
+ {getConnectionStatusComponent()}
+ {context.error && (
+
+ {context.error}
+
+ )}
+
+ )}
+
+ {!isPlusAccount(userProfile) && (
+
+ Real-time updates are not available in the Basic plan. Upgrade to Plus
+ to receive instant notifications when you or other circle members
+ complete, skip, or modify tasks.
+
+ )}
+
+ )
+}
+
+export default RealTimeSettings
diff --git a/src/components/SSEConnectionStatus.jsx b/src/components/SSEConnectionStatus.jsx
new file mode 100644
index 0000000..718d355
--- /dev/null
+++ b/src/components/SSEConnectionStatus.jsx
@@ -0,0 +1,106 @@
+import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
+import { Box, Chip, Tooltip, Typography } from '@mui/joy'
+import { useSSEContext } from '../hooks/useSSEContext'
+
+const SSEConnectionStatus = ({
+ variant = 'minimal',
+ showError = false,
+ sx = {},
+}) => {
+ const { isConnected, isConnecting, error, getConnectionStatus } =
+ useSSEContext()
+
+ const getStatusColor = () => {
+ if (isConnected) return 'success'
+ if (isConnecting) return 'warning'
+ return 'danger'
+ }
+
+ const getStatusIcon = () => {
+ if (isConnected) return
+ if (isConnecting) return
+ return
+ }
+
+ const getStatusText = () => {
+ if (isConnected) return 'Connected'
+ if (isConnecting) return 'Connecting...'
+ return 'Disconnected'
+ }
+
+ const getTooltipText = () => {
+ const status = getConnectionStatus()
+ if (error) return `Real-time updates (SSE): ${status} - ${error}`
+ if (!isConnected && !isConnecting) {
+ return `Real-time updates (SSE): ${status} - Join a circle to enable real-time updates`
+ }
+ return `Real-time updates (SSE): ${status}`
+ }
+
+ if (variant === 'minimal') {
+ return (
+
+
+
+ {showError && error && (
+
+ {error}
+
+ )}
+
+
+ )
+ }
+
+ if (variant === 'chip') {
+ return (
+
+
+ {getStatusText()}
+
+
+ )
+ }
+
+ // Full variant
+ return (
+
+
+ {getStatusIcon()}
+
+ {getStatusText()}
+
+
+ {showError && error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
+export default SSEConnectionStatus
diff --git a/src/components/SSESettings.jsx b/src/components/SSESettings.jsx
new file mode 100644
index 0000000..d9623ad
--- /dev/null
+++ b/src/components/SSESettings.jsx
@@ -0,0 +1,149 @@
+import { Sync, SyncDisabled } from '@mui/icons-material'
+import {
+ Box,
+ Card,
+ Chip,
+ FormControl,
+ FormHelperText,
+ FormLabel,
+ Switch,
+ Typography,
+} from '@mui/joy'
+import { useSSEContext } from '../hooks/useSSEContext'
+import { useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+import SSEConnectionStatus from './SSEConnectionStatus'
+
+const SSESettings = () => {
+ const { data: userProfile } = useUserProfile()
+ const {
+ isConnected,
+ isConnecting,
+ error,
+ getConnectionStatus,
+ toggleSSEEnabled,
+ isSSEEnabled,
+ } = useSSEContext()
+
+ const handleToggle = () => {
+ console.log('=== TOGGLE CLICKED ===')
+ if (!isPlusAccount(userProfile)) {
+ console.log('Not a Plus account, returning early')
+ return // Don't allow toggle for non-Plus users
+ }
+ const currentlyEnabled = isSSEEnabled()
+ console.log('SSE Settings - Toggle clicked:', {
+ currentlyEnabled,
+ newState: !currentlyEnabled,
+ userProfile,
+ isPlusAccount: isPlusAccount(userProfile),
+ })
+ toggleSSEEnabled(!currentlyEnabled)
+ }
+
+ const getStatusDescription = () => {
+ if (!isPlusAccount(userProfile)) {
+ return 'Real-time updates (SSE) are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
+ }
+
+ if (!isSSEEnabled()) {
+ return 'Real-time updates (SSE) are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
+ }
+
+ if (isConnected) {
+ return "Real-time updates (SSE) are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
+ }
+
+ if (isConnecting) {
+ return 'Connecting to real-time updates (SSE)...'
+ }
+
+ if (error) {
+ return `Real-time updates (SSE) are enabled but not working: ${error}`
+ }
+
+ return 'Real-time updates (SSE) are enabled but not currently connected.'
+ }
+
+ return (
+
+
+ {isSSEEnabled() && isPlusAccount(userProfile) ? (
+
+ ) : (
+
+ )}
+
+
+ Real-time Updates (SSE)
+ {!isPlusAccount(userProfile) && (
+
+ Plus Feature
+
+ )}
+
+
+ Get instant notifications via Server-Sent Events
+
+
+ {isSSEEnabled() && isPlusAccount(userProfile) && (
+
+ )}
+
+
+
+
+ Enable Real-time Updates (SSE)
+
+ {getStatusDescription()}
+
+
+
+
+
+ {isSSEEnabled() && isPlusAccount(userProfile) && (
+
+
+ Status:
+
+
+ {getConnectionStatus()}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+ {!isPlusAccount(userProfile) && (
+
+ Real-time updates (SSE) are not available in the Basic plan. Upgrade
+ to Plus to receive instant notifications when you or other circle
+ members complete, skip, or modify chores.
+
+ )}
+
+ )
+}
+
+export default SSESettings
diff --git a/src/components/WebSocketConnectionStatus.jsx b/src/components/WebSocketConnectionStatus.jsx
new file mode 100644
index 0000000..cf44aea
--- /dev/null
+++ b/src/components/WebSocketConnectionStatus.jsx
@@ -0,0 +1,106 @@
+import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
+import { Box, Chip, Tooltip, Typography } from '@mui/joy'
+import { useWebSocketContext } from '../contexts/WebSocketContext'
+
+const WebSocketConnectionStatus = ({
+ variant = 'minimal',
+ showError = false,
+ sx = {},
+}) => {
+ const { isConnected, isConnecting, error, getConnectionStatus } =
+ useWebSocketContext()
+
+ const getStatusColor = () => {
+ if (isConnected) return 'success'
+ if (isConnecting) return 'warning'
+ return 'danger'
+ }
+
+ const getStatusIcon = () => {
+ if (isConnected) return
+ if (isConnecting) return
+ return
+ }
+
+ const getStatusText = () => {
+ if (isConnected) return 'Connected'
+ if (isConnecting) return 'Connecting...'
+ return 'Disconnected'
+ }
+
+ const getTooltipText = () => {
+ const status = getConnectionStatus()
+ if (error) return `Real-time updates: ${status} - ${error}`
+ if (!isConnected && !isConnecting) {
+ return `Real-time updates: ${status} - Join a circle to enable real-time updates`
+ }
+ return `Real-time updates: ${status}`
+ }
+
+ if (variant === 'minimal') {
+ return (
+
+
+
+ {showError && error && (
+
+ {error}
+
+ )}
+
+
+ )
+ }
+
+ if (variant === 'chip') {
+ return (
+
+
+ {getStatusText()}
+
+
+ )
+ }
+
+ // Full variant
+ return (
+
+
+ {getStatusIcon()}
+
+ {getStatusText()}
+
+
+ {showError && error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
+export default WebSocketConnectionStatus
diff --git a/src/components/WebSocketSettings.jsx b/src/components/WebSocketSettings.jsx
new file mode 100644
index 0000000..2c37cfa
--- /dev/null
+++ b/src/components/WebSocketSettings.jsx
@@ -0,0 +1,143 @@
+import { Sync, SyncDisabled } from '@mui/icons-material'
+import {
+ Box,
+ Card,
+ Chip,
+ FormControl,
+ FormHelperText,
+ FormLabel,
+ Switch,
+ Typography,
+} from '@mui/joy'
+import { useWebSocketContext } from '../contexts/WebSocketContext'
+import { useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+import WebSocketConnectionStatus from './WebSocketConnectionStatus'
+
+const WebSocketSettings = () => {
+ const { data: userProfile } = useUserProfile()
+ const {
+ isConnected,
+ isConnecting,
+ error,
+ getConnectionStatus,
+ toggleWebSocketEnabled,
+ isWebSocketEnabled,
+ } = useWebSocketContext()
+
+ const handleToggle = () => {
+ if (!isPlusAccount(userProfile)) {
+ return // Don't allow toggle for non-Plus users
+ }
+ const currentlyEnabled = isWebSocketEnabled()
+ toggleWebSocketEnabled(!currentlyEnabled)
+ }
+
+ const getStatusDescription = () => {
+ if (!isPlusAccount(userProfile)) {
+ return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
+ }
+
+ if (!isWebSocketEnabled()) {
+ return 'Real-time updates are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
+ }
+
+ if (isConnected) {
+ return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
+ }
+
+ if (isConnecting) {
+ return 'Connecting to real-time updates...'
+ }
+
+ if (error) {
+ return `Real-time updates are enabled but not working: ${error}`
+ }
+
+ return 'Real-time updates are enabled but not currently connected.'
+ }
+
+ return (
+
+
+ {isWebSocketEnabled() && isPlusAccount(userProfile) ? (
+
+ ) : (
+
+ )}
+
+
+ Real-time Updates
+ {!isPlusAccount(userProfile) && (
+
+ Plus Feature
+
+ )}
+
+
+ Get instant notifications when chores are updated
+
+
+ {isWebSocketEnabled() && isPlusAccount(userProfile) && (
+
+ )}
+
+
+
+
+ Enable Real-time Updates
+
+ {getStatusDescription()}
+
+
+
+
+
+ {isWebSocketEnabled() && isPlusAccount(userProfile) && (
+
+
+ Status:
+
+
+ {getConnectionStatus()}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+ {!isPlusAccount(userProfile) && (
+
+ Real-time updates are not available in the Basic plan. Upgrade to Plus
+ to receive instant notifications when you or other circle members
+ complete, skip, or modify chores.
+
+ )}
+
+ )
+}
+
+export default WebSocketSettings
diff --git a/src/contexts/Contexts.jsx b/src/contexts/Contexts.jsx
index 1269154..2b3472a 100644
--- a/src/contexts/Contexts.jsx
+++ b/src/contexts/Contexts.jsx
@@ -1,9 +1,17 @@
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
+import SSEProvider from './SSEContext'
import ThemeContext from './ThemeContext'
+import WebSocketProvider from './WebSocketContext'
const Contexts = () => {
- const contexts = [ThemeContext, QueryContext, RouterContext]
+ const contexts = [
+ ThemeContext,
+ QueryContext,
+ SSEProvider,
+ WebSocketProvider,
+ RouterContext,
+ ]
return contexts.reduceRight((acc, Context) => {
return {acc}
diff --git a/src/contexts/SSEContext.jsx b/src/contexts/SSEContext.jsx
new file mode 100644
index 0000000..59f2195
--- /dev/null
+++ b/src/contexts/SSEContext.jsx
@@ -0,0 +1,25 @@
+import { createContext, useContext } from 'react'
+import { useSSE } from '../hooks/useSSE'
+
+export const SSEContext = createContext({
+ connectionState: 2, // CLOSED
+ isConnected: false,
+ isConnecting: false,
+ lastEvent: null,
+ error: null,
+ connect: () => {},
+ disconnect: () => {},
+ getConnectionStatus: () => 'disconnected',
+})
+
+export const useSSEContext = () => {
+ return useContext(SSEContext)
+}
+
+export const SSEProvider = ({ children }) => {
+ const sseState = useSSE()
+
+ return {children}
+}
+
+export default SSEProvider
diff --git a/src/contexts/WebSocketContext.jsx b/src/contexts/WebSocketContext.jsx
new file mode 100644
index 0000000..4a9835e
--- /dev/null
+++ b/src/contexts/WebSocketContext.jsx
@@ -0,0 +1,29 @@
+import { createContext, useContext } from 'react'
+import { useWebSocket } from '../hooks/useWebSocket'
+
+const WebSocketContext = createContext({
+ connectionState: 3, // CLOSED
+ isConnected: false,
+ isConnecting: false,
+ lastEvent: null,
+ error: null,
+ connect: () => {},
+ disconnect: () => {},
+ getConnectionStatus: () => 'disconnected',
+})
+
+export const useWebSocketContext = () => {
+ return useContext(WebSocketContext)
+}
+
+export const WebSocketProvider = ({ children }) => {
+ const webSocketState = useWebSocket()
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default WebSocketProvider
diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js
new file mode 100644
index 0000000..4b29207
--- /dev/null
+++ b/src/hooks/useSSE.js
@@ -0,0 +1,421 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { EventSourcePolyfill } from 'event-source-polyfill'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { apiManager, isTokenValid } from '../utils/TokenManager'
+
+const SSE_STATES = {
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSED: 2,
+}
+
+const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000, 30000] // Progressive backoff
+const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
+const CIRCUIT_BREAKER_RESET_TIME = 300000 // 5 minutes
+
+export const useSSE = () => {
+ const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
+ const [lastEvent, setLastEvent] = useState(null)
+ const [error, setError] = useState(null)
+ const [isCircuitBreakerOpen, setIsCircuitBreakerOpen] = useState(false)
+
+ const eventSourceRef = useRef(null)
+ const reconnectTimeoutRef = useRef(null)
+ const reconnectAttemptsRef = useRef(0)
+ const isManuallyClosedRef = useRef(false)
+ const lastHeartbeatRef = useRef(Date.now())
+ const heartbeatMonitorRef = useRef(null)
+
+ const queryClient = useQueryClient()
+
+ const getSSEUrl = useCallback(() => {
+ const token = localStorage.getItem('ca_token')
+ if (!token || !isTokenValid()) {
+ console.log('SSE: No valid authentication token')
+ return null
+ }
+
+ // Get the API URL from apiManager
+ const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1"
+
+ // Build SSE URL - let backend determine circle from authenticated user
+ const sseUrl = `${apiUrl}/realtime/sse`
+
+ return { url: sseUrl, token }
+ }, [])
+
+ const handleSSEMessage = useCallback(
+ event => {
+ try {
+ const eventData = JSON.parse(event.data)
+ setLastEvent(eventData)
+
+ // Update heartbeat timestamp
+ if (eventData.type === 'heartbeat') {
+ lastHeartbeatRef.current = Date.now()
+ }
+
+ // Handle different event types and update React Query cache accordingly
+ switch (eventData.type) {
+ case 'chore.created':
+ case 'chore.updated':
+ case 'chore.completed':
+ case 'chore.skipped':
+ queryClient.invalidateQueries(['choresHistory', 7])
+ queryClient.invalidateQueries(['chores'])
+
+ // If it's a specific chore event, also invalidate that chore's details
+ if (eventData.data.chore?.id) {
+ queryClient.invalidateQueries(['chore', eventData.data.chore.id])
+ queryClient.invalidateQueries([
+ 'choreDetails',
+ eventData.data.chore.id,
+ ])
+ }
+ break
+
+ case 'chore.deleted':
+ // Invalidate chores queries to refetch data
+ queryClient.invalidateQueries(['chores'])
+
+ // If it's a specific chore event, also invalidate that chore's details
+ if (eventData.data.chore?.id) {
+ queryClient.invalidateQueries(['chore', eventData.data.chore.id])
+ queryClient.invalidateQueries([
+ 'choreDetails',
+ eventData.data.chore.id,
+ ])
+ }
+ break
+
+ case 'subtask.updated':
+ case 'subtask.completed':
+ // Invalidate the specific chore that contains this subtask
+ if (eventData.data.choreId) {
+ queryClient.invalidateQueries(['chore', eventData.data.choreId])
+ queryClient.invalidateQueries([
+ 'choreDetails',
+ eventData.data.choreId,
+ ])
+ }
+ // Also invalidate general chores list
+ queryClient.invalidateQueries(['chores'])
+ break
+
+ case 'heartbeat':
+ // Heartbeat events don't need cache invalidation
+ console.debug('SSE Heartbeat received')
+ break
+
+ case 'connection.established':
+ console.log('SSE connection established')
+ setError(null)
+ lastHeartbeatRef.current = Date.now()
+ break
+
+ case 'error':
+ console.error('SSE error event:', eventData.data)
+ setError(eventData.data.message || 'SSE error occurred')
+ break
+
+ default:
+ console.log('Unknown SSE event type:', eventData.type)
+ }
+ } catch (err) {
+ console.error('Failed to parse SSE message:', err)
+ setError('Failed to parse server message')
+ return // Stop processing if JSON parsing fails
+ }
+ },
+ [queryClient],
+ )
+
+ const stopHeartbeatMonitor = useCallback(() => {
+ if (heartbeatMonitorRef.current) {
+ clearInterval(heartbeatMonitorRef.current)
+ heartbeatMonitorRef.current = null
+ }
+ }, [])
+
+ // Create connect function that can be called from anywhere
+ const connect = useCallback(() => {
+ if (isCircuitBreakerOpen) {
+ console.log('SSE: Circuit breaker is open, preventing connection attempt')
+ setError(
+ 'Connection blocked due to repeated failures. Please try again later.',
+ )
+ return
+ }
+
+ if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
+ console.error(
+ 'SSE: Maximum reconnection attempts reached, opening circuit breaker',
+ )
+ setIsCircuitBreakerOpen(true)
+ setError(
+ 'Maximum connection attempts reached. SSE disabled for 5 minutes.',
+ )
+
+ // Reset circuit breaker after timeout
+ setTimeout(() => {
+ console.log('SSE: Resetting circuit breaker')
+ setIsCircuitBreakerOpen(false)
+ reconnectAttemptsRef.current = 0
+ }, CIRCUIT_BREAKER_RESET_TIME)
+ return
+ }
+
+ // Prevent race conditions by checking if already connecting or connected
+ if (eventSourceRef.current?.readyState === SSE_STATES.OPEN) {
+ console.log('SSE: Already connected')
+ return // Already connected
+ }
+
+ if (eventSourceRef.current?.readyState === SSE_STATES.CONNECTING) {
+ console.log('SSE: Connection already in progress')
+ return // Already connecting
+ }
+
+ const sseConfig = getSSEUrl()
+ console.log('SSE connect - Config:', sseConfig)
+
+ if (!sseConfig) {
+ console.log('Cannot connect to SSE: missing URL, token, or user profile')
+ return
+ }
+
+ // Create connection logic inline to avoid circular dependency
+ try {
+ console.log('Connecting to SSE:', sseConfig.url)
+ setConnectionState(SSE_STATES.CONNECTING)
+ isManuallyClosedRef.current = false
+
+ // here use EventSource polyfill with Authorization header as the native EventSource does not support headers
+ // the other option was to pass via query param which is less secure and also there.
+ // TODO: use cookie-based once/if at all i move from local storage to httpOnly cookies.
+ eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
+ headers: {
+ Authorization: `Bearer ${sseConfig.token}`,
+ 'Cache-Control': 'no-cache',
+ Accept: 'text/event-stream',
+ },
+ })
+
+ eventSourceRef.current.onopen = () => {
+ console.log('SSE connection opened')
+ setConnectionState(SSE_STATES.OPEN)
+ setError(null)
+ reconnectAttemptsRef.current = 0
+ lastHeartbeatRef.current = Date.now()
+
+ // Start heartbeat monitor
+ if (heartbeatMonitorRef.current) {
+ clearInterval(heartbeatMonitorRef.current)
+ }
+ heartbeatMonitorRef.current = setInterval(() => {
+ const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
+ const heartbeatTimeout = 90000 // 90 seconds
+
+ if (timeSinceLastHeartbeat > heartbeatTimeout) {
+ console.warn(
+ 'SSE: No heartbeat received, connection may be stale. Reconnecting...',
+ )
+ if (!isManuallyClosedRef.current) {
+ // Clear current heartbeat monitor before reconnecting
+ stopHeartbeatMonitor()
+
+ // Schedule reconnect
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+
+ const attemptIndex = Math.min(
+ reconnectAttemptsRef.current,
+ RECONNECT_INTERVALS.length - 1,
+ )
+ const delay = RECONNECT_INTERVALS[attemptIndex]
+
+ console.log(
+ `Scheduling SSE reconnect in ${delay}ms (attempt ${
+ reconnectAttemptsRef.current + 1
+ })`,
+ )
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ reconnectAttemptsRef.current++
+ connect()
+ }, delay)
+ }
+ }
+ }, 30000) // Check every 30 seconds
+ }
+
+ eventSourceRef.current.onmessage = handleSSEMessage
+
+ eventSourceRef.current.onerror = error => {
+ console.error('SSE error:', error)
+ setConnectionState(SSE_STATES.CLOSED)
+ stopHeartbeatMonitor()
+
+ if (!isManuallyClosedRef.current) {
+ setError('Connection error occurred')
+
+ // Schedule reconnect
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+
+ const attemptIndex = Math.min(
+ reconnectAttemptsRef.current,
+ RECONNECT_INTERVALS.length - 1,
+ )
+ const delay = RECONNECT_INTERVALS[attemptIndex]
+
+ console.log(
+ `Scheduling SSE reconnect in ${delay}ms (attempt ${
+ reconnectAttemptsRef.current + 1
+ })`,
+ )
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ reconnectAttemptsRef.current++
+ connect()
+ }, delay)
+ }
+ }
+ } catch (err) {
+ console.error('Failed to create SSE connection:', err)
+ setError('Failed to establish connection')
+ setConnectionState(SSE_STATES.CLOSED)
+ }
+ }, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor, isCircuitBreakerOpen])
+
+ const disconnect = useCallback(() => {
+ isManuallyClosedRef.current = true
+
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ reconnectTimeoutRef.current = null
+ }
+
+ stopHeartbeatMonitor()
+
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close()
+ eventSourceRef.current = null
+ }
+
+ setConnectionState(SSE_STATES.CLOSED)
+ }, [stopHeartbeatMonitor])
+
+ const toggleSSEEnabled = useCallback(
+ enabled => {
+ console.log('SSE toggleSSEEnabled called:', {
+ enabled,
+ isTokenValid: isTokenValid(),
+ })
+ localStorage.setItem('sse_enabled', enabled.toString())
+ if (enabled && isTokenValid()) {
+ console.log('SSE toggleSSEEnabled: Calling connect()')
+ connect()
+ } else {
+ console.log('SSE toggleSSEEnabled: Calling disconnect()')
+ disconnect()
+ }
+ },
+ [connect, disconnect],
+ )
+
+ const isSSEEnabled = useCallback(() => {
+ return localStorage.getItem('sse_enabled') === 'true'
+ }, [])
+
+ // Auto-connect when SSE is enabled and token is valid
+ useEffect(() => {
+ console.log('SSE auto-connect effect triggered')
+ console.log('Token valid:', isTokenValid())
+
+ // Check if SSE is enabled in settings
+ const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
+ console.log('SSE enabled in settings:', isSSEEnabledSetting)
+
+ if (isTokenValid() && isSSEEnabledSetting) {
+ console.log('SSE: Conditions met, attempting to connect')
+ connect()
+ } else {
+ console.log('SSE: Conditions not met, disconnecting')
+ disconnect()
+ }
+
+ // Cleanup on unmount
+ return () => {
+ disconnect()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []) // Only run once on mount
+
+ // Cleanup timeouts on unmount
+ useEffect(() => {
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+ stopHeartbeatMonitor()
+ }
+ }, [stopHeartbeatMonitor])
+
+ // Handle visibility changes for better performance
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ // App went to background, maintain connection but log the state
+ console.log(
+ 'SSE: App backgrounded, maintaining connection but reducing activity',
+ )
+ } else {
+ // App came to foreground, ensure connection is active
+ console.log('SSE: App foregrounded, ensuring connection is active')
+
+ const isSSEEnabledSetting =
+ localStorage.getItem('sse_enabled') === 'true'
+ if (
+ isTokenValid() &&
+ isSSEEnabledSetting &&
+ connectionState !== SSE_STATES.OPEN
+ ) {
+ connect()
+ }
+ }
+ }
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ }, [connectionState, connect])
+
+ return {
+ connectionState,
+ isConnected: connectionState === SSE_STATES.OPEN,
+ isConnecting: connectionState === SSE_STATES.CONNECTING,
+ lastEvent,
+ error,
+ connect,
+ disconnect,
+ toggleSSEEnabled,
+ isSSEEnabled,
+ // Helper function to check connection status
+ getConnectionStatus: () => {
+ switch (connectionState) {
+ case SSE_STATES.CONNECTING:
+ return 'connecting'
+ case SSE_STATES.OPEN:
+ return 'connected'
+ case SSE_STATES.CLOSED:
+ default:
+ return 'disconnected'
+ }
+ },
+ }
+}
diff --git a/src/hooks/useSSEContext.js b/src/hooks/useSSEContext.js
new file mode 100644
index 0000000..af15e58
--- /dev/null
+++ b/src/hooks/useSSEContext.js
@@ -0,0 +1,6 @@
+import { useContext } from 'react'
+import { SSEContext } from '../contexts/SSEContext'
+
+export const useSSEContext = () => {
+ return useContext(SSEContext)
+}
diff --git a/src/hooks/useWebSocket.js b/src/hooks/useWebSocket.js
new file mode 100644
index 0000000..2850258
--- /dev/null
+++ b/src/hooks/useWebSocket.js
@@ -0,0 +1,313 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { apiManager, isTokenValid } from '../utils/TokenManager'
+
+const WEBSOCKET_STATES = {
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSING: 2,
+ CLOSED: 3,
+}
+
+const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000, 30000] // Progressive backoff
+
+export const useWebSocket = () => {
+ const [connectionState, setConnectionState] = useState(
+ WEBSOCKET_STATES.CLOSED,
+ )
+ const [lastEvent, setLastEvent] = useState(null)
+ const [error, setError] = useState(null)
+
+ const wsRef = useRef(null)
+ const reconnectTimeoutRef = useRef(null)
+ const reconnectAttemptsRef = useRef(0)
+ const isManuallyClosedRef = useRef(false)
+
+ const queryClient = useQueryClient()
+
+ const getWebSocketUrl = useCallback(() => {
+ const token = localStorage.getItem('ca_token')
+ if (!token || !isTokenValid()) {
+ console.log('WebSocket: No valid authentication token')
+ return null
+ }
+
+ const apiUrl = apiManager.getApiURL()
+
+ // Convert HTTP/HTTPS to WebSocket protocol and remove /api/v1 suffix
+ let wsUrl = apiUrl.replace(/\/api\/v1$/, '')
+ if (wsUrl.startsWith('http://')) {
+ wsUrl = wsUrl.replace('http://', 'ws://')
+ } else if (wsUrl.startsWith('https://')) {
+ wsUrl = wsUrl.replace('https://', 'wss://')
+ } else {
+ const isHttps = window.location.protocol === 'https:'
+ wsUrl = `${isHttps ? 'wss:' : 'ws:'}//${wsUrl}`
+ }
+
+ // Let backend determine circle from authenticated user
+ wsUrl = `${wsUrl}/api/v1/realtime/ws?token=${token}`
+
+ return wsUrl
+ }, [])
+
+ const handleWebSocketMessage = useCallback(
+ event => {
+ try {
+ const eventData = JSON.parse(event.data)
+ setLastEvent(eventData)
+
+ console.debug('WebSocket event received:', eventData.type, eventData)
+
+ // Handle different event types and update React Query cache accordingly
+ switch (eventData.type) {
+ case 'chore.created':
+ case 'chore.updated':
+ case 'chore.completed':
+ case 'chore.skipped':
+ case 'chore.deleted':
+ // Invalidate chores queries to refetch data
+ queryClient.invalidateQueries(['chores'])
+
+ // If it's a specific chore event, also invalidate that chore's details
+ if (eventData.data.chore?.id) {
+ queryClient.invalidateQueries(['chore', eventData.data.chore.id])
+ queryClient.invalidateQueries([
+ 'choreDetails',
+ eventData.data.chore.id,
+ ])
+ }
+ // expire the history so feed on dashboard gert updated :
+ // need to find a better way to do this as we don't need to do it with every single update for anything
+ // but not sure if i can do it with the fall-through switch case in javascript :)
+ queryClient.invalidateQueries(['choresHistory', 7])
+
+ break
+
+ case 'subtask.updated':
+ case 'subtask.completed':
+ // Invalidate the specific chore that contains this subtask
+ if (eventData.data.choreId) {
+ queryClient.invalidateQueries(['chore', eventData.data.choreId])
+ queryClient.invalidateQueries([
+ 'choreDetails',
+ eventData.data.choreId,
+ ])
+ }
+ // Also invalidate general chores list
+ queryClient.invalidateQueries(['chores'])
+ break
+
+ case 'heartbeat':
+ // Heartbeat events don't need cache invalidation
+ console.debug('Heartbeat!')
+ break
+
+ case 'connection.established':
+ console.log('WebSocket connection established')
+ setError(null)
+ break
+
+ case 'error':
+ console.error('WebSocket error event:', eventData.data)
+ setError(eventData.data.message || 'WebSocket error occurred')
+ break
+
+ default:
+ console.log('Unknown WebSocket event type:', eventData.type)
+ }
+ } catch (err) {
+ console.error('Failed to parse WebSocket message:', err)
+ setError('Failed to parse server message')
+ }
+ },
+ [queryClient],
+ )
+
+ const createWebSocketConnection = useCallback(
+ wsUrl => {
+ try {
+ setConnectionState(WEBSOCKET_STATES.CONNECTING)
+ isManuallyClosedRef.current = false
+
+ // Use query parameter authentication (token already included in URL)
+ wsRef.current = new WebSocket(wsUrl)
+
+ wsRef.current.onopen = () => {
+ setConnectionState(WEBSOCKET_STATES.OPEN)
+ setError(null)
+ reconnectAttemptsRef.current = 0
+ }
+
+ wsRef.current.onmessage = handleWebSocketMessage
+
+ wsRef.current.onerror = error => {
+ console.error('WebSocket error:', error)
+ setError('Connection error occurred')
+ }
+ } catch (err) {
+ console.error('Failed to create WebSocket connection:', err)
+ setError('Failed to establish connection')
+ setConnectionState(WEBSOCKET_STATES.CLOSED)
+ }
+ },
+ [handleWebSocketMessage],
+ )
+
+ const scheduleReconnect = useCallback(() => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+
+ const attemptIndex = Math.min(
+ reconnectAttemptsRef.current,
+ RECONNECT_INTERVALS.length - 1,
+ )
+ const delay = RECONNECT_INTERVALS[attemptIndex]
+
+ console.log(
+ `Scheduling WebSocket reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
+ )
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ reconnectAttemptsRef.current++
+ // Trigger reconnection
+ const wsUrl = getWebSocketUrl()
+ if (wsUrl && wsRef.current?.readyState !== WEBSOCKET_STATES.OPEN) {
+ createWebSocketConnection(wsUrl)
+ }
+ }, delay)
+ }, [getWebSocketUrl, createWebSocketConnection])
+
+ // Set up the onclose handler separately to avoid circular dependency
+ useEffect(() => {
+ if (wsRef.current) {
+ wsRef.current.onclose = event => {
+ console.log('WebSocket connection closed:', event.code, event.reason)
+ setConnectionState(WEBSOCKET_STATES.CLOSED)
+
+ // Handle different close codes
+ if (event.code === 4000) {
+ setError('Authentication failed - please refresh the page')
+ return // Don't attempt to reconnect for auth failures
+ } else if (event.code === 4001) {
+ setError('Authorization failed - check circle access')
+ return // Don't attempt to reconnect for auth failures
+ }
+
+ // Attempt to reconnect if not manually closed
+ if (!isManuallyClosedRef.current && event.code !== 1000) {
+ scheduleReconnect()
+ }
+ }
+ }
+ }, [scheduleReconnect])
+
+ const connect = useCallback(() => {
+ if (wsRef.current?.readyState === WEBSOCKET_STATES.OPEN) {
+ console.log('WebSocket: Already connected')
+ return // Already connected
+ }
+
+ const wsUrl = getWebSocketUrl()
+ console.log('WebSocket connect - URL:', wsUrl)
+
+ if (!wsUrl) {
+ console.log(
+ 'Cannot connect to WebSocket: missing URL, token, or user profile',
+ )
+ return
+ }
+
+ createWebSocketConnection(wsUrl)
+ }, [getWebSocketUrl, createWebSocketConnection])
+
+ const disconnect = useCallback(() => {
+ isManuallyClosedRef.current = true
+
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ reconnectTimeoutRef.current = null
+ }
+
+ if (wsRef.current) {
+ wsRef.current.close(1000, 'Manual disconnect')
+ wsRef.current = null
+ }
+
+ setConnectionState(WEBSOCKET_STATES.CLOSED)
+ }, [])
+
+ const toggleWebSocketEnabled = useCallback(
+ enabled => {
+ localStorage.setItem('websocket_enabled', enabled.toString())
+ if (enabled && isTokenValid()) {
+ connect()
+ } else {
+ disconnect()
+ }
+ },
+ [connect, disconnect],
+ )
+
+ const isWebSocketEnabled = useCallback(() => {
+ return localStorage.getItem('websocket_enabled') !== 'false'
+ }, [])
+
+ // Auto-connect when WebSocket is enabled and token is valid
+ useEffect(() => {
+ // Check if WebSocket is enabled in settings
+ const isWebSocketEnabledSetting =
+ localStorage.getItem('websocket_enabled') !== 'false'
+ console.log('WebSocket enabled in settings:', isWebSocketEnabledSetting)
+
+ if (isTokenValid() && isWebSocketEnabledSetting) {
+ console.log('WebSocket: Conditions met, attempting to connect')
+ connect()
+ } else {
+ console.log('WebSocket: Conditions not met, disconnecting')
+ disconnect()
+ }
+
+ // Cleanup on unmount
+ return () => {
+ disconnect()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []) // Only run once on mount
+
+ // Cleanup timeouts on unmount
+ useEffect(() => {
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ return {
+ connectionState,
+ isConnected: connectionState === WEBSOCKET_STATES.OPEN,
+ isConnecting: connectionState === WEBSOCKET_STATES.CONNECTING,
+ lastEvent,
+ error,
+ connect,
+ disconnect,
+ toggleWebSocketEnabled,
+ isWebSocketEnabled,
+ // Helper function to check connection status
+ getConnectionStatus: () => {
+ switch (connectionState) {
+ case WEBSOCKET_STATES.CONNECTING:
+ return 'connecting'
+ case WEBSOCKET_STATES.OPEN:
+ return 'connected'
+ case WEBSOCKET_STATES.CLOSING:
+ return 'disconnecting'
+ case WEBSOCKET_STATES.CLOSED:
+ default:
+ return 'disconnected'
+ }
+ },
+ }
+}
diff --git a/src/service/ErrorProvider.jsx b/src/service/ErrorProvider.jsx
deleted file mode 100644
index 5cfc120..0000000
--- a/src/service/ErrorProvider.jsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Error } from '@mui/icons-material'
-import { Box, Button, Snackbar, Typography } from '@mui/joy'
-import React, { createContext, useContext, useState } from 'react'
-
-const ErrorContext = createContext()
-
-export const useError = () => useContext(ErrorContext)
-
-export const ErrorProvider = ({ children }) => {
- const [error, setError] = useState(null)
-
- const showError = error => {
- setError(error)
- }
-
- return (
-
- {children}
- setError(null)}
- startDecorator={ }
- endDecorator={
- setError(null)}
- >
- Dismiss
-
- }
- >
- {typeof error === 'string' ? (
-
- {error}
-
- ) : (
-
-
- {error?.title}
-
-
- {error?.message}
-
-
- )}
-
-
- )
-}
diff --git a/src/service/NotificationProvider.jsx b/src/service/NotificationProvider.jsx
new file mode 100644
index 0000000..9d6dc39
--- /dev/null
+++ b/src/service/NotificationProvider.jsx
@@ -0,0 +1,246 @@
+import { CheckCircle, Error, Info, Warning } from '@mui/icons-material'
+import { Box, Button, Snackbar, Typography } from '@mui/joy'
+import React, { createContext, useContext, useState } from 'react'
+
+const NotificationContext = createContext()
+
+export const useNotification = () => useContext(NotificationContext)
+
+// For backward compatibility
+export const useError = () => {
+ const { showError } = useNotification()
+ return { showError }
+}
+
+// Notification types configuration with default titles
+const NOTIFICATION_TYPES = {
+ error: {
+ color: 'danger',
+ icon: ,
+ autoHideDuration: 6000,
+ showDismissButton: true,
+ defaultTitle: 'Error',
+ },
+ success: {
+ color: 'success',
+ icon: ,
+ autoHideDuration: 3000,
+ showDismissButton: false,
+ defaultTitle: 'Success',
+ },
+ warning: {
+ color: 'warning',
+ icon: ,
+ autoHideDuration: 4000,
+ showDismissButton: false,
+ defaultTitle: 'Warning',
+ },
+ info: {
+ color: 'primary',
+ icon: ,
+ autoHideDuration: 4000,
+ showDismissButton: false,
+ defaultTitle: 'Information',
+ },
+ custom: {
+ color: 'neutral',
+ icon: null,
+ autoHideDuration: null,
+ showDismissButton: false,
+ defaultTitle: 'Notification',
+ },
+}
+
+export const NotificationProvider = ({ children }) => {
+ const [notifications, setNotifications] = useState([])
+
+ const addNotification = notification => {
+ const id = Date.now() + Math.random()
+ const newNotification = {
+ id,
+ ...notification,
+ timestamp: Date.now(),
+ }
+
+ setNotifications(prev => [...prev, newNotification])
+
+ // Auto-remove notification if it has a duration
+ const config =
+ NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
+ if (config.autoHideDuration) {
+ setTimeout(() => {
+ removeNotification(id)
+ }, config.autoHideDuration)
+ }
+
+ return id
+ }
+
+ const removeNotification = id => {
+ setNotifications(prev => prev.filter(n => n.id !== id))
+ }
+
+ const clearAllNotifications = () => {
+ setNotifications([])
+ }
+
+ // Helper function to normalize notification input
+ const normalizeNotification = (input, type) => {
+ if (typeof input === 'string') {
+ return {
+ type,
+ message: input,
+ }
+ }
+
+ if (typeof input === 'object' && input !== null) {
+ // If it's already a properly structured notification
+ if (input.title || input.message) {
+ return {
+ type,
+ ...input,
+ }
+ }
+
+ // If it's a simple object with just message content
+ return {
+ type,
+ message: input.message || input.toString(),
+ title: input.title,
+ ...input,
+ }
+ }
+
+ return {
+ type,
+ message: input?.toString() || 'Unknown notification',
+ }
+ }
+
+ // Unified notification method
+ const showNotification = notification => {
+ // Handle different input formats
+ if (typeof notification === 'string') {
+ return addNotification(normalizeNotification(notification, 'info'))
+ }
+
+ return addNotification(
+ normalizeNotification(notification, notification.type || 'info'),
+ )
+ }
+
+ // Specific notification methods with enhanced language
+ const showError = error => {
+ return addNotification(normalizeNotification(error, 'error'))
+ }
+
+ const showSuccess = message => {
+ return addNotification(normalizeNotification(message, 'success'))
+ }
+
+ const showWarning = message => {
+ return addNotification(normalizeNotification(message, 'warning'))
+ }
+
+ const showInfo = message => {
+ return addNotification(normalizeNotification(message, 'info'))
+ }
+
+ const renderNotification = notification => {
+ const config =
+ NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
+
+ // Handle custom notifications with components
+ if (notification.type === 'custom' && notification.component) {
+ return (
+ removeNotification(notification.id)}
+ anchorOrigin={
+ notification.anchorOrigin || {
+ vertical: 'bottom',
+ horizontal: 'right',
+ }
+ }
+ {...(notification.snackbarProps || {})}
+ >
+ {React.cloneElement(notification.component, {
+ onClose: () => removeNotification(notification.id),
+ ...notification.componentProps,
+ })}
+
+ )
+ }
+
+ // Handle standard notifications
+ // Determine the icon to use
+ const notificationIcon = notification.icon || config.icon
+
+ // Determine title and message
+ const title = notification.title || config.defaultTitle
+ const message = notification.message
+
+ return (
+ removeNotification(notification.id)}
+ startDecorator={notificationIcon}
+ endDecorator={
+ config.showDismissButton ? (
+ removeNotification(notification.id)}
+ >
+ Dismiss
+
+ ) : null
+ }
+ anchorOrigin={
+ notification.anchorOrigin || {
+ vertical: 'bottom',
+ horizontal: 'right',
+ }
+ }
+ {...(notification.snackbarProps || {})}
+ >
+ {/* Enhanced structure like ErrorProvider - always show title and message for consistency */}
+ {title && message ? (
+
+
+ {title}
+
+
+ {message}
+
+
+ ) : (
+
+ {message || title || 'Notification'}
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {children}
+ {notifications.map(renderNotification)}
+
+ )
+}
diff --git a/src/utils/Colors.jsx b/src/utils/Colors.jsx
index 61b01de..987d830 100644
--- a/src/utils/Colors.jsx
+++ b/src/utils/Colors.jsx
@@ -80,11 +80,22 @@ export const TASK_COLOR = {
ASSIGNED_TO_OTHER: '#b39ddb',
// FOR PRIORITY:
- PRIORITY_1: '#F03A47',
- PRIORITY_2: '#ffc107',
- PRIORITY_3: '#00bcd4',
- PRIORITY_4: '#7e57c2',
- NO_PRIORITY: '#90a4ae',
+ // PRIORITY_1: '#F03A47',
+ // PRIORITY_2: '#ffc107',
+ // PRIORITY_3: '#00bcd4',
+ // PRIORITY_4: '#7e57c2',
+ // NO_PRIORITY: '#90a4ae',
+ // FOR PRIORITY:
+ // PRIORITY_1: '#F03A4780',
+ // PRIORITY_2: '#ffc10780',
+ // PRIORITY_3: '#00bcd480',
+ // PRIORITY_4: '#7e57c280',
+ PRIORITY_1: '#d32f2f',
+ PRIORITY_2: '#ed6c02',
+ PRIORITY_3: '#0288d1',
+ // PRIORITY_4: '#388e3c',
+ PRIORITY_4: '#90a4ae',
+ // NO_PRIORITY: '#90a4ae80',
}
export default LABEL_COLORS
diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx
index d9594c4..bf6c92f 100644
--- a/src/utils/Fetcher.jsx
+++ b/src/utils/Fetcher.jsx
@@ -146,7 +146,10 @@ const UpdateChoreAssignee = (id, assignee) => {
return Fetch(`/chores/${id}/assignee`, {
method: 'PUT',
headers: HEADERS(),
- body: JSON.stringify({ assignee: Number(assignee) }),
+ body: JSON.stringify({
+ assignee: Number(assignee),
+ updatedAt: new Date().toISOString(),
+ }),
})
}
@@ -499,6 +502,7 @@ const UpdateDueDate = (id, dueDate) => {
},
body: JSON.stringify({
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
+ updatedAt: new Date().toISOString(),
}),
})
}
diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx
index b8eb6b0..904f14b 100644
--- a/src/views/Authorization/ForgotPasswordView.jsx
+++ b/src/views/Authorization/ForgotPasswordView.jsx
@@ -8,21 +8,19 @@ import {
FormHelperText,
Input,
Sheet,
- Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import { API_URL } from './../../Config'
-import { ResetPassword } from '../../utils/Fetcher'
+import { useNotification } from '../../service/NotificationProvider'
+import { ResetPassword } from '../../utils/Fetcher'
const ForgotPasswordView = () => {
const navigate = useNavigate()
- // const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
- // const [snackbarMessage, setSnackbarMessage] = useState('')
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState(null)
+ const { showError, showNotification } = useNotification()
const validateEmail = email => {
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
@@ -48,12 +46,24 @@ const ForgotPasswordView = () => {
if (response.ok) {
setResetStatusOk(true)
- // wait 3 seconds and then redirect to login:
+ showNotification({
+ type: 'success',
+ title: 'Reset Email Sent',
+ message: 'Check your email for password reset instructions',
+ })
} else {
setResetStatusOk(false)
+ showError({
+ title: 'Reset Failed',
+ message: 'Failed to send reset email, please try again later',
+ })
}
} catch (error) {
setResetStatusOk(false)
+ showError({
+ title: 'Reset Failed',
+ message: 'Failed to send reset email, please try again later',
+ })
}
}
@@ -195,19 +205,6 @@ const ForgotPasswordView = () => {
>
)}
- {
- if (resetStatusOk) {
- navigate('/login')
- }
- }}
- >
- {resetStatusOk
- ? 'Reset email sent, check your email'
- : 'Reset email failed, try again later'}
-
diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx
index 22cde4c..8daee05 100644
--- a/src/views/Authorization/LoginSettings.jsx
+++ b/src/views/Authorization/LoginSettings.jsx
@@ -1,23 +1,15 @@
import { Preferences } from '@capacitor/preferences'
-import {
- Box,
- Button,
- Container,
- Input,
- Sheet,
- Snackbar,
- Typography,
-} from '@mui/joy'
+import { Box, Button, 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 { useNotification } from '../../service/NotificationProvider'
import { apiManager } from '../../utils/TokenManager'
const LoginSettings = () => {
- const [error, setError] = React.useState(null)
const Navigate = useNavigate()
-
const [serverURL, setServerURL] = React.useState('')
+ const { showError } = useNotification()
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -112,7 +104,11 @@ const LoginSettings = () => {
return
}
if (!isValidServerURL()) {
- setError('Invalid server URL')
+ showError({
+ title: 'Invalid Server URL',
+ message:
+ 'Please enter a valid server URL with protocol (http:// or https://)',
+ })
return
}
Preferences.set({
@@ -150,14 +146,6 @@ const LoginSettings = () => {
- setError(null)}
- autoHideDuration={3000}
- message={error}
- >
- {error}
-
)
}
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index 11261ef..3399497 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -12,7 +12,6 @@ import {
IconButton,
Input,
Sheet,
- Snackbar,
Typography,
} from '@mui/joy'
import Cookies from 'js-cookie'
@@ -22,21 +21,21 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
-import { useUserProfile } from '../../queries/UserQueries'
+import { useNotification } from '../../service/NotificationProvider'
import { login } from '../../utils/Fetcher'
import { apiManager } from '../../utils/TokenManager'
import MFAVerificationModal from './MFAVerificationModal'
const LoginView = () => {
// Only fetch user profile if token is valid to prevent unnecessary queries
- const { data: userProfileData } = useUserProfile()
+ // const { data: userProfileData } = useUserProfile()
const [userProfile, setUserProfile] = useState(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
- const [error, setError] = useState(null)
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const { data: resource } = useResource()
+ const { showError } = useNotification()
const Navigate = useNavigate()
useEffect(() => {
const initializeSocialLogin = async () => {
@@ -76,14 +75,23 @@ const LoginView = () => {
}
})
} else if (response.status === 401) {
- setError('Wrong username or password')
+ showError({
+ title: 'Login Failed',
+ message: 'Wrong username or password',
+ })
} else {
- setError('An error occurred, please try again')
+ showError({
+ title: 'Login Failed',
+ message: 'An error occurred, please try again',
+ })
console.log('Login failed')
}
})
.catch(err => {
- setError('Unable to communicate with server, please try again')
+ showError({
+ title: 'Connection Error',
+ message: 'Unable to communicate with server, please try again',
+ })
console.log('Login failed', err)
})
}
@@ -133,7 +141,10 @@ const LoginView = () => {
})
}
return response.json().then(() => {
- setError("Couldn't log in with Google, please try again")
+ showError({
+ title: 'Google Login Failed',
+ message: "Couldn't log in with Google, please try again",
+ })
})
})
}
@@ -167,7 +178,10 @@ const LoginView = () => {
}
const handleMFAError = errorMessage => {
- setError(errorMessage)
+ showError({
+ title: 'Two-Factor Authentication Failed',
+ message: errorMessage,
+ })
}
const handleMFAClose = () => {
@@ -380,7 +394,11 @@ const LoginView = () => {
loggedWithProvider(provider, data)
}}
onReject={() => {
- setError("Couldn't log in with Google, please try again")
+ showError({
+ title: 'Google Login Failed',
+ message:
+ "Couldn't log in with Google, please try again",
+ })
}}
>
{
- setError(null)}
- autoHideDuration={3000}
- message={error}
- >
- {error}
-
{
@@ -25,9 +25,7 @@ const SignupView = () => {
const [passwordError, setPasswordError] = React.useState('')
const [emailError, setEmailError] = React.useState('')
const [displayNameError, setDisplayNameError] = React.useState('')
- const [error, setError] = React.useState(null)
- const [snackbarOpen, setSnackbarOpen] = React.useState(false)
- const [snackbarMessage, setSnackbarMessage] = React.useState('')
+ const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
if (response.status === 200) {
@@ -104,11 +102,17 @@ const SignupView = () => {
if (response.status === 201) {
handleLogin(username, password)
} else if (response.status === 403) {
- setError('Signup disabled, please contact admin')
+ showError({
+ title: 'Signup Failed',
+ message: 'Signup disabled, please contact admin',
+ })
} else {
console.log('Signup failed')
response.json().then(res => {
- setError(res.error)
+ showError({
+ title: 'Signup Failed',
+ message: res.error || 'An error occurred during signup',
+ })
})
}
})
@@ -264,14 +268,6 @@ const SignupView = () => {
- setError(null)}
- autoHideDuration={5000}
- message={error}
- >
- {error}
-
)
}
diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx
index 8d82537..d891706 100644
--- a/src/views/Authorization/UpdatePasswordView.jsx
+++ b/src/views/Authorization/UpdatePasswordView.jsx
@@ -7,13 +7,13 @@ import {
FormHelperText,
Input,
Sheet,
- Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Logo from '../../Logo'
+import { useNotification } from '../../service/NotificationProvider'
import { ChangePassword } from '../../utils/Fetcher'
const UpdatePasswordView = () => {
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
const [passworConfirmationError, setPasswordConfirmationError] =
useState(null)
const [searchParams] = useSearchParams()
-
- const [updateStatusOk, setUpdateStatusOk] = useState(null)
+ const { showError, showNotification } = useNotification()
const verifiticationCode = searchParams.get('c')
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
const response = await ChangePassword(verifiticationCode, password)
if (response.ok) {
- setUpdateStatusOk(true)
+ showNotification({
+ type: 'success',
+ title: 'Password Updated',
+ message:
+ 'Your password has been updated successfully. Redirecting to login...',
+ })
// wait 3 seconds and then redirect to login:
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
- setUpdateStatusOk(false)
+ showError({
+ title: 'Password Update Failed',
+ message: 'Failed to update password, please try again later',
+ })
}
} catch (error) {
- setUpdateStatusOk(false)
+ showError({
+ title: 'Password Update Failed',
+ message: 'Failed to update password, please try again later',
+ })
}
}
return (
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
- {
- setUpdateStatusOk(null)
- }}
- >
- Password update failed, try again later
-
)
}
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index e2c8bc0..b8cb913 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -18,8 +18,6 @@ import {
RadioGroup,
Select,
Sheet,
- Snackbar,
- Stack,
Switch,
Typography,
} from '@mui/joy'
@@ -33,6 +31,7 @@ import {
useUpdateChore,
} from '../../queries/ChoreQueries.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
+import { useNotification } from '../../service/NotificationProvider'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
DeleteChore,
@@ -101,9 +100,6 @@ const ChoreEdit = () => {
const [createdBy, setCreatedBy] = useState(0)
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
- const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
- const [snackbarMessage, setSnackbarMessage] = useState('')
- const [snackbarColor, setSnackbarColor] = useState('warning')
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const updateChoreMutation = useUpdateChore()
@@ -113,6 +109,7 @@ const ChoreEdit = () => {
isLoading: isChoreLoading,
refetch: refetchChore,
} = useChore(choreId)
+ const { showSuccess, showError } = useNotification()
const [userLabels, setUserLabels] = useState([])
@@ -178,16 +175,10 @@ const ChoreEdit = () => {
const errorList = Object.keys(errors).map(key => (
{errors[key]}
))
- setSnackbarMessage(
-
-
- Please resolve the following errors:
-
- {errorList}
- ,
- )
- setSnackbarColor('danger')
- setIsSnackbarOpen(true)
+ showError({
+ title: 'Please resolve the following errors:',
+ message: {errorList}
,
+ })
return false
}
@@ -240,16 +231,18 @@ const ChoreEdit = () => {
SaveFunction(chore)
.then(() => {
- setSnackbarColor('success')
- setSnackbarMessage('Chore saved successfully!')
- setIsSnackbarOpen(true)
+ showSuccess({
+ title: 'Chore Saved',
+ message: 'Your task has been saved successfully!',
+ })
Navigate('/my/chores/')
})
.catch(error => {
console.error('Failed to save chore:', error)
- setSnackbarColor('danger')
- setSnackbarMessage('Failed to save chore, please try again.')
- setIsSnackbarOpen(true)
+ showError({
+ title: 'Save Failed',
+ message: 'Failed to save chore, please try again.',
+ })
})
}
useEffect(() => {
@@ -1099,20 +1092,6 @@ const ChoreEdit = () => {
/>
)}
{/* */}
- {
- setIsSnackbarOpen(false)
- setSnackbarMessage(null)
- }}
- color={snackbarColor}
- autoHideDuration={4000}
- sx={{ bottom: 70 }}
- invertedColors={true}
- variant='soft'
- >
- {snackbarMessage}
-
)
}
diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx
index d00035a..52dda97 100644
--- a/src/views/ChoreEdit/ChoreView.jsx
+++ b/src/views/ChoreEdit/ChoreView.jsx
@@ -33,6 +33,7 @@ import {
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
+import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
@@ -61,6 +62,7 @@ const ChoreView = () => {
const [infoCards, setInfoCards] = useState([])
const { choreId } = useParams()
const [note, setNote] = useState(null)
+ const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
@@ -71,18 +73,12 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [chorePriority, setChorePriority] = useState(null)
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
- const {
- data: circleMembersData,
- isLoading: isCircleMembersLoading,
- handleRefetch: handleCircleMembersRefetch,
- } = useCircleMembers()
+ const { data: circleMembersData, isLoading: isCircleMembersLoading } =
+ useCircleMembers()
const { impersonatedUser } = useImpersonateUser()
- const {
- data: choreData,
- isLoading: isChoreLoading,
- refetch: refetchChore,
- } = useChoreDetails(choreId)
+ const { data: choreData, isLoading: isChoreLoading } =
+ useChoreDetails(choreId)
useEffect(() => {
if (!choreData || !choreData.res || !circleMembersData) {
@@ -107,8 +103,10 @@ const ChoreView = () => {
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
- response.json().then(data => {
+ response.json().then(() => {
setChorePriority(priority)
+ // Invalidate chores cache to refetch data
+ queryClient.invalidateQueries(['chores'])
})
}
})
@@ -195,6 +193,8 @@ const ChoreView = () => {
clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null)
setSecondsLeftToCancel(null)
+ // Invalidate chores cache to refetch data
+ queryClient.invalidateQueries(['chores'])
})
.then(() => {
// refetch the chore details
@@ -216,6 +216,8 @@ const ChoreView = () => {
response.json().then(data => {
const newChore = data.res
setChore(newChore)
+ // Invalidate chores cache to refetch data
+ queryClient.invalidateQueries(['chores'])
})
}
})
diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx
index 981f84b..e4c4d24 100644
--- a/src/views/ChoreEdit/ThingTriggerSection.jsx
+++ b/src/views/ChoreEdit/ThingTriggerSection.jsx
@@ -7,9 +7,6 @@ import {
Chip,
FormControl,
Input,
- ListItem,
- ListItemContent,
- ListItemDecorator,
Option,
Select,
TextField,
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
onChange={(e, newValue) => setSelectedThing(newValue)}
getOptionLabel={option => option.name}
renderOption={(props, option) => (
-
+
-
+
{option.name}
-
-
+
+
type: {option.type} {' '}
state: {option.state}
-
+
-
+
)}
renderInput={params => (
diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx
index 2f806ee..058e11c 100644
--- a/src/views/Chores/ChoreCard.jsx
+++ b/src/views/Chores/ChoreCard.jsx
@@ -11,6 +11,7 @@ import {
Box,
Button,
Card,
+ Checkbox,
Chip,
CircularProgress,
Grid,
@@ -23,7 +24,7 @@ import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
-import { useError } from '../../service/ErrorProvider'
+import { useNotification } from '../../service/NotificationProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
@@ -47,6 +48,10 @@ const ChoreCard = ({
sx,
viewOnly,
onChipClick,
+ // Multi-select props
+ isMultiSelectMode = false,
+ isSelected = false,
+ onSelectionToggle,
}) => {
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
@@ -67,7 +72,7 @@ const ChoreCard = ({
const { impersonatedUser } = useImpersonateUser()
- const { showError } = useError()
+ const { showError } = useNotification()
const handleDelete = () => {
setConfirmModelConfig({
@@ -392,19 +397,71 @@ const ChoreCard = ({
flexDirection: 'column',
justifyContent: 'space-between',
p: 2,
- // backgroundColor: 'white',
boxShadow: 'sm',
borderRadius: 20,
key: `${chore.id}-card`,
-
- // mb: 2,
+ position: 'relative',
+ backgroundColor: 'background.surface',
+ border: '1px solid',
+ borderColor: 'divider',
+ transition: 'all 0.2s ease-in-out',
+ cursor: isMultiSelectMode ? 'pointer' : 'default',
+ '&:hover': {
+ boxShadow: 'md',
+ borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
+ },
+ // Add padding when in multi-select mode to account for checkbox
+ pl: isMultiSelectMode ? 6 : 2,
+ // Visual feedback when selected
+ ...(isMultiSelectMode &&
+ isSelected && {
+ borderColor: 'primary.500',
+ backgroundColor: 'primary.softBg',
+ boxShadow: 'sm',
+ }),
}}
>
+ {/* Multi-select checkbox */}
+ {isMultiSelectMode && (
+ e.stopPropagation()}
+ />
+ )}
{
- navigate(`/chores/${chore.id}`)
+ if (isMultiSelectMode) {
+ onSelectionToggle()
+ } else {
+ navigate(`/chores/${chore.id}`)
+ }
}}
>
{/* Box in top right with Chip showing next due date */}
diff --git a/src/views/Chores/CompactChoreCard.jsx b/src/views/Chores/CompactChoreCard.jsx
index 7a18903..5bd53f4 100644
--- a/src/views/Chores/CompactChoreCard.jsx
+++ b/src/views/Chores/CompactChoreCard.jsx
@@ -8,6 +8,7 @@ import {
import {
Box,
Button,
+ Checkbox,
Chip,
CircularProgress,
IconButton,
@@ -19,16 +20,18 @@ import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
-import { useError } from '../../service/ErrorProvider'
+import { useNotification } from '../../service/NotificationProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
-import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
+import {
+ getTextColorFromBackgroundColor,
+ TASK_COLOR,
+} from '../../utils/Colors.jsx'
import {
DeleteChore,
MarkChoreComplete,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
-import Priorities from '../../utils/Priorities'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import SelectModal from '../Modals/Inputs/SelectModal'
@@ -44,6 +47,10 @@ const CompactChoreCard = ({
sx,
viewOnly,
onChipClick,
+ // Multi-select props
+ isMultiSelectMode = false,
+ isSelected = false,
+ onSelectionToggle,
}) => {
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
@@ -64,7 +71,7 @@ const CompactChoreCard = ({
const { impersonatedUser } = useImpersonateUser()
- const { showError } = useError()
+ const { showError } = useNotification()
// All the existing handler methods (same as original ChoreCard)
const handleDelete = () => {
@@ -364,6 +371,21 @@ const CompactChoreCard = ({
return parts.join(' • ')
}
+ const getPriorityColor = priority => {
+ switch (priority) {
+ case 1:
+ return TASK_COLOR.PRIORITY_1
+ case 2:
+ return TASK_COLOR.PRIORITY_2
+ case 3:
+ return TASK_COLOR.PRIORITY_3
+ case 4:
+ return TASK_COLOR.PRIORITY_4
+ default:
+ return TASK_COLOR.NO_PRIORITY
+ }
+ }
+
return (
{
+ if (isMultiSelectMode) {
+ onSelectionToggle()
+ } else {
+ navigate(`/chores/${chore.id}`)
+ }
}}
- onClick={() => navigate(`/chores/${chore.id}`)}
>
- {/* Left side - Content */}
+ {/* Priority bar clickable area */}
+ {chore.priority > 0 && (
+ {
+ e.stopPropagation()
+ onChipClick({ priority: chore.priority })
+ }}
+ />
+ )}
+ {/* Animated transition container for Complete Button / Multi-select checkbox */}
+
+ {/* Complete Button */}
+
+ {
+ e.stopPropagation()
+ handleTaskCompletion()
+ }}
+ disabled={isPendingCompletion || notInCompletionWindow(chore)}
+ sx={{
+ width: 32,
+ height: 32,
+ borderRadius: '50%',
+ transition: 'all 0.2s ease',
+
+ '&:active': {
+ transform: 'scale(0.95)',
+ },
+ '&:disabled': {
+ opacity: 0.5,
+ transform: 'none',
+ },
+ }}
+ >
+ {isPendingCompletion ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Multi-select Checkbox */}
+
+ e.stopPropagation()}
+ />
+
+
+
+ {/* Content - Center */}
- {/* Line 1: Name + Due Date + Frequency */}
+ {/* Line 1: Name + Due Date */}
-
- {/* Chore Name */}
-
- {chore.name}
-
-
+ {chore.name}
+
- {/* Due Date */}
+ {/* Due Date - Inline with name */}
{getDueDateText(chore.nextDueDate)}
@@ -458,35 +629,7 @@ const CompactChoreCard = ({
{formatMetadata()}
- {/* Labels */}
- {chore.priority > 0 && (
- p.value === chore.priority)?.icon
- }
- onClick={e => {
- e.stopPropagation()
- onChipClick({ priority: chore.priority })
- }}
- sx={{
- ml: 0.5,
- // height: 16,
- // fontSize: 9,
- // px: 0.5,
- }}
- >
- P{chore.priority}
-
- )}
+ {/* Labels - Priority chip removed, now shown as vertical bar */}
{chore.labelsV2?.map(l => (
- {/* Right side - Actions */}
+ {/* Right side - Action Menu with animation */}
- {/* Complete Button */}
- {
- e.stopPropagation()
- handleTaskCompletion()
- }}
- disabled={isPendingCompletion || notInCompletionWindow(chore)}
- sx={{
- width: 32,
- height: 32,
- borderRadius: '50%',
- }}
- >
- {isPendingCompletion ? (
-
- ) : (
-
- )}
-
-
- {/* Chore Action Menu */}
setIsNFCModalOpen(true)}
onDelete={handleDelete}
sx={{
- width: 28,
- marginRight: -3,
- height: 28,
- // opacity: 0.6,
+ width: 32,
+ height: 32,
+ color: 'text.tertiary',
+ flexShrink: 0,
'&:hover': {
- opacity: 0,
+ color: 'text.secondary',
+ bgcolor: 'background.level1',
},
}}
/>
diff --git a/src/views/Chores/MultiSelectHelp.jsx b/src/views/Chores/MultiSelectHelp.jsx
new file mode 100644
index 0000000..11df46e
--- /dev/null
+++ b/src/views/Chores/MultiSelectHelp.jsx
@@ -0,0 +1,198 @@
+import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
+import {
+ Box,
+ Button,
+ Card,
+ Divider,
+ IconButton,
+ Modal,
+ ModalDialog,
+ Typography,
+} from '@mui/joy'
+import { useState } from 'react'
+
+const MultiSelectHelp = ({ isVisible = true }) => {
+ const [isHelpOpen, setIsHelpOpen] = useState(false)
+
+ if (!isVisible) return null
+
+ return (
+ <>
+ {/* Help Button */}
+ setIsHelpOpen(true)}
+ sx={{
+ position: 'fixed',
+ bottom: 24,
+ right: 24,
+ zIndex: 1000,
+ width: 48,
+ height: 48,
+ borderRadius: '50%',
+ boxShadow: 'lg',
+ }}
+ title='Show keyboard shortcuts'
+ >
+
+
+
+ {/* Help Modal */}
+ setIsHelpOpen(false)}>
+
+
+
+
+ Multi-select Mode
+
+ setIsHelpOpen(false)}
+ >
+
+
+
+
+
+ Use these keyboard shortcuts to work more efficiently with multiple
+ tasks:
+
+
+
+ {/* Selection shortcuts */}
+
+
+ Selection
+
+
+
+
+
+
+
+ {/* Action shortcuts */}
+
+
+ Actions
+
+
+
+
+
+
+
+ {/* Interface shortcuts */}
+
+
+ Interface
+
+
+
+
+
+
+
+
+
+
+ setIsHelpOpen(false)}
+ sx={{ minWidth: 120 }}
+ >
+ Got it!
+
+
+
+
+ >
+ )
+}
+
+const ShortcutItem = ({ keys, description }) => (
+
+
+ {description}
+
+
+ {keys.map((key, index) => (
+
+ {index > 0 && (
+
+ +
+
+ )}
+
+
+ {key}
+
+
+
+ ))}
+
+
+)
+
+export default MultiSelectHelp
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index bb8f445..bf82031 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -1,11 +1,19 @@
import {
Add,
+ Archive,
Bolt,
CancelRounded,
+ CheckBox,
+ CheckBoxOutlineBlank,
+ Close,
+ Delete,
+ Done,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
+ SelectAll,
+ SkipNext,
Sort,
Style,
Unarchive,
@@ -26,23 +34,27 @@ import {
List,
Menu,
MenuItem,
- Snackbar,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
-import { GetArchivedChores } from '../../utils/Fetcher'
+import { useNotification } from '../../service/NotificationProvider'
+import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
+import MultiSelectHelp from './MultiSelectHelp'
+import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
+import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
import TaskInput from '../components/AddTaskModal'
import {
canScheduleNotification,
@@ -55,8 +67,8 @@ import SortAndGrouping from './SortAndGrouping'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
- const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
- const [snackBarMessage, setSnackBarMessage] = useState(null)
+ const { showSuccess, showError } = useNotification()
+ const { impersonatedUser } = useImpersonateUser()
const [chores, setChores] = useState([])
const [archivedChores, setArchivedChores] = useState(null)
const [filteredChores, setFilteredChores] = useState([])
@@ -93,6 +105,11 @@ const MyChores = () => {
} = useChores()
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
+ // Multi-select state
+ const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
+ const [selectedChores, setSelectedChores] = useState(new Set())
+ const [confirmModelConfig, setConfirmModelConfig] = useState({})
+
useEffect(() => {
if (!choresLoading && !membersLoading && userProfile) {
setPerformers(membersData.res)
@@ -119,7 +136,14 @@ const MyChores = () => {
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
}
}
- }, [membersLoading, choresLoading, isUserProfileLoading])
+ }, [
+ membersLoading,
+ choresLoading,
+ isUserProfileLoading,
+ choresData,
+ membersData,
+ userProfile,
+ ])
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
@@ -137,20 +161,150 @@ const MyChores = () => {
}
}, [searchInputFocus])
- // add listern to Control/Command + K to focus on search input
+ // Keyboard shortcuts for multi-select and other actions
useEffect(() => {
const handleKeyDown = event => {
+ // Ctrl/Cmd + K to open task modal
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault()
setAddTaskModalOpen(true)
+ return
+ }
+
+ // Ctrl/Cmd + F to focus search input:
+ else if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
+ event.preventDefault()
+ searchInputRef.current?.focus()
+ return
+ }
+
+ // Ctrl/Cmd + S Toggle Multi-select mode
+ else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
+ event.preventDefault()
+ toggleMultiSelectMode()
+ return
+ }
+
+ // Ctrl/Cmd + A to select all - works both in and out of multi-select mode
+ else if (
+ (event.ctrlKey || event.metaKey) &&
+ event.key === 'a' &&
+ !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
+ ) {
+ event.preventDefault()
+ if (!isMultiSelectMode) {
+ // Enable multi-select mode and select all visible tasks
+ setIsMultiSelectMode(true)
+ setTimeout(() => {
+ selectAllVisibleChores()
+ }, 0)
+ // showSuccess({
+ // title: '🎯 Multi-select Mode Active',
+ // message: 'Selected all visible tasks. Press Esc to exit.',
+ // })
+ } else {
+ // Already in multi-select mode, check if all visible tasks are already selected
+ let visibleChores = []
+
+ if (searchTerm?.length > 0 || searchFilter !== 'All') {
+ visibleChores = filteredChores
+ const allVisibleSelected =
+ visibleChores.length > 0 &&
+ visibleChores.every(chore => selectedChores.has(chore.id))
+
+ if (allVisibleSelected) {
+ showSuccess({
+ title: '✅ All Tasks Selected',
+ message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
+ })
+ } else {
+ selectAllVisibleChores()
+ showSuccess({
+ title: '🎯 Tasks Selected',
+ message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
+ })
+ }
+ } else {
+ // Check expanded sections first
+ const expandedChores = choreSections
+ .filter((section, index) => openChoreSections[index])
+ .flatMap(section => section.content || [])
+
+ const allExpandedSelected =
+ expandedChores.length > 0 &&
+ expandedChores.every(chore => selectedChores.has(chore.id))
+
+ // Get all chores (including collapsed sections)
+ const allChores = choreSections.flatMap(
+ section => section.content || [],
+ )
+ const allChoresSelected =
+ allChores.length > 0 &&
+ allChores.every(chore => selectedChores.has(chore.id))
+
+ if (allChoresSelected) {
+ // All chores (including collapsed) are already selected
+ showSuccess({
+ title: '✅ All Tasks Selected',
+ message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
+ })
+ } else if (allExpandedSelected) {
+ // All expanded are selected, now select ALL (including collapsed)
+ selectAllVisibleChores() // This will now select all chores
+ const collapsedCount = allChores.length - expandedChores.length
+ showSuccess({
+ title: '🎯 All Tasks Selected',
+ message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
+ })
+ } else {
+ // Not all expanded are selected, select expanded only
+ selectAllVisibleChores() // This will select expanded only
+ showSuccess({
+ title: '🎯 Tasks Selected',
+ message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
+ })
+ }
+ }
+ }
+ }
+
+ // Multi-select keyboard shortcuts (only when in multi-select mode)
+ if (isMultiSelectMode) {
+ // Escape to clear selection or exit multi-select mode
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ if (selectedChores.size > 0) {
+ clearSelection()
+ } else {
+ setIsMultiSelectMode(false)
+ }
+ return
+ }
+
+ // Delete/Backspace key for bulk delete (with confirmation)
+ if (
+ (event.key === 'Delete' || event.key === 'Backspace') &&
+ selectedChores.size > 0
+ ) {
+ event.preventDefault()
+ handleBulkDelete()
+ return
+ }
+
+ // Enter key for bulk complete
+ if (event.key === 'Enter' && selectedChores.size > 0) {
+ event.preventDefault()
+ handleBulkComplete()
+ return
+ }
}
}
- document.addEventListener('keydown', handleKeyDown)
+ document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
- }, [])
+ }, [isMultiSelectMode, selectedChores.size])
const setSelectedChoreSectionWithCache = value => {
setSelectedChoreSection(value)
localStorage.setItem('selectedChoreSection', value)
@@ -182,6 +336,10 @@ const MyChores = () => {
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
+ // Multi-select props
+ isMultiSelectMode={isMultiSelectMode}
+ isSelected={selectedChores.has(chore.id)}
+ onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
}
@@ -283,24 +441,42 @@ const MyChores = () => {
switch (event) {
case 'completed':
- setSnackBarMessage('Completed')
+ showSuccess({
+ title: 'Task Completed',
+ message: 'Great job! The task has been marked as completed.',
+ })
break
case 'skipped':
- setSnackBarMessage('Skipped')
+ showSuccess({
+ title: 'Task Skipped',
+ message: 'The task has been moved to the next due date.',
+ })
break
case 'rescheduled':
- setSnackBarMessage('Rescheduled')
+ showSuccess({
+ title: 'Task Rescheduled',
+ message: 'The task due date has been updated successfully.',
+ })
break
case 'unarchive':
- setSnackBarMessage('Unarchive')
+ showSuccess({
+ title: 'Task Restored',
+ message: 'The task has been restored and is now active.',
+ })
break
case 'archive':
- setSnackBarMessage('Archived')
+ showSuccess({
+ title: 'Task Archived',
+ message:
+ 'The task has been archived and hidden from the active list.',
+ })
break
default:
- setSnackBarMessage('Updated')
+ showSuccess({
+ title: 'Task Updated',
+ message: 'Your changes have been saved successfully.',
+ })
}
- setIsSnackbarOpen(true)
}
const handleChoreDeleted = deletedChore => {
@@ -351,37 +527,310 @@ const MyChores = () => {
setFilteredChores(fuse.search(term).map(result => result.item))
}
+ // Multi-select helper functions
+ const toggleMultiSelectMode = () => {
+ const newMode = !isMultiSelectMode
+ setIsMultiSelectMode(newMode)
+
+ if (newMode) {
+ setSelectedChores(new Set()) // Clear selection when exiting multi-select
+ }
+ }
+
+ const toggleChoreSelection = choreId => {
+ const newSelection = new Set(selectedChores)
+ if (newSelection.has(choreId)) {
+ newSelection.delete(choreId)
+ } else {
+ newSelection.add(choreId)
+ }
+ setSelectedChores(newSelection)
+ }
+
+ const selectAllVisibleChores = () => {
+ let visibleChores = []
+
+ if (searchTerm?.length > 0 || searchFilter !== 'All') {
+ // If there's a search term or filter, all filtered chores are visible
+ visibleChores = filteredChores
+ } else {
+ // First, get chores from expanded sections only
+ const expandedChores = choreSections
+ .filter((section, index) => openChoreSections[index]) // Only expanded sections
+ .flatMap(section => section.content || []) // Get all chores from expanded sections
+
+ // Check if all expanded chores are already selected
+ const allExpandedSelected =
+ expandedChores.length > 0 &&
+ expandedChores.every(chore => selectedChores.has(chore.id))
+
+ if (allExpandedSelected) {
+ // If all expanded chores are already selected, select ALL chores (including collapsed sections)
+ visibleChores = choreSections.flatMap(section => section.content || [])
+ } else {
+ // Otherwise, just select expanded chores
+ visibleChores = expandedChores
+ }
+ }
+
+ if (visibleChores.length > 0) {
+ const allIds = new Set(visibleChores.map(chore => chore.id))
+ setSelectedChores(allIds)
+ }
+ }
+
+ const clearSelection = () => {
+ // if already empty, just exit multi-select mode:
+ if (selectedChores.size === 0) {
+ setIsMultiSelectMode(false)
+ return
+ }
+ setSelectedChores(new Set())
+ }
+
+ const getSelectedChoresData = () => {
+ const allChores = [...chores, ...(archivedChores || [])]
+ return Array.from(selectedChores)
+ .map(id => allChores.find(chore => chore.id === id))
+ .filter(Boolean)
+ }
+
+ // Bulk operations with improved UX and confirmation modal
+ const handleBulkComplete = async () => {
+ const selectedData = getSelectedChoresData()
+ if (selectedData.length === 0) return
+
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Complete Tasks',
+ confirmText: 'Complete',
+ cancelText: 'Cancel',
+ message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
+ onClose: async isConfirmed => {
+ if (isConfirmed === true) {
+ try {
+ const completedTasks = []
+ const failedTasks = []
+
+ for (const chore of selectedData) {
+ try {
+ await MarkChoreComplete(
+ chore.id,
+ impersonatedUser
+ ? { completedBy: impersonatedUser.userId }
+ : null,
+ null,
+ null,
+ )
+ completedTasks.push(chore)
+ } catch (error) {
+ failedTasks.push(chore)
+ }
+ }
+
+ if (completedTasks.length > 0) {
+ showSuccess({
+ title: '✅ Tasks Completed',
+ message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
+ })
+ }
+
+ if (failedTasks.length > 0) {
+ showError({
+ title: 'Some Tasks Failed',
+ message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
+ })
+ }
+
+ refetchChores()
+ clearSelection()
+ } catch (error) {
+ showError({
+ title: 'Bulk Complete Failed',
+ message: 'An unexpected error occurred. Please try again.',
+ })
+ }
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+ const handleBulkArchive = async () => {
+ const selectedData = getSelectedChoresData()
+ if (selectedData.length === 0) return
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Archive Tasks',
+ confirmText: 'Archive',
+ cancelText: 'Cancel',
+ message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
+ onClose: async isConfirmed => {
+ if (isConfirmed === true) {
+ try {
+ const archivedTasks = []
+ const failedTasks = []
+ for (const chore of selectedData) {
+ try {
+ const archivedChore = await ArchiveChore(chore.id)
+ archivedTasks.push(archivedChore)
+ // Remove from chores and filteredChores
+ setChores(chores.filter(c => c.id !== chore.id))
+ setFilteredChores(filteredChores.filter(c => c.id !== chore.id))
+ } catch (error) {
+ failedTasks.push(chore)
+ }
+ }
+ if (archivedTasks.length > 0) {
+ showSuccess({
+ title: '📦 Tasks Archived',
+ message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
+ })
+ // Update archived chores state
+ setArchivedChores([
+ ...(archivedChores || []),
+ ...archivedTasks.map(c => ({
+ ...c,
+ archived: true,
+ })),
+ ])
+ }
+ if (failedTasks.length > 0) {
+ showError({
+ title: 'Some Tasks Failed',
+ message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
+ })
+ }
+ clearSelection()
+ } catch (error) {
+ showError({
+ title: 'Bulk Archive Failed',
+ message: 'An unexpected error occurred. Please try again.',
+ })
+ }
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+ const handleBulkDelete = async () => {
+ const selectedData = getSelectedChoresData()
+ if (selectedData.length === 0) return
+
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete Tasks',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
+ onClose: async isConfirmed => {
+ if (isConfirmed === true) {
+ try {
+ const deletedTasks = []
+ const failedTasks = []
+
+ for (const chore of selectedData) {
+ try {
+ await DeleteChore(chore.id)
+ deletedTasks.push(chore)
+ } catch (error) {
+ failedTasks.push(chore)
+ }
+ }
+
+ if (deletedTasks.length > 0) {
+ showSuccess({
+ title: '🗑️ Tasks Deleted',
+ message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
+ })
+
+ const deletedIds = new Set(deletedTasks.map(c => c.id))
+ setChores(chores.filter(c => !deletedIds.has(c.id)))
+ setFilteredChores(
+ filteredChores.filter(c => !deletedIds.has(c.id)),
+ )
+ }
+
+ if (failedTasks.length > 0) {
+ showError({
+ title: 'Some Tasks Failed',
+ message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
+ })
+ }
+
+ clearSelection()
+ } catch (error) {
+ showError({
+ title: 'Bulk Delete Failed',
+ message: 'An unexpected error occurred. Please try again.',
+ })
+ }
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+
+ const handleBulkSkip = async () => {
+ const selectedData = getSelectedChoresData()
+ if (selectedData.length === 0) return
+
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Skip Tasks',
+ confirmText: 'Skip',
+ cancelText: 'Cancel',
+ message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
+ onClose: async isConfirmed => {
+ if (isConfirmed === true) {
+ try {
+ const skippedTasks = []
+ const failedTasks = []
+
+ for (const chore of selectedData) {
+ try {
+ await SkipChore(chore.id)
+ skippedTasks.push(chore)
+ } catch (error) {
+ failedTasks.push(chore)
+ }
+ }
+
+ if (skippedTasks.length > 0) {
+ showSuccess({
+ title: '⏭️ Tasks Skipped',
+ message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
+ })
+ }
+
+ if (failedTasks.length > 0) {
+ showError({
+ title: 'Some Tasks Failed',
+ message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
+ })
+ }
+
+ refetchChores()
+ clearSelection()
+ } catch (error) {
+ showError({
+ title: 'Bulk Skip Failed',
+ message: 'An unexpected error occurred. Please try again.',
+ })
+ }
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+
if (
isUserProfileLoading ||
userLabelsLoading ||
performers.length === 0 ||
choresLoading
) {
- console.log(
- 'userProfile:',
- userProfile,
- 'userLabelsLoading:',
- userLabelsLoading,
- 'performers:',
- performers.length,
- 'choresLoading:',
- choresLoading,
- )
-
return (
<>
-
- {JSON.stringify(userProfile) === 'null'}
-
-
- {userLabelsLoading}
-
-
- {performers.length === 0}
-
-
- {choresLoading}
-
>
)
@@ -405,7 +854,7 @@ const MyChores = () => {
}}
>
{
@@ -512,8 +961,39 @@ const MyChores = () => {
>
{isCompactView ? : }
+
+ {/* Multi-select Toggle Button */}
+
+ {isMultiSelectMode ? : }
+
- {showSearchFilter && (
+
+ {/* Search Filter with animation */}
+
{
- )}
+
+
+ {/* Multi-select Toolbar with animation */}
+
+
+ {/* Selection Info and Controls */}
+
+
+
+
+ {selectedChores.size} task
+ {selectedChores.size !== 1 ? 's' : ''} selected
+
+
+
+
+
+
+ }
+ disabled={
+ searchTerm?.length > 0 || searchFilter !== 'All'
+ ? selectedChores.size === filteredChores.length
+ : selectedChores.size ===
+ choreSections.flatMap(s => s.content || []).length
+ }
+ sx={{
+ minWidth: 'auto',
+ '--Button-paddingInline': '0.75rem',
+ }}
+ >
+ All
+
+
+ ) : (
+
+ )
+ }
+ sx={{
+ minWidth: 'auto',
+ '--Button-paddingInline': '0.75rem',
+ }}
+ >
+ {selectedChores.size === 0 ? 'Close' : 'Clear'}
+
+
+
+
+ {/* Action Buttons */}
+
+ }
+ disabled={selectedChores.size === 0}
+ sx={{
+ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ }}
+ >
+ Complete
+
+ }
+ disabled={selectedChores.size === 0}
+ sx={{
+ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ }}
+ >
+ Skip
+
+ }
+ disabled={selectedChores.size === 0}
+ sx={{
+ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ }}
+ >
+ Archive
+
+
+ }
+ disabled={selectedChores.size === 0}
+ sx={{
+ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ }}
+ >
+ Delete
+
+
+ {/*
+
+
+
+
+ */}
+
+
+
+
{searchFilter !== 'All' && (
{
/>
- {
- setIsSnackbarOpen(false)
- }}
- autoHideDuration={3000}
- variant='soft'
- color='success'
- size='lg'
- invertedColors
- >
- {snackBarMessage}
-
{addTaskModalOpen && (
{
+
+ {/* Multi-select Help - only show when in multi-select mode */}
+
+
+ {/* Confirmation Modal for bulk operations */}
+ {confirmModelConfig?.isOpen && (
+
+ )}
)
}
@@ -938,7 +1623,7 @@ const FILTERS = {
return chore.assignedTo === userID
})
},
- 'No Due Date': function (chores, userID) {
+ 'No Due Date': function (chores) {
return chores.filter(chore => {
return chore.nextDueDate === null
})
diff --git a/src/views/Chores/NotificationAccessSnackbar.jsx b/src/views/Chores/NotificationAccessSnackbar.jsx
index 14bb692..c042806 100644
--- a/src/views/Chores/NotificationAccessSnackbar.jsx
+++ b/src/views/Chores/NotificationAccessSnackbar.jsx
@@ -1,78 +1,81 @@
-import { Capacitor } from '@capacitor/core';
-import { Button, Snackbar, Stack, Typography } from '@mui/joy'
-import { Preferences } from '@capacitor/preferences';
-import { LocalNotifications } from '@capacitor/local-notifications';
-
-import {React, useEffect, useState} from 'react';
+import { Capacitor } from '@capacitor/core'
+import { LocalNotifications } from '@capacitor/local-notifications'
+import { Preferences } from '@capacitor/preferences'
+import { Button, Stack, Typography } from '@mui/joy'
+import { useEffect, useState } from 'react'
const NotificationAccessSnackbar = () => {
-
+ const [open, setOpen] = useState(false)
+ if (!Capacitor.isNativePlatform()) {
+ return null
+ }
+ const getNotificationPreferences = async () => {
+ const ret = await Preferences.get({ key: 'notificationPreferences' })
+ return JSON.parse(ret.value)
+ }
- const [open, setOpen] = useState(false);
-
- if (!Capacitor.isNativePlatform()) {
- return null;
- }
- const getNotificationPreferences = async () => {
- const ret = await Preferences.get({ key: 'notificationPreferences' });
- return JSON.parse(ret.value);
- };
-
- useEffect(() => {
- getNotificationPreferences().then((data) => {
- // if optOut is true then don't show the snackbar
- if(data?.optOut === true || data?.granted === true) {
- return;
- }
- setOpen(true);
- });
- }
- , []);
-
-
-
-return (
+ useEffect(() => {
+ getNotificationPreferences().then(data => {
+ // if optOut is true then don't show the snackbar
+ if (data?.optOut === true || data?.granted === true) {
+ return
+ }
+ setOpen(true)
+ })
+ }, [])
+ return (
setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
- sx={(theme) => ({
+ sx={theme => ({
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
maxWidth: 360,
})}
>
- Need Notification?
+ Need Notification?
- You need to enable permission to receive notifications, do you want to enable it?
+ You need to enable permission to receive notifications, do you want to
+ enable it?
-
- {
- const notificationPreferences = { optOut: false };
- LocalNotifications.requestPermissions().then((resp) => {
+
+ {
+ const notificationPreferences = { optOut: false }
+ LocalNotifications.requestPermissions().then(resp => {
if (resp.display === 'granted') {
- notificationPreferences['granted'] = true;
+ notificationPreferences['granted'] = true
}
})
- Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
- setOpen(false);
- }}>
- Yes
+ Preferences.set({
+ key: 'notificationPreferences',
+ value: JSON.stringify(notificationPreferences),
+ })
+ setOpen(false)
+ }}
+ >
+ Yes
{
- const notificationPreferences = { optOut: true };
- Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
- setOpen(false);
+ const notificationPreferences = { optOut: true }
+ Preferences.set({
+ key: 'notificationPreferences',
+ value: JSON.stringify(notificationPreferences),
+ })
+ setOpen(false)
}}
>
No, Keep it Disabled
@@ -80,8 +83,7 @@ return (
-
-)
+ )
}
-export default NotificationAccessSnackbar;
\ No newline at end of file
+export default NotificationAccessSnackbar
diff --git a/src/views/Landing/CookiePermissionSnackbar.jsx b/src/views/Landing/CookiePermissionSnackbar.jsx
index 7fe3724..f72b922 100644
--- a/src/views/Landing/CookiePermissionSnackbar.jsx
+++ b/src/views/Landing/CookiePermissionSnackbar.jsx
@@ -1,39 +1,42 @@
-import { Button, Snackbar } from '@mui/joy'
+import { Button } from '@mui/joy'
import Cookies from 'js-cookie'
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
+import { useNotification } from '../../service/NotificationProvider'
const CookiePermissionSnackbar = () => {
+ const { showNotification } = useNotification()
+
useEffect(() => {
const cookiePermission = Cookies.get('cookies_permission')
if (cookiePermission !== 'true') {
- setOpen(true)
+ showNotification({
+ type: 'custom',
+ component: ,
+ snackbarProps: {
+ autoHideDuration: null,
+ },
+ anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
+ })
}
- }, [])
+ }, [showNotification])
- const [open, setOpen] = useState(false)
- const handleClose = () => {
+ return null
+}
+
+const CookieAcceptComponent = ({ onClose }) => {
+ const handleAccept = () => {
Cookies.set('cookies_permission', 'true')
- setOpen(false)
+ onClose?.()
}
return (
- {
- if (reason === 'clickaway') {
- return
- }
- // Cookies.set('cookies_permission', 'true')
- handleClose()
- }}
- >
+
We use cookies to ensure you get the best experience on our website.
-
+
Accept
-
+
)
}
diff --git a/src/views/Landing/DemoMyChore.jsx b/src/views/Landing/DemoMyChore.jsx
index b63688b..9a09954 100644
--- a/src/views/Landing/DemoMyChore.jsx
+++ b/src/views/Landing/DemoMyChore.jsx
@@ -93,7 +93,7 @@ const DemoMyChore = () => {
// },
]
- const users = [{ displayName: 'Me', id: 1 }]
+ const users = [{ displayName: 'Me', id: 1, userId: 1 }]
return (
<>
diff --git a/src/views/Landing/HomeHero.jsx b/src/views/Landing/HomeHero.jsx
index 35286ec..012ec67 100644
--- a/src/views/Landing/HomeHero.jsx
+++ b/src/views/Landing/HomeHero.jsx
@@ -1,6 +1,6 @@
/* eslint-disable tailwindcss/no-custom-classname */
// import { StyledButton } from '@/components/styled-button'
-import { Button } from '@mui/joy'
+import { Button, IconButton, useColorScheme } from '@mui/joy'
import Typography from '@mui/joy/Typography'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid'
@@ -8,17 +8,17 @@ import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '@/assets/logo.svg'
+import screenShotMyChoreDark from '@/assets/screenshot-my-chore-dark.png'
import screenShotMyChore from '@/assets/screenshot-my-chore.png'
-import { GitHub } from '@mui/icons-material'
+import { DarkMode, GitHub, LightMode } from '@mui/icons-material'
import useWindowWidth from '../../hooks/useWindowWidth'
const HomeHero = () => {
const navigate = useNavigate()
const windowWidth = useWindowWidth()
const windowThreshold = 600
+ const { mode, setMode } = useColorScheme()
const HERO_TEXT_THAT = [
- // 'Donetick simplifies the entire process, from scheduling and reminders to automatic task assignment and progress tracking.',
- // 'Donetick is the intuitive task and chore management app designed for groups. Take charge of shared responsibilities, automate your workflow, and achieve more together.',
'An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized',
]
@@ -169,20 +169,60 @@ const HomeHero = () => {
{
+ e.target.style.transform = 'rotate(0deg) scale(1.05)'
+ }}
+ onMouseLeave={e => {
+ e.target.style.transform = 'rotate(5deg) scale(1)'
+ }}
/>
)}
+
+ {
+ setMode(mode === 'dark' ? 'light' : 'dark')
+ }}
+ sx={{
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ borderRadius: '50%',
+ boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)',
+ transition: 'background-color 0.3s',
+ }}
+ >
+ {mode === 'dark' ? (
+
+ ) : (
+
+ )}
+
+
)
}
diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx
index 81118c4..9430209 100644
--- a/src/views/Modals/Inputs/LabelModal.jsx
+++ b/src/views/Modals/Inputs/LabelModal.jsx
@@ -12,7 +12,7 @@ import {
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
-import { useError } from '../../../service/ErrorProvider.jsx'
+import { useNotification } from '../../../service/NotificationProvider.jsx'
import LABEL_COLORS from '../../../utils/Colors.jsx'
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
import { useLabels } from '../../Labels/LabelQueries'
@@ -23,7 +23,7 @@ function LabelModal({ isOpen, onClose, label }) {
const [error, setError] = useState('')
const { data: userLabels = [] } = useLabels()
const queryClient = useQueryClient()
- const { showError } = useError()
+ const { showError } = useNotification()
// Populate the form fields when editing
useEffect(() => {
diff --git a/src/views/Settings/APITokenSettings.jsx b/src/views/Settings/APITokenSettings.jsx
index 7addb98..899cc6b 100644
--- a/src/views/Settings/APITokenSettings.jsx
+++ b/src/views/Settings/APITokenSettings.jsx
@@ -13,19 +13,47 @@ import moment from 'moment'
import { useEffect, useState } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
+import { useNotification } from '../../service/NotificationProvider'
import {
CreateLongLiveToken,
DeleteLongLiveToken,
GetLongLiveTokens,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import TextModal from '../Modals/Inputs/TextModal'
const APITokenSettings = () => {
const { data: userProfile } = useUserProfile()
+ const { showNotification } = useNotification()
const [tokens, setTokens] = useState([])
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
const [showTokenId, setShowTokenId] = useState(null)
+ const [confirmModalConfig, setConfirmModalConfig] = useState({})
+
+ const showConfirmation = (
+ message,
+ title,
+ onConfirm,
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ color = 'primary',
+ ) => {
+ setConfirmModalConfig({
+ isOpen: true,
+ message,
+ title,
+ confirmText,
+ cancelText,
+ color,
+ onClose: isConfirmed => {
+ if (isConfirmed) {
+ onConfirm()
+ }
+ setConfirmModalConfig({})
+ },
+ })
+ }
useEffect(() => {
GetLongLiveTokens().then(resp => {
resp.json().then(data => {
@@ -100,18 +128,28 @@ const APITokenSettings = () => {
variant='outlined'
color='danger'
onClick={() => {
- const confirmed = confirm(
- `Are you sure you want to remove ${token.name} ?`,
+ showConfirmation(
+ `Are you sure you want to remove ${token.name}?`,
+ 'Remove Token',
+ () => {
+ DeleteLongLiveToken(token.id).then(resp => {
+ if (resp.ok) {
+ showNotification({
+ type: 'success',
+ title: 'Removed',
+ message: 'API token has been removed',
+ })
+ const newTokens = tokens.filter(
+ t => t.id !== token.id,
+ )
+ setTokens(newTokens)
+ }
+ })
+ },
+ 'Remove',
+ 'Cancel',
+ 'danger',
)
- if (confirmed) {
- DeleteLongLiveToken(token.id).then(resp => {
- if (resp.ok) {
- alert('Token removed')
- const newTokens = tokens.filter(t => t.id !== token.id)
- setTokens(newTokens)
- }
- })
- }
}}
>
Remove
@@ -130,7 +168,10 @@ const APITokenSettings = () => {
color='primary'
onClick={() => {
navigator.clipboard.writeText(token.token)
- alert('Token copied to clipboard')
+ showNotification({
+ type: 'success',
+ message: 'Token copied to clipboard',
+ })
setShowTokenId(null)
}}
>
@@ -166,6 +207,11 @@ const APITokenSettings = () => {
okText={'Generate Token'}
onSave={handleSaveToken}
/>
+
+ {/* Modals */}
+ {confirmModalConfig?.isOpen && (
+
+ )}
)
}
diff --git a/src/views/Settings/MFASettings.jsx b/src/views/Settings/MFASettings.jsx
index 31231fc..1e3c560 100644
--- a/src/views/Settings/MFASettings.jsx
+++ b/src/views/Settings/MFASettings.jsx
@@ -323,9 +323,23 @@ const MFASettings = () => {
)}
-
-
- Manual entry key: {setupData.secret}
+
+
+ Manual entry key:
+
+
+ {setupData.secret}
diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx
index 6e4d8fd..01d95df 100644
--- a/src/views/Settings/NotificationSetting.jsx
+++ b/src/views/Settings/NotificationSetting.jsx
@@ -1,7 +1,6 @@
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
-import { Close } from '@mui/icons-material'
import {
Box,
Button,
@@ -10,24 +9,23 @@ import {
FormControl,
FormHelperText,
FormLabel,
- IconButton,
Input,
Option,
Select,
- Snackbar,
Switch,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
+import { useNotification } from '../../service/NotificationProvider'
import {
UpdateNotificationTarget,
UpdateUserDetails,
} from '../../utils/Fetcher'
const NotificationSetting = () => {
- const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
+ const { showWarning } = useNotification()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const getNotificationPreferences = async () => {
@@ -70,13 +68,17 @@ const NotificationSetting = () => {
useEffect(() => {
getNotificationPreferences().then(resp => {
- setDeviceNotification(resp.granted)
- setDueNotification(resp.dueNotification)
- setPreDueNotification(resp.preDueNotification)
- setNaggingNotification(resp.naggingNotification)
+ if (resp) {
+ setDeviceNotification(Boolean(resp.granted))
+ setDueNotification(Boolean(resp.dueNotification ?? true))
+ setPreDueNotification(Boolean(resp.preDueNotification))
+ setNaggingNotification(Boolean(resp.naggingNotification))
+ }
})
getPushNotificationPreferences().then(resp => {
- setPushNotification(resp.granted)
+ if (resp) {
+ setPushNotification(Boolean(resp.granted))
+ }
})
}, [])
@@ -87,7 +89,7 @@ const NotificationSetting = () => {
)
const [chatID, setChatID] = useState(
- userProfile?.notification_target?.target_id,
+ userProfile?.notification_target?.target_id ?? 0,
)
const [error, setError] = useState('')
const SaveValidation = () => {
@@ -147,7 +149,11 @@ const NotificationSetting = () => {
setDeviceNotification(true)
setNotificationPreferences({ granted: true })
} else if (resp.display === 'denied') {
- setIsSnackbarOpen(true)
+ showWarning({
+ title: 'Notification Permission Denied',
+ message:
+ 'You have denied notification permissions. You can enable them later in your device settings.',
+ })
setDeviceNotification(false)
setNotificationPreferences({ granted: false })
}
@@ -251,12 +257,14 @@ const NotificationSetting = () => {
setPushNotification(true)
setPushNotificationPreferences({granted: true})
}
- if (resp.receive!== 'granted') {
- setIsSnackbarOpen(true)
+ if (resp.receive !== 'granted') {
+ showWarning({
+ title: 'Push Notification Permission Denied',
+ message: 'Push notifications have been disabled. You can enable them in your device settings if needed.',
+ })
setPushNotification(false)
setPushNotificationPreferences({granted: false})
console.log("User denied permission", resp)
-
}
})
}
@@ -313,7 +321,7 @@ const NotificationSetting = () => {
{
event.preventDefault()
if (chatID !== 0) {
@@ -440,30 +448,6 @@ const NotificationSetting = () => {
)}
- setIsSnackbarOpen(false)}
- endDecorator={
- setIsSnackbarOpen(false)}>
-
-
- }
- >
-
- Permission Denied
-
- You have denied the permission to receive notification on this
- device. Please enable it in your device settings
-
-
-
)
}
diff --git a/src/views/Settings/ProfileSettings.jsx b/src/views/Settings/ProfileSettings.jsx
index 7644f4b..3975fe5 100644
--- a/src/views/Settings/ProfileSettings.jsx
+++ b/src/views/Settings/ProfileSettings.jsx
@@ -6,14 +6,15 @@ import {
Card,
Divider,
Input,
- Snackbar,
Typography,
} from '@mui/joy'
import Modal from '@mui/joy/Modal'
import ModalDialog from '@mui/joy/ModalDialog'
+import imageCompression from 'browser-image-compression'
import { useRef, useState } from 'react'
import Cropper from 'react-easy-crop'
import { useUserProfile } from '../../queries/UserQueries'
+import { useNotification } from '../../service/NotificationProvider'
import { UpdateUserDetails } from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getCroppedImg } from '../../utils/imageCropUtils'
@@ -21,6 +22,7 @@ import { UploadFile } from '../../utils/TokenManager'
const ProfileSettings = () => {
const { data: userProfile } = useUserProfile()
+ const { showSuccess, showError } = useNotification()
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
const [timezone, setTimezone] = useState(
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -28,11 +30,6 @@ const ProfileSettings = () => {
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
- const [snackbar, setSnackbar] = useState({
- open: false,
- message: '',
- color: 'success',
- })
const fileInputRef = useRef()
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
@@ -60,12 +57,32 @@ const ProfileSettings = () => {
const croppedBlob = await getCroppedImg(
selectedFile,
croppedAreaPixels,
- 320,
- 320,
+ 160,
+ 160,
'image/jpeg',
)
+
+ // Compress the cropped image
+ const compressionOptions = {
+ maxSizeMB: 0.02, // Smaller size for profile images
+ maxWidthOrHeight: 160, // Match the cropped dimensions
+ useWebWorker: true,
+ fileType: 'image/jpeg',
+ initialQuality: 0.8,
+ }
+
+ const compressedFile = await imageCompression(
+ croppedBlob,
+ compressionOptions,
+ )
+
+ console.log(`Original size: ${(croppedBlob.size / 1024).toFixed(2)} KB`)
+ console.log(
+ `Compressed size: ${(compressedFile.size / 1024).toFixed(2)} KB`,
+ )
+
const formData = new FormData()
- formData.append('file', croppedBlob, 'profile.jpg')
+ formData.append('file', compressedFile, 'profile.jpg')
const response = await UploadFile('/users/profile_photo', {
method: 'POST',
body: formData,
@@ -75,16 +92,14 @@ const ProfileSettings = () => {
const url = resolvePhotoURL(data.url || data.sign)
setPhotoURL(url)
- setSnackbar({
- open: true,
- message: 'Profile photo updated!',
- color: 'success',
+ showSuccess({
+ title: 'Photo Updated',
+ message: 'Your profile photo has been updated successfully!',
})
} catch (err) {
- setSnackbar({
- open: true,
- message: 'Failed to upload photo.',
- color: 'danger',
+ showError({
+ title: 'Upload Failed',
+ message: 'Failed to upload your photo. Please try again.',
})
} finally {
setIsUploading(false)
@@ -100,19 +115,18 @@ const ProfileSettings = () => {
const response = await UpdateUserDetails(userDetails)
if (response.ok) {
- setSnackbar({
- open: true,
- message: 'Profile updated successfully!',
- color: 'success',
+ showSuccess({
+ title: 'Profile Updated',
+ message: 'Your profile information has been saved successfully!',
})
} else {
throw new Error('Failed to update profile')
}
} catch (err) {
- setSnackbar({
- open: true,
- message: 'Failed to update profile.',
- color: 'danger',
+ showError({
+ title: 'Update Failed',
+ message:
+ 'Unable to update your profile. Please check your connection and try again.',
})
} finally {
setIsSaving(false)
@@ -280,14 +294,6 @@ const ProfileSettings = () => {
Save
- setSnackbar({ ...snackbar, open: false })}
- >
- {snackbar.message}
-
)
}
diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx
index baa22d2..6e1dbc7 100644
--- a/src/views/Settings/Settings.jsx
+++ b/src/views/Settings/Settings.jsx
@@ -10,13 +10,13 @@ import {
FormControl,
FormHelperText,
Input,
- ListItem,
Option,
Select,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
+import RealTimeSettings from '../../components/RealTimeSettings'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import {
@@ -34,6 +34,7 @@ import {
UpdatePassword,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import APITokenSettings from './APITokenSettings'
import MFASettings from './MFASettings'
@@ -41,9 +42,11 @@ import NotificationSetting from './NotificationSetting'
import ProfileSettings from './ProfileSettings'
import StorageSettings from './StorageSettings'
import ThemeToggle from './ThemeToggle'
+import { useNotification } from '../../service/NotificationProvider'
const Settings = () => {
const { data: userProfile } = useUserProfile()
+ const { showNotification } = useNotification()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
@@ -54,6 +57,31 @@ const Settings = () => {
const [isAdmin, setIsAdmin] = useState(false)
const [changePasswordModal, setChangePasswordModal] = useState(false)
+ const [confirmModalConfig, setConfirmModalConfig] = useState({})
+
+ const showConfirmation = (
+ message,
+ title,
+ onConfirm,
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ color = 'primary',
+ ) => {
+ setConfirmModalConfig({
+ isOpen: true,
+ message,
+ title,
+ confirmText,
+ cancelText,
+ color,
+ onClose: isConfirmed => {
+ if (isConfirmed) {
+ onConfirm()
+ }
+ setConfirmModalConfig({})
+ },
+ })
+ }
useEffect(() => {
GetUserCircle().then(resp => {
resp.json().then(data => {
@@ -165,7 +193,10 @@ const Settings = () => {
variant='soft'
onClick={() => {
navigator.clipboard.writeText(userCircles[0]?.invite_code)
- alert('Code Copied to clipboard')
+ showNotification({
+ type: 'success',
+ message: 'Code copied to clipboard',
+ })
}}
>
Copy Code
@@ -180,27 +211,42 @@ const Settings = () => {
window.location.host +
`/circle/join?code=${userCircles[0]?.invite_code}`,
)
- alert('Link Copied to clipboard')
+ showNotification({
+ type: 'success',
+ message: 'Link copied to clipboard',
+ })
}}
>
Copy Link
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
{
- const confirmed = confirm(
- `Are you sure you want to leave your circle?`,
+ showConfirmation(
+ 'Are you sure you want to leave your circle?',
+ 'Leave Circle',
+ () => {
+ LeaveCircle(userCircles[0]?.id).then(resp => {
+ if (resp.ok) {
+ showNotification({
+ type: 'success',
+ message: 'Left circle successfully',
+ })
+ } else {
+ showNotification({
+ type: 'error',
+ message: 'Failed to leave circle',
+ })
+ }
+ })
+ },
+ 'Leave',
+ 'Cancel',
+ 'danger',
)
- if (confirmed) {
- LeaveCircle(userCircles[0]?.id).then(resp => {
- if (resp.ok) {
- alert('Left circle successfully.')
- } else {
- alert('Failed to leave circle.')
- }
- })
- }
}}
>
Leave Circle
@@ -257,7 +303,10 @@ const Settings = () => {
})
setCircleMembers(newCircleMembers)
} else {
- alert('Failed to update role')
+ showNotification({
+ type: 'error',
+ message: 'Failed to update role',
+ })
}
})
}}
@@ -278,7 +327,7 @@ const Settings = () => {
},
].map((option, index) => (
- {
>
{option.description}
-
+
))}
@@ -318,19 +367,26 @@ const Settings = () => {
color='danger'
size='sm'
onClick={() => {
- const confirmed = confirm(
+ showConfirmation(
`Are you sure you want to remove ${member.displayName} from your circle?`,
+ 'Remove Member',
+ () => {
+ DeleteCircleMember(
+ member.circleId,
+ member.userId,
+ ).then(resp => {
+ if (resp.ok) {
+ showNotification({
+ type: 'success',
+ message: 'Removed member successfully',
+ })
+ }
+ })
+ },
+ 'Remove',
+ 'Cancel',
+ 'danger',
)
- if (confirmed) {
- DeleteCircleMember(
- member.circleId,
- member.userId,
- ).then(resp => {
- if (resp.ok) {
- alert('Removed member successfully.')
- }
- })
- }
}}
>
Remove
@@ -353,18 +409,24 @@ const Settings = () => {
variant='soft'
color='success'
onClick={() => {
- const confirmed = confirm(
- `Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`,
+ showConfirmation(
+ `Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
+ 'Accept Member Request',
+ () => {
+ AcceptCircleMemberRequest(request.id).then(resp => {
+ if (resp.ok) {
+ showNotification({
+ type: 'success',
+ message: 'Accepted request successfully',
+ })
+ // reload the page
+ window.location.reload()
+ }
+ })
+ },
+ 'Accept',
+ 'Cancel',
)
- if (confirmed) {
- AcceptCircleMemberRequest(request.id).then(resp => {
- if (resp.ok) {
- alert('Accepted request successfully.')
- // reload the page
- window.location.reload()
- }
- })
- }
}}
>
Accept
@@ -393,18 +455,23 @@ const Settings = () => {
{
- const confirmed = confirm(
- `Are you sure you want to leave you circle and join '${circleInviteCode}'?`,
+ showConfirmation(
+ `Are you sure you want to leave your circle and join '${circleInviteCode}'?`,
+ 'Join Circle',
+ () => {
+ JoinCircle(circleInviteCode).then(resp => {
+ if (resp.ok) {
+ showNotification({
+ type: 'success',
+ message:
+ 'Joined circle successfully, wait for the circle owner to accept your request.',
+ })
+ }
+ })
+ },
+ 'Join',
+ 'Cancel',
)
- if (confirmed) {
- JoinCircle(circleInviteCode).then(resp => {
- if (resp.ok) {
- alert(
- 'Joined circle successfully, wait for the circle owner to accept your request.',
- )
- }
- })
- }
}}
>
Join Circle
@@ -479,9 +546,15 @@ const Settings = () => {
onClick={() => {
PutWebhookURL(webhookURL).then(resp => {
if (resp.ok) {
- alert('Webhook URL updated successfully.')
+ showNotification({
+ type: 'success',
+ message: 'Webhook URL updated successfully',
+ })
} else {
- alert('Failed to update webhook URL.')
+ showNotification({
+ type: 'error',
+ message: 'Failed to update webhook URL',
+ })
}
})
}}
@@ -493,6 +566,10 @@ const Settings = () => {
)}
>
)}
+
+ {/* WebSocket Settings */}
+ {/* */}
+
@@ -537,10 +614,14 @@ const Settings = () => {
ml: 1,
}}
variant='outlined'
+ color='danger'
onClick={() => {
CancelSubscription().then(resp => {
if (resp.ok) {
- alert('Subscription cancelled.')
+ showNotification({
+ type: 'success',
+ message: 'Subscription cancelled',
+ })
window.location.reload()
}
})
@@ -571,9 +652,15 @@ const Settings = () => {
if (password) {
UpdatePassword(password).then(resp => {
if (resp.ok) {
- alert('Password changed successfully')
+ showNotification({
+ type: 'success',
+ message: 'Password changed successfully',
+ })
} else {
- alert('Password change failed')
+ showNotification({
+ type: 'error',
+ message: 'Password change failed',
+ })
}
})
}
@@ -597,6 +684,11 @@ const Settings = () => {
+
+ {/* Modals */}
+ {confirmModalConfig?.isOpen && (
+
+ )}
)
}
diff --git a/src/views/Settings/StorageSettings.jsx b/src/views/Settings/StorageSettings.jsx
index 5672910..75d8e01 100644
--- a/src/views/Settings/StorageSettings.jsx
+++ b/src/views/Settings/StorageSettings.jsx
@@ -12,12 +12,38 @@ import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const StorageSettings = () => {
const Navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [usage, setUsage] = useState({ used: 0, total: 0 })
const [loading, setLoading] = useState(true)
+ const [confirmModalConfig, setConfirmModalConfig] = useState({})
+
+ const showConfirmation = (
+ message,
+ title,
+ onConfirm,
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ color = 'primary',
+ ) => {
+ setConfirmModalConfig({
+ isOpen: true,
+ message,
+ title,
+ confirmText,
+ cancelText,
+ color,
+ onClose: isConfirmed => {
+ if (isConfirmed) {
+ onConfirm()
+ }
+ setConfirmModalConfig({})
+ },
+ })
+ }
useEffect(() => {
if (isPlusAccount(userProfile)) {
@@ -101,13 +127,17 @@ const StorageSettings = () => {
variant='soft'
color='danger'
onClick={() => {
- const confirmed = confirm(
- `Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.`,
+ showConfirmation(
+ 'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
+ 'Clear All Local Storage',
+ () => {
+ localStorage.clear()
+ Navigate('/login')
+ },
+ 'Clear All',
+ 'Cancel',
+ 'danger',
)
- if (confirmed) {
- localStorage.clear()
- Navigate('/login')
- }
}}
>
Clear All Local Storage and Cache
@@ -116,20 +146,29 @@ const StorageSettings = () => {
variant='outlined'
color='danger'
onClick={() => {
- const confirmed = confirm(
- `Are you sure you want to clear only the offline cache and tasks?`,
+ 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',
)
- if (confirmed) {
- localStorage.removeItem('offline_cache')
- localStorage.removeItem('offline_request_queue')
- localStorage.removeItem('offlineTasks')
- }
}}
sx={{ mt: 1 }}
>
Clear Offline Cache and Offline Tasks
+
+ {/* Modals */}
+ {confirmModalConfig?.isOpen && (
+
+ )}
)
}
diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx
index dbf3400..17e170a 100644
--- a/src/views/Things/ThingsView.jsx
+++ b/src/views/Things/ThingsView.jsx
@@ -15,12 +15,11 @@ import {
Container,
Grid,
IconButton,
- Snackbar,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import { useError } from '../../service/ErrorProvider'
+import { useNotification } from '../../service/NotificationProvider'
import {
CreateThing,
DeleteThing,
@@ -169,11 +168,7 @@ const ThingsView = () => {
const [isShowEditThingStateModal, setIsShowEditStateModal] = useState(false)
const [createModalThing, setCreateModalThing] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
-
- const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
- const [snackbarMessage, setSnackbarMessage] = useState('')
- const [snackbarColor, setSnackbarColor] = useState('success')
- const { showError } = useError()
+ const { showError, showNotification } = useNotification()
useEffect(() => {
// fetch things
@@ -204,9 +199,11 @@ const ThingsView = () => {
currentThings.push(data.res)
setThings(currentThings)
}
- setSnackbarMessage('Thing saved successfully')
- setSnackbarColor('success')
- setIsSnackbarOpen(true)
+ showNotification({
+ type: 'success',
+ title: 'Thing Saved',
+ message: 'Thing saved successfully',
+ })
})
})
.catch(error => {
@@ -246,11 +243,10 @@ const ThingsView = () => {
currentThings.splice(thingIndex, 1)
setThings(currentThings)
} else if (response.status === 405) {
- setSnackbarMessage(
- 'Unable to delete thing with associated tasks',
- )
- setSnackbarColor('danger')
- setIsSnackbarOpen(true)
+ showError({
+ title: 'Unable to Delete Thing',
+ message: 'Unable to delete thing with associated tasks',
+ })
}
// if method not allwo show snackbar:
})
@@ -293,8 +289,11 @@ const ThingsView = () => {
)
currentThings[thingIndex] = data.res
setThings(currentThings)
- setSnackbarMessage('Thing state updated successfully')
- setIsSnackbarOpen(true)
+ showNotification({
+ type: 'success',
+ title: 'Thing Updated',
+ message: 'Thing state updated successfully',
+ })
})
})
.catch(error => {
@@ -399,19 +398,6 @@ const ThingsView = () => {
- {
- setIsSnackbarOpen(false)
- }}
- autoHideDuration={3000}
- variant='soft'
- color={snackbarColor}
- size='lg'
- invertedColors
- >
- {snackbarMessage}
-
)
}
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 93a82fc..0c6d20c 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -384,7 +384,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
return (
-
+
Create new task
Experimental Feature
diff --git a/src/views/components/CalendarView.jsx b/src/views/components/CalendarView.jsx
index 0595ddc..a296f2a 100644
--- a/src/views/components/CalendarView.jsx
+++ b/src/views/components/CalendarView.jsx
@@ -165,7 +165,6 @@ const CalendarView = ({ chores }) => {
return legendItems.map((item, index) => (
{
const handleMenuOutsideClick = event => {
diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx
index 3a0eb40..4365ed6 100644
--- a/src/views/components/RichTextEditor.jsx
+++ b/src/views/components/RichTextEditor.jsx
@@ -2,9 +2,9 @@ import imageCompression from 'browser-image-compression'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import QuillMarkdown from 'quilljs-markdown'
-import { useCallback, useContext, useEffect, useRef } from 'react'
-import { UserContext } from '../../contexts/UserContext'
-import { useError } from '../../service/ErrorProvider'
+import { useCallback, useEffect, useRef } from 'react'
+import { useUserProfile } from '../../queries/UserQueries'
+import { useNotification } from '../../service/NotificationProvider'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { UploadFile } from '../../utils/TokenManager'
import './RichTextEditor.css'
@@ -18,8 +18,8 @@ const RichTextEditor = ({
entityId,
entityType,
}) => {
- const { showError } = useError()
- const { userProfile } = useContext(UserContext)
+ const { showError } = useNotification()
+ const { data: userProfile } = useUserProfile()
const quillRef = useRef(null)
const editorRef = useRef(null)