import {Button, EmptyState, IconSpinner} from '@plunk/ui'; import type {Activity, CursorPaginatedResponse} from '@plunk/types'; import {network} from '../lib/network'; import {ActivityItem} from './ActivityItem'; import {Activity as ActivityIcon} from 'lucide-react'; import {useCallback, useEffect, useMemo, useState} from 'react'; export interface ActivityFeedProps { typeFilter?: string; dateRangeDays?: number; contactId?: string; } export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: ActivityFeedProps) { const [activities, setActivities] = useState([]); const [upcomingActivities, setUpcomingActivities] = useState([]); const [nextCursor, setNextCursor] = useState(); const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); // Memoize start date to prevent recreation on every render const startDate = useMemo(() => { const date = new Date(); date.setDate(date.getDate() - dateRangeDays); return date.toISOString(); }, [dateRangeDays]); const fetchActivities = useCallback( async (cursor?: string) => { try { if (cursor) { setIsLoadingMore(true); } else { setIsLoading(true); // Don't clear activities immediately - we'll do a smart merge // This preserves React component instances and their local state setNextCursor(undefined); setHasMore(true); } setError(null); const params = new URLSearchParams({ limit: '20', // Conservative limit to avoid overloading }); // Only apply startDate filter on initial load, not during pagination // When cursor is present, we're paginating backwards and should not limit by startDate if (!cursor) { params.set('startDate', startDate); } if (cursor) { params.set('cursor', cursor); } if (typeFilter) { params.set('types', typeFilter); } if (contactId) { params.set('contactId', contactId); } const result = await network.fetch>('GET', `/activity?${params.toString()}`); if (cursor) { // Append to existing activities (pagination) setActivities(prev => [...prev, ...result.data]); } else { // Smart merge: preserve existing activity objects by ID to maintain component state setActivities(prev => { // If no previous activities, just use new data if (prev.length === 0) { return result.data; } // Create a map of existing activities by ID for fast lookup const existingMap = new Map(prev.map(activity => [activity.id, activity])); // For each new activity, reuse existing object if ID matches (preserves React component instances) // Otherwise use new object. This maintains correct ordering while preserving component state. return result.data.map(newActivity => existingMap.get(newActivity.id) ?? newActivity); }); } setNextCursor(result.cursor); setHasMore(result.hasMore); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load activities'); console.error('Error fetching activities:', err); } finally { setIsLoading(false); setIsLoadingMore(false); } }, [typeFilter, startDate, contactId], ); // Fetch upcoming activities const fetchUpcomingActivities = useCallback(async () => { try { // Don't fetch upcoming if we're filtering by contact // (upcoming items aren't contact-specific) if (contactId) { setUpcomingActivities([]); return; } const params = new URLSearchParams({ limit: '20', daysAhead: dateRangeDays.toString(), }); const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`); // Smart merge: preserve existing activity objects by ID to maintain component state setUpcomingActivities(prev => { // If no previous activities, just use new data if (prev.length === 0) { return result.activities; } // Create a map of existing activities by ID for fast lookup const existingMap = new Map(prev.map(activity => [activity.id, activity])); // For each new activity, reuse existing object if ID matches return result.activities.map(newActivity => existingMap.get(newActivity.id) ?? newActivity); }); } catch (err) { console.error('Error fetching upcoming activities:', err); // Don't set error state for upcoming - just fail silently setUpcomingActivities([]); } }, [dateRangeDays, contactId]); // Initial fetch - only run once when filters change useEffect(() => { void fetchActivities(); void fetchUpcomingActivities(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [typeFilter, startDate, contactId]); // Auto-refresh every 30 seconds for real-time updates useEffect(() => { // Don't set up auto-refresh if still loading initial data if (isLoading) { return; } const interval = setInterval(() => { // Only refresh if we're on the first page and not already loading if (!isLoading && !isLoadingMore && activities.length > 0) { void fetchActivities(); void fetchUpcomingActivities(); } }, 30000); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, isLoadingMore, activities.length]); const loadMore = () => { if (nextCursor && hasMore && !isLoadingMore) { void fetchActivities(nextCursor); } }; if (isLoading) { return (
); } if (error) { return (

{error}

); } if (activities.length === 0 && upcomingActivities.length === 0) { return ( ); } return (
{/* Past Activity Timeline */} {activities.length > 0 && (
{activities.map((activity, index) => (
{index < activities.length - 1 &&
}
))}
)} {/* Load More Button */} {hasMore && (
)} {/* Separator between past and upcoming */} {activities.length > 0 && upcomingActivities.length > 0 && (
)} {/* Upcoming Activities */} {upcomingActivities.length > 0 && (
{upcomingActivities.map((activity, index) => (
{index < upcomingActivities.length - 1 &&
}
))}
)} {/* End of results indicator */} {!hasMore && activities.length > 0 && upcomingActivities.length === 0 && (

You've reached the end of the activity feed

)}
); }