Files
common-availability/frontend/src/components/AvailabilityHeatmap.tsx
Nikita Lokhmachev 4a0db63a30 feat: UX
2025-12-16 13:47:29 -05:00

180 lines
7.2 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 { days, hours } from '@/data/mockData';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Check, X } from 'lucide-react';
interface AvailabilityHeatmapProps {
slots: TimeSlot[];
selectedParticipants: Participant[];
onSlotSelect: (slot: TimeSlot) => void;
showPartialAvailability?: boolean;
}
export const AvailabilityHeatmap = ({
slots,
selectedParticipants,
onSlotSelect,
showPartialAvailability = false,
}: AvailabilityHeatmapProps) => {
const getSlot = (day: string, hour: number) => {
return slots.find((s) => s.day === day && 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 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>
);
}
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]">
{/* Header */}
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
<div></div>
{days.map((day) => (
<div
key={day}
className="text-center text-sm font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Grid */}
<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>
{days.map((day) => {
const slot = getSlot(day, hour);
if (!slot) return <div key={`${day}-${hour}`} className="h-12 bg-muted rounded" />;
const effectiveAvailability = getEffectiveAvailability(slot);
return (
<Popover key={`${day}-${hour}`}>
<PopoverTrigger asChild>
<button
className={cn(
"h-12 rounded-md transition-all duration-200 hover:scale-105 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
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">
{day} {formatHour(hour)}{formatHour(hour + 1)}
</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' && (
<Button
variant="schedule"
className="w-full mt-2"
onClick={() => onSlotSelect(slot)}
>
Schedule
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
})}
</div>
))}
</div>
</div>
</div>
{/* Legend */}
<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>
);
};