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.
255 lines
6.5 KiB
TypeScript
255 lines
6.5 KiB
TypeScript
/**
|
|
* Live Feed Component
|
|
*
|
|
* Displays a real-time feed of events from the LocalGreenChain system.
|
|
*/
|
|
|
|
import React, { useMemo } from 'react';
|
|
import classNames from 'classnames';
|
|
import { useLiveFeed } from '../../lib/realtime/useSocket';
|
|
import type { LiveFeedItem, RoomType, TransparencyEventType } from '../../lib/realtime/types';
|
|
import { EventCategory, getEventCategory } from '../../lib/realtime/events';
|
|
import { ConnectionStatus } from './ConnectionStatus';
|
|
|
|
interface LiveFeedProps {
|
|
rooms?: RoomType[];
|
|
eventTypes?: TransparencyEventType[];
|
|
maxItems?: number;
|
|
showConnectionStatus?: boolean;
|
|
showTimestamps?: boolean;
|
|
showClearButton?: boolean;
|
|
filterCategory?: EventCategory;
|
|
className?: string;
|
|
emptyMessage?: string;
|
|
}
|
|
|
|
/**
|
|
* Format timestamp for display
|
|
*/
|
|
function formatTimestamp(timestamp: number): string {
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - timestamp;
|
|
const diffSec = Math.floor(diffMs / 1000);
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
const diffHour = Math.floor(diffMin / 60);
|
|
|
|
if (diffSec < 60) {
|
|
return 'Just now';
|
|
} else if (diffMin < 60) {
|
|
return `${diffMin}m ago`;
|
|
} else if (diffHour < 24) {
|
|
return `${diffHour}h ago`;
|
|
} else {
|
|
return date.toLocaleDateString();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get color classes for event type
|
|
*/
|
|
function getColorClasses(color: string): { bg: string; border: string; text: string } {
|
|
switch (color) {
|
|
case 'green':
|
|
return {
|
|
bg: 'bg-green-50',
|
|
border: 'border-green-200',
|
|
text: 'text-green-800',
|
|
};
|
|
case 'blue':
|
|
return {
|
|
bg: 'bg-blue-50',
|
|
border: 'border-blue-200',
|
|
text: 'text-blue-800',
|
|
};
|
|
case 'yellow':
|
|
return {
|
|
bg: 'bg-yellow-50',
|
|
border: 'border-yellow-200',
|
|
text: 'text-yellow-800',
|
|
};
|
|
case 'red':
|
|
return {
|
|
bg: 'bg-red-50',
|
|
border: 'border-red-200',
|
|
text: 'text-red-800',
|
|
};
|
|
case 'purple':
|
|
return {
|
|
bg: 'bg-purple-50',
|
|
border: 'border-purple-200',
|
|
text: 'text-purple-800',
|
|
};
|
|
case 'gray':
|
|
default:
|
|
return {
|
|
bg: 'bg-gray-50',
|
|
border: 'border-gray-200',
|
|
text: 'text-gray-800',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single feed item component
|
|
*/
|
|
function FeedItem({
|
|
item,
|
|
showTimestamp,
|
|
}: {
|
|
item: LiveFeedItem;
|
|
showTimestamp: boolean;
|
|
}) {
|
|
const colors = getColorClasses(item.formatted.color);
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
'p-3 rounded-lg border transition-all duration-300 animate-fadeIn',
|
|
colors.bg,
|
|
colors.border
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* Icon */}
|
|
<span className="text-xl flex-shrink-0" role="img" aria-label={item.formatted.title}>
|
|
{item.formatted.icon}
|
|
</span>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className={classNames('font-medium text-sm', colors.text)}>
|
|
{item.formatted.title}
|
|
</span>
|
|
{showTimestamp && (
|
|
<span className="text-xs text-gray-400 flex-shrink-0">
|
|
{formatTimestamp(item.timestamp)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-1 truncate">
|
|
{item.formatted.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Live Feed component
|
|
*/
|
|
export function LiveFeed({
|
|
rooms,
|
|
eventTypes,
|
|
maxItems = 20,
|
|
showConnectionStatus = true,
|
|
showTimestamps = true,
|
|
showClearButton = true,
|
|
filterCategory,
|
|
className,
|
|
emptyMessage = 'No events yet. Real-time updates will appear here.',
|
|
}: LiveFeedProps) {
|
|
const { items, isConnected, status, clearFeed } = useLiveFeed({
|
|
rooms,
|
|
eventTypes,
|
|
maxEvents: maxItems,
|
|
});
|
|
|
|
// Filter items by category if specified
|
|
const filteredItems = useMemo(() => {
|
|
if (!filterCategory) return items;
|
|
|
|
return items.filter((item) => {
|
|
const category = getEventCategory(item.event.type);
|
|
return category === filterCategory;
|
|
});
|
|
}, [items, filterCategory]);
|
|
|
|
return (
|
|
<div className={classNames('flex flex-col h-full', className)}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-lg font-semibold text-gray-900">Live Feed</h3>
|
|
{showConnectionStatus && <ConnectionStatus size="sm" showLabel={false} />}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{filteredItems.length > 0 && (
|
|
<span className="text-sm text-gray-500">
|
|
{filteredItems.length} event{filteredItems.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
{showClearButton && filteredItems.length > 0 && (
|
|
<button
|
|
onClick={clearFeed}
|
|
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Feed content */}
|
|
<div className="flex-1 overflow-y-auto space-y-2">
|
|
{filteredItems.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<div className="text-4xl mb-2">📡</div>
|
|
<p className="text-gray-500 text-sm">{emptyMessage}</p>
|
|
{!isConnected && (
|
|
<p className="text-yellow-600 text-xs mt-2">
|
|
Status: {status}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
filteredItems.map((item) => (
|
|
<FeedItem
|
|
key={item.id}
|
|
item={item}
|
|
showTimestamp={showTimestamps}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compact live feed for sidebars
|
|
*/
|
|
export function CompactLiveFeed({
|
|
maxItems = 5,
|
|
className,
|
|
}: {
|
|
maxItems?: number;
|
|
className?: string;
|
|
}) {
|
|
const { items } = useLiveFeed({ maxEvents: maxItems });
|
|
|
|
if (items.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={classNames('space-y-1', className)}>
|
|
{items.slice(0, maxItems).map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center gap-2 py-1 text-sm"
|
|
>
|
|
<span>{item.formatted.icon}</span>
|
|
<span className="truncate text-gray-600">
|
|
{item.formatted.description}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LiveFeed;
|