From 20bc954d6bbda1a1a04c6be7ad9fd1508775cb7a Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:25:43 +0100 Subject: [PATCH] fix(calendar): prevent double navigation with lock mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 150ms lock after each page navigation to prevent: - Wheel events continuing to fire and triggering multiple navigations - Rapid swipes causing double-jumps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/calendar/ViewCarousel.svelte | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte index e595d0c1f..934fb0533 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -22,6 +22,7 @@ let offsetX = $state(0); let startX = 0; let isSwiping = $state(false); + let isLocked = false; // Prevent rapid double-navigation // Container ref let viewportEl: HTMLDivElement; @@ -29,6 +30,7 @@ // Threshold: 15% of viewport width triggers navigation const SNAP_THRESHOLD = 0.15; + const LOCK_DURATION = 150; // ms to wait after navigation // Calculate dates for previous/current/next views let prevDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, -1)); @@ -52,7 +54,7 @@ // Wheel handler (trackpad horizontal scroll) function handleWheel(e: WheelEvent) { - if (disableSwipe) return; + if (disableSwipe || isLocked) return; // Only handle horizontal scrolling if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; @@ -71,17 +73,32 @@ const threshold = viewportWidth * SNAP_THRESHOLD; if (offsetX > threshold) { - viewStore.goToPrevious(); - offsetX = 0; + navigateTo('prev'); } else if (offsetX < -threshold) { - viewStore.goToNext(); - offsetX = 0; + navigateTo('next'); } } + function navigateTo(direction: 'prev' | 'next') { + // Lock to prevent double navigation + isLocked = true; + offsetX = 0; + + if (direction === 'prev') { + viewStore.goToPrevious(); + } else { + viewStore.goToNext(); + } + + // Unlock after short delay + setTimeout(() => { + isLocked = false; + }, LOCK_DURATION); + } + // Touch handlers function handleTouchStart(e: TouchEvent) { - if (disableSwipe) return; + if (disableSwipe || isLocked) return; const target = e.target as HTMLElement; if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return; @@ -91,7 +108,7 @@ } function handleTouchMove(e: TouchEvent) { - if (!isSwiping || disableSwipe) return; + if (!isSwiping || disableSwipe || isLocked) return; const currentX = e.touches[0].clientX; offsetX = currentX - startX; @@ -105,13 +122,12 @@ const threshold = viewportWidth * SNAP_THRESHOLD; if (offsetX > threshold) { - viewStore.goToPrevious(); + navigateTo('prev'); } else if (offsetX < -threshold) { - viewStore.goToNext(); + navigateTo('next'); + } else { + offsetX = 0; } - - // Instant reset - offsetX = 0; } function handleTouchCancel() {