Implement Agent 6: Real-Time Updates feature for LocalGreenChain: - Add Socket.io server with room-based subscriptions - Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates) - Add SocketProvider context for application-wide state - Implement UI components: - ConnectionStatus: Shows WebSocket connection state - LiveFeed: Real-time event feed display - NotificationToast: Toast notifications with auto-dismiss - LiveChart: Real-time data visualization - Add event type definitions and formatting utilities - Create socket API endpoint for WebSocket initialization - Add socket stats endpoint for monitoring - Extend tailwind with fadeIn/slideIn animations Integrates with existing EventStream SSE system for fallback.
258 lines
5.9 KiB
TypeScript
258 lines
5.9 KiB
TypeScript
/**
|
|
* React Hook for Socket.io Real-Time Updates
|
|
*
|
|
* Provides easy-to-use hooks for real-time data in React components.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { getSocketClient, RealtimeSocketClient } from './socketClient';
|
|
import type {
|
|
ConnectionStatus,
|
|
TransparencyEvent,
|
|
RoomType,
|
|
TransparencyEventType,
|
|
ConnectionMetrics,
|
|
LiveFeedItem,
|
|
} from './types';
|
|
import { toFeedItem } from './events';
|
|
|
|
/**
|
|
* Hook configuration options
|
|
*/
|
|
export interface UseSocketOptions {
|
|
autoConnect?: boolean;
|
|
rooms?: RoomType[];
|
|
eventTypes?: TransparencyEventType[];
|
|
userId?: string;
|
|
maxEvents?: number;
|
|
}
|
|
|
|
/**
|
|
* Hook return type
|
|
*/
|
|
export interface UseSocketReturn {
|
|
status: ConnectionStatus;
|
|
isConnected: boolean;
|
|
events: TransparencyEvent[];
|
|
latestEvent: TransparencyEvent | null;
|
|
metrics: ConnectionMetrics;
|
|
connect: () => void;
|
|
disconnect: () => void;
|
|
joinRoom: (room: RoomType) => Promise<boolean>;
|
|
leaveRoom: (room: RoomType) => Promise<boolean>;
|
|
clearEvents: () => void;
|
|
}
|
|
|
|
/**
|
|
* Main socket hook for real-time updates
|
|
*/
|
|
export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
|
|
const {
|
|
autoConnect = true,
|
|
rooms = [],
|
|
eventTypes = [],
|
|
userId,
|
|
maxEvents = 100,
|
|
} = options;
|
|
|
|
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
|
const [events, setEvents] = useState<TransparencyEvent[]>([]);
|
|
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
|
|
const [metrics, setMetrics] = useState<ConnectionMetrics>({
|
|
status: 'disconnected',
|
|
eventsReceived: 0,
|
|
reconnectAttempts: 0,
|
|
rooms: [],
|
|
});
|
|
|
|
const clientRef = useRef<RealtimeSocketClient | null>(null);
|
|
const cleanupRef = useRef<(() => void)[]>([]);
|
|
|
|
// Initialize client
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const client = getSocketClient({ auth: { userId } });
|
|
clientRef.current = client;
|
|
|
|
// Set up listeners
|
|
const unsubStatus = client.onStatusChange((newStatus) => {
|
|
setStatus(newStatus);
|
|
setMetrics(client.getMetrics());
|
|
});
|
|
|
|
const unsubEvent = client.onEvent((event) => {
|
|
setLatestEvent(event);
|
|
setEvents((prev) => {
|
|
const updated = [event, ...prev];
|
|
return updated.slice(0, maxEvents);
|
|
});
|
|
setMetrics(client.getMetrics());
|
|
});
|
|
|
|
cleanupRef.current = [unsubStatus, unsubEvent];
|
|
|
|
// Auto connect
|
|
if (autoConnect) {
|
|
client.connect();
|
|
}
|
|
|
|
// Initial metrics
|
|
setMetrics(client.getMetrics());
|
|
|
|
return () => {
|
|
cleanupRef.current.forEach((cleanup) => cleanup());
|
|
};
|
|
}, [autoConnect, userId, maxEvents]);
|
|
|
|
// Join initial rooms
|
|
useEffect(() => {
|
|
if (!clientRef.current || status !== 'connected') return;
|
|
|
|
rooms.forEach((room) => {
|
|
clientRef.current?.joinRoom(room);
|
|
});
|
|
}, [status, rooms]);
|
|
|
|
// Subscribe to event types
|
|
useEffect(() => {
|
|
if (!clientRef.current || status !== 'connected' || eventTypes.length === 0) return;
|
|
|
|
clientRef.current.subscribeToTypes(eventTypes);
|
|
}, [status, eventTypes]);
|
|
|
|
const connect = useCallback(() => {
|
|
clientRef.current?.connect();
|
|
}, []);
|
|
|
|
const disconnect = useCallback(() => {
|
|
clientRef.current?.disconnect();
|
|
}, []);
|
|
|
|
const joinRoom = useCallback(async (room: RoomType) => {
|
|
return clientRef.current?.joinRoom(room) ?? false;
|
|
}, []);
|
|
|
|
const leaveRoom = useCallback(async (room: RoomType) => {
|
|
return clientRef.current?.leaveRoom(room) ?? false;
|
|
}, []);
|
|
|
|
const clearEvents = useCallback(() => {
|
|
setEvents([]);
|
|
setLatestEvent(null);
|
|
}, []);
|
|
|
|
return {
|
|
status,
|
|
isConnected: status === 'connected',
|
|
events,
|
|
latestEvent,
|
|
metrics,
|
|
connect,
|
|
disconnect,
|
|
joinRoom,
|
|
leaveRoom,
|
|
clearEvents,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for live feed display
|
|
*/
|
|
export function useLiveFeed(options: UseSocketOptions = {}): {
|
|
items: LiveFeedItem[];
|
|
isConnected: boolean;
|
|
status: ConnectionStatus;
|
|
clearFeed: () => void;
|
|
} {
|
|
const { events, isConnected, status, clearEvents } = useSocket(options);
|
|
|
|
const items = events.map((event) => toFeedItem(event));
|
|
|
|
return {
|
|
items,
|
|
isConnected,
|
|
status,
|
|
clearFeed: clearEvents,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for tracking a specific plant's real-time updates
|
|
*/
|
|
export function usePlantUpdates(plantId: string): {
|
|
events: TransparencyEvent[];
|
|
isConnected: boolean;
|
|
} {
|
|
return useSocket({
|
|
rooms: [`plant:${plantId}` as RoomType],
|
|
eventTypes: [
|
|
'plant.registered',
|
|
'plant.cloned',
|
|
'plant.transferred',
|
|
'plant.updated',
|
|
'transport.started',
|
|
'transport.completed',
|
|
],
|
|
}) as { events: TransparencyEvent[]; isConnected: boolean };
|
|
}
|
|
|
|
/**
|
|
* Hook for tracking a specific farm's real-time updates
|
|
*/
|
|
export function useFarmUpdates(farmId: string): {
|
|
events: TransparencyEvent[];
|
|
isConnected: boolean;
|
|
} {
|
|
return useSocket({
|
|
rooms: [`farm:${farmId}` as RoomType],
|
|
eventTypes: [
|
|
'farm.registered',
|
|
'farm.updated',
|
|
'batch.started',
|
|
'batch.harvested',
|
|
'agent.alert',
|
|
],
|
|
}) as { events: TransparencyEvent[]; isConnected: boolean };
|
|
}
|
|
|
|
/**
|
|
* Hook for connection status only (lightweight)
|
|
*/
|
|
export function useConnectionStatus(): {
|
|
status: ConnectionStatus;
|
|
isConnected: boolean;
|
|
latency: number | undefined;
|
|
} {
|
|
const { status, isConnected, metrics } = useSocket({ autoConnect: true });
|
|
|
|
return {
|
|
status,
|
|
isConnected,
|
|
latency: metrics.latency,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for event counts (useful for notification badges)
|
|
*/
|
|
export function useEventCount(options: UseSocketOptions = {}): {
|
|
count: number;
|
|
unreadCount: number;
|
|
markAllRead: () => void;
|
|
} {
|
|
const { events } = useSocket(options);
|
|
const [readCount, setReadCount] = useState(0);
|
|
|
|
const markAllRead = useCallback(() => {
|
|
setReadCount(events.length);
|
|
}, [events.length]);
|
|
|
|
return {
|
|
count: events.length,
|
|
unreadCount: Math.max(0, events.length - readCount),
|
|
markAllRead,
|
|
};
|
|
}
|
|
|
|
export default useSocket;
|