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
{item.description}
)} {item.tags && item.tags.length > 0 && ({item.description}
)} {width > 80 && (