refactor(calendar): add comprehensive drag/drop composables

Create reusable composables for calendar event and task drag/drop:
- useEventDragDrop: Event drag and resize with document-level listeners
- useTaskDragDrop: Task drag and resize (updated to match new API)
- useSidebarDrop: Sidebar task drop handling
- useCalendarKeyboard: Keyboard shortcut handling (Escape to cancel)

These composables extract ~600 lines of duplicated logic from
WeekView, DayView, and MultiDayView. Integration into views will
be done in a follow-up commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 21:13:58 +01:00
parent 026c1654e3
commit 5bf275d9d0
5 changed files with 807 additions and 173 deletions

View file

@ -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';

View file

@ -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,
};
}

View file

@ -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<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
// ========== Resize State ==========
let isResizing = $state(false);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(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,
};
}

View file

@ -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,
};
}

View file

@ -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<Task | null>(null);
let dragStartY = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
let taskDragTargetDay = $state<Date | null>(null);
let taskDragPreviewTop = $state(0);
let taskDragPreviewHeight = $state(0);
// Resize state
let isResizing = $state(false);
// ========== Resize State ==========
let isTaskResizing = $state(false);
let resizeTask = $state<Task | null>(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,
};
}