import React, { useEffect, useMemo, useRef, useState } from "react"; import { startOfWeek, endOfWeek, eachDayOfInterval, isToday, format, } from "date-fns"; import { Video, MapPin, Clock } from "lucide-react"; import { CalendarEventItem, CalendarEventRecord, CalendarSpace, } from "../types/calendar"; import { atprotoService } from "../services/atproto"; import { eventsOnDay, resolveEventDrop } from "../utils/calendarInteraction"; import { getEventDaySegment, getEventTimeLabel, isAllDayEvent, } from "../utils/dateUtils"; import { layoutWeekSpans } from "../utils/weekSpans"; interface CalendarWeekViewProps { currentDate: Date; events: CalendarEventItem[]; space: CalendarSpace; onSelectEvent: (event: CalendarEventItem) => void; onCreateEventOnDate: (date: Date) => void; onRescheduleEvent?: ( event: CalendarEventItem, updates: Partial, ) => Promise | void; } const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOUR_HEIGHT = 64; // px per hour export const CalendarWeekView: React.FC = ({ currentDate, events, space, onSelectEvent, onCreateEventOnDate, onRescheduleEvent, }) => { const scrollContainerRef = useRef(null); const [now, setNow] = useState(new Date()); const [dragOverSlot, setDragOverSlot] = useState<{ dayKey: string; hour: number; } | null>(null); const [draggingEventUri, setDraggingEventUri] = useState(null); const currentDid = atprotoService.getCurrentDid(); const weekStart = startOfWeek(currentDate); const weekEnd = endOfWeek(currentDate); const days = eachDayOfInterval({ start: weekStart, end: weekEnd }); const weekSpans = useMemo( () => layoutWeekSpans(events, days), [events, currentDate], ); useEffect(() => { const timer = setInterval(() => setNow(new Date()), 60000); return () => clearInterval(timer); }, []); useEffect(() => { if (scrollContainerRef.current) { const scrollHour = 8; scrollContainerRef.current.scrollTop = scrollHour * HOUR_HEIGHT; } }, []); const getEventsForDay = (day: Date) => eventsOnDay(events, day); const handleDropOnSlot = ( e: React.DragEvent, targetDay: Date, targetHour: number, ) => { e.preventDefault(); e.stopPropagation(); setDragOverSlot(null); setDraggingEventUri(null); const raw = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain"); const move = resolveEventDrop( raw, events, currentDid, targetDay, targetHour, ); if (move) onRescheduleEvent?.(move.event, move.updates); }; const handleDropOnColumn = ( e: React.DragEvent, targetDay: Date, ) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const offsetY = e.clientY - rect.top; const calculatedHour = Math.min( 23, Math.max(0, Math.floor(offsetY / HOUR_HEIGHT)), ); handleDropOnSlot(e, targetDay, calculatedHour); }; const currentMinutes = now.getHours() * 60 + now.getMinutes(); const currentIndicatorTop = (currentMinutes / 60) * HOUR_HEIGHT; return (
{days.map((day) => { const dayIsToday = isToday(day); return (
{format(day, "EEE")}
{format(day, "d")}
); })}
All-day
{days.map((day) => { const dayKey = format(day, "yyyy-MM-dd"); const allDayEvents = getEventsForDay(day).filter( (event) => isAllDayEvent(event.value) && !weekSpans.some((span) => span.event.uri === event.uri), ); return (
{ e.preventDefault(); e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => handleDropOnSlot(e, day, 0)} > {allDayEvents.map((event) => { const isOwner = event.repo === currentDid; const eventColor = event.calendarColor || space.color; const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ""}${event.hasConflict ? ", Overlapping event" : ""}`; return ( ); })}
); })}
{weekSpans.map((span) => { const color = span.event.calendarColor || space.color; const isOwner = span.event.repo === currentDid; return ( ); })}
{HOURS.map((hour) => (
{hour === 0 ? "" : `${String(hour).padStart(2, "0")}:00`}
))}
{days.map((day) => { const dayIsToday = isToday(day); const dayEvents = getEventsForDay(day).filter( (event) => !weekSpans.some((span) => span.event.uri === event.uri), ); const dayKey = format(day, "yyyy-MM-dd"); return (
{ e.preventDefault(); e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => handleDropOnColumn(e, day)} > {HOURS.map((hour) => { const isSlotDragOver = dragOverSlot?.dayKey === dayKey && dragOverSlot?.hour === hour; return (
{ const newDate = new Date(day); newDate.setHours(hour, 0, 0, 0); onCreateEventOnDate(newDate); }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if ( dragOverSlot?.dayKey !== dayKey || dragOverSlot?.hour !== hour ) { setDragOverSlot({ dayKey, hour }); } }} onDragEnter={(e) => { e.preventDefault(); setDragOverSlot({ dayKey, hour }); }} onDragLeave={(e) => { if ( e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node) ) { setDragOverSlot((prev) => prev && prev.dayKey === dayKey && prev.hour === hour ? null : prev, ); } }} onDrop={(e) => handleDropOnSlot(e, day, hour)} > {isSlotDragOver && (
{String(hour).padStart(2, "0")}:00
)}
); })} {dayIsToday && (
)} {dayEvents.map((event) => { try { if (isAllDayEvent(event.value)) return null; const segment = getEventDaySegment(event.value, day); if (!segment) return null; const startMinutes = segment.startMinute; const segmentMinutes = segment.endMinute - segment.startMinute; const durationMinutes = Math.min( 1440 - startMinutes, Math.max(25, segmentMinutes), ); const topPx = (startMinutes / 60) * HOUR_HEIGHT; const heightPx = (durationMinutes / 60) * HOUR_HEIGHT; const isVirtual = event.value.mode === "community.lexicon.calendar.event#virtual"; const isOwner = event.repo === currentDid; const eventColor = event.calendarColor || space.color; const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ""}${event.hasConflict ? ", Overlapping event" : ""}`; const isBeingDragged = draggingEventUri === event.uri; return (
{ if (!isOwner) return; e.stopPropagation(); e.dataTransfer.setData( "text/plain", JSON.stringify({ uri: event.uri, sourceDate: format(day, "yyyy-MM-dd"), }), ); e.dataTransfer.effectAllowed = "move"; setDraggingEventUri(event.uri); }} onDragEnd={() => { setDraggingEventUri(null); setDragOverSlot(null); }} onClick={(e) => { e.stopPropagation(); onSelectEvent(event); }} onKeyDown={(e) => { if ( !e.metaKey && !e.ctrlKey && !e.altKey && (e.key === "Enter" || e.key === " ") ) { e.preventDefault(); onSelectEvent(event); } }} role="button" tabIndex={0} aria-label={eventLabel} title={ event.calendarName ? `${event.value.name} · ${event.calendarName}` : event.value.name } style={{ top: `${topPx}px`, height: `${Math.max(1, heightPx - 2)}px`, backgroundColor: `${eventColor}22`, borderLeftColor: eventColor, }} className={`absolute inset-x-0.5 sm:inset-x-1 rounded-lg border-l-4 p-1 overflow-hidden shadow-sm hover:shadow-md transition group z-10 hover:brightness-95 ${ isBeingDragged ? "opacity-40 cursor-grabbing" : draggingEventUri ? "pointer-events-none opacity-60" : isOwner ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" }`} >
{isVirtual ? (
{getEventTimeLabel(event.value, day)}
{event.value.description && heightPx > 60 && (
{event.value.description}
)}
); } catch { return null; } })}
); })}
); };