diff --git a/apps/calendar/apps/web/src/lib/composables/index.ts b/apps/calendar/apps/web/src/lib/composables/index.ts index 553f49eac..35ee3f82c 100644 --- a/apps/calendar/apps/web/src/lib/composables/index.ts +++ b/apps/calendar/apps/web/src/lib/composables/index.ts @@ -3,6 +3,26 @@ * Reusable logic extracted from components */ +// Visible hours and time indicator +export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte'; + +// Event drag/drop and resize (comprehensive composable) +export { + useEventDragDrop, + type EventDragDropConfig, + type EventDragState, + type EventResizeState, +} from './useEventDragDrop.svelte'; + +// Task drag/drop and resize +export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svelte'; + +// Sidebar task drop handling +export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte'; + +// Keyboard handling +export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte'; + +// Legacy exports (kept for backwards compatibility, may be removed later) export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte'; export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte'; -export { useTaskDragDrop } from './useTaskDragDrop.svelte'; diff --git a/apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts new file mode 100644 index 000000000..3c229551d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts @@ -0,0 +1,41 @@ +/** + * Calendar Keyboard Handling Composable + * Handles keyboard shortcuts for calendar views (e.g., Escape to cancel drag/resize) + */ + +export interface CancellableOperation { + /** Check if operation is active */ + isActive: () => boolean; + /** Cancel the operation */ + cancel: () => void; +} + +/** + * Creates a keyboard handler that cancels operations on Escape key + * Automatically sets up and cleans up the event listener via $effect + * + * @param operations - Array of operations that can be cancelled (e.g., drag/drop, resize) + */ +export function useCalendarKeyboard(operations: CancellableOperation[]) { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + // Check if any operation is active + const activeOperation = operations.find((op) => op.isActive()); + if (activeOperation) { + e.preventDefault(); + activeOperation.cancel(); + } + } + } + + // Setup listener - call this in $effect + function setup() { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + + return { + setup, + handleKeyDown, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts new file mode 100644 index 000000000..345e0b4f7 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts @@ -0,0 +1,427 @@ +/** + * Event Drag & Drop + Resize Composable + * Extracts duplicated drag/resize logic from WeekView, DayView, MultiDayView + */ + +import type { CalendarEvent } from '@calendar/shared'; +import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; +import { eventsStore } from '$lib/stores/events.svelte'; +import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + +export interface EventDragDropConfig { + /** Reference to the container element for position calculations */ + containerEl: HTMLElement | null; + /** Array of visible days (for multi-day views) or single day (for day view) */ + days: Date[]; + /** First visible hour (for filtered hours mode) */ + firstVisibleHour: number; + /** Last visible hour (for filtered hours mode) */ + lastVisibleHour: number; + /** Total visible hours */ + totalVisibleHours: number; + /** Height of one hour in pixels */ + hourHeight: number; + /** Minutes per snap interval (default: 15) */ + snapMinutes?: number; + /** Function to convert minutes to percentage position */ + minutesToPercent: (minutes: number) => number; +} + +export interface EventDragState { + isDragging: boolean; + draggedEvent: CalendarEvent | null; + dragTargetDay: Date | null; + dragPreviewTop: number; + dragPreviewHeight: number; + hasMoved: boolean; +} + +export interface EventResizeState { + isResizing: boolean; + resizeEvent: CalendarEvent | null; + resizeEdge: 'top' | 'bottom'; + resizePreviewTop: number; + resizePreviewHeight: number; +} + +export function useEventDragDrop(getConfig: () => EventDragDropConfig) { + // ========== Drag State ========== + let isDragging = $state(false); + let draggedEvent = $state(null); + let dragOffsetMinutes = $state(0); + let dragTargetDay = $state(null); + let dragPreviewTop = $state(0); + let dragPreviewHeight = $state(0); + + // ========== Resize State ========== + let isResizing = $state(false); + let resizeEvent = $state(null); + let resizeEdge = $state<'top' | 'bottom'>('bottom'); + let resizeOriginalStart = $state(null); + let resizeOriginalEnd = $state(null); + let resizePreviewTop = $state(0); + let resizePreviewHeight = $state(0); + let resizeOffsetMinutes = $state(0); + + // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) + let hasMoved = $state(false); + + // ========== Helper Functions ========== + + function getSnapMinutes(): number { + return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; + } + + function snapToGrid(minutes: number): number { + const snap = getSnapMinutes(); + return Math.round(minutes / snap) * snap; + } + + /** + * Get day from X coordinate (for multi-day views) + */ + function getDayFromX(clientX: number): Date | null { + const config = getConfig(); + if (!config.containerEl) return null; + + const rect = config.containerEl.getBoundingClientRect(); + const relativeX = clientX - rect.left; + const dayWidth = rect.width / config.days.length; + const dayIndex = Math.floor(relativeX / dayWidth); + + if (dayIndex >= 0 && dayIndex < config.days.length) { + return config.days[dayIndex]; + } + return null; + } + + /** + * Get minutes from Y coordinate + */ + function getMinutesFromY(clientY: number): number { + const config = getConfig(); + if (!config.containerEl) return 0; + + const rect = config.containerEl.getBoundingClientRect(); + const scrollTop = config.containerEl.parentElement?.scrollTop || 0; + const relativeY = clientY - rect.top + scrollTop; + + // Account for hidden early hours + const visibleMinutes = + (relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60; + const totalMinutes = visibleMinutes + config.firstVisibleHour * 60; + + // Snap to interval + return snapToGrid(totalMinutes); + } + + // ========== Drag Functions ========== + + function startDrag(event: CalendarEvent, e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + const config = getConfig(); + + isDragging = true; + draggedEvent = event; + hasMoved = false; + + const start = toDate(event.startTime); + const end = toDate(event.endTime); + const duration = differenceInMinutes(end, start); + + // Calculate initial preview position + const startMinutes = start.getHours() * 60 + start.getMinutes(); + dragPreviewTop = config.minutesToPercent(startMinutes); + dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + dragTargetDay = start; + + // Calculate offset from event start to click position + const clickMinutes = getMinutesFromY(e.clientY); + dragOffsetMinutes = clickMinutes - startMinutes; + + document.addEventListener('pointermove', handleDragMove); + document.addEventListener('pointerup', handleDragEnd); + } + + function handleDragMove(e: PointerEvent) { + if (!isDragging || !draggedEvent) return; + + const config = getConfig(); + hasMoved = true; + + // Calculate new position + const newDay = getDayFromX(e.clientX); + const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; + + // Clamp to valid range + const clampedMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(config.lastVisibleHour * 60 - 15, newMinutes) + ); + + // Update preview + dragPreviewTop = config.minutesToPercent(clampedMinutes); + if (newDay) { + dragTargetDay = newDay; + } + } + + async function handleDragEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + + if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) { + cleanupDrag(); + return; + } + + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); + const duration = differenceInMinutes(end, start); + + // Calculate new start time + const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; + const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes)); + const newHours = Math.floor(clampedMinutes / 60); + const newMins = clampedMinutes % 60; + + let newStart = new Date(dragTargetDay); + newStart = setHours(newStart, newHours); + newStart = setMinutes(newStart, newMins); + + const newEnd = addMinutes(newStart, duration); + + // Update event via store + if (eventsStore.isDraftEvent(draggedEvent.id)) { + eventsStore.updateDraftEvent({ + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } else { + await eventsStore.updateEvent(draggedEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } + + cleanupDrag(); + } + + function cleanupDrag() { + isDragging = false; + draggedEvent = null; + dragTargetDay = null; + hasMoved = false; + } + + // ========== Resize Functions ========== + + function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + const config = getConfig(); + + isResizing = true; + resizeEvent = event; + resizeEdge = edge; + hasMoved = false; + + const start = toDate(event.startTime); + const end = toDate(event.endTime); + + resizeOriginalStart = start; + resizeOriginalEnd = end; + + // Set initial preview + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + const duration = differenceInMinutes(end, start); + resizePreviewTop = config.minutesToPercent(startMinutes); + resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + + // Calculate offset between snapped click position and actual event boundary + const clickMinutes = getMinutesFromY(e.clientY); + if (edge === 'top') { + resizeOffsetMinutes = clickMinutes - startMinutes; + } else { + resizeOffsetMinutes = clickMinutes - endMinutes; + } + + document.addEventListener('pointermove', handleResizeMove); + document.addEventListener('pointerup', handleResizeEnd); + } + + function handleResizeMove(e: PointerEvent) { + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return; + + const config = getConfig(); + hasMoved = true; + + const currentMinutes = getMinutesFromY(e.clientY); + // Apply offset to prevent jumping when drag starts + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; + const originalStartMinutes = + resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + if (resizeEdge === 'bottom') { + // Resize from bottom - change end time + const newEndMinutes = Math.max( + originalStartMinutes + 15, + Math.min(config.lastVisibleHour * 60, adjustedMinutes) + ); + const newDuration = newEndMinutes - originalStartMinutes; + resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100; + } else { + // Resize from top - change start time + const newStartMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(originalEndMinutes - 15, adjustedMinutes) + ); + const newDuration = originalEndMinutes - newStartMinutes; + resizePreviewTop = config.minutesToPercent(newStartMinutes); + resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100; + } + } + + async function handleResizeEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) { + cleanupResize(); + return; + } + + const config = getConfig(); + const currentMinutes = getMinutesFromY(e.clientY); + // Apply offset to prevent jumping + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; + const originalStartMinutes = + resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + let newStart = resizeOriginalStart; + let newEnd = resizeOriginalEnd; + + if (resizeEdge === 'bottom') { + const newEndMinutes = Math.max( + originalStartMinutes + 15, + Math.min(config.lastVisibleHour * 60, adjustedMinutes) + ); + const newHours = Math.floor(newEndMinutes / 60); + const newMins = newEndMinutes % 60; + newEnd = setHours(new Date(resizeOriginalEnd), newHours); + newEnd = setMinutes(newEnd, newMins); + } else { + const newStartMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(originalEndMinutes - 15, adjustedMinutes) + ); + const newHours = Math.floor(newStartMinutes / 60); + const newMins = newStartMinutes % 60; + newStart = setHours(new Date(resizeOriginalStart), newHours); + newStart = setMinutes(newStart, newMins); + } + + // Update event via store + if (eventsStore.isDraftEvent(resizeEvent.id)) { + eventsStore.updateDraftEvent({ + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } else { + await eventsStore.updateEvent(resizeEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } + + cleanupResize(); + } + + function cleanupResize() { + isResizing = false; + resizeEvent = null; + resizeOriginalStart = null; + resizeOriginalEnd = null; + resizeOffsetMinutes = 0; + hasMoved = false; + } + + // ========== Combined Cleanup ========== + + function cleanup() { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + cleanupDrag(); + cleanupResize(); + } + + /** + * Cancel any active drag/resize operation (e.g., on Escape key) + */ + function cancel() { + if (isDragging || isResizing) { + cleanup(); + } + } + + return { + // Drag state (reactive getters) + get isDragging() { + return isDragging; + }, + get draggedEvent() { + return draggedEvent; + }, + get dragTargetDay() { + return dragTargetDay; + }, + get dragPreviewTop() { + return dragPreviewTop; + }, + get dragPreviewHeight() { + return dragPreviewHeight; + }, + + // Resize state (reactive getters) + get isResizing() { + return isResizing; + }, + get resizeEvent() { + return resizeEvent; + }, + get resizeEdge() { + return resizeEdge; + }, + get resizePreviewTop() { + return resizePreviewTop; + }, + get resizePreviewHeight() { + return resizePreviewHeight; + }, + + // Shared state + get hasMoved() { + return hasMoved; + }, + + // Reset hasMoved after click handling + resetHasMoved() { + hasMoved = false; + }, + + // Methods + startDrag, + startResize, + cancel, + cleanup, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts new file mode 100644 index 000000000..b03aa49bc --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts @@ -0,0 +1,131 @@ +/** + * Sidebar Task Drop Composable + * Handles dropping tasks from sidebar into calendar day columns + */ + +import { todosStore } from '$lib/stores/todos.svelte'; +import { format } from 'date-fns'; +import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + +export interface SidebarDropConfig { + /** First visible hour (for filtered hours mode) */ + firstVisibleHour: number; + /** Total visible hours */ + totalVisibleHours: number; + /** Minutes per snap interval (default: 15) */ + snapMinutes?: number; +} + +export function useSidebarDrop(getConfig: () => SidebarDropConfig) { + // Track active drop target (for visual feedback) + let dropTarget = $state<{ day: Date; y: number } | null>(null); + + function getSnapMinutes(): number { + return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; + } + + function formatTime(hours: number, minutes: number): string { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + /** + * Handle dragover event on a day column + */ + function handleDragOver(e: DragEvent, day: Date) { + e.preventDefault(); + if (!e.dataTransfer) return; + + // Check if this is a sidebar task drag + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + + e.dataTransfer.dropEffect = 'move'; + dropTarget = { day, y: e.clientY }; + } + + /** + * Handle dragleave event + */ + function handleDragLeave(e: DragEvent) { + // Only clear if leaving the column entirely + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + dropTarget = null; + } + } + + /** + * Handle drop event on a day column + */ + async function handleDrop(e: DragEvent, day: Date) { + e.preventDefault(); + dropTarget = null; + + if (!e.dataTransfer) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + const config = getConfig(); + + // Calculate drop time from Y position + const dayColumn = (e.target as HTMLElement).closest('.day-column'); + if (!dayColumn) return; + + const rect = dayColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (config.totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snapMinutes = getSnapMinutes(); + const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; + const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = formatTime(hours, minutes); + + // Calculate end time + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = formatTime(endHours, endMins); + + // Update the task with scheduled time + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(day, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } + } + + /** + * Clear drop target (use when component unmounts or for manual cleanup) + */ + function clearDropTarget() { + dropTarget = null; + } + + return { + // State (reactive getter) + get dropTarget() { + return dropTarget; + }, + + // Methods + handleDragOver, + handleDragLeave, + handleDrop, + clearDropTarget, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts index f090319ec..bef9100b6 100644 --- a/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts +++ b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts @@ -1,306 +1,321 @@ /** - * Composable for Task Drag & Drop in Calendar Views - * Handles dragging tasks to reschedule and resizing to change duration + * Task Drag & Drop + Resize Composable + * Extracts duplicated task drag/resize logic from WeekView, DayView, MultiDayView + * + * Uses document-level event listeners for smooth drag operations across the entire screen. */ -import type { Task, UpdateTaskInput } from '$lib/api/todos'; +import type { Task } from '$lib/stores/todos.svelte'; import { todosStore } from '$lib/stores/todos.svelte'; -import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns'; +import { format } from 'date-fns'; +import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; -const SNAP_MINUTES = 15; - -interface UseTaskDragDropOptions { - /** Minimum snap interval in minutes */ +export interface TaskDragDropConfig { + /** Reference to the container element for position calculations */ + containerEl: HTMLElement | null; + /** Array of visible days (for multi-day views) or single day (for day view) */ + days: Date[]; + /** First visible hour (for filtered hours mode) */ + firstVisibleHour: number; + /** Total visible hours */ + totalVisibleHours: number; + /** Minutes per snap interval (default: 15) */ snapMinutes?: number; - /** Callback when task is updated */ - onTaskUpdate?: (task: Task) => void; } -export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) { - const snapMinutes = options.snapMinutes ?? SNAP_MINUTES; - - // Drag state - let isDragging = $state(false); +export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) { + // ========== Drag State ========== + let isTaskDragging = $state(false); let draggedTask = $state(null); - let dragStartY = $state(0); - let dragTargetDay = $state(null); - let dragPreviewTop = $state(0); - let dragPreviewHeight = $state(0); + let taskDragTargetDay = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); - // Resize state - let isResizing = $state(false); + // ========== Resize State ========== + let isTaskResizing = $state(false); let resizeTask = $state(null); - let resizeEdge = $state<'top' | 'bottom'>('bottom'); - let resizeStartY = $state(0); - let resizePreviewTop = $state(0); - let resizePreviewHeight = $state(0); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); - // Track if we actually moved + // Track if we actually moved during drag/resize let hasMoved = $state(false); - /** - * Start dragging a task - */ - function startDrag( - task: Task, - e: PointerEvent, - gridElement: HTMLElement, - firstVisibleHour: number, - totalVisibleHours: number - ) { + // ========== Helper Functions ========== + + function getSnapMinutes(): number { + return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; + } + + function formatTime(hours: number, minutes: number): string { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + // ========== Drag Functions ========== + + function startDrag(task: Task, e: PointerEvent) { e.preventDefault(); - isDragging = true; + + const config = getConfig(); + isTaskDragging = true; draggedTask = task; - dragStartY = e.clientY; hasMoved = false; - // Calculate initial position + // Initialize preview position from task's current time if (task.scheduledStartTime) { const [h, m] = task.scheduledStartTime.split(':').map(Number); - const startMinutes = h * 60 + m - firstVisibleHour * 60; - dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + const startMinutes = h * 60 + m - config.firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100; } - // Calculate height from duration const duration = task.estimatedDuration || 30; - dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + taskDragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; - // Capture pointer - (e.target as HTMLElement).setPointerCapture(e.pointerId); + document.addEventListener('pointermove', handleDragMove); + document.addEventListener('pointerup', handleDragEnd); } - /** - * Handle drag move - */ - function onDragMove( - e: PointerEvent, - gridElement: HTMLElement, - day: Date, - firstVisibleHour: number, - totalVisibleHours: number - ) { - if (!isDragging || !draggedTask) return; + function handleDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask) return; + const config = getConfig(); hasMoved = true; - dragTargetDay = day; - const rect = gridElement.getBoundingClientRect(); + // Find which day column we're over + if (config.containerEl) { + const dayColumns = config.containerEl.querySelectorAll('.day-column'); + for (let i = 0; i < dayColumns.length; i++) { + const col = dayColumns[i]; + const rect = col.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right) { + taskDragTargetDay = config.days[i]; + break; + } + } + } + + // Calculate vertical position + const targetColumn = config.containerEl?.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); const relativeY = e.clientY - rect.top; - const percentY = (relativeY / rect.height) * 100; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); // Snap to intervals - const minutesPerPercent = (totalVisibleHours * 60) / 100; - const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60; + const minutesPerPercent = (config.totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snapMinutes = getSnapMinutes(); const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; - - dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100; + taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; } - /** - * End drag and update task - */ - async function endDrag(firstVisibleHour: number, totalVisibleHours: number) { - if (!isDragging || !draggedTask || !hasMoved) { - isDragging = false; - draggedTask = null; - dragTargetDay = null; + async function handleDragEnd() { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + cleanupDrag(); return; } - // Calculate new time from position - const minutesFromMidnight = - (dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; - const hours = Math.floor(minutesFromMidnight / 60); - const minutes = Math.round(minutesFromMidnight % 60); + const config = getConfig(); - const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + // Calculate new time from position + const minutesFromStart = (taskDragPreviewTop / 100) * (config.totalVisibleHours * 60); + const totalMinutes = config.firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = formatTime(hours, minutes); // Calculate end time based on duration const duration = draggedTask.estimatedDuration || 30; - const endMinutes = minutesFromMidnight + duration; - const endHours = Math.floor(endMinutes / 60); - const endMins = Math.round(endMinutes % 60); - const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = formatTime(endHours, endMins); - const updateData: UpdateTaskInput = { - scheduledDate: dragTargetDay - ? format(dragTargetDay, 'yyyy-MM-dd') - : draggedTask.scheduledDate, + await todosStore.updateTodo(draggedTask.id, { + scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined, scheduledStartTime: newStartTime, scheduledEndTime: newEndTime, - }; + }); - const result = await todosStore.updateTodo(draggedTask.id, updateData); - if (result.data) { - options.onTaskUpdate?.(result.data); - } + cleanupDrag(); + } - isDragging = false; + function cleanupDrag() { + isTaskDragging = false; draggedTask = null; - dragTargetDay = null; + taskDragTargetDay = null; hasMoved = false; } - /** - * Start resizing a task - */ - function startResize( - task: Task, - edge: 'top' | 'bottom', - e: PointerEvent, - firstVisibleHour: number, - totalVisibleHours: number - ) { + // ========== Resize Functions ========== + + function startResize(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { e.preventDefault(); e.stopPropagation(); - isResizing = true; + + const config = getConfig(); + isTaskResizing = true; resizeTask = task; - resizeEdge = edge; - resizeStartY = e.clientY; + taskResizeEdge = edge; hasMoved = false; // Initialize preview position if (task.scheduledStartTime) { const [h, m] = task.scheduledStartTime.split(':').map(Number); - const startMinutes = h * 60 + m - firstVisibleHour * 60; - resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + const startMinutes = h * 60 + m - config.firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100; } const duration = task.estimatedDuration || 30; - resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; - (e.target as HTMLElement).setPointerCapture(e.pointerId); + document.addEventListener('pointermove', handleResizeMove); + document.addEventListener('pointerup', handleResizeEnd); } - /** - * Handle resize move - */ - function onResizeMove( - e: PointerEvent, - gridElement: HTMLElement, - firstVisibleHour: number, - totalVisibleHours: number - ) { - if (!isResizing || !resizeTask) return; + function handleResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask) return; + const config = getConfig(); hasMoved = true; - const rect = gridElement.getBoundingClientRect(); + const targetColumn = config.containerEl?.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); const relativeY = e.clientY - rect.top; const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); - const minutesPerPercent = (totalVisibleHours * 60) / 100; + const minutesPerPercent = (config.totalVisibleHours * 60) / 100; + const snapMinutes = getSnapMinutes(); - if (resizeEdge === 'top') { + if (taskResizeEdge === 'top') { // Adjust start time, keep end fixed - const originalEndPercent = resizePreviewTop + resizePreviewHeight; + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; const rawMinutes = percentY * minutesPerPercent; const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; - resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; - resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop); + taskResizePreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); } else { // Adjust end time, keep start fixed const rawMinutes = percentY * minutesPerPercent; const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; - const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; - resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop); + const newBottom = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); } } - /** - * End resize and update task - */ - async function endResize(firstVisibleHour: number, totalVisibleHours: number) { - if (!isResizing || !resizeTask || !hasMoved) { - isResizing = false; - resizeTask = null; + async function handleResizeEnd() { + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + cleanupResize(); return; } + const config = getConfig(); + // Calculate new times from position const startMinutes = - (resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + (taskResizePreviewTop / 100) * (config.totalVisibleHours * 60) + config.firstVisibleHour * 60; const endMinutes = - ((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) + - firstVisibleHour * 60; + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (config.totalVisibleHours * 60) + + config.firstVisibleHour * 60; const startHours = Math.floor(startMinutes / 60); const startMins = Math.round(startMinutes % 60); const endHours = Math.floor(endMinutes / 60); const endMins = Math.round(endMinutes % 60); - const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; - const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newStartTime = formatTime(startHours, startMins); + const newEndTime = formatTime(endHours, endMins); const newDuration = Math.round(endMinutes - startMinutes); - const updateData: UpdateTaskInput = { + await todosStore.updateTodo(resizeTask.id, { scheduledStartTime: newStartTime, scheduledEndTime: newEndTime, estimatedDuration: newDuration, - }; + }); - const result = await todosStore.updateTodo(resizeTask.id, updateData); - if (result.data) { - options.onTaskUpdate?.(result.data); - } + cleanupResize(); + } - isResizing = false; + function cleanupResize() { + isTaskResizing = false; resizeTask = null; hasMoved = false; } + // ========== Combined Cleanup ========== + + function cleanup() { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + cleanupDrag(); + cleanupResize(); + } + /** - * Cancel any ongoing drag/resize + * Cancel any active drag/resize operation */ function cancel() { - isDragging = false; - isResizing = false; - draggedTask = null; - resizeTask = null; - dragTargetDay = null; - hasMoved = false; + if (isTaskDragging || isTaskResizing) { + cleanup(); + } } return { - // State getters - get isDragging() { - return isDragging; + // Drag state (reactive getters) + get isTaskDragging() { + return isTaskDragging; }, get draggedTask() { return draggedTask; }, - get dragTargetDay() { - return dragTargetDay; + get taskDragTargetDay() { + return taskDragTargetDay; }, - get dragPreviewTop() { - return dragPreviewTop; + get taskDragPreviewTop() { + return taskDragPreviewTop; }, - get dragPreviewHeight() { - return dragPreviewHeight; + get taskDragPreviewHeight() { + return taskDragPreviewHeight; }, - get isResizing() { - return isResizing; + + // Resize state (reactive getters) + get isTaskResizing() { + return isTaskResizing; }, get resizeTask() { return resizeTask; }, - get resizePreviewTop() { - return resizePreviewTop; + get taskResizeEdge() { + return taskResizeEdge; }, - get resizePreviewHeight() { - return resizePreviewHeight; + get taskResizePreviewTop() { + return taskResizePreviewTop; }, + get taskResizePreviewHeight() { + return taskResizePreviewHeight; + }, + + // Shared state get hasMoved() { return hasMoved; }, // Methods startDrag, - onDragMove, - endDrag, startResize, - onResizeMove, - endResize, cancel, + cleanup, }; }