/** * DateGroupedSessions - Sessions organized by date categories with virtual scrolling. * Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll. */ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useStore } from '@renderer/store'; import { getNonEmptyCategories, groupSessionsByDate, separatePinnedSessions, } from '@renderer/utils/dateGrouping'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionItem } from './SessionItem'; import type { Session } from '@renderer/types/data'; import type { DateCategory } from '@renderer/types/tabs'; // Virtual list item types type VirtualItem = | { type: 'header'; category: DateCategory; id: string } | { type: 'pinned-header'; id: string } | { type: 'session'; session: Session; isPinned: boolean; id: string } | { type: 'loader'; id: string }; /** * Item height constants for virtual scroll positioning. * CRITICAL: These values MUST match the actual rendered heights of components. * If SessionItem height changes, update SESSION_HEIGHT here AND add h-[Xpx] to SessionItem. * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; export const DateGroupedSessions = (): React.JSX.Element => { const { sessions, selectedSessionId, selectedProjectId, sessionsLoading, sessionsError, sessionsHasMore, sessionsLoadingMore, sessionsTotalCount, fetchSessionsMore, pinnedSessionIds, } = useStore( useShallow((s) => ({ sessions: s.sessions, selectedSessionId: s.selectedSessionId, selectedProjectId: s.selectedProjectId, sessionsLoading: s.sessionsLoading, sessionsError: s.sessionsError, sessionsHasMore: s.sessionsHasMore, sessionsLoadingMore: s.sessionsLoadingMore, sessionsTotalCount: s.sessionsTotalCount, fetchSessionsMore: s.fetchSessionsMore, pinnedSessionIds: s.pinnedSessionIds, })) ); const parentRef = useRef(null); // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( () => separatePinnedSessions(sessions, pinnedSessionIds), [sessions, pinnedSessionIds] ); // Group only unpinned sessions by date const groupedSessions = useMemo(() => groupSessionsByDate(unpinnedSessions), [unpinnedSessions]); // Get non-empty categories in display order const nonEmptyCategories = useMemo( () => getNonEmptyCategories(groupedSessions), [groupedSessions] ); // Flatten sessions with date headers into virtual list items const virtualItems = useMemo((): VirtualItem[] => { const items: VirtualItem[] = []; // Add pinned section first if (pinnedSessions.length > 0) { items.push({ type: 'pinned-header', id: 'header-pinned', }); for (const session of pinnedSessions) { items.push({ type: 'session', session, isPinned: true, id: `session-${session.id}`, }); } } for (const category of nonEmptyCategories) { // Add header item items.push({ type: 'header', category, id: `header-${category}`, }); // Add session items for (const session of groupedSessions[category]) { items.push({ type: 'session', session, isPinned: false, id: `session-${session.id}`, }); } } // Add loader item if there are more sessions to load if (sessionsHasMore) { items.push({ type: 'loader', id: 'loader', }); } return items; }, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]); // Estimate item size based on type const estimateSize = useCallback( (index: number) => { const item = virtualItems[index]; if (!item) return SESSION_HEIGHT; switch (item.type) { case 'header': case 'pinned-header': return HEADER_HEIGHT; case 'loader': return LOADER_HEIGHT; case 'session': default: return SESSION_HEIGHT; } }, [virtualItems] ); // Set up virtualizer // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual API limitation, not fixable in user code const rowVirtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, estimateSize, overscan: OVERSCAN, }); // Get virtual items for dependency tracking const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRowsLength = virtualRows.length; // Load more when scrolling near end useEffect(() => { if (virtualRowsLength === 0) return; const lastItem = virtualRows[virtualRowsLength - 1]; if (!lastItem) return; // If we're within 3 items of the end and there's more to load, fetch more if ( lastItem.index >= virtualItems.length - 3 && sessionsHasMore && !sessionsLoadingMore && !sessionsLoading ) { void fetchSessionsMore(); } }, [ virtualRows, virtualRowsLength, virtualItems.length, sessionsHasMore, sessionsLoadingMore, sessionsLoading, fetchSessionsMore, ]); if (!selectedProjectId) { return (

Select a project to view sessions

); } if (sessionsLoading && sessions.length === 0) { const widths = [ { header: '30%', title: '75%', sub: '90%' }, { header: '22%', title: '60%', sub: '80%' }, { header: '26%', title: '85%', sub: '65%' }, ]; return (
{widths.map((w, i) => (
))}
); } if (sessionsError) { return (

Error loading sessions

{sessionsError}

); } if (sessions.length === 0) { return (

No sessions found

This project has no sessions yet

); } return (

Sessions

({sessions.length} {sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const item = virtualItems[virtualRow.index]; if (!item) return null; return (
{item.type === 'pinned-header' ? (
Pinned
) : item.type === 'header' ? (
{item.category}
) : item.type === 'loader' ? (
{sessionsLoadingMore ? ( <> Loading more sessions... ) : ( Scroll to load more )}
) : ( )}
); })}
); };