feat(frontend): Implement client-side SSE handling (#35)
Implements real-time event streaming on the frontend with: - Event types and type guards matching backend EventType enum - Zustand-based event store with per-project buffering - useProjectEvents hook with auto-reconnection and exponential backoff - ConnectionStatus component showing connection state - EventList component with expandable payloads and filtering All 105 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
225
frontend/src/lib/stores/eventStore.ts
Normal file
225
frontend/src/lib/stores/eventStore.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Event Store - Zustand store for project events
|
||||
*
|
||||
* Manages real-time events received via SSE with:
|
||||
* - Event buffer (configurable, default 100 events)
|
||||
* - Per-project event management
|
||||
* - Event filtering utilities
|
||||
*
|
||||
* @module lib/stores/eventStore
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { ProjectEvent, EventType } from '@/lib/types/events';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Maximum number of events to keep in buffer per project */
|
||||
const DEFAULT_MAX_EVENTS = 100;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface EventState {
|
||||
/** Events indexed by project ID */
|
||||
eventsByProject: Record<string, ProjectEvent[]>;
|
||||
|
||||
/** Maximum events to keep per project */
|
||||
maxEvents: number;
|
||||
}
|
||||
|
||||
interface EventActions {
|
||||
/**
|
||||
* Add an event to the store
|
||||
* @param event - The event to add
|
||||
*/
|
||||
addEvent: (event: ProjectEvent) => void;
|
||||
|
||||
/**
|
||||
* Add multiple events at once
|
||||
* @param events - Array of events to add
|
||||
*/
|
||||
addEvents: (events: ProjectEvent[]) => void;
|
||||
|
||||
/**
|
||||
* Clear all events for a specific project
|
||||
* @param projectId - Project ID to clear events for
|
||||
*/
|
||||
clearProjectEvents: (projectId: string) => void;
|
||||
|
||||
/**
|
||||
* Clear all events from the store
|
||||
*/
|
||||
clearAllEvents: () => void;
|
||||
|
||||
/**
|
||||
* Get events for a specific project
|
||||
* @param projectId - Project ID
|
||||
* @returns Array of events for the project
|
||||
*/
|
||||
getProjectEvents: (projectId: string) => ProjectEvent[];
|
||||
|
||||
/**
|
||||
* Get events filtered by type
|
||||
* @param projectId - Project ID
|
||||
* @param types - Event types to filter by
|
||||
* @returns Filtered array of events
|
||||
*/
|
||||
getFilteredEvents: (projectId: string, types: EventType[]) => ProjectEvent[];
|
||||
|
||||
/**
|
||||
* Set the maximum number of events to keep per project
|
||||
* @param max - Maximum event count
|
||||
*/
|
||||
setMaxEvents: (max: number) => void;
|
||||
}
|
||||
|
||||
export type EventStore = EventState & EventActions;
|
||||
|
||||
// ============================================================================
|
||||
// Store Implementation
|
||||
// ============================================================================
|
||||
|
||||
export const useEventStore = create<EventStore>((set, get) => ({
|
||||
// Initial state
|
||||
eventsByProject: {},
|
||||
maxEvents: DEFAULT_MAX_EVENTS,
|
||||
|
||||
addEvent: (event: ProjectEvent) => {
|
||||
set((state) => {
|
||||
const projectId = event.project_id;
|
||||
const existingEvents = state.eventsByProject[projectId] || [];
|
||||
|
||||
// Check for duplicate event IDs
|
||||
if (existingEvents.some((e) => e.id === event.id)) {
|
||||
return state; // Skip duplicate
|
||||
}
|
||||
|
||||
// Add new event and trim to max
|
||||
const updatedEvents = [...existingEvents, event].slice(-state.maxEvents);
|
||||
|
||||
return {
|
||||
eventsByProject: {
|
||||
...state.eventsByProject,
|
||||
[projectId]: updatedEvents,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addEvents: (events: ProjectEvent[]) => {
|
||||
if (events.length === 0) return;
|
||||
|
||||
set((state) => {
|
||||
const updatedEventsByProject = { ...state.eventsByProject };
|
||||
|
||||
// Group events by project
|
||||
const eventsByProjectId = events.reduce(
|
||||
(acc, event) => {
|
||||
if (!acc[event.project_id]) {
|
||||
acc[event.project_id] = [];
|
||||
}
|
||||
acc[event.project_id].push(event);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ProjectEvent[]>
|
||||
);
|
||||
|
||||
// Merge events for each project
|
||||
for (const [projectId, newEvents] of Object.entries(eventsByProjectId)) {
|
||||
const existingEvents = updatedEventsByProject[projectId] || [];
|
||||
|
||||
// Filter out duplicates
|
||||
const existingIds = new Set(existingEvents.map((e) => e.id));
|
||||
const uniqueNewEvents = newEvents.filter((e) => !existingIds.has(e.id));
|
||||
|
||||
// Merge and trim
|
||||
updatedEventsByProject[projectId] = [...existingEvents, ...uniqueNewEvents].slice(
|
||||
-state.maxEvents
|
||||
);
|
||||
}
|
||||
|
||||
return { eventsByProject: updatedEventsByProject };
|
||||
});
|
||||
},
|
||||
|
||||
clearProjectEvents: (projectId: string) => {
|
||||
set((state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [projectId]: _removed, ...rest } = state.eventsByProject;
|
||||
return { eventsByProject: rest };
|
||||
});
|
||||
},
|
||||
|
||||
clearAllEvents: () => {
|
||||
set({ eventsByProject: {} });
|
||||
},
|
||||
|
||||
getProjectEvents: (projectId: string) => {
|
||||
return get().eventsByProject[projectId] || [];
|
||||
},
|
||||
|
||||
getFilteredEvents: (projectId: string, types: EventType[]) => {
|
||||
const events = get().eventsByProject[projectId] || [];
|
||||
if (types.length === 0) return events;
|
||||
|
||||
const typeSet = new Set(types);
|
||||
return events.filter((event) => typeSet.has(event.type));
|
||||
},
|
||||
|
||||
setMaxEvents: (max: number) => {
|
||||
if (max < 1) {
|
||||
console.warn(`Invalid maxEvents value: ${max}, using default: ${DEFAULT_MAX_EVENTS}`);
|
||||
max = DEFAULT_MAX_EVENTS;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
// Trim existing events if necessary
|
||||
const trimmedEventsByProject: Record<string, ProjectEvent[]> = {};
|
||||
|
||||
for (const [projectId, events] of Object.entries(state.eventsByProject)) {
|
||||
trimmedEventsByProject[projectId] = events.slice(-max);
|
||||
}
|
||||
|
||||
return {
|
||||
maxEvents: max,
|
||||
eventsByProject: trimmedEventsByProject,
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// Selector Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to get events for a specific project
|
||||
* @param projectId - Project ID
|
||||
* @returns Array of events for the project
|
||||
*/
|
||||
export function useProjectEventsFromStore(projectId: string): ProjectEvent[] {
|
||||
return useEventStore((state) => state.eventsByProject[projectId] || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the latest event for a project
|
||||
* @param projectId - Project ID
|
||||
* @returns Latest event or undefined
|
||||
*/
|
||||
export function useLatestEvent(projectId: string): ProjectEvent | undefined {
|
||||
const events = useEventStore((state) => state.eventsByProject[projectId] || []);
|
||||
return events[events.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get event count for a project
|
||||
* @param projectId - Project ID
|
||||
* @returns Number of events
|
||||
*/
|
||||
export function useEventCount(projectId: string): number {
|
||||
return useEventStore((state) => (state.eventsByProject[projectId] || []).length);
|
||||
}
|
||||
@@ -3,5 +3,14 @@
|
||||
|
||||
export { useAuthStore, initializeAuth, type User } from './authStore';
|
||||
|
||||
// Event Store for SSE events
|
||||
export {
|
||||
useEventStore,
|
||||
useProjectEventsFromStore,
|
||||
useLatestEvent,
|
||||
useEventCount,
|
||||
type EventStore,
|
||||
} from './eventStore';
|
||||
|
||||
// Authentication Context (DI wrapper for auth store)
|
||||
export { useAuth, AuthProvider } from '../auth/AuthContext';
|
||||
|
||||
Reference in New Issue
Block a user