import { TimeSlot, Participant } from '@/types/calendar'; import { cn } from '@/lib/utils'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { useState, useRef, useEffect } from 'react'; import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react'; const DEFAULT_TIMEZONE = 'America/Toronto'; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; const hours = Array.from({ length: 24 }, (_, i) => i); // 0-23 // Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => { // Get "now" in the target timezone const now = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', }); // Parse today's date in the target timezone const todayStr = formatter.format(now); const [year, month, day] = todayStr.split('-').map(Number); // Calculate Monday of this week const todayDate = new Date(year, month - 1, day); const dayOfWeek = todayDate.getDay(); const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7); return dayNames.map((_, i) => { const d = new Date(mondayDate); d.setDate(mondayDate.getDate() + i); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; }); }; // Convert a date string and hour in a timezone to a UTC Date const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => { // Create a date string that represents the given hour in the given timezone // Then parse it to get the UTC equivalent const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:00:00`; // Use a trick: format in UTC then in target TZ to find the offset const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first // Get what hour this would be in the target timezone const tzHour = parseInt( new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', hour12: false, }).format(testDate) ); // Calculate offset in hours const offset = tzHour - testDate.getUTCHours(); // Adjust: if we want `hour` in timezone, subtract the offset to get UTC const utcDate = new Date(localDateStr + 'Z'); utcDate.setUTCHours(utcDate.getUTCHours() - offset); return utcDate; }; const MIN_WEEK_OFFSET = 0; const DEFAULT_MAX_WEEK_OFFSET = 1; const EXPANDED_MAX_WEEK_OFFSET = 4; // Format timezone for display (e.g., "America/Toronto" -> "Toronto (EST)") const formatTimezoneDisplay = (timezone: string): string => { try { const parts = timezone.split('/'); const city = parts[parts.length - 1].replace(/_/g, ' '); const now = new Date(); const formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short', }); const formattedParts = formatter.formatToParts(now); const tzAbbrev = formattedParts.find((p) => p.type === 'timeZoneName')?.value || ''; return `${city} (${tzAbbrev})`; } catch { return timezone; } }; // Get timezone abbreviation (e.g., "America/Toronto" -> "EST") const getTimezoneAbbrev = (timezone: string): string => { try { const now = new Date(); const formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short', }); const parts = formatter.formatToParts(now); return parts.find((p) => p.type === 'timeZoneName')?.value || ''; } catch { return ''; } }; // Convert an hour from one timezone to another const convertHourBetweenTimezones = ( hour: number, dateStr: string, fromTimezone: string, toTimezone: string ): number => { try { // Create a UTC date for the given hour in the source timezone const utcDate = toUTCDate(dateStr, hour, fromTimezone); // Format the hour in the target timezone const targetHour = parseInt( new Intl.DateTimeFormat('en-US', { timeZone: toTimezone, hour: 'numeric', hour12: false, }).format(utcDate) ); return targetHour; } catch { return hour; } }; interface AvailabilityHeatmapProps { slots: TimeSlot[]; selectedParticipants: Participant[]; onSlotSelect: (slot: TimeSlot) => void; showPartialAvailability?: boolean; isLoading?: boolean; weekOffset?: number; onWeekOffsetChange?: (offset: number) => void; displayTimezone?: string; showSecondaryTimezone?: boolean; secondaryTimezone?: string; } export const AvailabilityHeatmap = ({ slots, selectedParticipants, onSlotSelect, showPartialAvailability = false, isLoading = false, weekOffset = 0, onWeekOffsetChange, displayTimezone = DEFAULT_TIMEZONE, showSecondaryTimezone = false, secondaryTimezone = DEFAULT_TIMEZONE, }: AvailabilityHeatmapProps) => { const [expanded, setExpanded] = useState(false); const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET; const weekDates = getWeekDates(displayTimezone, weekOffset); // Get current time info in display timezone const getCurrentTimeInfo = () => { const now = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: displayTimezone, year: 'numeric', month: '2-digit', day: '2-digit', }); const hourFormatter = new Intl.DateTimeFormat('en-US', { timeZone: displayTimezone, hour: 'numeric', hour12: false, }); const todayStr = formatter.format(now); const currentHour = parseInt(hourFormatter.format(now)); return { todayStr, currentHour }; }; const { todayStr, currentHour } = getCurrentTimeInfo(); const todayIndex = weekDates.indexOf(todayStr); const scrollContainerRef = useRef(null); const currentHourRef = useRef(null); // Track scroll position for fade indicators const [canScrollUp, setCanScrollUp] = useState(false); const [canScrollDown, setCanScrollDown] = useState(true); const handleScroll = () => { if (!scrollContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; setCanScrollUp(scrollTop > 10); setCanScrollDown(scrollTop < scrollHeight - clientHeight - 10); }; // Initialize scroll indicators after content loads useEffect(() => { const timer = setTimeout(handleScroll, 150); return () => clearTimeout(timer); }, [slots.length, selectedParticipants.length, isLoading]); // Auto-scroll to position current hour as 3rd visible row useEffect(() => { // Only scroll when we have content to show if (selectedParticipants.length === 0 || isLoading) return; // Small delay to ensure DOM is ready after render const timer = setTimeout(() => { if (!scrollContainerRef.current) return; const rowHeight = 52; // h-12 (48px) + gap (4px) const rowsAbove = 2; // Calculate which hour should be at the top const targetHour = todayIndex >= 0 ? Math.max(0, currentHour - rowsAbove) : 7; // Default to 7am for other weeks scrollContainerRef.current.scrollTop = targetHour * rowHeight; }, 100); return () => clearTimeout(timer); }, [weekOffset, todayIndex, currentHour, selectedParticipants.length, isLoading, slots.length]); // Find a slot that matches the given display timezone date/hour const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => { // Convert display timezone date/hour to UTC const targetUTC = toUTCDate(dateStr, hour, displayTimezone); return slots.find((s) => { const slotDate = new Date(s.start_time); // Compare UTC timestamps (with some tolerance for rounding) return Math.abs(slotDate.getTime() - targetUTC.getTime()) < 60000; // 1 minute tolerance }); }; const getEffectiveAvailability = (slot: TimeSlot) => { if (slot.availability === 'partial' && !showPartialAvailability) { return 'none'; } return slot.availability; }; const formatHour = (hour: number) => { return `${hour.toString().padStart(2, '0')}:00`; }; const isSlotTooSoon = (dateStr: string, hour: number) => { // Convert to UTC and compare with current time const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone); const now = new Date(); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); return slotTimeUTC < twoHoursFromNow; }; const getWeekDateRange = () => { if (weekDates.length < 5) return ''; const monday = new Date(weekDates[0] + 'T12:00:00Z'); const friday = new Date(weekDates[4] + 'T12:00:00Z'); const format = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); return `${format(monday)} – ${format(friday)}`; }; // Format hour for display in popover (in the display timezone) const formatDisplayTime = (hour: number) => { // Create a date at that hour const date = new Date(); date.setHours(hour, 0, 0, 0); return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }).format(date); }; if (selectedParticipants.length === 0) { return (

