localgreenchain/components/realtime/LiveFeed.tsx
Claude 7098335ce7
Add real-time updates system with Socket.io
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.
2025-11-23 03:51:51 +00:00

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;