CalendarDebuggerTab.tsx24.5 KBView on GitHub import {
Calendar,
Check,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
Search,
Link,
} from 'lucide-react';
import type { CalendarListEntry } from '@/modules/calendar/types/calendar-types';
import type { CalendarEventWithCalendarId } from '@/modules/calendar/store/calendarSlice';
import { AnimatePresence, motion } from 'motion/react';
import React, { useMemo, useState } from 'react';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/styles/stylingUtils';
import { JsonTreeView } from './JsonTreeView';
interface CalendarTabProps {
calendars: CalendarListEntry[];
calendarEvents: CalendarEventWithCalendarId[];
selectedCalendarEventId: string | null;
calendarEventToConversation: Record<string, string>;
onCopy: (text: string, id: string) => void;
copiedId: string | null;
}
export const CalendarDebuggerTab: React.FC<CalendarTabProps> = ({
calendars,
calendarEvents,
selectedCalendarEventId,
calendarEventToConversation,
onCopy,
copiedId,
}) => {
const currentDate = useCedarStore((state) => state.currentDate);
const visibleCalendarIds = useCedarStore((state) => state.visibleCalendarIds);
const isLoadingCalendars = useCedarStore((state) => state.isLoadingCalendars);
const [expandedCalendars, setExpandedCalendars] = useState<Set<string>>(new Set());
const [expandedEvents, setExpandedEvents] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [showCalendarsSection, setShowCalendarsSection] = useState(true);
const [showEventsSection, setShowEventsSection] = useState(true);
const [showMappingsSection, setShowMappingsSection] = useState(true);
const safeStringify = (obj: unknown, indent = 2): string => {
try {
return JSON.stringify(obj, null, indent);
} catch (error) {
return `[Error serializing: ${error instanceof Error ? error.message : 'Unknown'}]`;
}
};
const formatDate = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const filteredCalendars = useMemo(() => {
if (!searchQuery) return calendars;
const query = searchQuery.toLowerCase();
return calendars.filter((calendar) => {
if (calendar.id?.toLowerCase().includes(query)) return true;
if (calendar.summary?.toLowerCase().includes(query)) return true;
return false;
});
}, [calendars, searchQuery]);
const filteredEvents = useMemo(() => {
if (!searchQuery) return calendarEvents;
const query = searchQuery.toLowerCase();
return calendarEvents.filter((event) => {
if (event.id?.toLowerCase().includes(query)) return true;
if (event.summary?.toLowerCase().includes(query)) return true;
if (event.calendarId?.toLowerCase().includes(query)) return true;
return false;
});
}, [calendarEvents, searchQuery]);
const filteredEventMappings = useMemo(() => {
const mappingEntries = Object.entries(calendarEventToConversation);
if (!searchQuery) return mappingEntries;
const filteredEventIds = new Set(filteredEvents.map((event) => event.id).filter(Boolean));
return mappingEntries.filter(([eventId]) => filteredEventIds.has(eventId));
}, [calendarEventToConversation, filteredEvents, searchQuery]);
const toggleCalendar = (calendarId: string) => {
setExpandedCalendars((prev) => {
const next = new Set(prev);
if (next.has(calendarId)) {
next.delete(calendarId);
} else {
next.add(calendarId);
}
return next;
});
};
const toggleEvent = (eventId: string) => {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(eventId)) {
next.delete(eventId);
} else {
next.add(eventId);
}
return next;
});
};
const visibleCalendarIdsArray = Array.from(visibleCalendarIds);
return (
<div className="flex h-full flex-col">
{/* State Overview Section */}
<div className="border-b border-gray-200 bg-emerald-50/50 p-3 dark:border-gray-700 dark:bg-emerald-950/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="font-medium text-emerald-900 dark:text-emerald-100">
Calendar State Overview
</span>
</div>
<button
onClick={() =>
onCopy(
safeStringify({
currentDate,
calendarsCount: calendars.length,
visibleCalendarIds: visibleCalendarIdsArray,
isLoadingCalendars,
eventsCount: calendarEvents.length,
selectedCalendarEventId,
mappingsCount: Object.keys(calendarEventToConversation).length,
calendarEventToConversation,
}),
'calendarDebugState',
)
}
className="rounded p-0.5 transition-colors hover:bg-emerald-200 dark:hover:bg-emerald-800"
>
{copiedId === 'calendarDebugState' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Current Date:</span>
<div className="font-mono">{formatDate(currentDate)}</div>
</div>
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Loading:</span>
<div
className={cn('font-mono', isLoadingCalendars ? 'text-yellow-600' : 'text-green-600')}
>
{isLoadingCalendars ? 'Yes' : 'No'}
</div>
</div>
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Calendars:</span>
<div className="font-mono">
{calendars.length} total, {visibleCalendarIds.size} visible
</div>
</div>
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Events:</span>
<div className="font-mono">{calendarEvents.length}</div>
</div>
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Selected Event:</span>
<div className="truncate font-mono text-xs">
{selectedCalendarEventId || (
<span className="text-gray-400">None</span>
)}
</div>
</div>
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Event → Conv Mappings:</span>
<div className="font-mono">{Object.keys(calendarEventToConversation).length}</div>
</div>
</div>
</div>
{/* Event → Conversation Mappings Section */}
{filteredEventMappings.length > 0 && (
<div className="border-b border-gray-200 bg-blue-50/50 p-3 dark:border-gray-700 dark:bg-blue-950/20">
<div
className="flex cursor-pointer items-center justify-between"
onClick={() => setShowMappingsSection(!showMappingsSection)}
>
<div className="flex items-center gap-2">
<Link className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium text-blue-900 dark:text-blue-100">
Event → Conversation Mappings ({filteredEventMappings.length})
</span>
</div>
{showMappingsSection ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
<AnimatePresence>
{showMappingsSection && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto rounded bg-white p-2 dark:bg-gray-800">
{filteredEventMappings.map(([eventId, conversationId]) => {
const event = calendarEvents.find((e) => e.id === eventId);
const isSelected = selectedCalendarEventId === eventId;
return (
<div
key=[redacted]
className={cn(
'flex items-center justify-between rounded border p-1.5 text-xs',
isSelected
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950'
: 'border-gray-200 dark:border-gray-700',
)}
>
<div className="flex flex-1 flex-col gap-0.5">
<div className="flex items-center gap-1">
{isSelected && (
<span className="rounded bg-blue-500 px-1 py-0.5 text-xs font-bold text-white">
SELECTED
</span>
)}
<span className="font-medium">
{event?.summary || 'Unknown Event'}
</span>
</div>
<div className="flex items-center gap-2 font-mono text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{eventId}</span>
<span>→</span>
<span className="truncate">{conversationId}</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onCopy(`${eventId} → ${conversationId}`, `mapping-${eventId}`);
}}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === `mapping-${eventId}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Search Bar */}
<div className="border-b border-gray-200 p-2 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search calendars and events..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded border border-gray-300 bg-white py-1 pl-7 pr-2 text-xs placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
{/* Content */}
<div className="flex-1 space-y-3 overflow-y-auto p-2">
{/* Calendars Section */}
<div>
<div
className="mb-2 flex cursor-pointer items-center justify-between text-xs font-semibold text-gray-600 dark:text-gray-400"
onClick={() => setShowCalendarsSection(!showCalendarsSection)}
>
<span>Calendars ({filteredCalendars.length})</span>
{showCalendarsSection ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
<AnimatePresence>
{showCalendarsSection && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{filteredCalendars.length === 0 ? (
<div className="py-4 text-center text-xs text-gray-500">
{searchQuery ? 'No calendars match' : 'No calendars loaded'}
</div>
) : (
<div className="space-y-2">
{filteredCalendars.map((calendar) => {
const calendarId = calendar.id || '';
const isExpanded = expandedCalendars.has(calendarId);
const isVisible = visibleCalendarIds.has(calendarId);
return (
<div
key=[redacted]
className="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-950"
>
<div
className={cn(
'flex cursor-pointer items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-900/80',
isExpanded ? 'rounded-t-lg' : 'rounded-lg',
)}
onClick={() => toggleCalendar(calendarId)}
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
{isVisible ? (
<Eye className="h-3 w-3 text-green-600" />
) : (
<EyeOff className="h-3 w-3 text-gray-400" />
)}
<span className="text-xs font-medium">
{calendar.summary || 'Unnamed Calendar'}
</span>
{calendar.primary && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Primary
</span>
)}
{calendar.backgroundColor && (
<div
className="h-3 w-3 rounded-full border border-gray-300"
style={{ backgroundColor: calendar.backgroundColor }}
/>
)}
</div>
<span className="ml-5 font-mono text-xs text-gray-500 dark:text-gray-400">
{calendarId}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(calendar), calendarId);
}}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === calendarId ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
className="rounded-b-lg p-2 pt-0"
onClick={(e) => e.stopPropagation()}
>
<JsonTreeView data={calendar} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-64" />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Events Section */}
<div>
<div
className="mb-2 flex cursor-pointer items-center justify-between text-xs font-semibold text-gray-600 dark:text-gray-400"
onClick={() => setShowEventsSection(!showEventsSection)}
>
<span>Events ({filteredEvents.length})</span>
{showEventsSection ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
<AnimatePresence>
{showEventsSection && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{filteredEvents.length === 0 ? (
<div className="py-4 text-center text-xs text-gray-500">
{searchQuery ? 'No events match' : 'No events loaded'}
</div>
) : (
<div className="space-y-2">
{filteredEvents.map((event, index) => {
const eventId = event.id || `event-${index}`;
const isExpanded = expandedEvents.has(eventId);
return (
<div
key=[redacted]
className="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-950"
>
<div
className={cn(
'flex cursor-pointer items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-900/80',
isExpanded ? 'rounded-t-lg' : 'rounded-lg',
)}
onClick={() => toggleEvent(eventId)}
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{event.summary || 'Untitled Event'}
</span>
{event.status && (
<span
className={cn(
'rounded px-1.5 py-0.5 text-xs',
event.status === 'confirmed'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: event.status === 'tentative'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
)}
>
{event.status}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span className="font-mono">{eventId}</span>
{event.calendarId && (
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
{event.calendarId}
</span>
)}
</div>
{(event.start || event.end) && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{event.start?.dateTime && formatDate(event.start.dateTime)}
{event.start?.date}
{event.end && ' → '}
{event.end?.dateTime && formatDate(event.end.dateTime)}
{event.end?.date}
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(event), eventId);
}}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === eventId ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
className="rounded-b-lg p-2 pt-0"
onClick={(e) => e.stopPropagation()}
>
<JsonTreeView data={event} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-64" />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-200 p-2 text-center text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
{searchQuery
? `${filteredCalendars.length} calendars, ${filteredEvents.length} events`
: `${calendars.length} calendars, ${calendarEvents.length} events`}
</div>
</div>
);
};