From 821285bfa420e5976ba876ecc5066497fad435d9 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 10 Feb 2026 02:04:02 -0800 Subject: [PATCH 1/2] feat: Add pull-to-refresh on list views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pull-to-refresh gesture for mobile users on the ListView page. Since Convex uses reactive queries (data is always live), the gesture provides visual feedback (arrow → spinner → done) confirming freshness. - New usePullToRefresh hook with touch event handling and resistance curve - PullToRefreshIndicator component with animated arrow/spinner - Integrated into ListView (Home already had a broken import, now resolved) - Shared hook exports for both Home and ListView pages --- src/components/PullToRefreshIndicator.tsx | 85 ++++++++++++++++ src/hooks/usePullToRefresh.ts | 119 ++++++++++++++++++++++ src/pages/ListView.tsx | 22 +++- 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/components/PullToRefreshIndicator.tsx create mode 100644 src/hooks/usePullToRefresh.ts diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx new file mode 100644 index 0000000..bfe8079 --- /dev/null +++ b/src/components/PullToRefreshIndicator.tsx @@ -0,0 +1,85 @@ +/** + * Visual indicator for pull-to-refresh gesture. + * Shows a spinner/arrow that responds to pull distance. + */ + +import React from "react"; + +interface PullToRefreshIndicatorProps { + pullDistance: number; + isRefreshing: boolean; + isPastThreshold?: boolean; + threshold?: number; +} + +export function PullToRefreshIndicator({ + pullDistance, + isRefreshing, + isPastThreshold: isPastThresholdProp, + threshold = 80, +}: PullToRefreshIndicatorProps) { + if (pullDistance === 0 && !isRefreshing) return null; + + const progress = Math.min(pullDistance / threshold, 1); + const isPastThreshold = isPastThresholdProp ?? progress >= 1; + const rotation = isPastThreshold ? 180 : progress * 180; + + return ( +
+
+ {isRefreshing ? ( + + + + + ) : ( + + + + )} +
+
+ ); +} diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..e800a53 --- /dev/null +++ b/src/hooks/usePullToRefresh.ts @@ -0,0 +1,119 @@ +/** + * Pull-to-refresh hook for mobile touch interactions. + * + * Since Convex uses reactive queries (data is always live), this hook + * provides visual feedback that data is fresh rather than actually + * re-fetching. It can optionally call a callback for additional refresh logic. + */ + +import { useState, useRef, useCallback, useEffect } from "react"; + +interface PullToRefreshOptions { + /** Called when user completes a pull-to-refresh gesture */ + onRefresh?: () => Promise | void; + /** Minimum pull distance in px to trigger refresh (default: 80) */ + threshold?: number; + /** Max pull distance in px (default: 150) */ + maxPull?: number; + /** Whether pull-to-refresh is enabled (default: true) */ + enabled?: boolean; +} + +export function usePullToRefresh(options: PullToRefreshOptions = {}) { + const { + onRefresh, + threshold = 80, + maxPull = 150, + enabled = true, + } = options; + + const [pullDistance, setPullDistance] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isPastThreshold, setIsPastThreshold] = useState(false); + + const touchStartY = useRef(null); + const pullRef = useRef(null); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (!enabled || isRefreshing) return; + // Only start pull if scrolled to top + const scrollEl = document.scrollingElement || document.documentElement; + if (scrollEl.scrollTop > 5) return; + touchStartY.current = e.touches[0].clientY; + }, + [enabled, isRefreshing] + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!enabled || isRefreshing || touchStartY.current === null) return; + + const currentY = e.touches[0].clientY; + const diff = currentY - touchStartY.current; + + if (diff > 0) { + const distance = Math.min(diff * 0.5, maxPull); + setPullDistance(distance); + setIsPastThreshold(distance >= threshold); + if (diff > 10) { + e.preventDefault(); + } + } else { + setPullDistance(0); + setIsPastThreshold(false); + } + }, + [enabled, isRefreshing, threshold, maxPull] + ); + + const handleTouchEnd = useCallback(async () => { + if (!enabled || touchStartY.current === null) return; + touchStartY.current = null; + + if (isPastThreshold) { + setPullDistance(threshold * 0.6); + setIsRefreshing(true); + + try { + if (onRefresh) { + await onRefresh(); + } else { + await new Promise((resolve) => setTimeout(resolve, 600)); + } + } finally { + setPullDistance(0); + setIsRefreshing(false); + setIsPastThreshold(false); + } + } else { + setPullDistance(0); + setIsPastThreshold(false); + } + }, [enabled, isPastThreshold, threshold, onRefresh]); + + useEffect(() => { + const container = pullRef.current; + if (!container || !enabled) return; + + container.addEventListener("touchstart", handleTouchStart, { passive: true }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd, { passive: true }); + + return () => { + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + }; + }, [enabled, handleTouchStart, handleTouchMove, handleTouchEnd]); + + return { + pullRef, + pullDistance, + isRefreshing, + isPastThreshold, + }; +} + +// Re-export the indicator component for convenience +export { PullToRefreshIndicator } from "../components/PullToRefreshIndicator"; diff --git a/src/pages/ListView.tsx b/src/pages/ListView.tsx index 857cd66..c75d509 100644 --- a/src/pages/ListView.tsx +++ b/src/pages/ListView.tsx @@ -23,6 +23,8 @@ import { canEdit, canInvite, canDeleteList } from "../lib/permissions"; import { groupByAisle, classifyItem } from "../lib/groceryAisles"; import { useCategories } from "../hooks/useCategories"; import { shareList } from "../lib/share"; +import { usePullToRefresh } from "../hooks/usePullToRefresh"; +import { PullToRefreshIndicator } from "../components/PullToRefreshIndicator"; import { AddItemInput } from "../components/AddItemInput"; import { ListItem } from "../components/ListItem"; import { CollaboratorList } from "../components/sharing/CollaboratorList"; @@ -510,6 +512,17 @@ export function ListView() { shortcuts, }); + // Pull-to-refresh for mobile — Convex queries are reactive, so this + // just provides visual feedback that data is live/fresh + const { pullRef, pullDistance, isRefreshing, isPastThreshold } = usePullToRefresh({ + onRefresh: async () => { + haptic('light'); + // Brief delay for visual feedback — data is already live via Convex + await new Promise(resolve => setTimeout(resolve, 500)); + }, + enabled: viewMode === "list", + }); + // Reset focus when items change significantly useEffect(() => { if (focusedIndex !== null && focusedIndex >= sortedItems.length) { @@ -592,7 +605,7 @@ export function ListView() { const totalCount = items.length; return ( -
+
{/* Header - Redesigned for less crowding */}
@@ -759,6 +772,13 @@ export function ListView() {
)} + {/* Pull-to-refresh indicator */} + + {/* Progress bar */} {totalCount > 0 && (
From 3bf67263dd4cb12ee3da503c872d7bf3fdbac5be Mon Sep 17 00:00:00 2001 From: krusty-agent Date: Tue, 10 Feb 2026 21:16:08 -0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20use=20=F0=9F=92=A9=20emoji=20for=20?= =?UTF-8?q?pull-to-refresh=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PullToRefreshIndicator.tsx | 42 ++++------------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx index bfe8079..2420692 100644 --- a/src/components/PullToRefreshIndicator.tsx +++ b/src/components/PullToRefreshIndicator.tsx @@ -40,44 +40,14 @@ export function PullToRefreshIndicator({ style={{ opacity: Math.min(progress * 1.5, 1) }} > {isRefreshing ? ( - - - - + 💩 ) : ( - - - + 💩 + )}