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>
434 lines
11 KiB
TypeScript
434 lines
11 KiB
TypeScript
/**
|
|
* 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 };
|