From 850548ac1e9c6e2e7eb2d21ad15ac2ada66387ae Mon Sep 17 00:00:00 2001 From: Yahor Barkouski Date: Sun, 29 Mar 2026 13:36:31 +0200 Subject: [PATCH] feat: virtualize timeline rendering --- .../__tests__/virtualTimeline.test.ts | 85 ++++++++++++ src/components/timeline/Timeline.tsx | 103 +++++++++----- src/components/timeline/TimelineGroup.tsx | 128 +++++++++++++----- .../timeline/useResponsiveColumns.ts | 40 ++++++ src/components/timeline/virtualTimeline.ts | 91 +++++++++++++ src/components/ui/scroll-area.tsx | 6 +- tsconfig.node.json | 7 +- 7 files changed, 390 insertions(+), 70 deletions(-) create mode 100644 electron/main/features/__tests__/virtualTimeline.test.ts create mode 100644 src/components/timeline/useResponsiveColumns.ts create mode 100644 src/components/timeline/virtualTimeline.ts diff --git a/electron/main/features/__tests__/virtualTimeline.test.ts b/electron/main/features/__tests__/virtualTimeline.test.ts new file mode 100644 index 0000000..381904e --- /dev/null +++ b/electron/main/features/__tests__/virtualTimeline.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + buildVirtualTimelineItems, + estimateVirtualTimelineItemSize, +} from "../../../../src/components/timeline/virtualTimeline"; +import type { Event } from "../../../../src/types"; + +function makeEvent(id: string, timestamp: number): Event { + return { id, timestamp } as Event; +} + +describe("buildVirtualTimelineItems", () => { + it("flattens grouped events into headers and chunked rows", () => { + const items = buildVirtualTimelineItems( + [ + ["Today", [makeEvent("1", 1), makeEvent("2", 2), makeEvent("3", 3)]], + ["Yesterday", [makeEvent("4", 4)]], + ], + 2, + ); + + expect(items).toHaveLength(5); + expect(items[0]).toMatchObject({ + type: "header", + date: "Today", + showPagination: true, + spacingAfter: 16, + }); + expect(items[1]).toMatchObject({ + type: "row", + date: "Today", + spacingAfter: 16, + }); + expect( + items[1]?.type === "row" ? items[1].events.map((event) => event.id) : [], + ).toEqual(["1", "2"]); + expect(items[2]).toMatchObject({ + type: "row", + date: "Today", + spacingAfter: 32, + }); + expect(items[3]).toMatchObject({ + type: "header", + date: "Yesterday", + showPagination: false, + }); + expect(items[4]).toMatchObject({ + type: "row", + date: "Yesterday", + spacingAfter: 0, + }); + }); + + it("falls back to a single column when the input is invalid", () => { + const items = buildVirtualTimelineItems( + [["Today", [makeEvent("1", 1), makeEvent("2", 2)]]], + 0, + ); + + expect(items).toHaveLength(3); + expect( + items[1]?.type === "row" ? items[1].events.map((event) => event.id) : [], + ).toEqual(["1"]); + expect( + items[2]?.type === "row" ? items[2].events.map((event) => event.id) : [], + ).toEqual(["2"]); + }); + + it("uses larger row estimates for narrower layouts", () => { + const [wideHeader, wideRow] = buildVirtualTimelineItems( + [["Today", [makeEvent("1", 1), makeEvent("2", 2), makeEvent("3", 3)]]], + 4, + ); + const [narrowHeader, narrowRow] = buildVirtualTimelineItems( + [["Today", [makeEvent("1", 1), makeEvent("2", 2), makeEvent("3", 3)]]], + 1, + ); + + expect(estimateVirtualTimelineItemSize(narrowHeader)).toBeGreaterThan(0); + expect(estimateVirtualTimelineItemSize(narrowRow)).toBeGreaterThan( + estimateVirtualTimelineItemSize(wideRow), + ); + expect(estimateVirtualTimelineItemSize(wideHeader)).toBeGreaterThan(0); + }); +}); diff --git a/src/components/timeline/Timeline.tsx b/src/components/timeline/Timeline.tsx index 31da2d8..16a1f92 100644 --- a/src/components/timeline/Timeline.tsx +++ b/src/components/timeline/Timeline.tsx @@ -1,5 +1,6 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; import { Loader2 } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useMemo, useRef } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useEvents } from "@/hooks/useEvents"; import { groupEventsByDate } from "@/lib/utils"; @@ -7,7 +8,12 @@ import { useAppStore } from "@/stores/app"; import type { Event } from "@/types"; import { BulkActions } from "./BulkActions"; import { TimelineFilters } from "./TimelineFilters"; -import { TimelineGroup } from "./TimelineGroup"; +import { TimelineEventRow, TimelineGroupHeader } from "./TimelineGroup"; +import { useResponsiveColumns } from "./useResponsiveColumns"; +import { + buildVirtualTimelineItems, + estimateVirtualTimelineItemSize, +} from "./virtualTimeline"; const SelectedBulkActions = memo(function SelectedBulkActions() { const selectedCount = useAppStore((s) => s.selectedEventIds.size); @@ -24,9 +30,26 @@ const TimelineList = memo(function TimelineList({ hasNextPage: boolean; totalPages: number; }) { + const viewportRef = useRef(null); + const contentRef = useRef(null); + const columns = useResponsiveColumns(contentRef); + + const entries = useMemo(() => Array.from(groups.entries()), [groups]); + const items = useMemo( + () => buildVirtualTimelineItems(entries, columns), + [entries, columns], + ); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => viewportRef.current, + estimateSize: (index) => estimateVirtualTimelineItemSize(items[index]), + overscan: 3, + }); + if (groups.size === 0) { return ( -
+

No events yet. Screenshots will appear here once captured.

@@ -34,21 +57,43 @@ const TimelineList = memo(function TimelineList({ ); } - const entries = Array.from(groups.entries()); - return ( - <> - {entries.map(([date, dateEvents], index) => ( - - ))} - + +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = items[virtualItem.index]; + if (!item) return null; + + return ( +
+
+ {item.type === "header" ? ( + + ) : ( + + )} +
+
+ ); + })} +
+
+
); }); @@ -64,21 +109,17 @@ export function Timeline() { - -
- {isLoading && events.length === 0 ? ( -
- -
- ) : ( - - )} + {isLoading && events.length === 0 ? ( +
+
- + ) : ( + + )}
); } diff --git a/src/components/timeline/TimelineGroup.tsx b/src/components/timeline/TimelineGroup.tsx index 7d77a65..456162b 100644 --- a/src/components/timeline/TimelineGroup.tsx +++ b/src/components/timeline/TimelineGroup.tsx @@ -13,6 +13,89 @@ interface TimelineGroupProps { totalPages?: number; } +interface TimelineGroupHeaderProps { + date: string; + showPagination?: boolean; + hasNextPage?: boolean; + totalPages?: number; +} + +interface TimelineEventRowProps { + events: Event[]; + showProject?: boolean; + columns?: number; +} + +export function TimelineGroupHeader({ + date, + showPagination = false, + hasNextPage = false, + totalPages = 1, +}: TimelineGroupHeaderProps) { + const pagination = useAppStore((s) => s.pagination); + const setPagination = useAppStore((s) => s.setPagination); + + return ( +
+

{date}

+ {showPagination && ( +
+ + Page {pagination.page + 1} of {totalPages} + + + +
+ )} +
+ ); +} + +export function TimelineEventRow({ + events, + showProject = false, + columns, +}: TimelineEventRowProps) { + const style = columns + ? { + gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, + } + : undefined; + + return ( +
+ {events.map((event) => ( + + ))} +
+ ); +} + export function TimelineGroup({ date, events, @@ -21,46 +104,17 @@ export function TimelineGroup({ hasNextPage = false, totalPages = 1, }: TimelineGroupProps) { - const pagination = useAppStore((s) => s.pagination); - const setPagination = useAppStore((s) => s.setPagination); - return (
-
-

{date}

- {showPagination && ( -
- - Page {pagination.page + 1} of {totalPages} - - - -
- )} -
-
- {events.map((event) => ( - - ))} +
+
+
); } diff --git a/src/components/timeline/useResponsiveColumns.ts b/src/components/timeline/useResponsiveColumns.ts new file mode 100644 index 0000000..57fed24 --- /dev/null +++ b/src/components/timeline/useResponsiveColumns.ts @@ -0,0 +1,40 @@ +import { type RefObject, useEffect, useState } from "react"; + +function getColumnCount(width: number): number { + if (width >= 1280) return 4; + if (width >= 1024) return 3; + if (width >= 768) return 2; + return 1; +} + +export function useResponsiveColumns( + containerRef: RefObject, +): number { + const [columns, setColumns] = useState(4); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateColumns = (width: number) => { + setColumns((current) => { + const next = getColumnCount(width); + return current === next ? current : next; + }); + }; + + updateColumns(container.getBoundingClientRect().width); + + const observer = new ResizeObserver((entries) => { + const width = + entries[0]?.contentRect.width ?? + container.getBoundingClientRect().width; + updateColumns(width); + }); + + observer.observe(container); + return () => observer.disconnect(); + }, [containerRef]); + + return columns; +} diff --git a/src/components/timeline/virtualTimeline.ts b/src/components/timeline/virtualTimeline.ts new file mode 100644 index 0000000..5161cbd --- /dev/null +++ b/src/components/timeline/virtualTimeline.ts @@ -0,0 +1,91 @@ +import type { Event } from "../../types"; + +const HEADER_GAP_PX = 16; +const ROW_GAP_PX = 16; +const GROUP_GAP_PX = 32; + +const HEADER_ESTIMATE_PX = 28; +const PAGINATED_HEADER_ESTIMATE_PX = 36; + +const ROW_ESTIMATE_BY_COLUMNS: Record = { + 1: 520, + 2: 400, + 3: 320, + 4: 280, +}; + +export type VirtualTimelineItem = + | { + type: "header"; + date: string; + key: string; + showPagination: boolean; + spacingAfter: number; + estimatedSize: number; + } + | { + type: "row"; + date: string; + events: Event[]; + key: string; + spacingAfter: number; + estimatedSize: number; + }; + +function chunkEvents(events: Event[], size: number): Event[][] { + const rows: Event[][] = []; + for (let index = 0; index < events.length; index += size) { + rows.push(events.slice(index, index + size)); + } + return rows; +} + +function getRowEstimate(columns: number): number { + return ROW_ESTIMATE_BY_COLUMNS[columns] ?? ROW_ESTIMATE_BY_COLUMNS[4]; +} + +export function buildVirtualTimelineItems( + groups: Array, + columns: number, +): VirtualTimelineItem[] { + const safeColumns = Math.max(1, Math.floor(columns) || 1); + const rowEstimate = getRowEstimate(safeColumns); + const items: VirtualTimelineItem[] = []; + + groups.forEach(([date, events], groupIndex) => { + const rows = chunkEvents(events, safeColumns); + const isLastGroup = groupIndex === groups.length - 1; + + items.push({ + type: "header", + date, + key: `header:${date}`, + showPagination: groupIndex === 0, + spacingAfter: + rows.length > 0 ? HEADER_GAP_PX : isLastGroup ? 0 : GROUP_GAP_PX, + estimatedSize: + groupIndex === 0 ? PAGINATED_HEADER_ESTIMATE_PX : HEADER_ESTIMATE_PX, + }); + + rows.forEach((rowEvents, rowIndex) => { + const isLastRow = rowIndex === rows.length - 1; + items.push({ + type: "row", + date, + events: rowEvents, + key: `row:${date}:${rowIndex}`, + spacingAfter: isLastRow ? (isLastGroup ? 0 : GROUP_GAP_PX) : ROW_GAP_PX, + estimatedSize: rowEstimate, + }); + }); + }); + + return items; +} + +export function estimateVirtualTimelineItemSize( + item: VirtualTimelineItem | undefined, +): number { + if (!item) return 0; + return item.estimatedSize + item.spacingAfter; +} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index 0a39d5b..40630e2 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -6,18 +6,22 @@ type ScrollAreaProps = React.ComponentPropsWithoutRef< typeof ScrollAreaPrimitive.Root > & { stableGutter?: boolean; + viewportRef?: React.Ref< + React.ElementRef + >; }; const ScrollArea = React.forwardRef< React.ElementRef, ScrollAreaProps ->(({ className, children, stableGutter, ...props }, ref) => ( +>(({ className, children, stableGutter, viewportRef, ...props }, ref) => (