Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions electron/main/features/__tests__/virtualTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
103 changes: 72 additions & 31 deletions src/components/timeline/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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";
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);
Expand All @@ -24,31 +30,70 @@ const TimelineList = memo(function TimelineList({
hasNextPage: boolean;
totalPages: number;
}) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(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 (
<div className="text-center py-12">
<div className="p-6 text-center py-12">
<p className="text-muted-foreground">
No events yet. Screenshots will appear here once captured.
</p>
</div>
);
}

const entries = Array.from(groups.entries());

return (
<>
{entries.map(([date, dateEvents], index) => (
<TimelineGroup
key={date}
date={date}
events={dateEvents}
showPagination={index === 0}
hasNextPage={hasNextPage}
totalPages={totalPages}
/>
))}
</>
<ScrollArea className="flex-1" stableGutter viewportRef={viewportRef}>
<div ref={contentRef} className="p-6">
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
if (!item) return null;

return (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<div style={{ paddingBottom: `${item.spacingAfter}px` }}>
{item.type === "header" ? (
<TimelineGroupHeader
date={item.date}
showPagination={item.showPagination}
hasNextPage={hasNextPage}
totalPages={totalPages}
/>
) : (
<TimelineEventRow events={item.events} columns={columns} />
)}
</div>
</div>
);
})}
</div>
</div>
</ScrollArea>
);
});

Expand All @@ -64,21 +109,17 @@ export function Timeline() {

<SelectedBulkActions />

<ScrollArea className="flex-1" stableGutter>
<div className="p-6 space-y-8">
{isLoading && events.length === 0 ? (
<div className="h-full flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<TimelineList
groups={groupedEvents}
hasNextPage={hasNextPage}
totalPages={totalPages}
/>
)}
{isLoading && events.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</ScrollArea>
) : (
<TimelineList
groups={groupedEvents}
hasNextPage={hasNextPage}
totalPages={totalPages}
/>
)}
</div>
);
}
128 changes: 91 additions & 37 deletions src/components/timeline/TimelineGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">{date}</h3>
{showPagination && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>
Page {pagination.page + 1} of {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={pagination.page === 0}
onClick={() =>
setPagination({ page: Math.max(0, pagination.page - 1) })
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={!hasNextPage}
onClick={() => setPagination({ page: pagination.page + 1 })}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}

export function TimelineEventRow({
events,
showProject = false,
columns,
}: TimelineEventRowProps) {
const style = columns
? {
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}
: undefined;

return (
<div
className={
columns
? "grid gap-4"
: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
}
style={style}
>
{events.map((event) => (
<EventCard key={event.id} event={event} showProject={showProject} />
))}
</div>
);
}

export function TimelineGroup({
date,
events,
Expand All @@ -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 (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-muted-foreground">{date}</h3>
{showPagination && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>
Page {pagination.page + 1} of {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={pagination.page === 0}
onClick={() =>
setPagination({ page: Math.max(0, pagination.page - 1) })
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={!hasNextPage}
onClick={() => setPagination({ page: pagination.page + 1 })}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{events.map((event) => (
<EventCard key={event.id} event={event} showProject={showProject} />
))}
<div className="mb-4">
<TimelineGroupHeader
date={date}
showPagination={showPagination}
hasNextPage={hasNextPage}
totalPages={totalPages}
/>
</div>
<TimelineEventRow events={events} showProject={showProject} />
</div>
);
}
Loading
Loading