import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { format, differenceInDays, startOfDay, addDays, isBefore, isAfter } from 'date-fns' import { Calendar, Rows3, Rows4 } from 'lucide-react' const STATUS_COLORS = { todo: 'bg-gray-500', in_progress: 'bg-blue-500', done: 'bg-emerald-500', planning: 'bg-amber-500', active: 'bg-blue-500', paused: 'bg-orange-500', completed: 'bg-emerald-500', cancelled: 'bg-red-400', draft: 'bg-gray-400', in_review: 'bg-yellow-500', approved: 'bg-indigo-500', scheduled: 'bg-purple-500', published: 'bg-emerald-500', planned: 'bg-amber-400', // tracks organic_social: 'bg-green-500', paid_social: 'bg-blue-500', paid_search: 'bg-amber-500', seo_content: 'bg-purple-500', production: 'bg-red-500', } const PRIORITY_BORDER = { urgent: 'ring-2 ring-red-400', high: 'ring-2 ring-orange-300', medium: '', low: '', } const ZOOM_LEVELS = [ { key: 'day', label: 'Day', pxPerDay: 48 }, { key: 'week', label: 'Week', pxPerDay: 20 }, ] function getInitials(name) { if (!name) return '?' return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) } export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) { const containerRef = useRef(null) const didDragRef = useRef(false) const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives const [zoomIdx, setZoomIdx] = useState(0) const [barMode, setBarMode] = useState('expanded') // 'compact' | 'expanded' const [tooltip, setTooltip] = useState(null) const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd } const dragStateRef = useRef(null) const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay const today = useMemo(() => startOfDay(new Date()), []) // Clear optimistic overrides when fresh data arrives useEffect(() => { optimisticRef.current = {} }, [items]) // Map items const mapped = useMemo(() => { return items.map(raw => { const m = mapItem(raw) const opt = optimisticRef.current[m.id] return { ...m, _raw: raw, startDate: opt?.startDate || (m.startDate ? startOfDay(new Date(m.startDate)) : null), endDate: opt?.endDate || (m.endDate ? startOfDay(new Date(m.endDate)) : null), } }) }, [items, mapItem]) // Compute time range const { earliest, latest, totalDays, days } = useMemo(() => { let earliest = addDays(today, -7) let latest = addDays(today, 28) mapped.forEach(item => { const s = item.startDate || today const e = item.endDate || addDays(s, 3) if (isBefore(s, earliest)) earliest = addDays(s, -3) if (isAfter(e, latest)) latest = addDays(e, 7) }) const totalDays = differenceInDays(latest, earliest) + 1 const days = [] for (let i = 0; i < totalDays; i++) { days.push(addDays(earliest, i)) } return { earliest, latest, totalDays, days } }, [mapped, today]) // Auto-scroll to today on mount useEffect(() => { if (containerRef.current) { const todayOffset = differenceInDays(today, earliest) * pxPerDay containerRef.current.scrollLeft = Math.max(0, todayOffset - 200) } }, [earliest, pxPerDay, today]) // Drag handlers const handleMouseDown = useCallback((e, item, mode) => { if (readOnly || !onDateChange) return e.preventDefault() e.stopPropagation() didDragRef.current = false const initial = { itemId: item.id, mode, startX: e.clientX, origStart: item.startDate || today, origEnd: item.endDate || addDays(item.startDate || today, 3), } dragStateRef.current = initial setDragState(initial) }, [readOnly, onDateChange, today]) useEffect(() => { if (!dragState) return const handleMouseMove = (e) => { const cur = dragStateRef.current if (!cur) return const dx = e.clientX - cur.startX const dayDelta = Math.round(dx / pxPerDay) if (dayDelta === 0) return didDragRef.current = true const newState = { ...cur } if (cur.mode === 'move') { newState.currentStart = addDays(cur.origStart, dayDelta) newState.currentEnd = addDays(cur.origEnd, dayDelta) } else if (cur.mode === 'resize-left') { const newStart = addDays(cur.origStart, dayDelta) if (isBefore(newStart, cur.origEnd)) { newState.currentStart = newStart newState.currentEnd = cur.origEnd } } else if (cur.mode === 'resize-right') { const newEnd = addDays(cur.origEnd, dayDelta) if (isAfter(newEnd, cur.origStart)) { newState.currentStart = cur.origStart newState.currentEnd = newEnd } } dragStateRef.current = newState setDragState(newState) } const handleMouseUp = () => { const prev = dragStateRef.current dragStateRef.current = null setDragState(null) if (prev && (prev.currentStart || prev.currentEnd) && onDateChange) { const startDate = prev.currentStart || prev.origStart const endDate = prev.currentEnd || prev.origEnd // Keep bar in place visually until fresh data arrives optimisticRef.current[prev.itemId] = { startDate, endDate } onDateChange(prev.itemId, { startDate: format(startDate, 'yyyy-MM-dd'), endDate: format(endDate, 'yyyy-MM-dd'), }) } } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [dragState?.itemId, pxPerDay, onDateChange]) const getBarPosition = useCallback((item) => { let start, end // If this item is being dragged, use the drag state if (dragState && dragState.itemId === item.id && (dragState.currentStart || dragState.currentEnd)) { start = dragState.currentStart || item.startDate || today end = dragState.currentEnd || item.endDate || addDays(start, 3) } else { // Check optimistic position (keeps bar in place after drop, before API data refreshes) const opt = optimisticRef.current[item.id] start = opt?.startDate || item.startDate || today end = opt?.endDate || item.endDate || addDays(start, 3) } // Ensure end is after start if (!isAfter(end, start)) end = addDays(start, 1) const left = differenceInDays(start, earliest) * pxPerDay const width = Math.max(pxPerDay, (differenceInDays(end, start) + 1) * pxPerDay) return { left, width } }, [earliest, pxPerDay, today, dragState]) const scrollToToday = () => { if (containerRef.current) { const todayOffset = differenceInDays(today, earliest) * pxPerDay containerRef.current.scrollTo({ left: Math.max(0, todayOffset - 200), behavior: 'smooth' }) } } const isExpanded = barMode === 'expanded' const rowHeight = isExpanded ? 100 : 52 const barHeight = isExpanded ? 84 : 36 const headerHeight = 48 const labelWidth = isExpanded ? 280 : 220 const todayOffset = differenceInDays(today, earliest) * pxPerDay if (items.length === 0) { return (

No items to display

Add items with dates to see the timeline

) } return (
{/* Toolbar */}
{ZOOM_LEVELS.map((z, i) => ( ))}
{/* Timeline */}
{/* Day header */}
Item
{days.map((day, i) => { const isToday = differenceInDays(day, today) === 0 const isWeekend = day.getDay() === 0 || day.getDay() === 6 const isMonthStart = day.getDate() === 1 return (
{pxPerDay >= 30 &&
{format(day, 'd')}
} {pxPerDay >= 40 &&
{format(day, 'EEE')}
} {pxPerDay < 30 && day.getDate() % 7 === 1 &&
{format(day, 'd')}
}
) })}
{/* Rows */} {mapped.map((item, idx) => { const { left, width } = getBarPosition(item) const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400' const priorityRing = PRIORITY_BORDER[item.priority] || '' const isDragging = dragState?.itemId === item.id return (
{/* Label column */}
{isExpanded ? ( <>
{item.thumbnailUrl ? (
) : item.assigneeName ? (
{getInitials(item.assigneeName)}
) : null} {item.label}
{item.description && (

{item.description}

)} {item.tags && item.tags.length > 0 && (
{item.tags.slice(0, 4).map((tag, i) => ( {tag} ))}
)} ) : ( <> {item.thumbnailUrl ? (
) : item.assigneeName ? (
{getInitials(item.assigneeName)}
) : null} {item.label} )}
{/* Bar area */}
{/* Today line */} {todayOffset >= 0 && (
{idx === 0 && (
Today
)}
)} {/* The bar */}
handleMouseDown(e, item, 'move')} onClick={(e) => { if (didDragRef.current) { didDragRef.current = false return } if (onItemClick) { onItemClick(item._raw) } }} onMouseEnter={(e) => { if (!dragState) { const rect = e.currentTarget.getBoundingClientRect() setTooltip({ item, x: rect.left + rect.width / 2, y: rect.top - 8, }) } }} onMouseLeave={() => setTooltip(null)} > {/* Left resize handle */} {!readOnly && onDateChange && (
handleMouseDown(e, item, 'resize-left')} /> )} {/* Bar content */} {isExpanded ? (
{item.assigneeName && width > 60 && ( {getInitials(item.assigneeName)} )} {width > 80 && ( {item.label} )} {width > 120 && item.status && ( {item.status.replace(/_/g, ' ')} )}
{width > 100 && item.description && (

{item.description}

)} {width > 80 && (
{item.tags && item.tags.slice(0, 3).map((tag, i) => ( {tag} ))} {width > 140 && item.startDate && item.endDate && ( {format(item.startDate, 'MMM d')} – {format(item.endDate, 'MMM d')} )}
)}
) : (
{item.assigneeName && width > 60 && ( {getInitials(item.assigneeName)} )} {width > 80 && ( {item.label} )}
)} {/* Right resize handle */} {!readOnly && onDateChange && (
handleMouseDown(e, item, 'resize-right')} /> )}
) })}
{/* Tooltip */} {tooltip && !dragState && (
{tooltip.item.label}
{tooltip.item.startDate && (
Start: {format(tooltip.item.startDate, 'MMM d, yyyy')}
)} {tooltip.item.endDate && (
End: {format(tooltip.item.endDate, 'MMM d, yyyy')}
)} {tooltip.item.assigneeName && (
Assignee: {tooltip.item.assigneeName}
)} {tooltip.item.status && (
Status: {tooltip.item.status.replace(/_/g, ' ')}
)}
{!readOnly && onDateChange && (
Drag to move · Drag edges to resize
)}
)}
) }