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:
2025-12-30 01:34:41 +01:00
parent d6db6af964
commit fcda8f0f96
14 changed files with 3138 additions and 0 deletions

View 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);
}

View File

@@ -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';