From b2a59eecf153dfd9605b65ebf420cd72c1105a12 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 2 Dec 2025 10:06:49 +0000 Subject: [PATCH] feat: implement real-time list synchronization - Add useRealtimeSync hook for polling server updates every 3 seconds - Create API endpoint to check last updated timestamp for lists - Integrate real-time sync into ListDetailView component - Automatically refresh when other users make changes to shared lists - Poll on interval and when page becomes visible again --- .kiro/specs/ultimate-lists-app/tasks.md | 8 ++ app/api/lists/[id]/last-updated/route.ts | 82 ++++++++++++++++++ components/ListDetailView.tsx | 8 ++ hooks/index.ts | 1 + hooks/useRealtimeSync.ts | 106 +++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 app/api/lists/[id]/last-updated/route.ts create mode 100644 hooks/useRealtimeSync.ts diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 1cd6684..7bf7fef 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -226,6 +226,14 @@ - [x] 19. Final checkpoint - Ensure all tests pass - Ensure all tests pass, ask the user if questions arise. +- [x] 19.5. Implement real-time list synchronization + - Create useRealtimeSync hook for polling server updates + - Add API endpoint to check last updated timestamp + - Integrate real-time sync into ListDetailView component + - Automatically refresh when other users make changes + - Poll every 3 seconds and on page visibility change + - _Requirements: Collaborative features (enhancement)_ + - [ ] 20. End-to-end testing and polish - Test complete user flows from list creation to task completion - Verify PWA installation on iOS Safari diff --git a/app/api/lists/[id]/last-updated/route.ts b/app/api/lists/[id]/last-updated/route.ts new file mode 100644 index 0000000..8786b1f --- /dev/null +++ b/app/api/lists/[id]/last-updated/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { auth } from '@/auth' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + const userId = session.user.id + const listId = params.id + + // Check if user has access to this list + const list = await prisma.list.findFirst({ + where: { + id: listId, + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, + select: { + updatedAt: true, + items: { + select: { + updatedAt: true, + }, + orderBy: { + updatedAt: 'desc', + }, + take: 1, + }, + }, + }) + + if (!list) { + return NextResponse.json( + { error: 'List not found or access denied' }, + { status: 404 } + ) + } + + // Get the most recent update time (either list or its items) + const listUpdated = list.updatedAt.getTime() + const itemUpdated = list.items[0]?.updatedAt.getTime() || 0 + const lastUpdated = Math.max(listUpdated, itemUpdated) + + return NextResponse.json( + { lastUpdated: new Date(lastUpdated).toISOString() }, + { + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + } + ) + } catch (error) { + console.error('Error fetching last updated timestamp:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/components/ListDetailView.tsx b/components/ListDetailView.tsx index b0d7786..db3928d 100644 --- a/components/ListDetailView.tsx +++ b/components/ListDetailView.tsx @@ -11,6 +11,7 @@ import ShareListModal from './ShareListModal' import PermissionBadge from './PermissionBadge' import { searchItems, getItemsByFilter } from '@/lib/actions' import { useSearch, useFilters, useScrollPosition } from '@/hooks' +import { useRealtimeSync } from '@/hooks/useRealtimeSync' interface Item { id: string @@ -73,6 +74,13 @@ export default function ListDetailView({ // Use scroll position preservation hook useScrollPosition({ key: `list-${listId}` }) + // Use real-time sync hook to poll for updates + useRealtimeSync({ + listId, + enabled: true, + pollInterval: 3000, // Poll every 3 seconds + }) + // Extract all unique tags from items const availableTags = useMemo(() => { const tagSet = new Set() diff --git a/hooks/index.ts b/hooks/index.ts index 3e501de..9827d05 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -10,3 +10,4 @@ export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate' export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition' export { useOfflineSync } from './useOfflineSync' export type { QueuedMutation } from './useOfflineSync' +export { useRealtimeSync } from './useRealtimeSync' diff --git a/hooks/useRealtimeSync.ts b/hooks/useRealtimeSync.ts new file mode 100644 index 0000000..b4f28eb --- /dev/null +++ b/hooks/useRealtimeSync.ts @@ -0,0 +1,106 @@ +'use client' + +import { useEffect, useRef, useCallback, useState } from 'react' +import { useRouter } from 'next/navigation' + +interface UseRealtimeSyncOptions { + listId: string + enabled?: boolean + pollInterval?: number + onUpdate?: () => void +} + +/** + * Hook to enable real-time synchronization of list data + * Polls the server for updates and refreshes the page when changes are detected + */ +export function useRealtimeSync({ + listId, + enabled = true, + pollInterval = 3000, // Poll every 3 seconds + onUpdate +}: UseRealtimeSyncOptions) { + const router = useRouter() + const [lastUpdated, setLastUpdated] = useState(Date.now()) + const intervalRef = useRef(null) + const isPollingRef = useRef(false) + + const checkForUpdates = useCallback(async () => { + // Prevent concurrent polling + if (isPollingRef.current) return + + try { + isPollingRef.current = true + + // Fetch the latest update timestamp for the list + const response = await fetch(`/api/lists/${listId}/last-updated`, { + method: 'GET', + headers: { + 'Cache-Control': 'no-cache', + }, + }) + + if (response.ok) { + const data = await response.json() + const serverTimestamp = new Date(data.lastUpdated).getTime() + + // If server timestamp is newer than our last known update, refresh + if (serverTimestamp > lastUpdated) { + setLastUpdated(serverTimestamp) + + // Refresh the current route to get latest data + router.refresh() + + // Call optional callback + if (onUpdate) { + onUpdate() + } + } + } + } catch (error) { + console.error('Error checking for updates:', error) + } finally { + isPollingRef.current = false + } + }, [listId, lastUpdated, router, onUpdate]) + + useEffect(() => { + if (!enabled) { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + return + } + + // Start polling + intervalRef.current = setInterval(checkForUpdates, pollInterval) + + // Cleanup on unmount + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [enabled, pollInterval, checkForUpdates]) + + // Also check for updates when the page becomes visible again + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && enabled) { + checkForUpdates() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [enabled, checkForUpdates]) + + return { + lastUpdated, + checkForUpdates, + } +}