Select participants to view availability

Add people from the search above to see their common free times

); } if (isLoading) { return (

Loading availability...

); } return (

Common Availability — Week of {getWeekDateRange()}

{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')} {`Times in ${formatTimezoneDisplay(displayTimezone)}`}

{onWeekOffsetChange && (
{weekOffset !== 0 && ( )} {weekOffset < maxWeekOffset ? ( ) : !expanded ? ( ) : ( )}
)}
{/* Scroll fade indicator - top */}
{/* Scroll fade indicator - bottom */}
↓ Scroll for more hours
{showSecondaryTimezone ? ( <>
{getTimezoneAbbrev(displayTimezone)}
{getTimezoneAbbrev(secondaryTimezone)}
) : (
)} {dayNames.map((dayName, i) => (
{dayName}
{weekDates[i]?.slice(5).replace('-', '/')}
))}
{hours.map((hour) => (
= 0 && hour === currentHour ? currentHourRef : undefined} className={cn( "grid gap-1", showSecondaryTimezone ? "grid-cols-[50px_50px_repeat(5,1fr)]" : "grid-cols-[60px_repeat(5,1fr)]" )} > {showSecondaryTimezone ? ( <>
{todayIndex >= 0 && hour === currentHour && ( )} {formatHour(hour)}
{formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))}
) : (
{todayIndex >= 0 && hour === currentHour && ( )} {formatHour(hour)}
)} {weekDates.map((dateStr, dayIndex) => { const slot = getSlot(dateStr, hour); const dayName = dayNames[dayIndex]; const tooSoon = isSlotTooSoon(dateStr, hour); if (!slot) return
; const effectiveAvailability = getEffectiveAvailability(slot); return ( )}
); })}
))}
All free
{showPartialAvailability && (
Partial
)}
No overlap
); };