From 98a47a62de9cbb0891454ee20779de6a7b74b28b Mon Sep 17 00:00:00 2001 From: mARTin Date: Wed, 25 Mar 2026 02:29:19 +0100 Subject: [PATCH] feat: long-press drag-to-create calendar events on mobile Tap and hold (~500ms) on an empty time slot to activate, then drag to select time range. Opens calendar event modal with pre-filled times. Includes haptic vibration feedback on activation. Normal tap and scroll gestures are not affected. v1.73.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/components/WeeklyView.tsx | 127 +++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 72e81b2..73588fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.72.2", + "version": "1.73.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 4593465..b50cb82 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -2223,7 +2223,7 @@ export default function WeeklyView() { initialEndTime?: string; }>({ isOpen: false }); - // Slot drag-to-create calendar event state + // Slot drag-to-create calendar event state (mouse + touch) const slotDragJustEndedRef = useRef(false); const slotDragRef = useRef<{ active: boolean; @@ -2237,6 +2237,15 @@ export default function WeeklyView() { startSlot: string; endSlot: string; } | null>(null); + // Touch long-press state for mobile drag-to-create + const touchLongPressRef = useRef<{ + timerId: ReturnType; + startX: number; + startY: number; + date: Date; + slot: string; + activated: boolean; + } | null>(null); // Calendar event resize/drag state const [eventDragState, setEventDragState] = useState<{ @@ -3960,6 +3969,86 @@ export default function WeeklyView() { }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Touch long-press drag-to-create: touchmove + touchend on document + useEffect(() => { + const handleTouchMove = (e: TouchEvent) => { + const lp = touchLongPressRef.current; + if (!lp) return; + + const touch = e.touches[0]; + if (!lp.activated) { + // Before long-press fires: cancel if finger moves too much (scrolling) + if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) { + clearTimeout(lp.timerId); + touchLongPressRef.current = null; + } + return; + } + + // Long-press active: prevent scrolling and track slot under finger + e.preventDefault(); + const el = document.elementFromPoint(touch.clientX, touch.clientY); + const slotEl = el?.closest('[data-slot]') as HTMLElement | null; + if (slotEl) { + const slot = slotEl.getAttribute('data-slot'); + if (slot) { + const drag = slotDragRef.current; + if (drag) { + drag.currentSlot = slot; + const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`; + const startSlot = drag.startSlot <= slot ? drag.startSlot : slot; + const endSlot = drag.startSlot <= slot ? slot : drag.startSlot; + setSlotDragSelection({ dateStr, startSlot, endSlot }); + } + } + } + }; + + const handleTouchEnd = () => { + const lp = touchLongPressRef.current; + if (lp) { + clearTimeout(lp.timerId); + touchLongPressRef.current = null; + } + + const drag = slotDragRef.current; + slotDragRef.current = null; + if (!drag || !drag.active) { + setSlotDragSelection(null); + return; + } + setSlotDragSelection(null); + slotDragJustEndedRef.current = true; + setTimeout(() => { slotDragJustEndedRef.current = false; }, 300); + + const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot; + const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot; + + const [eh, em] = endSlot.split(':').map(Number); + const endMinutes = eh * 60 + em + effectiveCellDurationRef.current; + const endH = Math.floor(endMinutes / 60); + const endM = endMinutes % 60; + const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`; + + setCalendarEventModal({ + isOpen: true, + initialDate: drag.date, + initialStartTime: startSlot, + initialEndTime: endTime, + }); + }; + + // Use passive: false for touchmove so we can preventDefault after long-press activates + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + return () => { + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Handle recurring event drag confirm (this/all) const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => { if (!pendingRecurringDrag) return; @@ -7856,6 +7945,42 @@ export default function WeeklyView() { startY: e.clientY, }; }} + onTouchStart={(e) => { + if (isProtected || isOccupiedByAnyTask) return; + const target = e.target as HTMLElement; + if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return; + const touch = e.touches[0]; + const slotDate = new Date(date); + const slotName = slot; + // Cancel any existing long-press + if (touchLongPressRef.current) { + clearTimeout(touchLongPressRef.current.timerId); + } + const timerId = setTimeout(() => { + // Long-press activated — vibrate for feedback + if (navigator.vibrate) navigator.vibrate(50); + slotDragRef.current = { + active: true, + date: slotDate, + startSlot: slotName, + currentSlot: slotName, + startY: touch.clientY, + }; + if (touchLongPressRef.current) { + touchLongPressRef.current.activated = true; + } + const ds = `${slotDate.getFullYear()}-${String(slotDate.getMonth() + 1).padStart(2, '0')}-${String(slotDate.getDate()).padStart(2, '0')}`; + setSlotDragSelection({ dateStr: ds, startSlot: slotName, endSlot: slotName }); + }, 500); + touchLongPressRef.current = { + timerId, + startX: touch.clientX, + startY: touch.clientY, + date: slotDate, + slot: slotName, + activated: false, + }; + }} onDragOver={(e) => !isProtected && !isOccupiedByTask && handleDragOver(e, date.getDay(), slot)