diff --git a/src/components/RealTimeSettings.jsx b/src/components/RealTimeSettings.jsx
new file mode 100644
index 0000000..882a770
--- /dev/null
+++ b/src/components/RealTimeSettings.jsx
@@ -0,0 +1,244 @@
+import { Sync, SyncDisabled } from '@mui/icons-material'
+import {
+ Box,
+ Card,
+ Chip,
+ FormControl,
+ FormHelperText,
+ FormLabel,
+ Option,
+ Select,
+ Typography,
+} from '@mui/joy'
+import { useState } from 'react'
+import { useWebSocketContext } from '../contexts/WebSocketContext'
+import { useSSEContext } from '../hooks/useSSEContext'
+import { useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+import SSEConnectionStatus from './SSEConnectionStatus'
+import WebSocketConnectionStatus from './WebSocketConnectionStatus'
+
+const REALTIME_TYPES = {
+ DISABLED: 'disabled',
+ WEBSOCKET: 'websocket',
+ SSE: 'sse',
+}
+
+const RealTimeSettings = () => {
+ const { data: userProfile } = useUserProfile()
+
+ // WebSocket context
+ const webSocketContext = useWebSocketContext()
+
+ // SSE context
+ const sseContext = useSSEContext()
+
+ // Get current realtime type from localStorage
+ const getCurrentRealtimeType = () => {
+ const wsEnabled = localStorage.getItem('websocket_enabled') !== 'false'
+ const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
+
+ if (sseEnabled) return REALTIME_TYPES.SSE
+ if (wsEnabled) return REALTIME_TYPES.WEBSOCKET
+ return 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('websocket_enabled', 'false')
+ localStorage.setItem('sse_enabled', 'false')
+ webSocketContext.disconnect()
+ sseContext.disconnect()
+ break
+ case REALTIME_TYPES.WEBSOCKET:
+ localStorage.setItem('websocket_enabled', 'true')
+ localStorage.setItem('sse_enabled', 'false')
+ sseContext.disconnect()
+ webSocketContext.connect()
+ break
+ case REALTIME_TYPES.SSE:
+ localStorage.setItem('websocket_enabled', 'false')
+ localStorage.setItem('sse_enabled', 'true')
+ webSocketContext.disconnect()
+ sseContext.connect()
+ break
+ }
+ }
+
+ const getCurrentContext = () => {
+ switch (realtimeType) {
+ case REALTIME_TYPES.WEBSOCKET:
+ return webSocketContext
+ 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 chores are updated.'
+ }
+
+ if (realtimeType === REALTIME_TYPES.DISABLED) {
+ return 'Real-time updates are disabled. Enable WebSocket or SSE to see live changes when you or other circle members complete, skip, or modify chores.'
+ }
+
+ const typeLabel =
+ realtimeType === REALTIME_TYPES.WEBSOCKET ? 'WebSocket' : 'SSE'
+
+ if (context.isConnected) {
+ return `Real-time updates (${typeLabel}) are working. You'll see live changes when you or other circle members complete, skip, or modify chores.`
+ }
+
+ if (context.isConnecting) {
+ return `Connecting to real-time updates (${typeLabel})...`
+ }
+
+ if (context.error) {
+ return `Real-time updates (${typeLabel}) are enabled but not working: ${context.error}`
+ }
+
+ return `Real-time updates (${typeLabel}) are enabled but not currently connected.`
+ }
+
+ const getConnectionStatusComponent = () => {
+ switch (realtimeType) {
+ case REALTIME_TYPES.WEBSOCKET:
+ return
+ case REALTIME_TYPES.SSE:
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+ {realtimeType !== REALTIME_TYPES.DISABLED &&
+ isPlusAccount(userProfile) ? (
+
+ ) : (
+
+ )}
+
+
+ Real-time Updates
+ {!isPlusAccount(userProfile) && (
+
+ Plus Feature
+
+ )}
+
+
+ Get instant notifications when chores are updated
+
+
+ {realtimeType !== REALTIME_TYPES.DISABLED &&
+ isPlusAccount(userProfile) &&
+ getConnectionStatusComponent()}
+
+
+
+
+ Real-time Connection Type
+
+ Choose how to receive real-time updates
+
+
+
+
+
+ {getStatusDescription()}
+
+ {realtimeType !== REALTIME_TYPES.DISABLED &&
+ isPlusAccount(userProfile) && (
+
+
+ Status:
+
+
+ {context.getConnectionStatus()}
+
+ {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 chores.
+
+ )}
+
+ {realtimeType !== REALTIME_TYPES.DISABLED &&
+ isPlusAccount(userProfile) && (
+
+
+ Connection Types:
+
+
+ • WebSocket: Traditional bi-directional real-time
+ connection
+
+
+ • SSE: Server-Sent Events - lighter weight,
+ one-way updates from server
+
+
+ )}
+
+ )
+}
+
+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/WebSocketSettings.jsx b/src/components/WebSocketSettings.jsx
index c950863..2c37cfa 100644
--- a/src/components/WebSocketSettings.jsx
+++ b/src/components/WebSocketSettings.jsx
@@ -91,7 +91,7 @@ const WebSocketSettings = () => {
{
const contexts = [
ThemeContext,
QueryContext,
+ SSEProvider,
WebSocketProvider,
RouterContext,
]
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/hooks/useSSE.js b/src/hooks/useSSE.js
new file mode 100644
index 0000000..455f482
--- /dev/null
+++ b/src/hooks/useSSE.js
@@ -0,0 +1,455 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { EventSourcePolyfill } from 'event-source-polyfill'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+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 { data: userProfile } = useUserProfile()
+
+ const getSSEUrl = useCallback(() => {
+ if (!userProfile?.circleID) {
+ console.log(
+ 'SSE: User not part of any circle - real-time features unavailable',
+ )
+ return null
+ }
+
+ 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
+ const sseUrl = `${apiUrl}/realtime/sse?circleId=${userProfile.circleID}`
+
+ console.log('SSE: Generated URL:', sseUrl)
+ return { url: sseUrl, token }
+ }, [userProfile])
+
+ const handleSSEMessage = useCallback(
+ event => {
+ try {
+ const eventData = JSON.parse(event.data)
+ setLastEvent(eventData)
+
+ console.log('SSE event received:', eventData.type, 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(() => {
+ console.log('SSE connect called')
+ console.log('SSE current state:', eventSourceRef.current?.readyState)
+
+ // Circuit breaker: prevent infinite reconnection loops
+ if (isCircuitBreakerOpen) {
+ console.warn(
+ '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
+
+ // Option 1: Use EventSource polyfill with Authorization header (Recommended)
+ // This is the most secure and standard way to authenticate SSE connections
+ 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])
+
+ 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,
+ userProfile: userProfile?.circleID,
+ isTokenValid: isTokenValid(),
+ isPlusAccount: isPlusAccount(userProfile),
+ })
+ localStorage.setItem('sse_enabled', enabled.toString())
+ if (enabled && userProfile?.circleID && isTokenValid()) {
+ console.log('SSE toggleSSEEnabled: Calling connect()')
+ connect()
+ } else {
+ console.log('SSE toggleSSEEnabled: Calling disconnect()')
+ disconnect()
+ }
+ },
+ [connect, disconnect, userProfile],
+ )
+
+ const isSSEEnabled = useCallback(() => {
+ return localStorage.getItem('sse_enabled') === 'true'
+ }, [])
+
+ // Auto-connect when user profile is available and token is valid
+ useEffect(() => {
+ console.log('SSE auto-connect effect triggered')
+ console.log('UserProfile:', userProfile)
+ console.log('circleID:', userProfile?.circleID)
+ console.log('Token valid:', isTokenValid())
+ console.log('Is Plus account:', isPlusAccount(userProfile))
+
+ // Check if SSE is enabled in settings
+ const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
+ console.log('SSE enabled in settings:', isSSEEnabledSetting)
+
+ if (
+ userProfile?.circleID &&
+ isTokenValid() &&
+ isSSEEnabledSetting &&
+ isPlusAccount(userProfile)
+ ) {
+ console.log('SSE: Conditions met, attempting to connect')
+ connect()
+ } else {
+ console.log('SSE: Conditions not met, disconnecting')
+ if (!isPlusAccount(userProfile)) {
+ console.log('SSE: Not a Plus account - feature unavailable')
+ }
+ disconnect()
+ }
+
+ // Cleanup on unmount
+ return () => {
+ disconnect()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userProfile?.circleID, userProfile?.expiration]) // Only depend on essential userProfile fields
+
+ // 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 (
+ userProfile?.circleID &&
+ isTokenValid() &&
+ isSSEEnabledSetting &&
+ isPlusAccount(userProfile) &&
+ connectionState !== SSE_STATES.OPEN
+ ) {
+ connect()
+ }
+ }
+ }
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userProfile?.circleID, userProfile?.expiration, connectionState])
+
+ 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..a2399e3
--- /dev/null
+++ b/src/hooks/useSSEContext.js
@@ -0,0 +1,7 @@
+import { useContext } from 'react'
+import { SSEContext } from '../contexts/SSEContext'
+
+export const useSSEContext = () => {
+ console.log('=== useSSEContext called ===')
+ return useContext(SSEContext)
+}
diff --git a/src/hooks/useWebSocket.js b/src/hooks/useWebSocket.js
index aa02613..7644401 100644
--- a/src/hooks/useWebSocket.js
+++ b/src/hooks/useWebSocket.js
@@ -42,25 +42,21 @@ export const useWebSocket = () => {
return null
}
- // Get the API URL from apiManager and convert to WebSocket URL
- const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1"
+ const apiUrl = apiManager.getApiURL()
// Convert HTTP/HTTPS to WebSocket protocol and remove /api/v1 suffix
- let wsUrl = apiUrl.replace(/\/api\/v1$/, '') // 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 {
- // If no protocol specified, use the current page's protocol
const isHttps = window.location.protocol === 'https:'
wsUrl = `${isHttps ? 'wss:' : 'ws:'}//${wsUrl}`
}
- // Add the WebSocket endpoint path
wsUrl = `${wsUrl}/api/v1/realtime/ws?token=${token}&circleId=${userProfile.circleID}`
- console.log('WebSocket: Generated URL:', wsUrl)
return wsUrl
}, [userProfile])
@@ -70,16 +66,14 @@ export const useWebSocket = () => {
const eventData = JSON.parse(event.data)
setLastEvent(eventData)
- console.log('WebSocket event received:', eventData.type, 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':
- queryClient.invalidateQueries(['choresHistory', 7])
case 'chore.skipped':
- queryClient.invalidateQueries(['choresHistory', 7])
case 'chore.deleted':
// Invalidate chores queries to refetch data
queryClient.invalidateQueries(['chores'])
@@ -92,6 +86,11 @@ export const useWebSocket = () => {
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':
@@ -110,7 +109,7 @@ export const useWebSocket = () => {
case 'heartbeat':
// Heartbeat events don't need cache invalidation
- console.debug('Heartbeat received')
+ console.debug('Heartbeat!')
break
case 'connection.established':
@@ -136,10 +135,7 @@ export const useWebSocket = () => {
const createWebSocketConnection = useCallback(
wsUrl => {
- const token = localStorage.getItem('ca_token')
-
try {
- console.log('Connecting to WebSocket:', wsUrl)
setConnectionState(WEBSOCKET_STATES.CONNECTING)
isManuallyClosedRef.current = false
@@ -147,7 +143,6 @@ export const useWebSocket = () => {
wsRef.current = new WebSocket(wsUrl)
wsRef.current.onopen = () => {
- console.log('WebSocket connection opened')
setConnectionState(WEBSOCKET_STATES.OPEN)
setError(null)
reconnectAttemptsRef.current = 0
@@ -218,9 +213,6 @@ export const useWebSocket = () => {
}, [scheduleReconnect])
const connect = useCallback(() => {
- console.log('WebSocket connect called')
- console.log('WebSocket current state:', wsRef.current?.readyState)
-
if (wsRef.current?.readyState === WEBSOCKET_STATES.OPEN) {
console.log('WebSocket: Already connected')
return // Already connected
@@ -273,12 +265,6 @@ export const useWebSocket = () => {
// Auto-connect when user profile is available and token is valid
useEffect(() => {
- console.log('WebSocket auto-connect effect triggered')
- console.log('UserProfile:', userProfile)
- console.log('circleID:', userProfile?.circleID)
- console.log('Token valid:', isTokenValid())
- console.log('Is Plus account:', isPlusAccount(userProfile))
-
// Check if WebSocket is enabled in settings
const isWebSocketEnabledSetting =
localStorage.getItem('websocket_enabled') !== 'false'
diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx
index d1b7f1d..058e11c 100644
--- a/src/views/Chores/ChoreCard.jsx
+++ b/src/views/Chores/ChoreCard.jsx
@@ -405,12 +405,20 @@ const ChoreCard = ({
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.2s ease-in-out',
+ cursor: isMultiSelectMode ? 'pointer' : 'default',
'&:hover': {
boxShadow: 'md',
- borderColor: 'primary.300',
+ 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 */}
@@ -420,13 +428,12 @@ const ChoreCard = ({
onChange={onSelectionToggle}
sx={{
position: 'absolute',
- top: 12,
+ top: '50%',
left: 12,
+ transform: 'translateY(-50%)',
zIndex: 2,
bgcolor: 'background.surface',
borderRadius: 'md',
- boxShadow: 'sm',
- border: '2px solid',
borderColor: 'divider',
'&:hover': {
bgcolor: 'background.level1',
@@ -448,8 +455,13 @@ const ChoreCard = ({
{
- 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/MyChores.jsx b/src/views/Chores/MyChores.jsx
index 4a9985e..96a0fd5 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -1,5 +1,6 @@
import {
Add,
+ Archive,
Bolt,
CancelRounded,
CheckBox,
@@ -40,7 +41,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
-import { GetArchivedChores } from '../../utils/Fetcher'
+import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
@@ -637,7 +638,63 @@ const MyChores = () => {
},
})
}
-
+ 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
@@ -1056,17 +1113,22 @@ const MyChores = () => {
{isMultiSelectMode && (
{
},
}}
>
+ {/* Primary Actions - Safe operations */}
+
}
@@ -1191,19 +1256,53 @@ const MyChores = () => {
>
Skip
+
+ {/* Visual separator for destructive actions */}
+
+
+ {/* Secondary Actions - Less destructive */}
}
+ disabled={selectedChores.size === 0}
+ sx={{
+ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ borderStyle: 'dashed',
+ }}
+ >
+ Archive
+
+
+ {/* Most destructive action - visually distinct */}
+ }
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
+ borderWidth: '2px',
+ '&:hover': {
+ borderWidth: '2px',
+ backgroundColor: 'danger.softBg',
+ },
}}
>
Delete
+
{/*
{
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))
+ }
})
}, [])
@@ -85,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 = () => {
@@ -317,7 +321,7 @@ const NotificationSetting = () => {
{
event.preventDefault()
if (chatID !== 0) {
diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx
index 5af77e4..395f76a 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 {
@@ -35,7 +35,6 @@ import {
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
-import WebSocketSettings from '../../components/WebSocketSettings'
import APITokenSettings from './APITokenSettings'
import MFASettings from './MFASettings'
import NotificationSetting from './NotificationSetting'
@@ -279,7 +278,7 @@ const Settings = () => {
},
].map((option, index) => (
))}
@@ -496,7 +495,8 @@ const Settings = () => {
)}
{/* WebSocket Settings */}
-
+ {/* */}
+