Files
common-availability/frontend/src/components/AvailabilityHeatmap.tsx
Joyce 26311c867a feat: add email, scheduler, and Zulip integration services
- Add email service for sending meeting invites with ICS attachments
- Add scheduler for background calendar sync jobs
- Add Zulip service for meeting notifications
- Make ics_url optional for participants
- Add /api/schedule endpoint with 2-hour lead time validation
- Update frontend to support scheduling flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:52:02 -05:00

222 lines
8.7 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 { Check, X, Loader2 } from 'lucide-react';
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
const getWeekDates = () => {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - now.getDay() + 1);
monday.setHours(0, 0, 0, 0);
return dayNames.map((_, i) => {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
return date.toISOString().split('T')[0]; // "YYYY-MM-DD"
});
};
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();
const getSlot = (dateStr: string, hour: number) => {
return slots.find((s) => s.day === dateStr && s.hour === hour);
};
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) => {
const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`);
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTime < twoHoursFromNow;
};
const getWeekDateRange = () => {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - now.getDay() + 1);
const friday = new Date(monday);
friday.setDate(monday.getDate() + 4);
const format = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return `${format(monday)} ${format(friday)}`;
};
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">
<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 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) => (
<div
key={dayName}
className="text-center text-sm font-medium text-muted-foreground py-2"
>
{dayName}
</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} {formatHour(hour)}{formatHour(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]} {isAvailable ? 'free' : 'busy'}
</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>
);
};