update
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user