296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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 { Check, X, Loader2 } from 'lucide-react';
|
||
|
||
const TIMEZONE = 'America/Toronto';
|
||
|
||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
||
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
|
||
|
||
// Get the dates for Mon-Fri of the current week in a specific timezone
|
||
const getWeekDates = (timezone: string): 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);
|
||
|
||
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;
|
||
};
|
||
|
||
interface AvailabilityHeatmapProps {
|
||
slots: TimeSlot[];
|
||
selectedParticipants: Participant[];
|
||
onSlotSelect: (slot: TimeSlot) => void;
|
||
showPartialAvailability?: boolean;
|
||
isLoading?: boolean;
|
||
}
|
||
|
||
export const AvailabilityHeatmap = ({
|
||
slots,
|
||
selectedParticipants,
|
||
onSlotSelect,
|
||
showPartialAvailability = false,
|
||
isLoading = false,
|
||
}: AvailabilityHeatmapProps) => {
|
||
const weekDates = getWeekDates(TIMEZONE);
|
||
|
||
// 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);
|
||
|
||
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, TIMEZONE);
|
||
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>
|
||
<h3 className="text-lg font-semibold text-foreground">
|
||
Common Availability — Week of {getWeekDateRange()}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
|
||
</p>
|
||
</div>
|
||
</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>
|
||
{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} 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>
|
||
{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 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>
|
||
);
|
||
};
|