"use client"; import { useMemo, useState, useCallback, useRef, useEffect, type DragEvent } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO, } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; import { getEventEndDate } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { toast } from "@/stores/toast-store"; interface CalendarMonthViewProps { selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; onSelectDate: (date: Date) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; firstDayOfWeek?: number; isMobile?: boolean; } export function CalendarMonthView({ selectedDate, events, calendars, onSelectDate, onSelectEvent, onHoverEvent, onHoverLeave, firstDayOfWeek = 1, isMobile, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const days = useMemo(() => { const monthStart = startOfMonth(selectedDate); const monthEnd = endOfMonth(selectedDate); const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart }); return eachDayOfInterval({ start: gridStart, end: gridEnd }); }, [selectedDate, weekStart]); const calendarMap = useMemo(() => { const map = new Map(); calendars.forEach((c) => map.set(c.id, c)); return map; }, [calendars]); const eventsByDate = useMemo(() => { const map = new Map(); events.forEach((e) => { try { const start = new Date(e.start); const end = getEventEndDate(e); const startDay = new Date(start); startDay.setHours(0, 0, 0, 0); const endDay = new Date(end); endDay.setHours(0, 0, 0, 0); const cursor = new Date(startDay); while (cursor <= endDay) { const key = format(cursor, "yyyy-MM-dd"); const arr = map.get(key) || []; arr.push(e); map.set(key, arr); cursor.setDate(cursor.getDate() + 1); } } catch { /* skip invalid dates */ } }); return map; }, [events]); const dayHeaders = firstDayOfWeek === 0 ? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const : ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; const weeks = useMemo(() => { const result: Date[][] = []; for (let i = 0; i < days.length; i += 7) { result.push(days.slice(i, i + 7)); } return result; }, [days]); const [dropDayKey, setDropDayKey] = useState(null); const [overflowDay, setOverflowDay] = useState<{ key: string; events: CalendarEvent[]; anchorRect: DOMRect; dayLabel: string } | null>(null); const overflowRef = useRef(null); useEffect(() => { if (!overflowDay) return; const handleClickOutside = (e: MouseEvent) => { if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) { setOverflowDay(null); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") setOverflowDay(null); }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [overflowDay]); const handleMoreClick = useCallback((e: React.MouseEvent, dayKey: string, dayEvents: CalendarEvent[], dayLabel: string) => { e.stopPropagation(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setOverflowDay({ key: dayKey, events: dayEvents, anchorRect: rect, dayLabel }); }, []); const handleCellDragOver = useCallback((e: DragEvent, dayKey: string) => { if (!e.dataTransfer.types.includes("application/x-calendar-event")) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDropDayKey((prev) => prev === dayKey ? prev : dayKey); }, []); const handleCellDragLeave = useCallback((e: DragEvent) => { const related = e.relatedTarget as Node | null; if (!e.currentTarget.contains(related)) setDropDayKey(null); }, []); const handleCellDrop = useCallback(async (e: DragEvent, day: Date) => { e.preventDefault(); setDropDayKey(null); const json = e.dataTransfer.getData("application/x-calendar-event"); if (!json) return; try { const data = JSON.parse(json); const originalStart = parseISO(data.originalStart); const newStart = new Date(day); newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0); const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss"); if (newStartISO === data.originalStart) return; const client = useAuthStore.getState().client; if (!client) return; const event = useCalendarStore.getState().events.find(e => e.id === data.eventId); const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined); } catch { toast.error(t("notifications.event_move_error")); } }, [t]); return (
{dayHeaders.map((d) => (
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
))}
{weeks.map((week, wi) => (
{week.map((day) => { const inMonth = isSameMonth(day, selectedDate); const selected = isSameDay(day, selectedDate); const today = isToday(day); const key = format(day, "yyyy-MM-dd"); const dayEvents = eventsByDate.get(key) || []; const maxVisible = isMobile ? 0 : 3; const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return (
onSelectDate(day)} onDragOver={(e) => handleCellDragOver(e, key)} onDragLeave={handleCellDragLeave} onDrop={(e) => handleCellDrop(e, day)} className={cn( "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation", !inMonth && "bg-muted/30", "hover:bg-muted/50", selected && isMobile && "bg-primary/10", dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10" )} >
{format(day, "d")}
{isMobile ? ( dayEvents.length > 0 && (
{dayEvents.slice(0, 3).map((ev) => { const calId = Object.keys(ev.calendarIds)[0]; const cal = calendarMap.get(calId); const evColor = ev.color || cal?.color || "#3b82f6"; return ( ); })} {dayEvents.length > 3 && ( )}
) ) : (
{dayEvents.slice(0, maxVisible).map((ev) => { const calId = Object.keys(ev.calendarIds)[0]; return ( onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} draggable /> ); })} {dayEvents.length > maxVisible && ( )}
)}
); })}
))}
{overflowDay && (() => { const viewportW = typeof window !== "undefined" ? window.innerWidth : 1024; const viewportH = typeof window !== "undefined" ? window.innerHeight : 768; const popoverW = 260; const popoverMaxH = 320; let left = overflowDay.anchorRect.left; let top = overflowDay.anchorRect.bottom + 4; if (left + popoverW > viewportW - 8) left = viewportW - popoverW - 8; if (left < 8) left = 8; if (top + popoverMaxH > viewportH - 8) top = overflowDay.anchorRect.top - popoverMaxH - 4; if (top < 8) top = 8; return (
{overflowDay.dayLabel}
{overflowDay.events.map((ev) => { const calId = Object.keys(ev.calendarIds)[0]; return ( { setOverflowDay(null); onSelectEvent(ev, rect); }} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} draggable /> ); })}
); })()}
); }