WellNuo/hooks/useOnlineStatus.ts
Sergei 290b0e218b 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>
2026-02-01 09:35:45 -08:00

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 };