From 290b0e218b25ee81d60ba87f60b6657e2868edd0 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 09:35:45 -0800 Subject: [PATCH] Add online status check service and hook for device monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a comprehensive online status monitoring system: - OnlineStatusService: Centralized service for checking device/sensor online status with caching, configurable thresholds, and batch operations - useOnlineStatus hook: React hook for easy component integration with auto-polling, loading states, and app state handling - Support for status levels: online (<5 min), warning (5-60 min), offline (>60 min) - Utility functions for status colors, labels, and formatting Includes 49 passing tests covering service and hook functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- hooks/__tests__/useOnlineStatus.test.tsx | 432 +++++++++++++++++ hooks/useOnlineStatus.ts | 433 +++++++++++++++++ .../__tests__/onlineStatusService.test.ts | 434 ++++++++++++++++++ services/onlineStatusService.ts | 369 +++++++++++++++ 4 files changed, 1668 insertions(+) create mode 100644 hooks/__tests__/useOnlineStatus.test.tsx create mode 100644 hooks/useOnlineStatus.ts create mode 100644 services/__tests__/onlineStatusService.test.ts create mode 100644 services/onlineStatusService.ts diff --git a/hooks/__tests__/useOnlineStatus.test.tsx b/hooks/__tests__/useOnlineStatus.test.tsx new file mode 100644 index 0000000..3f0429c --- /dev/null +++ b/hooks/__tests__/useOnlineStatus.test.tsx @@ -0,0 +1,432 @@ +/** + * Tests for useOnlineStatus hook + */ + +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { AppState, AppStateStatus } from 'react-native'; +import { useOnlineStatus, useMultipleOnlineStatus } from '../useOnlineStatus'; +import { onlineStatusService, BeneficiaryOnlineStatus } from '@/services/onlineStatusService'; + +// Mock the onlineStatusService +jest.mock('@/services/onlineStatusService', () => ({ + onlineStatusService: { + checkBeneficiaryStatus: jest.fn(), + checkMultipleBeneficiaries: jest.fn(), + }, +})); + +// Mock AppState +jest.mock('react-native', () => { + const listeners: ((state: AppStateStatus) => void)[] = []; + return { + AppState: { + addEventListener: jest.fn((event: string, callback: (state: AppStateStatus) => void) => { + listeners.push(callback); + return { + remove: jest.fn(() => { + const index = listeners.indexOf(callback); + if (index > -1) listeners.splice(index, 1); + }), + }; + }), + // Helper to trigger state change in tests + __triggerStateChange: (state: AppStateStatus) => { + listeners.forEach(cb => cb(state)); + }, + }, + }; +}); + +const mockService = onlineStatusService as jest.Mocked; + +// Helper to create mock status data +const createMockStatus = (overrides: Partial = {}): BeneficiaryOnlineStatus => ({ + beneficiaryId: 'ben-123', + totalDevices: 2, + onlineCount: 1, + warningCount: 0, + offlineCount: 1, + overallStatus: 'warning', + devices: [ + { + deviceId: 'dev-1', + deviceName: 'Device 1', + status: 'online', + lastSeen: new Date(), + lastSeenMinutesAgo: 2, + lastSeenFormatted: '2 min ago', + }, + { + deviceId: 'dev-2', + deviceName: 'Device 2', + status: 'offline', + lastSeen: new Date(Date.now() - 2 * 60 * 60 * 1000), + lastSeenMinutesAgo: 120, + lastSeenFormatted: '2 hours ago', + }, + ], + lastChecked: new Date(), + ...overrides, +}); + +describe('useOnlineStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('initial loading', () => { + it('should start in loading state', () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + const { result } = renderHook(() => useOnlineStatus('ben-123')); + + expect(result.current.isLoading).toBe(true); + }); + + it('should fetch data on mount by default', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + renderHook(() => useOnlineStatus('ben-123')); + + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledWith('ben-123', true); + }); + }); + + it('should not fetch on mount when fetchOnMount is false', async () => { + const { result } = renderHook(() => + useOnlineStatus('ben-123', { fetchOnMount: false }) + ); + + expect(mockService.checkBeneficiaryStatus).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('should handle null beneficiaryId', async () => { + const { result } = renderHook(() => useOnlineStatus(null)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.status).toBeNull(); + expect(mockService.checkBeneficiaryStatus).not.toHaveBeenCalled(); + }); + }); + + describe('successful data fetch', () => { + it('should populate status data', async () => { + const mockStatus = createMockStatus(); + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: mockStatus, + }); + + const { result } = renderHook(() => useOnlineStatus('ben-123')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.status).toEqual(mockStatus); + expect(result.current.overallStatus).toBe('warning'); + expect(result.current.devices).toHaveLength(2); + expect(result.current.onlineCount).toBe(1); + expect(result.current.offlineCount).toBe(1); + expect(result.current.totalDevices).toBe(2); + }); + + it('should provide device lookup functions', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + const { result } = renderHook(() => useOnlineStatus('ben-123')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // isDeviceOnline + expect(result.current.isDeviceOnline('dev-1')).toBe(true); + expect(result.current.isDeviceOnline('dev-2')).toBe(false); + expect(result.current.isDeviceOnline('non-existent')).toBe(false); + + // getDeviceStatus + const device1 = result.current.getDeviceStatus('dev-1'); + expect(device1?.status).toBe('online'); + expect(result.current.getDeviceStatus('non-existent')).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle API errors', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: false, + error: 'Network error', + }); + + const { result } = renderHook(() => useOnlineStatus('ben-123')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.status).toBeNull(); + }); + + it('should handle exceptions', async () => { + mockService.checkBeneficiaryStatus.mockRejectedValue(new Error('Unexpected error')); + + const { result } = renderHook(() => useOnlineStatus('ben-123')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Unexpected error'); + }); + }); + + describe('refresh', () => { + it('should allow manual refresh', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + const { result } = renderHook(() => useOnlineStatus('ben-123')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.refresh(); + }); + + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(2); + }); + + it('should set isRefreshing during refresh', async () => { + let resolvePromise: (value: any) => void; + mockService.checkBeneficiaryStatus.mockImplementation( + () => + new Promise(resolve => { + resolvePromise = resolve; + }) + ); + + const { result } = renderHook(() => + useOnlineStatus('ben-123', { fetchOnMount: false }) + ); + + // Start refresh + let refreshPromise: Promise; + act(() => { + refreshPromise = result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.isRefreshing).toBe(true); + }); + + // Resolve the API call + act(() => { + resolvePromise!({ ok: true, data: createMockStatus() }); + }); + + await act(async () => { + await refreshPromise; + }); + + expect(result.current.isRefreshing).toBe(false); + }); + }); + + describe('polling', () => { + it('should poll when enabled', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + renderHook(() => + useOnlineStatus('ben-123', { + enablePolling: true, + pollingInterval: 5000, + }) + ); + + // Initial fetch + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(1); + }); + + // Advance timer by polling interval + act(() => { + jest.advanceTimersByTime(5000); + }); + + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(2); + }); + + // Advance again + act(() => { + jest.advanceTimersByTime(5000); + }); + + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(3); + }); + }); + + it('should stop polling on unmount', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + const { unmount } = renderHook(() => + useOnlineStatus('ben-123', { + enablePolling: true, + pollingInterval: 5000, + }) + ); + + // Initial fetch + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(1); + }); + + // Unmount + unmount(); + + // Advance timer - should not trigger new fetch + act(() => { + jest.advanceTimersByTime(10000); + }); + + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledTimes(1); + }); + }); + + describe('beneficiaryId changes', () => { + it('should refetch when beneficiaryId changes', async () => { + mockService.checkBeneficiaryStatus.mockResolvedValue({ + ok: true, + data: createMockStatus(), + }); + + const { rerender } = renderHook( + ({ id }: { id: string }) => useOnlineStatus(id), + { initialProps: { id: 'ben-123' } } + ); + + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledWith('ben-123', true); + }); + + // Change beneficiaryId + rerender({ id: 'ben-456' }); + + await waitFor(() => { + expect(mockService.checkBeneficiaryStatus).toHaveBeenCalledWith('ben-456', true); + }); + }); + }); +}); + +describe('useMultipleOnlineStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should fetch statuses for multiple beneficiaries', async () => { + const results = new Map([ + ['ben-1', { ok: true, data: createMockStatus({ beneficiaryId: 'ben-1' }) }], + ['ben-2', { ok: true, data: createMockStatus({ beneficiaryId: 'ben-2' }) }], + ]); + + mockService.checkMultipleBeneficiaries.mockResolvedValue(results); + + const { result } = renderHook(() => + useMultipleOnlineStatus(['ben-1', 'ben-2']) + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.statuses.size).toBe(2); + expect(result.current.getStatus('ben-1')?.beneficiaryId).toBe('ben-1'); + expect(result.current.getStatus('ben-2')?.beneficiaryId).toBe('ben-2'); + }); + + it('should handle errors for individual beneficiaries', async () => { + const results = new Map([ + ['ben-1', { ok: true, data: createMockStatus({ beneficiaryId: 'ben-1' }) }], + ['ben-2', { ok: false, error: 'Failed to fetch' }], + ]); + + mockService.checkMultipleBeneficiaries.mockResolvedValue(results); + + const { result } = renderHook(() => + useMultipleOnlineStatus(['ben-1', 'ben-2']) + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.statuses.size).toBe(1); + expect(result.current.errors.get('ben-2')).toBe('Failed to fetch'); + }); + + it('should allow manual refresh', async () => { + mockService.checkMultipleBeneficiaries.mockResolvedValue(new Map()); + + const { result } = renderHook(() => + useMultipleOnlineStatus(['ben-1', 'ben-2']) + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockService.checkMultipleBeneficiaries).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.refresh(); + }); + + expect(mockService.checkMultipleBeneficiaries).toHaveBeenCalledTimes(2); + }); + + it('should handle empty beneficiary array', async () => { + const { result } = renderHook(() => useMultipleOnlineStatus([])); + + expect(result.current.isLoading).toBe(false); + expect(result.current.statuses.size).toBe(0); + expect(mockService.checkMultipleBeneficiaries).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/useOnlineStatus.ts b/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..83922d0 --- /dev/null +++ b/hooks/useOnlineStatus.ts @@ -0,0 +1,433 @@ +/** + * useOnlineStatus Hook + * + * React hook for monitoring device/sensor online status. + * Provides automatic polling, loading states, and easy status access. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import { + onlineStatusService, + BeneficiaryOnlineStatus, + OnlineStatus, + DeviceStatusInfo, +} from '@/services/onlineStatusService'; + +export interface UseOnlineStatusOptions { + /** Enable automatic polling (default: false) */ + enablePolling?: boolean; + /** Polling interval in milliseconds (default: 60000 = 1 minute) */ + pollingInterval?: number; + /** Whether to fetch immediately on mount (default: true) */ + fetchOnMount?: boolean; + /** Pause polling when app is in background (default: true) */ + pauseInBackground?: boolean; +} + +export interface UseOnlineStatusResult { + /** Loading state for initial fetch */ + isLoading: boolean; + /** Refreshing state for subsequent fetches */ + isRefreshing: boolean; + /** Error message if fetch failed */ + error: string | null; + /** Full status data */ + status: BeneficiaryOnlineStatus | null; + /** Overall status (online/warning/offline) */ + overallStatus: OnlineStatus | null; + /** Array of device status info */ + devices: DeviceStatusInfo[]; + /** Count of online devices */ + onlineCount: number; + /** Count of devices with warning */ + warningCount: number; + /** Count of offline devices */ + offlineCount: number; + /** Total number of devices */ + totalDevices: number; + /** Manually trigger a refresh */ + refresh: () => Promise; + /** Check if a specific device is online */ + isDeviceOnline: (deviceId: string) => boolean; + /** Get status for a specific device */ + getDeviceStatus: (deviceId: string) => DeviceStatusInfo | undefined; +} + +const DEFAULT_OPTIONS: UseOnlineStatusOptions = { + enablePolling: false, + pollingInterval: 60000, // 1 minute + fetchOnMount: true, + pauseInBackground: true, +}; + +/** + * Hook for monitoring online status of a beneficiary's devices + * + * @param beneficiaryId - The beneficiary ID to monitor + * @param options - Configuration options + * @returns Status data and control functions + * + * @example + * ```tsx + * function SensorList({ beneficiaryId }) { + * const { + * isLoading, + * devices, + * overallStatus, + * refresh, + * } = useOnlineStatus(beneficiaryId, { enablePolling: true }); + * + * if (isLoading) return ; + * + * return ( + * + * Status: {overallStatus} + * {devices.map(device => ( + * + * ))} + * + * ); + * } + * ``` + */ +export function useOnlineStatus( + beneficiaryId: string | null | undefined, + options: UseOnlineStatusOptions = {} +): UseOnlineStatusResult { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + + // Refs for cleanup and tracking + const pollingIntervalRef = useRef | null>(null); + const isBackgroundRef = useRef(false); + const mountedRef = useRef(true); + + /** + * Fetch status from service + */ + const fetchStatus = useCallback( + async (isInitial = false) => { + if (!beneficiaryId) { + setIsLoading(false); + setStatus(null); + return; + } + + try { + if (isInitial) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + setError(null); + + const result = await onlineStatusService.checkBeneficiaryStatus( + beneficiaryId, + true // Force refresh + ); + + // Check if component is still mounted + if (!mountedRef.current) return; + + if (result.ok && result.data) { + setStatus(result.data); + setError(null); + } else { + setError(result.error || 'Failed to fetch status'); + } + } catch (err) { + if (!mountedRef.current) return; + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (mountedRef.current) { + setIsLoading(false); + setIsRefreshing(false); + } + } + }, + [beneficiaryId] + ); + + /** + * Manual refresh function + */ + const refresh = useCallback(async () => { + await fetchStatus(false); + }, [fetchStatus]); + + /** + * Check if specific device is online + */ + const isDeviceOnline = useCallback( + (deviceId: string): boolean => { + if (!status) return false; + const device = status.devices.find(d => d.deviceId === deviceId); + return device?.status === 'online'; + }, + [status] + ); + + /** + * Get status for specific device + */ + const getDeviceStatus = useCallback( + (deviceId: string): DeviceStatusInfo | undefined => { + if (!status) return undefined; + return status.devices.find(d => d.deviceId === deviceId); + }, + [status] + ); + + // Initial fetch on mount + useEffect(() => { + mountedRef.current = true; + + if (opts.fetchOnMount && beneficiaryId) { + fetchStatus(true); + } else { + setIsLoading(false); + } + + return () => { + mountedRef.current = false; + }; + }, [beneficiaryId, opts.fetchOnMount, fetchStatus]); + + // Setup polling + useEffect(() => { + if (!opts.enablePolling || !beneficiaryId) { + return; + } + + // Clear any existing interval + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + + // Start polling + pollingIntervalRef.current = setInterval(() => { + // Skip if in background and pauseInBackground is enabled + if (opts.pauseInBackground && isBackgroundRef.current) { + return; + } + + fetchStatus(false); + }, opts.pollingInterval); + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [beneficiaryId, opts.enablePolling, opts.pollingInterval, opts.pauseInBackground, fetchStatus]); + + // Handle app state changes (background/foreground) + useEffect(() => { + if (!opts.pauseInBackground) { + return; + } + + const handleAppStateChange = (nextState: AppStateStatus) => { + const wasBackground = isBackgroundRef.current; + isBackgroundRef.current = nextState !== 'active'; + + // Refresh when coming back to foreground + if (wasBackground && nextState === 'active' && beneficiaryId) { + fetchStatus(false); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription.remove(); + }; + }, [opts.pauseInBackground, beneficiaryId, fetchStatus]); + + // Computed values + const devices = status?.devices || []; + const onlineCount = status?.onlineCount || 0; + const warningCount = status?.warningCount || 0; + const offlineCount = status?.offlineCount || 0; + const totalDevices = status?.totalDevices || 0; + const overallStatus = status?.overallStatus || null; + + return { + isLoading, + isRefreshing, + error, + status, + overallStatus, + devices, + onlineCount, + warningCount, + offlineCount, + totalDevices, + refresh, + isDeviceOnline, + getDeviceStatus, + }; +} + +/** + * Hook for checking status of multiple beneficiaries + * + * @param beneficiaryIds - Array of beneficiary IDs to monitor + * @param options - Configuration options + * @returns Map of beneficiary statuses and control functions + */ +export function useMultipleOnlineStatus( + beneficiaryIds: string[], + options: UseOnlineStatusOptions = {} +): { + isLoading: boolean; + statuses: Map; + errors: Map; + refresh: () => Promise; + getStatus: (beneficiaryId: string) => BeneficiaryOnlineStatus | undefined; +} { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + const [isLoading, setIsLoading] = useState(true); + const [statuses, setStatuses] = useState>(new Map()); + const [errors, setErrors] = useState>(new Map()); + + const mountedRef = useRef(true); + const pollingIntervalRef = useRef | null>(null); + const isBackgroundRef = useRef(false); + + const fetchStatuses = useCallback( + async (isInitial = false) => { + if (beneficiaryIds.length === 0) { + setIsLoading(false); + return; + } + + try { + if (isInitial) { + setIsLoading(true); + } + + const results = await onlineStatusService.checkMultipleBeneficiaries( + beneficiaryIds, + true + ); + + if (!mountedRef.current) return; + + const newStatuses = new Map(); + const newErrors = new Map(); + + results.forEach((result, id) => { + if (result.ok && result.data) { + newStatuses.set(id, result.data); + } else if (result.error) { + newErrors.set(id, result.error); + } + }); + + setStatuses(newStatuses); + setErrors(newErrors); + } catch (err) { + if (!mountedRef.current) return; + // Set error for all beneficiaries + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + const newErrors = new Map(); + beneficiaryIds.forEach(id => newErrors.set(id, errorMsg)); + setErrors(newErrors); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, + [beneficiaryIds] + ); + + const refresh = useCallback(async () => { + await fetchStatuses(false); + }, [fetchStatuses]); + + const getStatus = useCallback( + (beneficiaryId: string) => statuses.get(beneficiaryId), + [statuses] + ); + + // Initial fetch + useEffect(() => { + mountedRef.current = true; + + if (opts.fetchOnMount) { + fetchStatuses(true); + } else { + setIsLoading(false); + } + + return () => { + mountedRef.current = false; + }; + }, [JSON.stringify(beneficiaryIds), opts.fetchOnMount]); + + // Polling + useEffect(() => { + if (!opts.enablePolling || beneficiaryIds.length === 0) { + return; + } + + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + + pollingIntervalRef.current = setInterval(() => { + if (opts.pauseInBackground && isBackgroundRef.current) { + return; + } + fetchStatuses(false); + }, opts.pollingInterval); + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [beneficiaryIds, opts.enablePolling, opts.pollingInterval, opts.pauseInBackground]); + + // App state handling + useEffect(() => { + if (!opts.pauseInBackground) { + return; + } + + const handleAppStateChange = (nextState: AppStateStatus) => { + const wasBackground = isBackgroundRef.current; + isBackgroundRef.current = nextState !== 'active'; + + if (wasBackground && nextState === 'active' && beneficiaryIds.length > 0) { + fetchStatuses(false); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription.remove(); + }; + }, [opts.pauseInBackground, beneficiaryIds.length]); + + return { + isLoading, + statuses, + errors, + refresh, + getStatus, + }; +} + +export type { OnlineStatus, DeviceStatusInfo, BeneficiaryOnlineStatus }; diff --git a/services/__tests__/onlineStatusService.test.ts b/services/__tests__/onlineStatusService.test.ts new file mode 100644 index 0000000..9483db2 --- /dev/null +++ b/services/__tests__/onlineStatusService.test.ts @@ -0,0 +1,434 @@ +/** + * Tests for OnlineStatusService + */ + +import { + OnlineStatusService, + calculateOnlineStatus, + formatLastSeen, + getStatusColor, + getStatusLabel, + calculateOverallStatus, + DEFAULT_THRESHOLDS, + DeviceStatusInfo, +} from '../onlineStatusService'; +import { api } from '../api'; + +// Mock the api module +jest.mock('../api', () => ({ + api: { + getDevicesForBeneficiary: jest.fn(), + }, +})); + +const mockApi = api as jest.Mocked; + +describe('OnlineStatusService', () => { + describe('calculateOnlineStatus', () => { + it('should return online for recent timestamps (< 5 minutes)', () => { + const now = new Date(); + const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000); + + expect(calculateOnlineStatus(twoMinutesAgo)).toBe('online'); + }); + + it('should return online for just now', () => { + const now = new Date(); + expect(calculateOnlineStatus(now)).toBe('online'); + }); + + it('should return warning for timestamps between 5 and 60 minutes', () => { + const now = new Date(); + const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000); + + expect(calculateOnlineStatus(thirtyMinutesAgo)).toBe('warning'); + }); + + it('should return offline for timestamps older than 60 minutes', () => { + const now = new Date(); + const twoHoursAgo = new Date(now.getTime() - 120 * 60 * 1000); + + expect(calculateOnlineStatus(twoHoursAgo)).toBe('offline'); + }); + + it('should respect custom thresholds', () => { + const now = new Date(); + const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000); + + // With default thresholds, 10 minutes should be warning + expect(calculateOnlineStatus(tenMinutesAgo, DEFAULT_THRESHOLDS)).toBe('warning'); + + // With custom thresholds, 10 minutes could be online + expect(calculateOnlineStatus(tenMinutesAgo, { onlineMaxMinutes: 15, warningMaxMinutes: 60 })).toBe('online'); + }); + }); + + describe('formatLastSeen', () => { + it('should return "Just now" for timestamps less than 1 minute ago', () => { + const now = new Date(); + expect(formatLastSeen(now)).toBe('Just now'); + + const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000); + expect(formatLastSeen(thirtySecondsAgo)).toBe('Just now'); + }); + + it('should return minutes for timestamps less than 1 hour ago', () => { + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + expect(formatLastSeen(fiveMinutesAgo)).toBe('5 min ago'); + + const fortyFiveMinutesAgo = new Date(now.getTime() - 45 * 60 * 1000); + expect(formatLastSeen(fortyFiveMinutesAgo)).toBe('45 min ago'); + }); + + it('should return hours for timestamps less than 24 hours ago', () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + expect(formatLastSeen(oneHourAgo)).toBe('1 hour ago'); + + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); + expect(formatLastSeen(threeHoursAgo)).toBe('3 hours ago'); + }); + + it('should return days for timestamps older than 24 hours', () => { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + expect(formatLastSeen(oneDayAgo)).toBe('1 day ago'); + + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + expect(formatLastSeen(threeDaysAgo)).toBe('3 days ago'); + }); + }); + + describe('getStatusColor', () => { + it('should return green for online', () => { + expect(getStatusColor('online')).toBe('#22c55e'); + }); + + it('should return amber for warning', () => { + expect(getStatusColor('warning')).toBe('#f59e0b'); + }); + + it('should return red for offline', () => { + expect(getStatusColor('offline')).toBe('#ef4444'); + }); + }); + + describe('getStatusLabel', () => { + it('should return proper labels', () => { + expect(getStatusLabel('online')).toBe('Online'); + expect(getStatusLabel('warning')).toBe('Warning'); + expect(getStatusLabel('offline')).toBe('Offline'); + }); + }); + + describe('calculateOverallStatus', () => { + const createDevice = (status: 'online' | 'warning' | 'offline'): DeviceStatusInfo => ({ + deviceId: `device-${Math.random()}`, + deviceName: 'Test Device', + status, + lastSeen: new Date(), + lastSeenMinutesAgo: 0, + lastSeenFormatted: 'Just now', + }); + + it('should return offline for empty array', () => { + expect(calculateOverallStatus([])).toBe('offline'); + }); + + it('should return online when all devices are online', () => { + const devices = [ + createDevice('online'), + createDevice('online'), + createDevice('online'), + ]; + expect(calculateOverallStatus(devices)).toBe('online'); + }); + + it('should return warning when at least one device has warning (but none offline)', () => { + const devices = [ + createDevice('online'), + createDevice('warning'), + createDevice('online'), + ]; + expect(calculateOverallStatus(devices)).toBe('warning'); + }); + + it('should return offline when at least one device is offline', () => { + const devices = [ + createDevice('online'), + createDevice('warning'), + createDevice('offline'), + ]; + expect(calculateOverallStatus(devices)).toBe('offline'); + }); + + it('should prioritize offline over warning', () => { + const devices = [ + createDevice('offline'), + createDevice('warning'), + ]; + expect(calculateOverallStatus(devices)).toBe('offline'); + }); + }); + + describe('OnlineStatusService class', () => { + let service: OnlineStatusService; + + beforeEach(() => { + service = new OnlineStatusService(); + jest.clearAllMocks(); + }); + + describe('setThresholds / getThresholds', () => { + it('should update thresholds', () => { + service.setThresholds({ onlineMaxMinutes: 10 }); + const thresholds = service.getThresholds(); + expect(thresholds.onlineMaxMinutes).toBe(10); + expect(thresholds.warningMaxMinutes).toBe(60); // Default + }); + }); + + describe('checkBeneficiaryStatus', () => { + it('should return error when API fails', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: false, + error: { message: 'Network error' }, + } as any); + + const result = await service.checkBeneficiaryStatus('ben-123'); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('should return empty status when no devices', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: [], + } as any); + + const result = await service.checkBeneficiaryStatus('ben-123'); + + expect(result.ok).toBe(true); + expect(result.data?.totalDevices).toBe(0); + expect(result.data?.devices).toHaveLength(0); + expect(result.data?.overallStatus).toBe('offline'); // No devices = offline + }); + + it('should correctly transform devices', async () => { + const now = new Date(); + const mockDevices = [ + { + deviceId: 'dev-1', + wellId: 123, + mac: 'AABBCCDDEEFF', + name: 'WP_123_ddeeff', + status: 'online', + lastSeen: now, + beneficiaryId: 'ben-123', + deploymentId: 456, + source: 'api', + }, + { + deviceId: 'dev-2', + wellId: 456, + mac: '112233445566', + name: 'WP_456_445566', + status: 'offline', + lastSeen: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago + beneficiaryId: 'ben-123', + deploymentId: 456, + source: 'api', + }, + ]; + + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: mockDevices, + } as any); + + const result = await service.checkBeneficiaryStatus('ben-123'); + + expect(result.ok).toBe(true); + expect(result.data?.totalDevices).toBe(2); + expect(result.data?.onlineCount).toBe(1); + expect(result.data?.offlineCount).toBe(1); + expect(result.data?.overallStatus).toBe('offline'); // One offline = overall offline + }); + + it('should use cached data when available and valid', async () => { + const mockDevices = [ + { + deviceId: 'dev-1', + name: 'WP_123_ddeeff', + status: 'online', + lastSeen: new Date(), + wellId: 123, + mac: 'AABBCCDDEEFF', + }, + ]; + + mockApi.getDevicesForBeneficiary.mockResolvedValue({ + ok: true, + data: mockDevices, + } as any); + + // First call - should hit API + await service.checkBeneficiaryStatus('ben-123'); + expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(1); + + // Second call - should use cache + await service.checkBeneficiaryStatus('ben-123'); + expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(1); + + // Force refresh - should hit API again + await service.checkBeneficiaryStatus('ben-123', true); + expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(2); + }); + }); + + describe('checkMultipleBeneficiaries', () => { + it('should check multiple beneficiaries', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValue({ + ok: true, + data: [], + } as any); + + const results = await service.checkMultipleBeneficiaries(['ben-1', 'ben-2', 'ben-3']); + + expect(results.size).toBe(3); + expect(results.get('ben-1')?.ok).toBe(true); + expect(results.get('ben-2')?.ok).toBe(true); + expect(results.get('ben-3')?.ok).toBe(true); + }); + }); + + describe('isDeviceOnline', () => { + it('should return true for online device', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: [ + { + deviceId: 'dev-1', + name: 'Test', + status: 'online', + lastSeen: new Date(), + }, + ], + } as any); + + const isOnline = await service.isDeviceOnline('ben-123', 'dev-1'); + expect(isOnline).toBe(true); + }); + + it('should return false for offline device', async () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: [ + { + deviceId: 'dev-1', + name: 'Test', + status: 'offline', + lastSeen: twoHoursAgo, + }, + ], + } as any); + + const isOnline = await service.isDeviceOnline('ben-123', 'dev-1'); + expect(isOnline).toBe(false); + }); + + it('should return false for non-existent device', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: [], + } as any); + + const isOnline = await service.isDeviceOnline('ben-123', 'non-existent'); + expect(isOnline).toBe(false); + }); + }); + + describe('getStatusSummary', () => { + it('should return correct summary', async () => { + const now = new Date(); + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: [ + { deviceId: 'd1', name: 'D1', status: 'online', lastSeen: now }, + { deviceId: 'd2', name: 'D2', status: 'online', lastSeen: now }, + { deviceId: 'd3', name: 'D3', status: 'warning', lastSeen: new Date(now.getTime() - 30 * 60 * 1000) }, + { deviceId: 'd4', name: 'D4', status: 'offline', lastSeen: new Date(now.getTime() - 2 * 60 * 60 * 1000) }, + ], + } as any); + + const summary = await service.getStatusSummary('ben-123'); + + expect(summary).not.toBeNull(); + expect(summary?.total).toBe(4); + expect(summary?.online).toBe(2); + expect(summary?.warning).toBe(1); + expect(summary?.offline).toBe(1); + }); + + it('should return null when API fails', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: false, + error: 'Failed', + } as any); + + const summary = await service.getStatusSummary('ben-123'); + expect(summary).toBeNull(); + }); + }); + + describe('clearCache', () => { + it('should clear all cached data', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValue({ + ok: true, + data: [], + } as any); + + // Populate cache + await service.checkBeneficiaryStatus('ben-1'); + await service.checkBeneficiaryStatus('ben-2'); + + expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(2); + + // Verify cache is being used + await service.checkBeneficiaryStatus('ben-1'); + expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(2); + + // Clear cache + service.clearCache(); + + // Now should hit API again + await service.checkBeneficiaryStatus('ben-1'); + expect(mockApi.getDevicesForBeneficiary).toHaveBeenCalledTimes(3); + }); + }); + + describe('getCachedStatus', () => { + it('should return cached status if valid', async () => { + mockApi.getDevicesForBeneficiary.mockResolvedValueOnce({ + ok: true, + data: [{ deviceId: 'd1', name: 'D1', status: 'online', lastSeen: new Date() }], + } as any); + + // Populate cache + await service.checkBeneficiaryStatus('ben-123'); + + const cached = service.getCachedStatus('ben-123'); + expect(cached).not.toBeUndefined(); + expect(cached?.beneficiaryId).toBe('ben-123'); + }); + + it('should return undefined for non-cached beneficiary', () => { + const cached = service.getCachedStatus('non-existent'); + expect(cached).toBeUndefined(); + }); + }); + }); +}); diff --git a/services/onlineStatusService.ts b/services/onlineStatusService.ts new file mode 100644 index 0000000..55eada8 --- /dev/null +++ b/services/onlineStatusService.ts @@ -0,0 +1,369 @@ +/** + * Online Status Service + * + * Provides functionality to check and monitor device/sensor online status. + * Supports individual status checks and batch monitoring with configurable thresholds. + */ + +import { api } from './api'; +import type { WPSensor } from '@/types'; + +/** + * Online status levels + * - online: Device is actively communicating (< 5 minutes since last seen) + * - warning: Device may have issues (5-60 minutes since last seen) + * - offline: Device is not communicating (> 60 minutes since last seen) + */ +export type OnlineStatus = 'online' | 'warning' | 'offline'; + +/** + * Thresholds for determining online status (in minutes) + */ +export interface StatusThresholds { + /** Maximum minutes for 'online' status (default: 5) */ + onlineMaxMinutes: number; + /** Maximum minutes for 'warning' status (default: 60) */ + warningMaxMinutes: number; +} + +export const DEFAULT_THRESHOLDS: StatusThresholds = { + onlineMaxMinutes: 5, + warningMaxMinutes: 60, +}; + +/** + * Device status information + */ +export interface DeviceStatusInfo { + deviceId: string; + deviceName: string; + status: OnlineStatus; + lastSeen: Date; + lastSeenMinutesAgo: number; + lastSeenFormatted: string; + wellId?: number; + mac?: string; +} + +/** + * Beneficiary online status summary + */ +export interface BeneficiaryOnlineStatus { + beneficiaryId: string; + totalDevices: number; + onlineCount: number; + warningCount: number; + offlineCount: number; + overallStatus: OnlineStatus; + devices: DeviceStatusInfo[]; + lastChecked: Date; +} + +/** + * Status check result + */ +export interface StatusCheckResult { + ok: boolean; + data?: BeneficiaryOnlineStatus; + error?: string; +} + +/** + * Calculate minutes since a given date + */ +function getMinutesAgo(date: Date): number { + const now = new Date(); + return Math.floor((now.getTime() - date.getTime()) / (1000 * 60)); +} + +/** + * Determine online status based on lastSeen timestamp + */ +export function calculateOnlineStatus( + lastSeen: Date, + thresholds: StatusThresholds = DEFAULT_THRESHOLDS +): OnlineStatus { + const minutesAgo = getMinutesAgo(lastSeen); + + if (minutesAgo < thresholds.onlineMaxMinutes) { + return 'online'; + } else if (minutesAgo < thresholds.warningMaxMinutes) { + return 'warning'; + } + return 'offline'; +} + +/** + * Format last seen time for display + */ +export function formatLastSeen(lastSeen: Date): string { + const minutesAgo = getMinutesAgo(lastSeen); + + if (minutesAgo < 1) { + return 'Just now'; + } else if (minutesAgo < 60) { + return `${minutesAgo} min ago`; + } else if (minutesAgo < 24 * 60) { + const hours = Math.floor(minutesAgo / 60); + return `${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(minutesAgo / (24 * 60)); + return `${days} day${days > 1 ? 's' : ''} ago`; + } +} + +/** + * Get status color for UI display + */ +export function getStatusColor(status: OnlineStatus): string { + switch (status) { + case 'online': + return '#22c55e'; // green + case 'warning': + return '#f59e0b'; // amber + case 'offline': + return '#ef4444'; // red + } +} + +/** + * Get status label for display + */ +export function getStatusLabel(status: OnlineStatus): string { + switch (status) { + case 'online': + return 'Online'; + case 'warning': + return 'Warning'; + case 'offline': + return 'Offline'; + } +} + +/** + * Calculate overall status for a group of devices + * Returns the "worst" status among all devices + */ +export function calculateOverallStatus(devices: DeviceStatusInfo[]): OnlineStatus { + if (devices.length === 0) { + return 'offline'; + } + + // If any device is offline, overall is offline + if (devices.some(d => d.status === 'offline')) { + return 'offline'; + } + + // If any device has warning, overall is warning + if (devices.some(d => d.status === 'warning')) { + return 'warning'; + } + + // All devices are online + return 'online'; +} + +/** + * Transform WPSensor to DeviceStatusInfo + */ +function sensorToStatusInfo(sensor: WPSensor): DeviceStatusInfo { + const lastSeen = sensor.lastSeen instanceof Date ? sensor.lastSeen : new Date(sensor.lastSeen); + const minutesAgo = getMinutesAgo(lastSeen); + + return { + deviceId: sensor.deviceId, + deviceName: sensor.name, + status: sensor.status === 'warning' ? 'warning' : sensor.status as OnlineStatus, + lastSeen: lastSeen, + lastSeenMinutesAgo: minutesAgo, + lastSeenFormatted: formatLastSeen(lastSeen), + wellId: sensor.wellId, + mac: sensor.mac, + }; +} + +/** + * Online Status Service + * Singleton service for checking device online status + */ +class OnlineStatusService { + private thresholds: StatusThresholds = DEFAULT_THRESHOLDS; + private cache = new Map(); + private cacheMaxAgeMs = 30000; // 30 seconds cache + + /** + * Set custom status thresholds + */ + setThresholds(thresholds: Partial): void { + this.thresholds = { ...this.thresholds, ...thresholds }; + } + + /** + * Get current thresholds + */ + getThresholds(): StatusThresholds { + return { ...this.thresholds }; + } + + /** + * Set cache max age + */ + setCacheMaxAge(maxAgeMs: number): void { + this.cacheMaxAgeMs = maxAgeMs; + } + + /** + * Clear all cached data + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Check if cached data is still valid + */ + private isCacheValid(cacheEntry: { timestamp: number }): boolean { + return Date.now() - cacheEntry.timestamp < this.cacheMaxAgeMs; + } + + /** + * Check online status for a single beneficiary's devices + * @param beneficiaryId - The beneficiary ID to check + * @param forceRefresh - Skip cache and fetch fresh data + * @returns Status check result + */ + async checkBeneficiaryStatus( + beneficiaryId: string, + forceRefresh = false + ): Promise { + // Check cache first (unless force refresh) + if (!forceRefresh) { + const cached = this.cache.get(beneficiaryId); + if (cached && this.isCacheValid(cached)) { + return { ok: true, data: cached.data }; + } + } + + try { + // Fetch devices from API + const response = await api.getDevicesForBeneficiary(beneficiaryId); + + if (!response.ok) { + return { + ok: false, + error: typeof response.error === 'string' ? response.error : response.error?.message || 'Failed to fetch devices', + }; + } + + const sensors = response.data || []; + + // Transform sensors to status info + const devices: DeviceStatusInfo[] = sensors.map(sensorToStatusInfo); + + // Count statuses + const onlineCount = devices.filter(d => d.status === 'online').length; + const warningCount = devices.filter(d => d.status === 'warning').length; + const offlineCount = devices.filter(d => d.status === 'offline').length; + + const result: BeneficiaryOnlineStatus = { + beneficiaryId, + totalDevices: devices.length, + onlineCount, + warningCount, + offlineCount, + overallStatus: calculateOverallStatus(devices), + devices, + lastChecked: new Date(), + }; + + // Update cache + this.cache.set(beneficiaryId, { data: result, timestamp: Date.now() }); + + return { ok: true, data: result }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Check online status for multiple beneficiaries + * @param beneficiaryIds - Array of beneficiary IDs to check + * @param forceRefresh - Skip cache and fetch fresh data + * @returns Map of beneficiary ID to status result + */ + async checkMultipleBeneficiaries( + beneficiaryIds: string[], + forceRefresh = false + ): Promise> { + const results = new Map(); + + // Process in parallel but limit concurrency + const batchSize = 3; + for (let i = 0; i < beneficiaryIds.length; i += batchSize) { + const batch = beneficiaryIds.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(id => this.checkBeneficiaryStatus(id, forceRefresh)) + ); + + batch.forEach((id, index) => { + results.set(id, batchResults[index]); + }); + } + + return results; + } + + /** + * Get cached status for a beneficiary (no API call) + * Returns undefined if not cached or cache expired + */ + getCachedStatus(beneficiaryId: string): BeneficiaryOnlineStatus | undefined { + const cached = this.cache.get(beneficiaryId); + if (cached && this.isCacheValid(cached)) { + return cached.data; + } + return undefined; + } + + /** + * Check if a specific device is online + * Convenience method for quick device checks + */ + async isDeviceOnline(beneficiaryId: string, deviceId: string): Promise { + const result = await this.checkBeneficiaryStatus(beneficiaryId); + if (!result.ok || !result.data) { + return false; + } + + const device = result.data.devices.find(d => d.deviceId === deviceId); + return device?.status === 'online'; + } + + /** + * Get summary statistics for a beneficiary's devices + */ + async getStatusSummary( + beneficiaryId: string + ): Promise<{ online: number; warning: number; offline: number; total: number } | null> { + const result = await this.checkBeneficiaryStatus(beneficiaryId); + if (!result.ok || !result.data) { + return null; + } + + return { + online: result.data.onlineCount, + warning: result.data.warningCount, + offline: result.data.offlineCount, + total: result.data.totalDevices, + }; + } +} + +// Export singleton instance +export const onlineStatusService = new OnlineStatusService(); + +// Export class for testing +export { OnlineStatusService };