From 5cc27f852b02446659e0ab85305916c9f5e5a5f0 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 6 Aug 2025 00:12:19 +0200 Subject: feat: Implement pull-to-refresh functionality across various components - Added `PullToRefresh` component to enable pull-to-refresh behavior in `StopList` and `Estimates` pages. - Integrated `usePullToRefresh` hook to manage pull-to-refresh state and actions. - Created `UpdateNotification` component to inform users of available updates from the service worker. - Enhanced service worker management with `ServiceWorkerManager` class for better update handling and caching strategies. - Updated CSS styles for new components and improved layout for better user experience. - Refactored API caching logic in service worker to handle multiple endpoints and dynamic cache expiration. - Added auto-refresh functionality for estimates data to keep information up-to-date. --- src/frontend/app/hooks/useAutoRefresh.ts | 63 +++++++++++++++++++ src/frontend/app/hooks/usePullToRefresh.ts | 99 ++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/frontend/app/hooks/useAutoRefresh.ts create mode 100644 src/frontend/app/hooks/usePullToRefresh.ts (limited to 'src/frontend/app/hooks') diff --git a/src/frontend/app/hooks/useAutoRefresh.ts b/src/frontend/app/hooks/useAutoRefresh.ts new file mode 100644 index 0000000..172fa94 --- /dev/null +++ b/src/frontend/app/hooks/useAutoRefresh.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef, useCallback } from "react"; + +interface UseAutoRefreshOptions { + onRefresh: () => Promise; + interval?: number; + enabled?: boolean; +} + +export function useAutoRefresh({ + onRefresh, + interval = 30000, // 30 seconds default + enabled = true, +}: UseAutoRefreshOptions) { + const intervalRef = useRef(null); + const refreshCallbackRef = useRef(onRefresh); + + // Update callback ref when it changes + useEffect(() => { + refreshCallbackRef.current = onRefresh; + }, [onRefresh]); + + const startAutoRefresh = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + if (enabled) { + intervalRef.current = setInterval(() => { + refreshCallbackRef.current(); + }, interval); + } + }, [interval, enabled]); + + const stopAutoRefresh = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + startAutoRefresh(); + return stopAutoRefresh; + }, [startAutoRefresh, stopAutoRefresh]); + + // Handle visibility change to pause/resume auto-refresh + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + stopAutoRefresh(); + } else { + startAutoRefresh(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [startAutoRefresh, stopAutoRefresh]); + + return { startAutoRefresh, stopAutoRefresh }; +} diff --git a/src/frontend/app/hooks/usePullToRefresh.ts b/src/frontend/app/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..b34502b --- /dev/null +++ b/src/frontend/app/hooks/usePullToRefresh.ts @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from "react"; + +interface PullToRefreshOptions { + onRefresh: () => Promise; + threshold?: number; + resistance?: number; + enabled?: boolean; +} + +export function usePullToRefresh({ + onRefresh, + threshold = 80, + resistance = 2.5, + enabled = true, +}: PullToRefreshOptions) { + const containerRef = useRef(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [pullDistance, setPullDistance] = useState(0); + const [isDragging, setIsDragging] = useState(false); + + const startY = useRef(0); + const currentY = useRef(0); + const isAtTop = useRef(true); + + useEffect(() => { + const container = containerRef.current; + if (!container || !enabled) return; + + let rafId: number; + + const checkScrollPosition = () => { + isAtTop.current = container.scrollTop <= 5; + }; + + const handleTouchStart = (e: TouchEvent) => { + if (!isAtTop.current || isRefreshing) return; + + startY.current = e.touches[0].clientY; + currentY.current = startY.current; + setIsDragging(true); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isDragging || isRefreshing || !isAtTop.current) return; + + currentY.current = e.touches[0].clientY; + const deltaY = currentY.current - startY.current; + + if (deltaY > 0) { + e.preventDefault(); + const distance = Math.min(deltaY / resistance, threshold * 1.5); + setPullDistance(distance); + } + }; + + const handleTouchEnd = async () => { + if (!isDragging || isRefreshing) return; + + setIsDragging(false); + + if (pullDistance >= threshold) { + setIsRefreshing(true); + try { + await onRefresh(); + } finally { + setIsRefreshing(false); + } + } + + setPullDistance(0); + }; + + const handleScroll = () => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(checkScrollPosition); + }; + + container.addEventListener("touchstart", handleTouchStart, { passive: false }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd); + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + if (rafId) cancelAnimationFrame(rafId); + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + container.removeEventListener("scroll", handleScroll); + }; + }, [enabled, isRefreshing, pullDistance, threshold, resistance, onRefresh, isDragging]); + + return { + containerRef, + isRefreshing, + pullDistance, + isDragging, + canRefresh: pullDistance >= threshold, + }; +} -- cgit v1.3