From cecbea0faf5bd0d98ff6e3d70a0cbb79ef06cd3f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 7 Oct 2025 06:55:25 -0500 Subject: [PATCH] activity stream panel --- .../components/timeline/ActivityStream.tsx | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 web/src/components/timeline/ActivityStream.tsx diff --git a/web/src/components/timeline/ActivityStream.tsx b/web/src/components/timeline/ActivityStream.tsx new file mode 100644 index 000000000..dda453c0a --- /dev/null +++ b/web/src/components/timeline/ActivityStream.tsx @@ -0,0 +1,181 @@ +import { useMemo, useEffect, useRef } from "react"; +import { ObjectLifecycleSequence } from "@/types/timeline"; +import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle"; +import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; +import { useActivityStream } from "@/contexts/ActivityStreamContext"; + +type ActivityStreamProps = { + timelineData: ObjectLifecycleSequence[]; + currentTime: number; + onSeek: (timestamp: number) => void; +}; + +export default function ActivityStream({ + timelineData, + currentTime, + onSeek, +}: ActivityStreamProps) { + const { selectedObjectId } = useActivityStream(); + const scrollRef = useRef(null); + + // Group activities by timestamp (within 1 second resolution window) + const groupedActivities = useMemo(() => { + const groups: { [key: number]: ObjectLifecycleSequence[] } = {}; + + timelineData.forEach((activity) => { + const groupKey = Math.floor(activity.timestamp); + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(activity); + }); + + return Object.entries(groups) + .map(([timestamp, activities]) => ({ + timestamp: parseInt(timestamp), + activities: activities.sort((a, b) => a.timestamp - b.timestamp), + })) + .sort((a, b) => a.timestamp - b.timestamp); + }, [timelineData]); + + // Filter activities if object is selected + const filteredGroups = useMemo(() => { + if (!selectedObjectId) { + return groupedActivities; + } + return groupedActivities + .map((group) => ({ + ...group, + activities: group.activities.filter( + (activity) => activity.source_id === selectedObjectId, + ), + })) + .filter((group) => group.activities.length > 0); + }, [groupedActivities, selectedObjectId]); + + // Auto-scroll to current time + useEffect(() => { + if (!scrollRef.current) return; + + const currentGroupIndex = filteredGroups.findIndex( + (group) => group.timestamp >= currentTime, + ); + + if (currentGroupIndex !== -1) { + const element = scrollRef.current.children[ + currentGroupIndex + ] as HTMLElement; + if (element) { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + } + }, [currentTime, filteredGroups]); + + return ( +
+
+ {filteredGroups.length === 0 ? ( +
+ No activities found +
+ ) : ( + filteredGroups.map((group) => ( + + )) + )} +
+
+ ); +} + +type ActivityGroupProps = { + group: { + timestamp: number; + activities: ObjectLifecycleSequence[]; + }; + isCurrent: boolean; + onSeek: (timestamp: number) => void; +}; + +function ActivityGroup({ group, isCurrent, onSeek }: ActivityGroupProps) { + const shouldExpand = group.activities.length > 1; + + return ( +
onSeek(group.timestamp)} + > +
+
+
+ {new Date(group.timestamp * 1000).toLocaleTimeString()} +
+ {shouldExpand && ( +
+ {group.activities.length} activities +
+ )} +
+
+ +
+ {group.activities.map((activity, index) => ( + + ))} +
+
+ ); +} + +type ActivityItemProps = { + activity: ObjectLifecycleSequence; + onSeek: (timestamp: number) => void; +}; + +function ActivityItem({ activity }: ActivityItemProps) { + const { selectedObjectId, setSelectedObjectId } = useActivityStream(); + const handleObjectClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (selectedObjectId === activity.source_id) { + setSelectedObjectId(undefined); + } else { + setSelectedObjectId(activity.source_id); + } + }; + + return ( +
+
+ +
+
{getLifecycleItemDescription(activity)}
+ {activity.source_id && ( + + )} +
+ ); +}