"use client"; import { useState, useEffect, useMemo, useCallback } from "react"; import { useTranslations } from "next-intl"; import { addMinutes, differenceInMinutes, format } from "date-fns"; import { Avatar } from "@/components/ui/avatar"; import { useAuthStore } from "@/stores/auth-store"; import { cn } from "@/lib/utils"; import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy"; export interface FreeBusyViewProps { participants: { name?: string; email: string }[]; startDate: Date; endDate: Date; onTimeSelect?: (start: Date, end: Date) => void; } const SLOT_MINUTES = 30; const WORK_START_HOUR = 8; const WORK_END_HOUR = 18; const statusColors: Record = { free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800", busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800", tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800", unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800", unknown: "bg-muted border-muted-foreground/20", }; const statusHoverColors: Record = { free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60", busy: "hover:bg-red-200 dark:hover:bg-red-800/60", tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60", unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60", unknown: "hover:bg-muted-foreground/20", }; function clampToSlot(d: Date): Date { const clone = new Date(d); clone.setSeconds(0, 0); const mins = clone.getMinutes(); const remainder = mins % SLOT_MINUTES; if (remainder !== 0) { clone.setMinutes(mins - remainder, 0, 0); } return clone; } function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] { const hours: { label: string; slots: FreeBusySlot[] }[] = []; let cursor = clampToSlot(start); while (cursor < end) { const hourEnd = new Date(cursor); hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0); const hourSlots: FreeBusySlot[] = []; let slotCursor = new Date(cursor); while (slotCursor < hourEnd && slotCursor < end) { const slotEnd = addMinutes(slotCursor, SLOT_MINUTES); hourSlots.push({ start: new Date(slotCursor), end: slotEnd > end ? new Date(end) : slotEnd, status: "unknown", }); slotCursor = slotEnd; } hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots }); cursor = hourEnd; } return hours; } function isWorkingHour(hour: number): boolean { return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR); } export function FreeBusyView({ participants, startDate, endDate, onTimeSelect, }: FreeBusyViewProps) { const t = useTranslations("calendar"); const client = useAuthStore((s) => s.client); const [freeBusyData, setFreeBusyData] = useState | null>(null); const [loading, setLoading] = useState(false); const [hoveredSlot, setHoveredSlot] = useState<{ participant: string; slotIndex: number; } | null>(null); const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]); const totalHalfHourSlots = useMemo(() => { let c = 0; for (const h of hourSlots) c += h.slots.length; return c; }, [hourSlots]); const now = new Date(); const showNowLine = now >= startDate && now <= endDate; const nowPositionPercent = showNowLine ? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100)) : null; useEffect(() => { if (!client || participants.length === 0) return; let cancelled = false; setLoading(true); fetchFreeBusy(client, participants, startDate, endDate) .then((data) => { if (!cancelled) { setFreeBusyData(data); setLoading(false); } }) .catch(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [client, participants, startDate, endDate]); const handleSlotClick = useCallback( (slot: FreeBusySlot) => { if (slot.status === "free" && onTimeSelect) { onTimeSelect(new Date(slot.start), new Date(slot.end)); } }, [onTimeSelect] ); const timezone = useMemo( () => Intl.DateTimeFormat().resolvedOptions().timeZone, [] ); if (participants.length === 0) { return (

{t("freeBusy.no_participants")}

); } return (
{t("freeBusy.timezone")}: {timezone}
{loading && (
{t("freeBusy.loading")}
)}
{hourSlots.map((hour, i) => ( ))} {participants.map((p) => { const key = p.email.toLowerCase(); const slots = freeBusyData?.get(key); return ( {hourSlots.map((hour) => hour.slots.map((hourSlot, si) => { const globalSlotIndex = hourSlots .slice(0, hourSlots.indexOf(hour)) .reduce((acc, h) => acc + h.slots.length, 0) + si; const slot = slots?.[globalSlotIndex]; const status = slot?.status ?? "unknown"; const isFree = status === "free"; const isHovered = hoveredSlot?.participant === key && hoveredSlot?.slotIndex === globalSlotIndex; return ( ); }) )} ); })}
{t("participants.title")} {hour.label}
{p.name || p.email}
{p.name && (
{p.email}
)}
isFree ? handleSlotClick(slot!) : undefined } onMouseEnter={() => setHoveredSlot({ participant: key, slotIndex: globalSlotIndex, }) } onMouseLeave={() => setHoveredSlot(null)} > {status === "free" && (   )}
{showNowLine && nowPositionPercent !== null && (
)}
{t("freeBusy.free")} {t("freeBusy.busy")} {t("freeBusy.tentative")} {t("freeBusy.unavailable")} {t("freeBusy.unknown")}
); }