diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx
new file mode 100644
index 000000000..a4c700f8e
--- /dev/null
+++ b/web/src/components/timeline/EventSegment.tsx
@@ -0,0 +1,236 @@
+import { useEventUtils } from "@/hooks/use-event-utils";
+import { useSegmentUtils } from "@/hooks/use-segment-utils";
+import { Event } from "@/types/event";
+import { useMemo } from "react";
+
+type EventSegmentProps = {
+ events: Event[];
+ segmentTime: number;
+ segmentDuration: number;
+ timestampSpread: number;
+ showMinimap: boolean;
+ minimapStartTime?: number;
+ minimapEndTime?: number;
+ severityType: string;
+};
+
+export function EventSegment({
+ events,
+ segmentTime,
+ segmentDuration,
+ timestampSpread,
+ showMinimap,
+ minimapStartTime,
+ minimapEndTime,
+ severityType,
+}: EventSegmentProps) {
+ const { isStartOfEvent, isEndOfEvent } = useEventUtils(
+ events,
+ segmentDuration
+ );
+ const {
+ getSeverity,
+ getReviewed,
+ displaySeverityType,
+ shouldShowRoundedCorners,
+ } = useSegmentUtils(segmentDuration, events, severityType);
+
+ const { alignDateToTimeline } = useEventUtils(events, segmentDuration);
+
+ const severity = useMemo(
+ () => getSeverity(segmentTime),
+ [getSeverity, segmentTime]
+ );
+ const reviewed = useMemo(
+ () => getReviewed(segmentTime),
+ [getReviewed, segmentTime]
+ );
+ const showRoundedCorners = useMemo(
+ () => shouldShowRoundedCorners(segmentTime),
+ [shouldShowRoundedCorners, segmentTime]
+ );
+
+ const timestamp = useMemo(() => new Date(segmentTime), [segmentTime]);
+ const segmentKey = useMemo(
+ () => Math.floor(segmentTime / 1000),
+ [segmentTime]
+ );
+
+ const alignedMinimapStartTime = useMemo(
+ () => alignDateToTimeline(minimapStartTime ?? 0),
+ [minimapStartTime, alignDateToTimeline]
+ );
+ const alignedMinimapEndTime = useMemo(
+ () => alignDateToTimeline(minimapEndTime ?? 0),
+ [minimapEndTime, alignDateToTimeline]
+ );
+
+ const isInMinimapRange = useMemo(() => {
+ return (
+ showMinimap &&
+ minimapStartTime &&
+ minimapEndTime &&
+ segmentTime > minimapStartTime &&
+ segmentTime < minimapEndTime
+ );
+ }, [showMinimap, minimapStartTime, minimapEndTime, segmentTime]);
+
+ const isFirstSegmentInMinimap = useMemo(() => {
+ return showMinimap && segmentTime === alignedMinimapStartTime;
+ }, [showMinimap, segmentTime, alignedMinimapStartTime]);
+
+ const isLastSegmentInMinimap = useMemo(() => {
+ return showMinimap && segmentTime === alignedMinimapEndTime;
+ }, [showMinimap, segmentTime, alignedMinimapEndTime]);
+
+ const segmentClasses = `flex flex-row ${
+ showMinimap
+ ? isInMinimapRange
+ ? "bg-muted"
+ : isLastSegmentInMinimap
+ ? ""
+ : "opacity-80"
+ : ""
+ } ${
+ isFirstSegmentInMinimap || isLastSegmentInMinimap
+ ? "relative h-2 border-b border-gray-500"
+ : ""
+ }`;
+
+ return (
+
+ {isFirstSegmentInMinimap && (
+
+ {new Date(alignedMinimapStartTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ month: "short",
+ day: "2-digit",
+ })}
+
+ )}
+
+ {isLastSegmentInMinimap && (
+
+ {new Date(alignedMinimapEndTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ month: "short",
+ day: "2-digit",
+ })}
+
+ )}
+
+ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
+
+ )}
+
+
+ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
+
+ {timestamp.getMinutes() % timestampSpread === 0 &&
+ timestamp.getSeconds() === 0 &&
+ timestamp.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ )}
+
+
+ {severity == displaySeverityType && (
+
+ )}
+
+ {severity != displaySeverityType && (
+
+ )}
+
+ );
+}
+
+export default EventSegment;
diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx
new file mode 100644
index 000000000..bfd682fd5
--- /dev/null
+++ b/web/src/components/timeline/ReviewTimeline.tsx
@@ -0,0 +1,243 @@
+import useDraggableHandler from "@/hooks/use-handle-dragging";
+import {
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+ RefObject,
+} from "react";
+import EventSegment from "./EventSegment";
+import { useEventUtils } from "@/hooks/use-event-utils";
+import { Event } from "@/types/event";
+
+export type ReviewTimelineProps = {
+ segmentDuration: number;
+ timestampSpread: number;
+ timelineStart: number;
+ timelineDuration?: number;
+ showHandlebar?: boolean;
+ handlebarTime?: number;
+ showMinimap?: boolean;
+ minimapStartTime?: number;
+ minimapEndTime?: number;
+ events: Event[];
+ severityType: string;
+ contentRef: RefObject;
+};
+
+export function ReviewTimeline({
+ segmentDuration,
+ timestampSpread,
+ timelineStart,
+ timelineDuration = 24 * 60 * 60,
+ showHandlebar = false,
+ handlebarTime,
+ showMinimap = false,
+ minimapStartTime,
+ minimapEndTime,
+ events,
+ severityType,
+ contentRef,
+}: ReviewTimelineProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [currentTimeSegment, setCurrentTimeSegment] = useState(0);
+ const scrollTimeRef = useRef(null);
+ const timelineRef = useRef(null);
+ const currentTimeRef = useRef(null);
+ const observer = useRef(null);
+
+ const { alignDateToTimeline } = useEventUtils(events, segmentDuration);
+
+ const { handleMouseDown, handleMouseUp, handleMouseMove } =
+ useDraggableHandler({
+ contentRef,
+ timelineRef,
+ scrollTimeRef,
+ alignDateToTimeline,
+ segmentDuration,
+ showHandlebar,
+ timelineDuration,
+ timelineStart,
+ isDragging,
+ setIsDragging,
+ currentTimeRef,
+ });
+
+ function handleResize() {
+ // TODO: handle screen resize for mobile
+ if (timelineRef.current && contentRef.current) {
+ }
+ }
+
+ useEffect(() => {
+ if (contentRef.current) {
+ const content = contentRef.current;
+ observer.current = new ResizeObserver(() => {
+ handleResize();
+ });
+ observer.current.observe(content);
+ return () => {
+ observer.current?.unobserve(content);
+ };
+ }
+ }, []);
+
+ // Generate segments for the timeline
+ const generateSegments = useCallback(() => {
+ const segmentCount = timelineDuration / segmentDuration;
+ const segmentAlignedTime = alignDateToTimeline(timelineStart);
+
+ return Array.from({ length: segmentCount }, (_, index) => {
+ const segmentTime = segmentAlignedTime - index * segmentDuration * 1000;
+
+ return (
+
+ );
+ });
+ }, [
+ segmentDuration,
+ timestampSpread,
+ timelineStart,
+ timelineDuration,
+ showMinimap,
+ minimapStartTime,
+ minimapEndTime,
+ ]);
+
+ const segments = useMemo(
+ () => generateSegments(),
+ [
+ segmentDuration,
+ timestampSpread,
+ timelineStart,
+ timelineDuration,
+ showMinimap,
+ minimapStartTime,
+ minimapEndTime,
+ events,
+ ]
+ );
+
+ useEffect(() => {
+ if (showHandlebar) {
+ requestAnimationFrame(() => {
+ if (currentTimeRef.current && currentTimeSegment) {
+ currentTimeRef.current.textContent = new Date(
+ currentTimeSegment
+ ).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ ...(segmentDuration < 60 && { second: "2-digit" }),
+ });
+ }
+ });
+ }
+ }, [currentTimeSegment, showHandlebar]);
+
+ useEffect(() => {
+ if (timelineRef.current && handlebarTime && showHandlebar) {
+ const { scrollHeight: timelineHeight } = timelineRef.current;
+
+ // Calculate the height of an individual segment
+ const segmentHeight =
+ timelineHeight / (timelineDuration / segmentDuration);
+
+ // Calculate the segment index corresponding to the target time
+ const alignedHandlebarTime = alignDateToTimeline(handlebarTime);
+ const segmentIndex = Math.ceil(
+ (timelineStart - alignedHandlebarTime) / (segmentDuration * 1000)
+ );
+
+ // Calculate the top position based on the segment index
+ const newTopPosition = Math.max(0, segmentIndex * segmentHeight);
+
+ // Set the top position of the handle
+ const thumb = scrollTimeRef.current;
+ if (thumb) {
+ requestAnimationFrame(() => {
+ thumb.style.top = `${newTopPosition}px`;
+ });
+ }
+
+ setCurrentTimeSegment(alignedHandlebarTime);
+ }
+ }, [
+ handlebarTime,
+ segmentDuration,
+ showHandlebar,
+ timelineDuration,
+ timelineStart,
+ alignDateToTimeline,
+ ]);
+
+ useEffect(() => {
+ generateSegments();
+ if (!currentTimeSegment && !handlebarTime) {
+ setCurrentTimeSegment(timelineStart);
+ }
+ // TODO: touch events for mobile
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ currentTimeSegment,
+ generateSegments,
+ timelineStart,
+ handleMouseUp,
+ handleMouseMove,
+ ]);
+
+ return (
+
+
{segments}
+ {showHandlebar && (
+
+ )}
+
+ );
+}
+
+export default ReviewTimeline;
+
+// TODO: more minor tick marks for segmentDuration < 60
+// theme colors
diff --git a/web/src/hooks/use-event-utils.ts b/web/src/hooks/use-event-utils.ts
new file mode 100644
index 000000000..f0aa38f70
--- /dev/null
+++ b/web/src/hooks/use-event-utils.ts
@@ -0,0 +1,37 @@
+import { useCallback } from 'react';
+import { Event } from '@/types/event';
+
+export const useEventUtils = (events: Event[], segmentDuration: number) => {
+ const isStartOfEvent = useCallback((time: number): boolean => {
+ return events.some((event) => {
+ const segmentStart = getSegmentStart(event.start_time);
+ return time >= segmentStart && time < segmentStart + segmentDuration * 1000;
+ });
+ }, [events, segmentDuration]);
+
+ const isEndOfEvent = useCallback((time: number): boolean => {
+ return events.some((event) => {
+ if (typeof event.end_time === 'number') {
+ const segmentEnd = getSegmentEnd(event.end_time);
+ return time >= segmentEnd - segmentDuration * 1000 && time < segmentEnd;
+ }
+ return false; // Return false if end_time is undefined
+ });
+ }, [events, segmentDuration]);
+
+ const getSegmentStart = useCallback((time: number): number => {
+ return Math.floor(time / (segmentDuration * 1000)) * (segmentDuration * 1000);
+ }, [segmentDuration]);
+
+ const getSegmentEnd = useCallback((time: number): number => {
+ return Math.ceil(time / (segmentDuration * 1000)) * (segmentDuration * 1000);
+ }, [segmentDuration]);
+
+ const alignDateToTimeline = useCallback((time: number): number => {
+ const remainder = time % (segmentDuration * 1000);
+ const adjustment = remainder !== 0 ? segmentDuration * 1000 - remainder : 0;
+ return time + adjustment;
+ }, [segmentDuration]);
+
+ return { isStartOfEvent, isEndOfEvent, getSegmentStart, getSegmentEnd, alignDateToTimeline };
+};
diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-handle-dragging.ts
new file mode 100644
index 000000000..d0e180646
--- /dev/null
+++ b/web/src/hooks/use-handle-dragging.ts
@@ -0,0 +1,127 @@
+import { useCallback } from "react";
+
+interface DragHandlerProps {
+ contentRef: React.RefObject;
+ timelineRef: React.RefObject;
+ scrollTimeRef: React.RefObject;
+ alignDateToTimeline: (time: number) => number;
+ segmentDuration: number;
+ showHandlebar: boolean;
+ timelineDuration: number;
+ timelineStart: number;
+ isDragging: boolean;
+ setIsDragging: React.Dispatch>;
+ currentTimeRef: React.MutableRefObject;
+}
+
+// TODO: handle mobile touch events
+function useDraggableHandler({
+ contentRef,
+ timelineRef,
+ scrollTimeRef,
+ alignDateToTimeline,
+ segmentDuration,
+ showHandlebar,
+ timelineDuration,
+ timelineStart,
+ isDragging,
+ setIsDragging,
+ currentTimeRef,
+}: DragHandlerProps) {
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ },
+ [setIsDragging]
+ );
+
+ const handleMouseUp = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (isDragging) {
+ setIsDragging(false);
+ }
+ },
+ [isDragging, setIsDragging]
+ );
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (!contentRef.current || !timelineRef.current || !scrollTimeRef.current) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (isDragging) {
+ const {
+ scrollHeight: timelineHeight,
+ clientHeight: visibleTimelineHeight,
+ scrollTop: scrolled,
+ offsetTop: timelineTop,
+ } = timelineRef.current;
+
+ const segmentHeight =
+ timelineHeight / (timelineDuration / segmentDuration);
+
+ const getCumulativeScrollTop = (
+ element: HTMLElement | null
+ ) => {
+ let scrollTop = 0;
+ while (element) {
+ scrollTop += element.scrollTop;
+ element = element.parentElement;
+ }
+ return scrollTop;
+ };
+
+ const parentScrollTop = getCumulativeScrollTop(timelineRef.current);
+
+ const newHandlePosition = Math.min(
+ visibleTimelineHeight - timelineTop + parentScrollTop,
+ Math.max(
+ segmentHeight + scrolled,
+ e.clientY - timelineTop + parentScrollTop
+ )
+ );
+
+ const segmentIndex = Math.floor(newHandlePosition / segmentHeight);
+ const segmentStartTime = alignDateToTimeline(
+ Math.floor(timelineStart - segmentIndex * segmentDuration * 1000)
+ );
+
+ if (showHandlebar) {
+ const thumb = scrollTimeRef.current;
+ requestAnimationFrame(() => {
+ thumb.style.top = `${newHandlePosition - segmentHeight}px`;
+ if (currentTimeRef.current) {
+ currentTimeRef.current.textContent = new Date(
+ segmentStartTime
+ ).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ ...(segmentDuration < 60 && { second: "2-digit" }),
+ });
+ }
+ });
+ }
+ }
+ },
+ [
+ isDragging,
+ contentRef,
+ segmentDuration,
+ showHandlebar,
+ timelineDuration,
+ timelineStart,
+ ]
+ );
+
+ return { handleMouseDown, handleMouseUp, handleMouseMove };
+}
+
+export default useDraggableHandler;
diff --git a/web/src/hooks/use-segment-utils.ts b/web/src/hooks/use-segment-utils.ts
new file mode 100644
index 000000000..09b2ef493
--- /dev/null
+++ b/web/src/hooks/use-segment-utils.ts
@@ -0,0 +1,89 @@
+import { useCallback, useMemo } from 'react';
+import { Event } from '@/types/event';
+
+export const useSegmentUtils = (
+ segmentDuration: number,
+ events: Event[],
+ severityType: string,
+) => {
+ const getSegmentStart = useCallback((time: number): number => {
+ return Math.floor(time / (segmentDuration * 1000)) * (segmentDuration * 1000);
+ }, [segmentDuration]);
+
+ const getSegmentEnd = useCallback((time: number | undefined): number => {
+ if (time) {
+ return Math.ceil(time / (segmentDuration * 1000)) * (segmentDuration * 1000);
+ } else {
+ return Date.now()+(segmentDuration*1000);
+ }
+ }, [segmentDuration]);
+
+ const mapSeverityToNumber = useCallback((severity: string): number => {
+ switch (severity) {
+ case "motion":
+ return 1;
+ case "detection":
+ return 2;
+ case "alert":
+ return 3;
+ default:
+ return 0;
+ }
+ }, []);
+
+ const displaySeverityType = useMemo(
+ () => mapSeverityToNumber(severityType ?? ""),
+ [severityType]
+ );
+
+ const getSeverity = useCallback((time: number): number => {
+ const activeEvents = events?.filter((event) => {
+ const segmentStart = getSegmentStart(event.start_time);
+ const segmentEnd = getSegmentEnd(event.end_time);
+ return time >= segmentStart && time < segmentEnd;
+ });
+ if (activeEvents?.length === 0) return 0; // No event at this time
+ const severityValues = activeEvents?.map((event) =>
+ mapSeverityToNumber(event.severity)
+ );
+ return Math.max(...severityValues);
+ }, [events, getSegmentStart, getSegmentEnd, mapSeverityToNumber]);
+
+ const getReviewed = useCallback((time: number): boolean => {
+ return events.some((event) => {
+ const segmentStart = getSegmentStart(event.start_time);
+ const segmentEnd = getSegmentEnd(event.end_time);
+ return (
+ time >= segmentStart && time < segmentEnd && event.has_been_reviewed
+ );
+ });
+ }, [events, getSegmentStart, getSegmentEnd]);
+
+ const shouldShowRoundedCorners = useCallback(
+ (segmentTime: number): boolean => {
+ const prevSegmentTime = segmentTime - segmentDuration * 1000;
+ const nextSegmentTime = segmentTime + segmentDuration * 1000;
+
+ const hasPrevEvent = events.some((e) => {
+ return (
+ prevSegmentTime >= getSegmentStart(e.start_time) &&
+ prevSegmentTime < getSegmentEnd(e.end_time) &&
+ e.severity === severityType
+ );
+ });
+
+ const hasNextEvent = events.some((e) => {
+ return (
+ nextSegmentTime >= getSegmentStart(e.start_time) &&
+ nextSegmentTime < getSegmentEnd(e.end_time) &&
+ e.severity === severityType
+ );
+ });
+
+ return !hasPrevEvent || !hasNextEvent;
+ },
+ [events, getSegmentStart, getSegmentEnd, segmentDuration, severityType]
+ );
+
+ return { getSegmentStart, getSegmentEnd, getSeverity, displaySeverityType, getReviewed, shouldShowRoundedCorners };
+};