feat: UX
This commit is contained in:
179
frontend/src/components/AvailabilityHeatmap.tsx
Normal file
179
frontend/src/components/AvailabilityHeatmap.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user