"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 { Check } from "lucide-react"; import { EventCard } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import type { PendingEventPreview } from "./event-modal"; 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; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; isMobile?: boolean; pendingPreview?: PendingEventPreview | null; tasks?: CalendarTask[]; onToggleTaskComplete?: (task: CalendarTask) => void; } const HOUR_HEIGHT = 60; const HOURS = Array.from({ length: 24 }, (_, i) => i); export function CalendarWeekView({ selectedDate, events, calendars, onSelectDate, onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onCreateAtTime, firstDayOfWeek = 1, timeFormat = "24h", isMobile, pendingPreview, tasks, onToggleTaskComplete, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); const scrollRef = useRef(null); const rootRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekDays = useMemo(() => { const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); }, [selectedDate, weekStart]); 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 && !isTimedEventFullDayOnDate(ev, cursor)) { 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(() => { const explicitAllDay = buildWeekSegmentsRaw( events.filter((event) => event.showWithoutTime), weekDays, ); const timedFullDay = buildTimedFullDayWeekSegments( events.filter((event) => !event.showWithoutTime), weekDays, ); return packWeekSegments([...explicitAllDay, ...timedFullDay]); }, [events, weekDays]); const allDayRowCount = useMemo(() => { return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); }, [allDaySegments]); // Tasks grouped by day for the week const tasksByDay = useMemo(() => { if (!tasks?.length) return new Map(); const map = new Map(); for (const task of tasks) { if (!task.due) continue; try { const key = format(parseISO(task.due), "yyyy-MM-dd"); const existing = map.get(key) || []; existing.push(task); map.set(key, existing); } catch { /* skip */ } } return map; }, [tasks]); // Max tasks on any single day in this week const taskRowCount = useMemo(() => { let max = 0; for (const day of weekDays) { const key = format(day, "yyyy-MM-dd"); const count = tasksByDay.get(key)?.length ?? 0; if (count > max) max = count; } return max; }, [tasksByDay, weekDays]); const hasAllDay = useMemo(() => { return allDaySegments.length > 0 || taskRowCount > 0; }, [allDaySegments, taskRowCount]); useEffect(() => { if (scrollRef.current) { const now = new Date(); scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } // On mobile, scroll horizontally to center today's column if (isMobile && rootRef.current) { const todayIdx = weekDays.findIndex(d => isToday(d)); if (todayIdx >= 0) { const gutter = 40; const colWidth = (rootRef.current.scrollWidth - gutter) / 7; const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2; rootRef.current.scrollLeft = Math.max(0, target); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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"), }, isMobile, }); 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 = 7; return (
{hasAllDay && (
{t("events.all_day")}
{weekDays.map((day) => (
))}
{allDaySegments.map((segment) => { const calId = getPrimaryCalendarId(segment.event); return (
onSelectEvent(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} />
); })}
{/* Task chips in all-day area */} {taskRowCount > 0 && (
{weekDays.map((day, dayIndex) => { const key = format(day, "yyyy-MM-dd"); const dayTasks = tasksByDay.get(key) || []; return dayTasks.map((task, taskIndex) => { const isCompleted = task.progress === "completed"; const cal = calendars.find(c => task.calendarIds[c.id]); const color = cal?.color || "#3b82f6"; return (
onToggleTaskComplete?.(task)} > {isCompleted && } {task.title}
); }); })}
)}
)}
{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, day); 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, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); const baseTop = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const isResizing = resizeVisual?.eventId === ev.id; const top = isResizing ? resizeVisual!.topPx : baseTop; const height = isResizing ? resizeVisual!.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; return (
onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} draggable />
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} >
handleResizePointerDown(ev.id, "bottom", startMinutes, 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)}
)} {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( (() => { const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); if (endMin <= startMin) endMin = 1440; const durationMin = Math.max(15, endMin - startMin); const cal = calendars.find(c => c.id === pendingPreview.calendarId); const color = cal?.color || "hsl(var(--primary))"; return (
{pendingPreview.title}
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); })() )}
); })}
); }