Add online status check service and hook for device monitoring

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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-02-01 09:35:45 -08:00
parent 3260119ece
commit 290b0e218b
4 changed files with 1668 additions and 0 deletions

View File

@ -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<typeof onlineStatusService>;
// Helper to create mock status data
const createMockStatus = (overrides: Partial<BeneficiaryOnlineStatus> = {}): 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<void>;
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();
});
});

433
hooks/useOnlineStatus.ts Normal file
View File

@ -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<void>;
/** 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 <Loading />;
*
* return (
* <View>
* <Text>Status: {overallStatus}</Text>
* {devices.map(device => (
* <DeviceCard key={device.deviceId} device={device} />
* ))}
* </View>
* );
* }
* ```
*/
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<string | null>(null);
const [status, setStatus] = useState<BeneficiaryOnlineStatus | null>(null);
// Refs for cleanup and tracking
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | 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<string, BeneficiaryOnlineStatus>;
errors: Map<string, string>;
refresh: () => Promise<void>;
getStatus: (beneficiaryId: string) => BeneficiaryOnlineStatus | undefined;
} {
const opts = { ...DEFAULT_OPTIONS, ...options };
const [isLoading, setIsLoading] = useState(true);
const [statuses, setStatuses] = useState<Map<string, BeneficiaryOnlineStatus>>(new Map());
const [errors, setErrors] = useState<Map<string, string>>(new Map());
const mountedRef = useRef(true);
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | 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<string, BeneficiaryOnlineStatus>();
const newErrors = new Map<string, string>();
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<string, string>();
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 };

View File

@ -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<typeof api>;
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();
});
});
});
});

View File

@ -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<string, { data: BeneficiaryOnlineStatus; timestamp: number }>();
private cacheMaxAgeMs = 30000; // 30 seconds cache
/**
* Set custom status thresholds
*/
setThresholds(thresholds: Partial<StatusThresholds>): 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<StatusCheckResult> {
// 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<Map<string, StatusCheckResult>> {
const results = new Map<string, StatusCheckResult>();
// 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<boolean> {
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 };