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 */} + + + {/* Visual separator for destructive actions */} + + + {/* Secondary Actions - Less destructive */} + + {/* Most destructive action - visually distinct */} + + {/* { 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 */} - + {/* */} +