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:
parent
3260119ece
commit
290b0e218b
432
hooks/__tests__/useOnlineStatus.test.tsx
Normal file
432
hooks/__tests__/useOnlineStatus.test.tsx
Normal 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
433
hooks/useOnlineStatus.ts
Normal 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 };
|
||||||
434
services/__tests__/onlineStatusService.test.ts
Normal file
434
services/__tests__/onlineStatusService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
369
services/onlineStatusService.ts
Normal file
369
services/onlineStatusService.ts
Normal 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 };
|
||||||
Loading…
x
Reference in New Issue
Block a user