"use client"; import { useMemo, useEffect, useRef, useState } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { startOfWeek, addDays, format, isSameDay, isToday, parseISO, } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; interface CalendarWeekViewProps { selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; onSelectDate: (date: Date) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; onCreateAtTime: (date: Date, endDate?: Date) => void; firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; isMobile?: boolean; } const HOUR_HEIGHT = 60; const HOURS = Array.from({ length: 24 }, (_, i) => i); export function CalendarWeekView({ selectedDate, events, calendars, onSelectDate, onSelectEvent, onHoverEvent, onHoverLeave, onCreateAtTime, firstDayOfWeek = 1, timeFormat = "24h", isMobile, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); const scrollRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekDays = useMemo(() => { if (isMobile) { // Show 3-day window centered on selected date return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1)); } const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); }, [selectedDate, weekStart, isMobile]); const calendarMap = useMemo(() => { const map = new Map(); calendars.forEach((c) => map.set(c.id, c)); return map; }, [calendars]); const timedEvents = useMemo(() => { const timed: Map = new Map(); events.forEach((ev) => { try { const { startDay, endDay } = getEventDayBounds(ev); const cursor = new Date(startDay); while (cursor <= endDay) { const key = format(cursor, "yyyy-MM-dd"); if (!ev.showWithoutTime) { const arr = timed.get(key) || []; arr.push(ev); timed.set(key, arr); } cursor.setDate(cursor.getDate() + 1); } } catch { /* skip invalid dates */ } }); return timed; }, [events]); const allDaySegments = useMemo(() => buildWeekSegments( events.filter((event) => event.showWithoutTime), weekDays, ), [events, weekDays]); const allDayRowCount = useMemo(() => { return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); }, [allDaySegments]); const hasAllDay = useMemo(() => { return allDaySegments.length > 0; }, [allDaySegments]); useEffect(() => { if (scrollRef.current) { const now = new Date(); scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } }, []); const [nowMinutes, setNowMinutes] = useState(() => { const now = new Date(); return now.getHours() * 60 + now.getMinutes(); }); useEffect(() => { const interval = setInterval(() => { setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes()); }, 60000); return () => clearInterval(interval); }, []); const { dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp, quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, } = useTimeGridInteractions({ hourHeight: HOUR_HEIGHT, calendars, onCreateRange: onCreateAtTime, errorMessages: { resize: t("notifications.event_resize_error"), move: t("notifications.event_move_error"), created: t("notifications.event_created"), error: t("notifications.event_error"), }, }); const formatHour = (h: number): string => { if (timeFormat === "12h") { const d = new Date(2000, 0, 1, h); return intlFormatter.dateTime(d, { hour: "numeric", minute: "2-digit", hour12: true }); } return format(new Date(2000, 0, 1, h), "HH:mm"); }; const colCount = isMobile ? 3 : 7; return (
{hasAllDay && (
{t("events.all_day")}
{weekDays.map((day) => (
))}
{allDaySegments.map((segment) => { const calId = Object.keys(segment.event.calendarIds)[0]; return (
onSelectEvent(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} />
); })}
)}
{weekDays.map((day) => { const todayCol = isToday(day); const selected = isSameDay(day, selectedDate); const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return ( ); })}
{HOURS.map((h) => (
{h > 0 && ( {formatHour(h)} )}
))}
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; const todayCol = isToday(day); const layouted = layoutOverlappingEvents(dayEvents); return (
handleGridPointerDown(e, key, day)} onPointerMove={handleGridPointerMove} onPointerUp={handleGridPointerUp} onDragOver={(e) => handleColumnDragOver(e, key)} onDragLeave={handleColumnDragLeave} onDrop={(e) => handleColumnDrop(e, day)} > {HOURS.map((h) => (
handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" style={{ height: HOUR_HEIGHT }} /> ))} {layouted.map(({ event: ev, column, totalColumns }) => { const start = parseISO(ev.start); const startMin = start.getHours() * 60 + start.getMinutes(); const durMin = Math.max(15, parseDuration(ev.duration)); const top = (startMin / 60) * HOUR_HEIGHT; const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const calId = Object.keys(ev.calendarIds)[0]; const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; return (
onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} draggable />
handleResizePointerDown(ev.id, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} >
); })} {todayCol && (
)} {quickCreate?.dayKey === key && ( )} {dragCreate?.dayKey === key && (
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
)} {dropTarget?.dayKey === key && (
{formatSnapTime(dropTarget.minutes, timeFormat)}
)}
); })}
); }