This commit is contained in:
Joyce
2026-02-05 13:45:32 -05:00
parent 7a0f11ee88
commit 26e553bfd0
10 changed files with 569 additions and 57 deletions

View File

@@ -6,13 +6,13 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
const TIMEZONE = 'America/Toronto';
const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
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[] => {
@@ -78,6 +78,65 @@ 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[];
@@ -86,6 +145,9 @@ interface AvailabilityHeatmapProps {
isLoading?: boolean;
weekOffset?: number;
onWeekOffsetChange?: (offset: number) => void;
displayTimezone?: string;
showSecondaryTimezone?: boolean;
secondaryTimezone?: string;
}
export const AvailabilityHeatmap = ({
@@ -96,15 +158,82 @@ export const AvailabilityHeatmap = ({
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(TIMEZONE, weekOffset);
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, TIMEZONE);
const targetUTC = toUTCDate(dateStr, hour, displayTimezone);
return slots.find((s) => {
const slotDate = new Date(s.start_time);
@@ -126,7 +255,7 @@ export const AvailabilityHeatmap = ({
const isSlotTooSoon = (dateStr: string, hour: number) => {
// Convert to UTC and compare with current time
const slotTimeUTC = toUTCDate(dateStr, hour, TIMEZONE);
const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone);
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTimeUTC < twoHoursFromNow;
@@ -185,6 +314,10 @@ export const AvailabilityHeatmap = ({
</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 && (
@@ -241,10 +374,48 @@ export const AvailabilityHeatmap = ({
)}
</div>
<div className="overflow-x-auto">
<div className="min-w-[600px]">
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
<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}
@@ -260,10 +431,36 @@ export const AvailabilityHeatmap = ({
<div className="space-y-1">
{hours.map((hour) => (
<div key={hour} className="grid grid-cols-[60px_repeat(5,1fr)] gap-1">
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
{formatHour(hour)}
</div>
<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];
@@ -338,6 +535,7 @@ export const AvailabilityHeatmap = ({
</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">