Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

DebatesCalendar.tsx22.1 kB
/** * DebatesCalendar Component * * Displays a calendar view with square indicators showing debate activity: * - Blue squares: House Debates (without Question Period) * - Red squares: Question Period * - Green squares: Committee Testimony * - Purple outlined squares: Scheduled Meetings * * Features: * - Toggle between 1 month and 3 month view (persisted in localStorage) * - Drag to select date ranges * - Keyboard navigation (arrows + Enter, Shift for range selection) * - Rich hover popovers showing event details * - ESC to clear selection */ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useQuery } from '@apollo/client'; import { GET_DEBATES_CALENDAR_DATA } from '@/lib/queries'; import { ChevronLeft, ChevronRight, X, Calendar } from 'lucide-react'; import { format, startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths, isSameDay, isSameMonth, startOfWeek, endOfWeek, addDays, subDays } from 'date-fns'; import { enUS, fr } from 'date-fns/locale'; import { useLocale } from 'next-intl'; interface ScheduledMeetingInfo { committee_code: string; committee_name: string; number: number; in_camera: boolean; } interface DebatesByDate { date: string; hasHouseDebates: boolean; hasQuestionPeriod: boolean; hasCommittee: boolean; hasScheduledMeeting: boolean; scheduledMeetings: ScheduledMeetingInfo[]; } interface DebatesCalendarProps { onDateRangeSelect: (start: Date | null, end: Date | null) => void; selectedStartDate: Date | null; selectedEndDate: Date | null; onViewModeChange?: (monthsToShow: 1 | 3) => void; } const STORAGE_KEY = 'canadagpt_calendar_view_mode'; export function DebatesCalendar({ onDateRangeSelect, selectedStartDate, selectedEndDate, onViewModeChange }: DebatesCalendarProps) { const locale = useLocale(); const dateLocale = locale === 'fr' ? fr : enUS; const calendarRef = useRef<HTMLDivElement>(null); const [focusedDate, setFocusedDate] = useState<Date | null>(null); // View mode: 1 or 3 months, persisted in localStorage const [monthsToShow, setMonthsToShow] = useState<1 | 3>(1); const [currentMonth, setCurrentMonth] = useState(new Date()); // Drag selection state const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState<Date | null>(null); const [dragPreview, setDragPreview] = useState<Date | null>(null); // Hover popover state const [hoveredDate, setHoveredDate] = useState<string | null>(null); const [popoverPosition, setPopoverPosition] = useState<{ top: number; left: number } | null>(null); // Load view preference from localStorage useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); if (stored === '1' || stored === '3') { const value = parseInt(stored) as 1 | 3; setMonthsToShow(value); onViewModeChange?.(value); } }, [onViewModeChange]); // Save view preference to localStorage const toggleMonthsView = () => { const newValue = monthsToShow === 1 ? 3 : 1; setMonthsToShow(newValue); localStorage.setItem(STORAGE_KEY, newValue.toString()); onViewModeChange?.(newValue); }; // Calculate calendar range based on view mode const previousMonth = subMonths(currentMonth, 1); const nextMonth = addMonths(currentMonth, 1); const calendarStart = monthsToShow === 3 ? format(startOfMonth(previousMonth), 'yyyy-MM-dd') : format(startOfMonth(currentMonth), 'yyyy-MM-dd'); const calendarEnd = monthsToShow === 3 ? format(endOfMonth(nextMonth), 'yyyy-MM-dd') : format(endOfMonth(currentMonth), 'yyyy-MM-dd'); // Fetch calendar data const { data, loading } = useQuery(GET_DEBATES_CALENDAR_DATA, { variables: { startDate: calendarStart, endDate: calendarEnd, }, }); const debatesData: DebatesByDate[] = data?.debatesCalendarData || []; // Create a map for quick lookup const debatesMap = new Map<string, DebatesByDate>(); debatesData.forEach((item) => { debatesMap.set(item.date, item); }); const clearSelection = useCallback(() => { onDateRangeSelect(null, null); setDragStart(null); setDragPreview(null); setIsDragging(false); }, [onDateRangeSelect]); // ESC key to clear selection useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && (selectedStartDate || isDragging)) { clearSelection(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedStartDate, isDragging, clearSelection]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!focusedDate) return; let newDate: Date | null = null; switch (e.key) { case 'ArrowLeft': e.preventDefault(); newDate = subDays(focusedDate, 1); break; case 'ArrowRight': e.preventDefault(); newDate = addDays(focusedDate, 1); break; case 'ArrowUp': e.preventDefault(); newDate = subDays(focusedDate, 7); break; case 'ArrowDown': e.preventDefault(); newDate = addDays(focusedDate, 7); break; case 'Enter': e.preventDefault(); const dateStr = format(focusedDate, 'yyyy-MM-dd'); const debate = debatesMap.get(dateStr); if (debate && (debate.hasHouseDebates || debate.hasQuestionPeriod || debate.hasCommittee || debate.hasScheduledMeeting)) { if (e.shiftKey && selectedStartDate) { // Shift+Enter for range selection const start = focusedDate < selectedStartDate ? focusedDate : selectedStartDate; const end = focusedDate < selectedStartDate ? selectedStartDate : focusedDate; onDateRangeSelect(start, end); } else { // Enter for single date selection onDateRangeSelect(focusedDate, focusedDate); } } break; } if (newDate) { setFocusedDate(newDate); // Update currentMonth if we navigate outside visible range if (!isSameMonth(newDate, currentMonth)) { setCurrentMonth(newDate); } } }; if (focusedDate) { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); } }, [focusedDate, currentMonth, selectedStartDate, debatesMap, onDateRangeSelect]); const goToPreviousMonth = () => { setCurrentMonth(subMonths(currentMonth, 1)); }; const goToNextMonth = () => { setCurrentMonth(addMonths(currentMonth, 1)); }; const goToToday = () => { setCurrentMonth(new Date()); setFocusedDate(new Date()); }; // Mouse handlers for drag selection const handleMouseDown = (date: Date, hasDebates: boolean) => { if (!hasDebates) return; setIsDragging(true); setDragStart(date); setFocusedDate(date); onDateRangeSelect(date, date); }; const handleMouseEnter = (date: Date, event: React.MouseEvent<HTMLButtonElement>) => { if (isDragging && dragStart) { setDragPreview(date); const start = dragStart < date ? dragStart : date; const end = dragStart < date ? date : dragStart; onDateRangeSelect(start, end); } // Update popover position const rect = event.currentTarget.getBoundingClientRect(); setPopoverPosition({ top: rect.bottom + window.scrollY, left: rect.left + rect.width / 2 + window.scrollX, }); }; const handleMouseUp = () => { setIsDragging(false); setDragPreview(null); }; // Add global mouse up listener useEffect(() => { if (isDragging) { window.addEventListener('mouseup', handleMouseUp); return () => window.removeEventListener('mouseup', handleMouseUp); } }, [isDragging]); const isInDragRange = (day: Date): boolean => { if (!isDragging || !dragStart || !dragPreview) return false; const start = dragStart < dragPreview ? dragStart : dragPreview; const end = dragStart < dragPreview ? dragPreview : dragStart; return day >= start && day <= end; }; const renderMonth = (monthDate: Date) => { const monthStart = startOfMonth(monthDate); const monthEnd = endOfMonth(monthDate); const startDate = startOfWeek(monthStart); const endDate = endOfWeek(monthEnd); const days = eachDayOfInterval({ start: startDate, end: endDate }); return ( <div key={monthDate.toISOString()} className="flex-1 min-w-[240px]"> <h3 className="text-base font-semibold text-text-primary mb-2 text-center"> {format(monthDate, 'MMMM yyyy', { locale: dateLocale })} </h3> {/* Weekday headers */} <div className="grid grid-cols-7 gap-1 mb-1"> {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, index) => ( <div key={index} className="text-center text-xs font-medium text-text-tertiary p-0.5"> {day} </div> ))} </div> {/* Calendar grid */} <div className="grid grid-cols-7 gap-1"> {days.map((day) => { const dateStr = format(day, 'yyyy-MM-dd'); const debate = debatesMap.get(dateStr); const hasDebates = debate && (debate.hasHouseDebates || debate.hasQuestionPeriod || debate.hasCommittee || debate.hasScheduledMeeting); const isCurrentMonth = isSameMonth(day, monthDate); const isToday = isSameDay(day, new Date()); const isSelected = (selectedStartDate && isSameDay(day, selectedStartDate)) || (selectedEndDate && isSameDay(day, selectedEndDate)); const isInRange = selectedStartDate && selectedEndDate && day >= selectedStartDate && day <= selectedEndDate; const isFocused = focusedDate && isSameDay(day, focusedDate); const isHovered = hoveredDate === dateStr; return ( <div key={day.toISOString()} className="relative"> <button onMouseDown={() => handleMouseDown(day, Boolean(hasDebates && isCurrentMonth))} onMouseEnter={(e) => { if (hasDebates && isCurrentMonth) { setHoveredDate(dateStr); handleMouseEnter(day, e); } }} onMouseLeave={() => { setHoveredDate(null); setPopoverPosition(null); }} onFocus={() => setFocusedDate(day)} disabled={!isCurrentMonth || !hasDebates} tabIndex={isCurrentMonth && hasDebates ? 0 : -1} className={` relative w-full h-12 p-1 text-sm rounded-lg transition-all flex flex-col items-center justify-start gap-0.5 ${isCurrentMonth ? 'text-text-primary' : 'text-text-tertiary'} ${hasDebates && isCurrentMonth ? 'cursor-pointer border-2 border-transparent hover:border-accent-red hover:shadow-md' : 'cursor-default border-2 border-transparent'} ${isToday ? 'ring-2 ring-accent-red ring-offset-1' : ''} ${isSelected ? 'bg-accent-red/30 border-accent-red' : ''} ${isInRange && !isSelected ? 'bg-accent-red/10' : ''} ${isInDragRange(day) && isDragging ? 'bg-accent-red/20' : ''} ${!isCurrentMonth || !hasDebates ? 'opacity-40' : ''} ${isFocused ? 'ring-2 ring-blue-500' : ''} `} > {/* Day number at top */} <div className="text-center text-xs leading-none">{format(day, 'd')}</div> {/* Debate indicator squares - 2x2 grid below the number */} {hasDebates && isCurrentMonth && ( <div className="grid grid-cols-2 gap-[2px] w-[18px] h-[18px]"> {/* Top Left - House (Green) */} <div className={`w-2 h-2 rounded-sm ${debate?.hasHouseDebates ? 'bg-green-500' : 'bg-text-tertiary/15'}`} /> {/* Top Right - QP (Blue) */} <div className={`w-2 h-2 rounded-sm ${debate?.hasQuestionPeriod ? 'bg-blue-500' : 'bg-text-tertiary/15'}`} /> {/* Bottom Left - Committee (Yellow) */} <div className={`w-2 h-2 rounded-sm ${debate?.hasCommittee ? 'bg-yellow-500' : 'bg-text-tertiary/15'}`} /> {/* Bottom Right - Scheduled (Purple) */} <div className={`w-2 h-2 rounded-sm ${debate?.hasScheduledMeeting ? 'bg-purple-500' : 'bg-text-tertiary/15'}`} /> </div> )} </button> {/* Rich hover popover */} {isHovered && debate && popoverPosition && ( <div className="fixed z-50 p-3 bg-bg-elevated border-2 border-accent-red rounded-lg shadow-xl min-w-[220px] transition-all animate-in fade-in duration-200" style={{ top: `${popoverPosition.top + 8}px`, left: `${popoverPosition.left}px`, transform: 'translateX(-50%)', }} > <div className="text-xs font-semibold text-text-primary mb-2"> {format(day, 'EEEE, MMMM d, yyyy', { locale: dateLocale })} </div> <div className="space-y-1.5"> {debate.hasHouseDebates && ( <div className="flex items-center gap-2"> <div className="w-2 h-2 rounded-sm bg-green-500 flex-shrink-0" /> <span className="text-xs text-text-secondary"> {locale === 'fr' ? 'Débats de la Chambre' : 'House Debates'} </span> </div> )} {debate.hasQuestionPeriod && ( <div className="flex items-center gap-2"> <div className="w-2 h-2 rounded-sm bg-blue-500 flex-shrink-0" /> <span className="text-xs text-text-secondary"> {locale === 'fr' ? 'Période des questions' : 'Question Period'} </span> </div> )} {debate.hasCommittee && ( <div className="flex items-center gap-2"> <div className="w-2 h-2 rounded-sm bg-yellow-500 flex-shrink-0" /> <span className="text-xs text-text-secondary"> {locale === 'fr' ? 'Témoignages de comité' : 'Committee Testimony'} </span> </div> )} {debate.hasScheduledMeeting && debate.scheduledMeetings.length > 0 && ( <div className="space-y-1 mt-2 pt-2 border-t border-border-subtle"> <div className="flex items-center gap-2"> <div className="w-2 h-2 rounded-sm border-2 border-purple-500 flex-shrink-0" /> <span className="text-xs font-medium text-text-primary"> {locale === 'fr' ? 'Réunions prévues:' : 'Scheduled Meetings:'} </span> </div> {debate.scheduledMeetings.map((meeting, idx) => ( <div key={idx} className="pl-4 text-xs text-text-tertiary"> {meeting.committee_name} {meeting.in_camera && ( <span className="ml-1 text-[10px] text-text-quaternary"> ({locale === 'fr' ? 'huis clos' : 'in camera'}) </span> )} </div> ))} </div> )} </div> </div> )} </div> ); })} </div> </div> ); }; return ( <div ref={calendarRef} className="bg-bg-secondary border border-border-subtle rounded-lg p-4 mb-6"> {/* Calendar header */} <div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-2"> <button onClick={goToPreviousMonth} className="p-1 hover:bg-bg-tertiary rounded transition-colors" aria-label="Previous month" > <ChevronLeft className="h-5 w-5 text-text-secondary" /> </button> <button onClick={goToNextMonth} className="p-1 hover:bg-bg-tertiary rounded transition-colors" aria-label="Next month" > <ChevronRight className="h-5 w-5 text-text-secondary" /> </button> <button onClick={goToToday} className="px-3 py-1 text-sm bg-bg-tertiary hover:bg-bg-tertiary/80 rounded transition-colors text-text-primary" > {locale === 'fr' ? "Aujourd'hui" : 'Today'} </button> {/* View toggle */} <button onClick={toggleMonthsView} className="px-3 py-1 text-sm bg-bg-tertiary hover:bg-bg-tertiary/80 rounded transition-colors text-text-primary flex items-center gap-1.5" aria-label={monthsToShow === 1 ? 'Show 3 months' : 'Show 1 month'} > <Calendar className="h-4 w-4" /> <span>{monthsToShow === 1 ? '3' : '1'} {locale === 'fr' ? 'mois' : 'month'}</span> </button> </div> {/* Selected range display with clear button */} {selectedStartDate && ( <div className="flex items-center gap-2 bg-accent-red/10 px-3 py-1 rounded-lg border border-accent-red/30"> <span className="text-sm font-medium text-text-primary"> {format(selectedStartDate, 'MMM d', { locale: dateLocale })} {selectedEndDate && !isSameDay(selectedStartDate, selectedEndDate) && ` - ${format(selectedEndDate, 'MMM d', { locale: dateLocale })}`} </span> <button onClick={clearSelection} className="p-0.5 hover:bg-accent-red/20 rounded transition-colors" aria-label="Clear selection" title={locale === 'fr' ? 'Effacer (ESC)' : 'Clear (ESC)'} > <X className="h-4 w-4 text-accent-red" /> </button> </div> )} </div> {/* Legend - 2x2 Grid Positions */} <div className="flex items-center gap-4 mb-3 text-xs text-text-secondary flex-wrap"> <div className="flex items-center gap-1.5"> <div className="grid grid-cols-2 gap-[1.5px] w-[15px] h-[15px]"> <div className="w-1.5 h-1.5 rounded-sm bg-green-500" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> </div> <span>{locale === 'fr' ? 'Chambre' : 'House'}</span> </div> <div className="flex items-center gap-1.5"> <div className="grid grid-cols-2 gap-[1.5px] w-[15px] h-[15px]"> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-blue-500" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> </div> <span>{locale === 'fr' ? 'PQ' : 'QP'}</span> </div> <div className="flex items-center gap-1.5"> <div className="grid grid-cols-2 gap-[1.5px] w-[15px] h-[15px]"> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-yellow-500" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> </div> <span>{locale === 'fr' ? 'Comité' : 'Committee'}</span> </div> <div className="flex items-center gap-1.5"> <div className="grid grid-cols-2 gap-[1.5px] w-[15px] h-[15px]"> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-text-tertiary/15" /> <div className="w-1.5 h-1.5 rounded-sm bg-purple-500" /> </div> <span>{locale === 'fr' ? 'Prévue' : 'Scheduled'}</span> </div> <div className="text-text-tertiary italic ml-auto"> {locale === 'fr' ? 'Glisser pour sélectionner une plage' : 'Drag to select range'} </div> </div> {/* Calendar grid */} {loading ? ( <div className="text-center text-text-tertiary py-8"> {locale === 'fr' ? 'Chargement...' : 'Loading...'} </div> ) : ( <div className={`grid gap-4 ${monthsToShow === 3 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}> {monthsToShow === 3 ? ( <> {renderMonth(previousMonth)} {renderMonth(currentMonth)} {renderMonth(nextMonth)} </> ) : ( renderMonth(currentMonth) )} </div> )} </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server