"use client" import { useState, useEffect, useRef, useCallback } from "react" import { AnimatePresence, motion } from "motion/react" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { isYouTubeUrl } from "@/lib/url-helpers" import { SyncLogoIcon } from "@ui/assets/icons" import { DocumentIcon } from "@/components/document-icon" import { CheckIcon, ChevronDownIcon } from "lucide-react" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] // ─── Time period helpers ───────────────────────────────────────────────────── function getTimePeriodLabel(date: Date, now: Date): string { const docDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()) const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const diffDays = Math.round( (todayDay.getTime() - docDay.getTime()) / 86400000, ) if (diffDays === 0) return "Today" if (diffDays === 1) return "Yesterday" if (diffDays < 7) return date.toLocaleDateString("en-US", { weekday: "long" }) if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString("en-US", { month: "long", day: "numeric" }) return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", }) } // ─── Document type helpers ──────────────────────────────────────────────────── type CategoryInfo = { label: string; singularLabel: string; key: string } function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo { if (doc.source === "mcp") return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" } if (isYouTubeUrl(doc.url)) return { label: "YouTube Videos", singularLabel: "YouTube Video", key: "youtube", } switch (doc.type) { case "tweet": return { label: "Tweets", singularLabel: "Tweet", key: "tweet" } case "google_doc": return { label: "Google Docs", singularLabel: "Google Doc", key: "google_doc", } case "google_slide": return { label: "Google Slides", singularLabel: "Google Slide", key: "google_slide", } case "google_sheet": return { label: "Google Sheets", singularLabel: "Google Sheet", key: "google_sheet", } case "notion_doc": return { label: "Notion Docs", singularLabel: "Notion Doc", key: "notion_doc", } case "text": return { label: "Notes", singularLabel: "Note", key: "text" } case "pdf": return { label: "PDFs", singularLabel: "PDF", key: "pdf" } case "image": return { label: "Images", singularLabel: "Image", key: "image" } case "video": return { label: "Videos", singularLabel: "Video", key: "video" } case "onedrive": return { label: "OneDrive Files", singularLabel: "OneDrive File", key: "onedrive", } case "webpage": return { label: "Web Pages", singularLabel: "Web Page", key: "webpage" } default: return doc.url?.startsWith("https://") ? { label: "Web Pages", singularLabel: "Web Page", key: "webpage" } : { label: "Notes", singularLabel: "Note", key: "text" } } } function getPreviewText(doc: DocumentWithMemories): string { return doc.summary || doc.content || doc.title || "" } function isTemporaryId(id: string | null | undefined): boolean { if (!id) return false return id.startsWith("temp-") || id.startsWith("temp-file-") } function SelectionBox({ isSelected, isPartial = false, }: { isSelected: boolean isPartial?: boolean }) { return ( {isSelected ? ( ) : isPartial ? ( ) : null} ) } // ─── Grouped data structures ───────────────────────────────────────────────── type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] } type PeriodGroup = { label: string; typeGroups: TypeGroup[] } function groupDocuments( documents: DocumentWithMemories[], now: Date, ): PeriodGroup[] { const sorted = [...documents].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ) const periodMap = new Map() const periodOrder: string[] = [] for (const doc of sorted) { const label = getTimePeriodLabel(new Date(doc.createdAt), now) if (!periodMap.has(label)) { periodMap.set(label, []) periodOrder.push(label) } periodMap.get(label)?.push(doc) } return periodOrder.map((label) => { const docs = periodMap.get(label) ?? [] const categoryMap = new Map< string, { info: CategoryInfo; docs: DocumentWithMemories[] } >() const categoryOrder: string[] = [] for (const doc of docs) { const info = getDocumentTypeInfo(doc) if (!categoryMap.has(info.key)) { categoryMap.set(info.key, { info, docs: [] }) categoryOrder.push(info.key) } categoryMap.get(info.key)?.docs.push(doc) } return { label, typeGroups: categoryOrder.flatMap((key) => { const entry = categoryMap.get(key) return entry ? [{ categoryInfo: entry.info, docs: entry.docs }] : [] }), } }) } // ─── Individual timeline card ───────────────────────────────────────────────── function TimelineCard({ doc, onOpenDocument, isSelectionMode = false, isSelected = false, onToggleSelection, indent = false, isLast = false, }: { doc: DocumentWithMemories onOpenDocument: (doc: DocumentWithMemories) => void isSelectionMode?: boolean isSelected?: boolean onToggleSelection?: (doc: DocumentWithMemories) => void indent?: boolean isLast?: boolean }) { const preview = getPreviewText(doc) const typeLabel = doc.type ? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ") : "Document" const totalMemories = doc.memoryEntries.length const canSelect = !isTemporaryId(doc.id) && !isTemporaryId(doc.customId) const handleClick = () => { if (isSelectionMode && canSelect) { onToggleSelection?.(doc) return } onOpenDocument(doc) } return ( ) } // ─── Collapsed group card ───────────────────────────────────────────────────── function GroupCard({ group, isExpanded, onToggle, onOpenDocument, isSelectionMode, selectedDocumentIds, onToggleSelection, expandKey, }: { group: TypeGroup isExpanded: boolean onToggle: () => void onOpenDocument: (doc: DocumentWithMemories) => void isSelectionMode: boolean selectedDocumentIds: Set onToggleSelection?: (documentId: string) => void expandKey: string }) { const firstDoc = group.docs[0] if (!firstDoc) return null const preview = getPreviewText(firstDoc) const count = group.docs.length const { label, singularLabel } = group.categoryInfo const countLabel = count === 1 ? `1 ${singularLabel}` : `${count} ${label}` const totalMemories = group.docs.reduce( (sum, d) => sum + d.memoryEntries.length, 0, ) const selectableDocs = group.docs.filter( (doc) => !isTemporaryId(doc.id) && !isTemporaryId(doc.customId) && doc.id, ) const selectedCount = selectableDocs.filter( (doc) => doc.id && selectedDocumentIds.has(doc.id), ).length const isGroupSelected = selectableDocs.length > 0 && selectedCount === selectableDocs.length const isGroupPartial = selectedCount > 0 && !isGroupSelected const handleGroupSelect = () => { for (const doc of selectableDocs) { if (!doc.id) continue const shouldToggle = isGroupSelected ? selectedDocumentIds.has(doc.id) : !selectedDocumentIds.has(doc.id) if (shouldToggle) onToggleSelection?.(doc.id) } } return (
{isSelectionMode && selectableDocs.length > 0 && ( )}
{isExpanded && ( {group.docs.map((doc, index) => ( { if (doc.id) onToggleSelection?.(doc.id) }} indent isLast={index === group.docs.length - 1} /> ))} )}
) } // ─── Main TimelineView ──────────────────────────────────────────────────────── interface TimelineViewProps { documents: DocumentWithMemories[] onOpenDocument: (document: DocumentWithMemories) => void hasNextPage?: boolean isFetchingNextPage?: boolean onLoadMore?: () => void isSelectionMode?: boolean selectedDocumentIds?: Set onToggleSelection?: (documentId: string) => void } export function TimelineView({ documents, onOpenDocument, hasNextPage, isFetchingNextPage, onLoadMore, isSelectionMode = false, selectedDocumentIds = new Set(), onToggleSelection, }: TimelineViewProps) { const [now] = useState(() => new Date()) const [expandedGroups, setExpandedGroups] = useState>(new Set()) const sentinelRef = useRef(null) useEffect(() => { if (!sentinelRef.current || !onLoadMore) return const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) { onLoadMore() } }, { threshold: 0.1 }, ) observer.observe(sentinelRef.current) return () => observer.disconnect() }, [hasNextPage, isFetchingNextPage, onLoadMore]) const toggleGroup = useCallback((key: string) => { setExpandedGroups((prev) => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) }, []) const periodGroups = groupDocuments(documents, now) const handleTimelineCardSelection = useCallback( (doc: DocumentWithMemories) => { if (doc.id) onToggleSelection?.(doc.id) }, [onToggleSelection], ) return (
{periodGroups.map((period, periodIndex) => { const periodHasExpandedGroup = period.typeGroups.some((group) => expandedGroups.has(`${period.label}::${group.categoryInfo.key}`), ) return (
{period.label}
{periodHasExpandedGroup && ( )}
{period.typeGroups.map((group) => { const expandKey = `${period.label}::${group.categoryInfo.key}` if (group.docs.length === 1) { const doc = group.docs[0] if (!doc) return null return ( ) } return ( toggleGroup(expandKey)} onOpenDocument={onOpenDocument} isSelectionMode={isSelectionMode} selectedDocumentIds={selectedDocumentIds} onToggleSelection={onToggleSelection} /> ) })}
) })}
) }