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..c950863
--- /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..5b2ec02 100644
--- a/src/contexts/Contexts.jsx
+++ b/src/contexts/Contexts.jsx
@@ -1,9 +1,15 @@
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import ThemeContext from './ThemeContext'
+import WebSocketProvider from './WebSocketContext'
const Contexts = () => {
- const contexts = [ThemeContext, QueryContext, RouterContext]
+ const contexts = [
+ ThemeContext,
+ QueryContext,
+ WebSocketProvider,
+ RouterContext,
+ ]
return contexts.reduceRight((acc, Context) => {
return {acc}
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/useWebSocket.js b/src/hooks/useWebSocket.js
new file mode 100644
index 0000000..aa02613
--- /dev/null
+++ b/src/hooks/useWebSocket.js
@@ -0,0 +1,343 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useUserProfile } from '../queries/UserQueries'
+import { isPlusAccount } from '../utils/Helpers'
+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 { data: userProfile } = useUserProfile()
+
+ const getWebSocketUrl = useCallback(() => {
+ if (!userProfile?.circleID) {
+ console.log(
+ 'WebSocket: User not part of any circle - real-time features unavailable',
+ )
+ return null
+ }
+
+ const token = localStorage.getItem('ca_token')
+ if (!token || !isTokenValid()) {
+ console.log('WebSocket: No valid authentication token')
+ return null
+ }
+
+ // Get the API URL from apiManager and convert to WebSocket URL
+ const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1"
+
+ // Convert HTTP/HTTPS to WebSocket protocol and remove /api/v1 suffix
+ let wsUrl = apiUrl.replace(/\/api\/v1$/, '') // Remove /api/v1 suffix
+ 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])
+
+ const handleWebSocketMessage = useCallback(
+ event => {
+ try {
+ const eventData = JSON.parse(event.data)
+ setLastEvent(eventData)
+
+ console.log('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'])
+
+ // 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('Heartbeat received')
+ 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 => {
+ const token = localStorage.getItem('ca_token')
+
+ try {
+ console.log('Connecting to WebSocket:', wsUrl)
+ setConnectionState(WEBSOCKET_STATES.CONNECTING)
+ isManuallyClosedRef.current = false
+
+ // Use query parameter authentication (token already included in URL)
+ wsRef.current = new WebSocket(wsUrl)
+
+ wsRef.current.onopen = () => {
+ console.log('WebSocket connection opened')
+ 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(() => {
+ 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
+ }
+
+ 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 && userProfile?.circleID && isTokenValid()) {
+ connect()
+ } else {
+ disconnect()
+ }
+ },
+ [connect, disconnect, userProfile],
+ )
+
+ const isWebSocketEnabled = useCallback(() => {
+ return localStorage.getItem('websocket_enabled') !== 'false'
+ }, [])
+
+ // 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'
+ console.log('WebSocket enabled in settings:', isWebSocketEnabledSetting)
+
+ if (
+ userProfile?.circleID &&
+ isTokenValid() &&
+ isWebSocketEnabledSetting &&
+ isPlusAccount(userProfile)
+ ) {
+ console.log('WebSocket: Conditions met, attempting to connect')
+ connect()
+ } else {
+ console.log('WebSocket: Conditions not met, disconnecting')
+ if (!isPlusAccount(userProfile)) {
+ console.log('WebSocket: Not a Plus account - feature unavailable')
+ }
+ disconnect()
+ }
+
+ // Cleanup on unmount
+ return () => {
+ disconnect()
+ }
+ }, [userProfile, connect, disconnect])
+
+ // 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/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx
index baa22d2..5af77e4 100644
--- a/src/views/Settings/Settings.jsx
+++ b/src/views/Settings/Settings.jsx
@@ -35,6 +35,7 @@ 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'
@@ -493,6 +494,9 @@ const Settings = () => {
)}
>
)}
+
+ {/* WebSocket Settings */}
+