"use client"; import { format, formatDistanceToNow } from "date-fns"; import { Activity, AlertTriangle, ArrowRight, Bot, Camera, CheckCircle, Eye, Hand, MessageSquare, Pause, Play, Settings, User, Volume2, XCircle, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; import { api } from "~/trpc/react"; import type { WebSocketMessage } from "~/hooks/useWebSocket"; interface EventsLogProps { trialId: string; refreshKey: number; isLive: boolean; maxEvents?: number; realtimeEvents?: WebSocketMessage[]; isWebSocketConnected?: boolean; } interface TrialEvent { id: string; trialId: string; eventType: string; timestamp: Date; data: Record | null; notes: string | null; createdAt: Date; } const eventTypeConfig = { trial_started: { label: "Trial Started", icon: Play, color: "text-green-600", bgColor: "bg-green-100", importance: "high", }, trial_completed: { label: "Trial Completed", icon: CheckCircle, color: "text-blue-600", bgColor: "bg-blue-100", importance: "high", }, trial_aborted: { label: "Trial Aborted", icon: XCircle, color: "text-red-600", bgColor: "bg-red-100", importance: "high", }, step_transition: { label: "Step Change", icon: ArrowRight, color: "text-purple-600", bgColor: "bg-purple-100", importance: "medium", }, wizard_action: { label: "Wizard Action", icon: User, color: "text-blue-600", bgColor: "bg-blue-100", importance: "medium", }, robot_action: { label: "Robot Action", icon: Bot, color: "text-green-600", bgColor: "bg-green-100", importance: "medium", }, wizard_intervention: { label: "Intervention", icon: Hand, color: "text-orange-600", bgColor: "bg-orange-100", importance: "high", }, manual_intervention: { label: "Manual Control", icon: Hand, color: "text-orange-600", bgColor: "bg-orange-100", importance: "high", }, emergency_action: { label: "Emergency", icon: AlertTriangle, color: "text-red-600", bgColor: "bg-red-100", importance: "critical", }, emergency_stop: { label: "Emergency Stop", icon: AlertTriangle, color: "text-red-600", bgColor: "bg-red-100", importance: "critical", }, recording_control: { label: "Recording", icon: Camera, color: "text-indigo-600", bgColor: "bg-indigo-100", importance: "low", }, video_control: { label: "Video Control", icon: Camera, color: "text-indigo-600", bgColor: "bg-indigo-100", importance: "low", }, audio_control: { label: "Audio Control", icon: Volume2, color: "text-indigo-600", bgColor: "bg-indigo-100", importance: "low", }, pause_interaction: { label: "Paused", icon: Pause, color: "text-yellow-600", bgColor: "bg-yellow-100", importance: "medium", }, participant_response: { label: "Participant", icon: MessageSquare, color: "text-slate-600", bgColor: "bg-slate-100", importance: "medium", }, system_event: { label: "System", icon: Settings, color: "text-slate-600", bgColor: "bg-slate-100", importance: "low", }, annotation: { label: "Annotation", icon: MessageSquare, color: "text-blue-600", bgColor: "bg-blue-100", importance: "medium", }, default: { label: "Event", icon: Activity, color: "text-slate-600", bgColor: "bg-slate-100", importance: "low", }, }; export function EventsLog({ trialId, refreshKey, isLive, maxEvents = 100, realtimeEvents = [], isWebSocketConnected = false, }: EventsLogProps) { const [events, setEvents] = useState([]); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); const [filter, setFilter] = useState("all"); const scrollAreaRef = useRef(null); const bottomRef = useRef(null); // Fetch trial events (less frequent when WebSocket is connected) const { data: eventsData, isLoading } = api.trials.getEvents.useQuery( { trialId, limit: maxEvents, type: filter === "all" ? undefined : (filter as | "error" | "custom" | "trial_start" | "trial_end" | "step_start" | "step_end" | "wizard_intervention"), }, { refetchInterval: isLive && !isWebSocketConnected ? 2000 : 10000, // Less frequent polling when WebSocket is active refetchOnWindowFocus: false, enabled: !isWebSocketConnected || !isLive, // Reduce API calls when WebSocket is connected }, ); // Convert WebSocket events to trial events format (type-safe) const convertWebSocketEvent = useCallback( (wsEvent: WebSocketMessage): TrialEvent => { const eventType = wsEvent.type === "trial_action_executed" ? "wizard_action" : wsEvent.type === "intervention_logged" ? "wizard_intervention" : wsEvent.type === "step_changed" ? "step_transition" : wsEvent.type || "system_event"; const rawData = wsEvent.data; const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null; const data: Record | null = isRecord(rawData) ? rawData : null; const ts = isRecord(rawData) && typeof rawData.timestamp === "number" ? rawData.timestamp : Date.now(); const notes = isRecord(rawData) && typeof rawData.notes === "string" ? rawData.notes : null; return { id: `ws-${Date.now()}-${Math.random()}`, trialId, eventType, timestamp: new Date(ts), data, notes, createdAt: new Date(ts), }; }, [trialId], ); // Update events when data changes (prioritize WebSocket events) useEffect(() => { let newEvents: TrialEvent[] = []; // Add database events if (eventsData) { type ApiTrialEvent = { id: string; trialId: string; eventType: string; timestamp: string | Date; data: unknown; }; const apiEvents = (eventsData as unknown as ApiTrialEvent[]) ?? []; newEvents = apiEvents.map((event) => ({ id: event.id, trialId: event.trialId, eventType: event.eventType, timestamp: new Date(event.timestamp), data: typeof event.data === "object" && event.data !== null ? (event.data as Record) : null, notes: null, createdAt: new Date(event.timestamp), })); } // Add real-time WebSocket events if (realtimeEvents.length > 0) { const wsEvents = realtimeEvents.map(convertWebSocketEvent); newEvents = [...newEvents, ...wsEvents]; } // Sort by timestamp and remove duplicates const uniqueEvents = newEvents .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) .filter( (event, index, arr) => index === arr.findIndex( (e) => e.eventType === event.eventType && Math.abs(e.timestamp.getTime() - event.timestamp.getTime()) < 1000, ), ) .slice(-maxEvents); // Keep only the most recent events setEvents(uniqueEvents); }, [ eventsData, refreshKey, realtimeEvents, trialId, maxEvents, convertWebSocketEvent, ]); // Auto-scroll to bottom when new events arrive useEffect(() => { if (isAutoScrollEnabled && bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: "smooth" }); } }, [events, isAutoScrollEnabled]); const getEventConfig = (eventType: string) => { return ( eventTypeConfig[eventType as keyof typeof eventTypeConfig] || eventTypeConfig.default ); }; const formatEventData = ( eventType: string, data: Record | null, ): string | null => { if (!data) return null; const str = (k: string): string | undefined => { const v = data[k]; return typeof v === "string" ? v : undefined; }; const num = (k: string): number | undefined => { const v = data[k]; return typeof v === "number" ? v : undefined; }; switch (eventType) { case "step_transition": { const fromIdx = num("from_step"); const toIdx = num("to_step"); const stepName = str("step_name"); if (typeof toIdx === "number") { const fromLabel = typeof fromIdx === "number" ? `${fromIdx + 1} → ` : ""; const nameLabel = stepName ? `: ${stepName}` : ""; return `Step ${fromLabel}${toIdx + 1}${nameLabel}`; } return "Step changed"; } case "wizard_action": { const actionType = str("action_type"); const stepName = str("step_name"); const actionLabel = actionType ? actionType.replace(/_/g, " ") : "Action executed"; const inStep = stepName ? ` in ${stepName}` : ""; return `${actionLabel}${inStep}`; } case "robot_action": { const actionName = str("action_name") ?? "Robot action"; const hasParams = typeof data.parameters !== "undefined" && data.parameters !== null; return `${actionName}${hasParams ? " with parameters" : ""}`; } case "emergency_action": { const emergency = str("emergency_type"); return `Emergency: ${ emergency ? emergency.replace(/_/g, " ") : "Unknown" }`; } case "recording_control": { const action = str("action"); return `Recording ${action === "start_recording" ? "started" : "stopped"}`; } case "video_control": { const action = str("action"); return `Video ${action === "video_on" ? "enabled" : "disabled"}`; } case "audio_control": { const action = str("action"); return `Audio ${action === "audio_on" ? "enabled" : "disabled"}`; } case "wizard_intervention": { return ( str("content") ?? str("intervention_type") ?? "Intervention recorded" ); } default: { const message = str("message"); if (message) return message; const description = str("description"); if (description) return description; return null; } } }; const getEventImportanceOrder = (importance: string) => { const order = { critical: 0, high: 1, medium: 2, low: 3 }; return order[importance as keyof typeof order] || 4; }; // Group events by time proximity (within 30 seconds) const groupedEvents = events.reduce( (groups: TrialEvent[][], event, index) => { if ( index === 0 || Math.abs( event.timestamp.getTime() - (events[index - 1]?.timestamp.getTime() ?? 0), ) > 30000 ) { groups.push([event]); } else { groups[groups.length - 1]?.push(event); } return groups; }, [], ); // uniqueEventTypes removed (unused) if (isLoading) { return (

Events Log

Loading events...

); } return (
{/* Header */}

Events Log {isLive && (
{isWebSocketConnected ? "REAL-TIME" : "LIVE"}
)}

{events.length} events {isWebSocketConnected && ( Real-time )}
{/* Filter Controls */}
{/* Events List */}
{events.length === 0 ? (

No events yet

Events will appear here as the trial progresses

) : ( groupedEvents.map((group, groupIndex) => (
{/* Time Header */}
{group[0] ? format(group[0].timestamp, "HH:mm:ss") : ""}
{group[0] ? formatDistanceToNow(group[0].timestamp, { addSuffix: true, }) : ""}
{/* Events in Group */} {group .sort( (a, b) => getEventImportanceOrder( getEventConfig(a.eventType).importance, ) - getEventImportanceOrder( getEventConfig(b.eventType).importance, ), ) .map((event) => { const config = getEventConfig(event.eventType); const EventIcon = config.icon; const eventData = formatEventData( event.eventType, event.data, ); return (
{config.label} {config.importance === "critical" && ( CRITICAL )} {config.importance === "high" && ( HIGH )}
{eventData && (

{eventData}

)} {event.notes && (

{event.notes}

)} {event.data && typeof event.data === "object" && Object.keys(event.data).length > 0 && (
View details
                                  {JSON.stringify(event.data, null, 2)}
                                
)}
{format(event.timestamp, "HH:mm")}
); })}
)) )}
{/* Auto-scroll Control */} {events.length > 0 && (
)}
); }