Files
common-availability/frontend/src/components/AvailabilityHeatmap.tsx
2026-02-05 13:45:32 -05:00

559 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(null);
const currentHourRef = useRef<HTMLDivElement>(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 (
<div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in">
<div className="text-muted-foreground">
<p className="text-lg font-medium mb-2">Select participants to view availability</p>
<p className="text-sm">Add people from the search above to see their common free times</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
<p className="text-muted-foreground">Loading availability...</p>
</div>
);
}
return (
<div className="bg-card rounded-xl shadow-card p-6 animate-slide-up">
<div className="mb-6 flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-foreground">
Common Availability Week of {getWeekDateRange()}
</h3>
</div>
<p className="text-sm text-muted-foreground mt-1">
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
<span className="mx-2"></span>
<span className="text-primary font-medium">
{`Times in ${formatTimezoneDisplay(displayTimezone)}`}
</span>
</p>
</div>
{onWeekOffsetChange && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={weekOffset <= MIN_WEEK_OFFSET}
onClick={() => onWeekOffsetChange(weekOffset - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
{weekOffset !== 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => onWeekOffsetChange(0)}
>
This week
</Button>
)}
{weekOffset < maxWeekOffset ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onWeekOffsetChange(weekOffset + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
) : !expanded ? (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1"
onClick={() => setExpanded(true)}
>
<ChevronsRight className="w-3.5 h-3.5" />
Look further ahead
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled
>
<ChevronRight className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
<div className="relative">
{/* Scroll fade indicator - top */}
<div
className={cn(
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-card to-transparent z-20 pointer-events-none transition-opacity duration-200",
canScrollUp ? "opacity-100" : "opacity-0"
)}
/>
{/* Scroll fade indicator - bottom */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card via-card/80 to-transparent z-20 pointer-events-none transition-opacity duration-200 flex items-end justify-center pb-2",
canScrollDown ? "opacity-100" : "opacity-0"
)}
>
<span className="text-xs text-muted-foreground animate-pulse"> Scroll for more hours</span>
</div>
<div
className="overflow-x-auto overflow-y-auto max-h-[500px]"
ref={scrollContainerRef}
onScroll={handleScroll}
>
<div className="min-w-[600px]">
<div className={cn(
"grid gap-1 mb-2 sticky top-0 bg-card z-10",
showSecondaryTimezone
? "grid-cols-[50px_50px_repeat(5,1fr)]"
: "grid-cols-[60px_repeat(5,1fr)]"
)}>
{showSecondaryTimezone ? (
<>
<div className="text-center text-xs font-medium text-primary py-2">
{getTimezoneAbbrev(displayTimezone)}
</div>
<div className="text-center text-xs font-medium text-muted-foreground py-2">
{getTimezoneAbbrev(secondaryTimezone)}
</div>
</>
) : (
<div></div>
)}
{dayNames.map((dayName, i) => (
<div
key={dayName}
className="text-center text-sm font-medium text-muted-foreground py-2"
>
<div>{dayName}</div>
<div className="text-xs opacity-70">
{weekDates[i]?.slice(5).replace('-', '/')}
</div>
</div>
))}
</div>
<div className="space-y-1">
{hours.map((hour) => (
<div
key={hour}
ref={todayIndex >= 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 ? (
<>
<div className="text-xs text-primary font-medium flex items-center justify-center gap-1">
{todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)}
</div>
<div className="text-xs text-muted-foreground flex items-center justify-center">
{formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))}
</div>
</>
) : (
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
{todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)}
</div>
)}
{weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(dateStr, hour);
const dayName = dayNames[dayIndex];
const tooSoon = isSlotTooSoon(dateStr, hour);
if (!slot) return <div key={`${dateStr}-${hour}`} className="h-12 bg-muted rounded" />;
const effectiveAvailability = getEffectiveAvailability(slot);
return (
<Popover key={`${dateStr}-${hour}`}>
<PopoverTrigger asChild>
<button
className={cn(
"h-12 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
tooSoon && "opacity-40 cursor-not-allowed",
!tooSoon && "hover:scale-105 hover:shadow-md",
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90",
effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90"
)}
/>
</PopoverTrigger>
<PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3">
<div className="font-semibold text-foreground">
{dayName} {formatDisplayTime(hour)}{formatDisplayTime(hour + 1)}
</div>
{tooSoon && (
<div className="text-sm text-muted-foreground italic">
This time slot has passed or is too soon to schedule
</div>
)}
<div className="space-y-2">
{selectedParticipants.map((participant) => {
const isAvailable = slot.availableParticipants.includes(participant.name);
return (
<div
key={participant.id}
className="flex items-center gap-2 text-sm"
>
{isAvailable ? (
<Check className="w-4 h-4 text-availability-full" />
) : (
<X className="w-4 h-4 text-destructive" />
)}
<span className={cn(
isAvailable ? "text-foreground" : "text-muted-foreground"
)}>
{participant.name.split(' ')[0]}
</span>
</div>
);
})}
</div>
{effectiveAvailability !== 'none' && !tooSoon && (
<Button
variant="schedule"
className="w-full mt-2"
onClick={() => onSlotSelect(slot)}
>
Schedule
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-border">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-availability-full"></div>
<span className="text-xs text-muted-foreground">All free</span>
</div>
{showPartialAvailability && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-availability-partial"></div>
<span className="text-xs text-muted-foreground">Partial</span>
</div>
)}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-availability-none"></div>
<span className="text-xs text-muted-foreground">No overlap</span>
</div>
</div>
</div>
);
};