Spaces:
Paused
Paused
| /** | |
| * CalendarWidget - Multi-Calendar Unified View | |
| * Integrates Google Calendar, Outlook, Apple Calendar | |
| * | |
| * Features: | |
| * - Auto-discovery of calendar sources | |
| * - Unified timeline view | |
| * - Quick meeting join | |
| * - Event creation | |
| * - Broadcasting calendar events to other widgets | |
| */ | |
| import React, { useEffect, useState } from 'react'; | |
| import { Calendar, Video, Users, Clock, Plus, ChevronLeft, ChevronRight } from 'lucide-react'; | |
| import { useLiveData } from '@/hooks/useLiveData'; | |
| import { useWidgetCommunication } from '@/contexts/WidgetContext'; | |
| import { cn } from '@/lib/utils'; | |
| interface CalendarEvent { | |
| id: string; | |
| title: string; | |
| start: string; | |
| end: string; | |
| attendees?: string[]; | |
| location?: string; | |
| meetingLink?: string; | |
| calendar: string; | |
| color?: string; | |
| } | |
| interface CalendarWidgetProps { | |
| widgetId: string; | |
| } | |
| export default function CalendarWidget({ widgetId }: CalendarWidgetProps) { | |
| const [selectedDate, setSelectedDate] = useState(new Date()); | |
| const [viewMode, setViewMode] = useState<'day' | 'week' | 'month'>('day'); | |
| // AUTO-DISCOVERY: Connect to calendar sources | |
| const { | |
| data: calendarData, | |
| connected, | |
| recommendedSources, | |
| connectionStatus, | |
| refetch, | |
| } = useLiveData({ | |
| widgetId, | |
| widgetType: 'calendar', | |
| requiredSources: ['google-calendar', 'outlook-calendar'], | |
| optionalSources: ['apple-calendar', 'notion-calendar'], | |
| autoConnect: true, | |
| pollInterval: 60000, // Refresh every minute | |
| }); | |
| // INTER-WIDGET COMMUNICATION | |
| const { broadcastEvent, subscribeToEvent } = useWidgetCommunication(widgetId); | |
| // Broadcast upcoming meeting | |
| useEffect(() => { | |
| if (calendarData?.events) { | |
| const now = new Date(); | |
| const upcomingMeeting = calendarData.events.find((event: CalendarEvent) => { | |
| const start = new Date(event.start); | |
| const diff = start.getTime() - now.getTime(); | |
| return diff > 0 && diff < 5 * 60 * 1000; // 5 minutes before | |
| }); | |
| if (upcomingMeeting) { | |
| broadcastEvent({ | |
| type: 'calendar.meeting.upcoming', | |
| data: { | |
| meetingId: upcomingMeeting.id, | |
| title: upcomingMeeting.title, | |
| start: upcomingMeeting.start, | |
| attendees: upcomingMeeting.attendees, | |
| meetingLink: upcomingMeeting.meetingLink, | |
| }, | |
| }); | |
| } | |
| } | |
| }, [calendarData]); | |
| // Listen to task completion events to update calendar | |
| useEffect(() => { | |
| const unsub = subscribeToEvent('task.completed', (event) => { | |
| // Remove related calendar block if task is done | |
| if (event.data.calendarBlockId) { | |
| // TODO: Remove calendar block | |
| } | |
| }); | |
| return () => unsub(); | |
| }, []); | |
| const handleJoinMeeting = (meeting: CalendarEvent) => { | |
| if (meeting.meetingLink) { | |
| window.open(meeting.meetingLink, '_blank'); | |
| broadcastEvent({ | |
| type: 'calendar.meeting.joined', | |
| data: { | |
| meetingId: meeting.id, | |
| title: meeting.title, | |
| }, | |
| }); | |
| } | |
| }; | |
| const todayEvents = calendarData?.events?.filter((event: CalendarEvent) => { | |
| const eventDate = new Date(event.start); | |
| return eventDate.toDateString() === selectedDate.toDateString(); | |
| }) || []; | |
| const nextMeeting = todayEvents.filter((event: CalendarEvent) => { | |
| return new Date(event.start) > new Date(); | |
| })[0]; | |
| return ( | |
| <div className="h-full flex flex-col bg-gradient-to-br from-blue-500/10 to-purple-500/10 backdrop-blur-sm border border-blue-500/30 rounded-lg overflow-hidden"> | |
| {/* Header */} | |
| <div className="p-4 border-b border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-transparent"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <Calendar className="w-5 h-5 text-blue-400" /> | |
| <span className="font-display text-sm uppercase tracking-wider text-blue-400"> | |
| Calendar | |
| </span> | |
| </div> | |
| {/* View Mode Toggle */} | |
| <div className="flex gap-1 bg-black/20 rounded p-1"> | |
| {(['day', 'week', 'month'] as const).map((mode) => ( | |
| <button | |
| key={mode} | |
| onClick={() => setViewMode(mode)} | |
| className={cn( | |
| "px-2 py-1 rounded text-xs transition-all", | |
| viewMode === mode | |
| ? "bg-blue-500 text-white" | |
| : "text-blue-300 hover:bg-blue-500/20" | |
| )} | |
| > | |
| {mode} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Date Navigator */} | |
| <div className="flex items-center justify-between"> | |
| <button | |
| onClick={() => { | |
| const newDate = new Date(selectedDate); | |
| newDate.setDate(newDate.getDate() - 1); | |
| setSelectedDate(newDate); | |
| }} | |
| className="p-1 hover:bg-blue-500/20 rounded transition-colors" | |
| > | |
| <ChevronLeft className="w-4 h-4 text-blue-300" /> | |
| </button> | |
| <div className="text-center"> | |
| <div className="text-lg font-semibold text-white"> | |
| {selectedDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} | |
| </div> | |
| <div className="text-xs text-blue-300"> | |
| {todayEvents.length} events | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => { | |
| const newDate = new Date(selectedDate); | |
| newDate.setDate(newDate.getDate() + 1); | |
| setSelectedDate(newDate); | |
| }} | |
| className="p-1 hover:bg-blue-500/20 rounded transition-colors" | |
| > | |
| <ChevronRight className="w-4 h-4 text-blue-300" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Connection Status */} | |
| {!connected && ( | |
| <div className="p-3 bg-orange-500/10 border-b border-orange-500/30"> | |
| <div className="flex items-center gap-2 text-xs text-orange-400"> | |
| <Clock className="w-4 h-4" /> | |
| <span>Connecting to calendar sources...</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Next Meeting Highlight */} | |
| {nextMeeting && ( | |
| <div className="p-3 bg-green-500/10 border-b border-green-500/30"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <div className="text-xs text-green-400 mb-1">Up Next</div> | |
| <div className="text-sm font-medium text-white">{nextMeeting.title}</div> | |
| <div className="text-xs text-green-300"> | |
| {new Date(nextMeeting.start).toLocaleTimeString('en-US', { | |
| hour: 'numeric', | |
| minute: '2-digit' | |
| })} | |
| </div> | |
| </div> | |
| {nextMeeting.meetingLink && ( | |
| <button | |
| onClick={() => handleJoinMeeting(nextMeeting)} | |
| className="flex items-center gap-2 px-3 py-2 bg-green-500 text-white rounded text-xs hover:bg-green-600 transition-colors" | |
| > | |
| <Video className="w-3 h-3" /> | |
| Join | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Events List */} | |
| <div className="flex-1 overflow-y-auto p-3 space-y-2"> | |
| {connected && todayEvents.length === 0 && ( | |
| <div className="text-center py-8"> | |
| <Calendar className="w-8 h-8 text-blue-400/30 mx-auto mb-2" /> | |
| <p className="text-sm text-blue-300/50">No events for this day</p> | |
| </div> | |
| )} | |
| {todayEvents.map((event: CalendarEvent) => { | |
| const isPast = new Date(event.end) < new Date(); | |
| const isNow = new Date(event.start) <= new Date() && new Date(event.end) >= new Date(); | |
| return ( | |
| <div | |
| key={event.id} | |
| className={cn( | |
| "p-3 rounded-lg border transition-all", | |
| isNow && "bg-green-500/20 border-green-500/50", | |
| !isNow && !isPast && "bg-blue-500/10 border-blue-500/30 hover:bg-blue-500/20", | |
| isPast && "bg-gray-500/10 border-gray-500/20 opacity-50" | |
| )} | |
| > | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="flex-1"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <div | |
| className="w-2 h-2 rounded-full" | |
| style={{ backgroundColor: event.color || '#3b82f6' }} | |
| /> | |
| <span className="text-sm font-medium text-white"> | |
| {event.title} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-3 text-xs text-blue-300"> | |
| <div className="flex items-center gap-1"> | |
| <Clock className="w-3 h-3" /> | |
| {new Date(event.start).toLocaleTimeString('en-US', { | |
| hour: 'numeric', | |
| minute: '2-digit' | |
| })} | |
| </div> | |
| {event.attendees && event.attendees.length > 0 && ( | |
| <div className="flex items-center gap-1"> | |
| <Users className="w-3 h-3" /> | |
| {event.attendees.length} | |
| </div> | |
| )} | |
| </div> | |
| {event.location && ( | |
| <div className="text-xs text-blue-300/70 mt-1"> | |
| 📍 {event.location} | |
| </div> | |
| )} | |
| </div> | |
| {event.meetingLink && !isPast && ( | |
| <button | |
| onClick={() => handleJoinMeeting(event)} | |
| className="p-1.5 bg-blue-500/20 hover:bg-blue-500/30 rounded transition-colors" | |
| > | |
| <Video className="w-4 h-4 text-blue-400" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Source Recommendations */} | |
| {recommendedSources.length > 0 && ( | |
| <div className="p-3 border-t border-blue-500/20 bg-orange-500/10"> | |
| <div className="text-xs text-orange-400 mb-2"> | |
| Missing Calendar Sources: | |
| </div> | |
| {recommendedSources.map((source) => ( | |
| <div key={source.id} className="flex items-center justify-between mb-1"> | |
| <span className="text-xs text-orange-300">{source.name}</span> | |
| <button | |
| onClick={() => { | |
| // Enable source | |
| refetch(); | |
| }} | |
| className="px-2 py-1 bg-orange-500 text-white rounded text-xs" | |
| > | |
| Enable | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Quick Actions */} | |
| <div className="p-3 border-t border-blue-500/20 bg-black/20"> | |
| <button className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors"> | |
| <Plus className="w-4 h-4" /> | |
| <span className="text-sm">New Event</span> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |