WellNuo/services/onlineStatusService.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

370 lines
9.5 KiB
TypeScript

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