diff --git a/package.json b/package.json index 6c9af5b..fd1e623 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.77.2", + "version": "1.77.3", "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/app/globals.css b/src/app/globals.css index acbad44..d531c86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2780,6 +2780,8 @@ h3 { min-width: 0; overflow: hidden; position: relative; + /* Tell iOS Safari that vertical pan = scroll, even if child elements have JS touch handlers */ + touch-action: pan-y; } /* Side Navigation Arrows (hover overlays) */ @@ -3165,6 +3167,8 @@ h3 { .time-grid-wrapper .weekly-days-grid { flex: 1; overflow: visible; + /* Align grid items to the top so sticky headers work correctly */ + align-items: start; } .time-grid-wrapper .weekly-day-column { @@ -3176,10 +3180,16 @@ h3 { .time-grid-wrapper .weekly-day-header { flex-shrink: 0; + /* Sticky works because time-grid-wrapper is the overflow-y:auto ancestor */ + position: sticky; + top: 0; + z-index: 20; + background-color: var(--weekly-bg, white); } .time-grid-wrapper .time-slots-container { overflow: visible; + flex: 1; } /* All-Day Events Section */ diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index e7a1f9f..8b90d7e 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -4151,27 +4151,73 @@ export default function WeeklyView() { } }, [upgradeToDragListeners]); - // Permanent global passive touchmove + touchend listeners (attached once at mount) + // All touch listeners for the time-grid are attached ONCE at mount as native listeners. + // CRITICAL: do NOT use React onTouchStart on slot divs — React registers those as + // non-passive at the app root, causing iOS Safari to wait for JS before committing + // a scroll gesture, which blocks vertical scrolling on day columns entirely. useEffect(() => { + // TOUCHSTART — passive, on document. Reads data attributes from slot element. + // Slot divs must have data-slot, data-slot-blocked, and their column must have data-date. + const handleTouchStart = (e: TouchEvent) => { + const target = e.target as Element; + const slotEl = target.closest('[data-slot]') as HTMLElement | null; + if (!slotEl) return; + if (slotEl.dataset.slotBlocked) return; + if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return; + + const touch = e.touches[0]; + const colEl = slotEl.closest('[data-date]') as HTMLElement | null; + if (!colEl?.dataset.date) return; + const slotDate = new Date(colEl.dataset.date); + const slotName = slotEl.dataset.slot!; + + if (touchLongPressRef.current) { + clearTimeout(touchLongPressRef.current.timerId); + } + const timerId = setTimeout(() => { + 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; + } + upgradeToDragListeners(); + 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, + }; + }; + + // TOUCHMOVE — passive, cancels long-press if finger moves (user is scrolling) const handlePassiveTouchMove = (e: TouchEvent) => { const lp = touchLongPressRef.current; if (!lp || lp.activated) return; const touch = e.touches[0]; - // Cancel long-press if finger moves more than 10px (user is scrolling) if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) { clearTimeout(lp.timerId); touchLongPressRef.current = null; } }; + // TOUCHEND — passive, completes drag or cancels const handleTouchEnd = () => { const lp = touchLongPressRef.current; if (lp) { clearTimeout(lp.timerId); touchLongPressRef.current = null; } - - // Deactivate drag listener if it was upgraded cancelDragListeners(); const drag = slotDragRef.current; @@ -4201,18 +4247,19 @@ export default function WeeklyView() { }); }; + document.addEventListener('touchstart', handleTouchStart, { passive: true }); document.addEventListener('touchmove', handlePassiveTouchMove, { passive: true }); document.addEventListener('touchend', handleTouchEnd, { passive: true }); document.addEventListener('touchcancel', handleTouchEnd, { passive: true }); return () => { + document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchmove', handlePassiveTouchMove); document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('touchcancel', handleTouchEnd); }; - }, [cancelDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps + }, [cancelDragListeners, upgradeToDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps - // No-op: onTouchStart on slots only updates the ref — no DOM side effects here - const attachTouchListeners = useCallback(() => { /* listeners are permanent, see useEffect above */ }, []); + const attachTouchListeners = useCallback(() => { /* no-op — all listeners are permanent */ }, []); // Handle recurring event drag confirm (this/all) const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => { @@ -8059,6 +8106,7 @@ export default function WeeklyView() {