import { useMemo, useState, useRef, useEffect, useCallback } from 'react' import { addDays, differenceInDays, format, isWeekend, isToday, startOfDay, endOfDay, startOfMonth, getMonth, getWeek, isAfter, isBefore, } from 'date-fns' import type { TimelineItem, GanttTask, GanttChartProps, GanttFilterState, GanttViewMode, } from '../types' import { parseDateRangeFromTitle, getHierarchyLevel } from '../lib/dates' import { calculateExpectedProgress, calculateHealthStatus, getHealthColor } from '../lib/progress' export const ROW_HEIGHT = 36 export const ZOOM_LEVELS = [8, 12, 16, 24, 32, 48] const DEFAULT_ZOOM_INDEX = 3 export const INDENT_WIDTH = 20 const DEFAULT_CATEGORY_COLORS: Record = { financial: '#22c55e', product: '#3b82f6', team: '#a855f7', market: '#f97316', other: '#6b7280', } function defaultStatusToProgress(status: string): number { if (status === 'completed') return 100 if (status === 'in_progress' || status === 'active' || status === 'on_track') return 50 return 0 } const DEFAULT_FILTER_STATE: GanttFilterState = { search: '', statuses: [], categories: [], dateRange: { start: null, end: null }, } interface DragState { taskId: string type: 'move' | 'resize-start' | 'resize-end' startX: number originalStart: Date originalEnd: Date currentStart: Date currentEnd: Date } export function useGanttState({ items, dependencies = [], onItemClick, onDateChange, statusToProgress = defaultStatusToProgress, categoryColors = DEFAULT_CATEGORY_COLORS, hierarchical = true, initialZoom = DEFAULT_ZOOM_INDEX, showFilterBar = false, initialViewMode = 'timeline', boardStatuses, onItemEdit, onStatusChange, persistCollapseKey, }: GanttChartProps) { const [zoomIndex, setZoomIndex] = useState(initialZoom) const [collapsed, setCollapsed] = useState>(() => { if (persistCollapseKey && typeof window !== 'undefined') { try { const saved = localStorage.getItem(`gantt-collapse-${persistCollapseKey}`) if (saved) return new Set(JSON.parse(saved)) } catch { /* ignore */ } } return new Set() }) const [mounted, setMounted] = useState(false) const [dragState, setDragState] = useState(null) const [filters, setFilters] = useState(DEFAULT_FILTER_STATE) const [isFullscreen, setIsFullscreen] = useState(false) const [viewMode, setViewMode] = useState(initialViewMode) const [detailItem, setDetailItem] = useState(null) const [focusedRowIndex, setFocusedRowIndex] = useState(-1) const [boardDragItem, setBoardDragItem] = useState(null) const dayWidth = ZOOM_LEVELS[zoomIndex] const bodyScrollRef = useRef(null) const headerTimelineRef = useRef(null) const wrapperRef = useRef(null) const infoColumnRef = useRef(null) useEffect(() => { setMounted(true) }, []) // Persist collapse state to localStorage useEffect(() => { if (persistCollapseKey && typeof window !== 'undefined') { localStorage.setItem(`gantt-collapse-${persistCollapseKey}`, JSON.stringify([...collapsed])) } }, [collapsed, persistCollapseKey]) // Fullscreen change listener useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) }, []) const toggleFullscreen = useCallback(() => { if (!wrapperRef.current) return if (document.fullscreenElement) { document.exitFullscreen() } else { wrapperRef.current.requestFullscreen() } }, []) // Handle item click — open detail modal if onItemEdit is provided, otherwise delegate to onItemClick const handleItemClick = useCallback((item: TimelineItem) => { if (onItemEdit) { setDetailItem(item) } onItemClick?.(item) }, [onItemEdit, onItemClick]) // Extract unique statuses and categories from items for filter dropdowns const { uniqueStatuses, uniqueCategories } = useMemo(() => { const statuses = new Set() const categories = new Set() const collect = (itemList: TimelineItem[]) => { for (const item of itemList) { statuses.add(item.status) if (item.category) categories.add(item.category) if (item.children) collect(item.children) } } collect(items) return { uniqueStatuses: [...statuses].sort(), uniqueCategories: [...categories].sort() } }, [items]) // Filter items const filteredItems = useMemo(() => { if (!showFilterBar) return items const { search, statuses, categories, dateRange } = filters const hasFilters = search || statuses.length > 0 || categories.length > 0 || dateRange.start || dateRange.end if (!hasFilters) return items const searchLower = search.toLowerCase() const matchesItem = (item: TimelineItem): boolean => { if (search && !item.title.toLowerCase().includes(searchLower) && !item.description?.toLowerCase().includes(searchLower)) return false if (statuses.length > 0 && !statuses.includes(item.status)) return false if (categories.length > 0 && (!item.category || !categories.includes(item.category))) return false if (dateRange.start || dateRange.end) { const itemEnd = item.endDate ? new Date(item.endDate) : null const itemStart = item.startDate ? new Date(item.startDate) : null if (dateRange.start && itemEnd && isBefore(itemEnd, dateRange.start)) return false if (dateRange.end && itemStart && isAfter(itemStart, dateRange.end)) return false } return true } const filterTree = (itemList: TimelineItem[]): TimelineItem[] => { const result: TimelineItem[] = [] for (const item of itemList) { const filteredChildren = item.children ? filterTree(item.children) : undefined if (matchesItem(item) || (filteredChildren && filteredChildren.length > 0)) { result.push({ ...item, children: filteredChildren }) } } return result } return filterTree(items) }, [items, filters, showFilterBar]) const hasActiveFilters = filters.search || filters.statuses.length > 0 || filters.categories.length > 0 || filters.dateRange.start || filters.dateRange.end const toggleCollapse = useCallback((id: string) => { setCollapsed((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }, []) // Build hierarchy from children[] arrays or dependencies const { childrenMap, parentMap, itemMap } = useMemo(() => { const childrenMap = new Map() const parentMap = new Map() const itemMap = new Map(filteredItems.map((item) => [item.id, item])) if (hierarchical) { for (const item of filteredItems) { if (item.children && item.children.length > 0) { const childIds = item.children .filter((child) => itemMap.has(child.id)) .map((child) => child.id) if (childIds.length > 0) { childrenMap.set(item.id, childIds) for (const childId of childIds) { parentMap.set(childId, item.id) } } } } for (const dep of dependencies) { if (dep.type === 'blocks') { const parentItem = itemMap.get(dep.fromId) const childItem = itemMap.get(dep.toId) if (!parentItem || !childItem) continue const parentLevel = getHierarchyLevel(parentItem.title) const childLevel = getHierarchyLevel(childItem.title) if (parentLevel > childLevel && !parentMap.has(dep.toId)) { const children = childrenMap.get(dep.fromId) || [] children.push(dep.toId) childrenMap.set(dep.fromId, children) parentMap.set(dep.toId, dep.fromId) } } } } return { childrenMap, parentMap, itemMap } }, [filteredItems, dependencies, hierarchical]) // Parse dates for all items const itemDates = useMemo(() => { const now = new Date() const currentYear = now.getFullYear() const dates = new Map() for (const item of filteredItems) { const parsedRange = parseDateRangeFromTitle(item.title, currentYear) if (parsedRange) { dates.set(item.id, parsedRange); continue } const startStr = item.startDate const endStr = item.endDate if (startStr && endStr) { const s = new Date(startStr) const e = new Date(endStr) if (!isNaN(s.getTime()) && !isNaN(e.getTime())) { dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue } } if (endStr) { const e = new Date(endStr) if (!isNaN(e.getTime())) { const s = item.startDate ? new Date(item.startDate) : item.createdAt ? new Date(item.createdAt) : addDays(e, -14) dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue } } if (startStr) { const s = new Date(startStr) if (!isNaN(s.getTime())) { dates.set(item.id, { start: startOfDay(s), end: endOfDay(addDays(s, 14)) }); continue } } const fallbackStart = item.createdAt ? new Date(item.createdAt) : now dates.set(item.id, { start: startOfDay(fallbackStart), end: endOfDay(addDays(fallbackStart, 14)) }) } return dates }, [filteredItems]) // Build flattened task tree const tasks = useMemo(() => { const roots: string[] = [] for (const item of filteredItems) { const parentId = parentMap.get(item.id) if (!parentId || !itemMap.has(parentId)) roots.push(item.id) } const sortByDate = (ids: string[]) => [...ids].sort((a, b) => { const dateA = itemDates.get(a)?.start || new Date() const dateB = itemDates.get(b)?.start || new Date() return dateA.getTime() - dateB.getTime() }) const taskList: GanttTask[] = [] const visited = new Set() const traverse = (id: string, depth: number) => { if (visited.has(id)) return visited.add(id) const item = itemMap.get(id) if (!item) return const dates = itemDates.get(id) if (!dates) return const children = childrenMap.get(id) || [] const hasChildren = children.length > 0 const progress = item.progress ?? statusToProgress(item.status) const now = new Date() const taskStart = startOfDay(dates.start) const taskEnd = endOfDay(dates.end) const totalDuration = taskEnd.getTime() - taskStart.getTime() const elapsed = now.getTime() - taskStart.getTime() const timeProgress = totalDuration > 0 ? Math.max(0, Math.min(100, (elapsed / totalDuration) * 100)) : 0 const expectedProgress = calculateExpectedProgress(taskStart, taskEnd, now) const healthResult = calculateHealthStatus(progress, expectedProgress, item.status === 'blocked') taskList.push({ item, start: taskStart, end: taskEnd, progress, timeProgress, depth, hasChildren, parentId: parentMap.get(id) || null, healthStatus: healthResult.status, }) if (hasChildren && !collapsed.has(id)) { for (const childId of sortByDate(children)) traverse(childId, depth + 1) } } for (const rootId of sortByDate(roots)) traverse(rootId, 0) return taskList }, [filteredItems, itemDates, itemMap, childrenMap, parentMap, collapsed, statusToProgress]) // Keyboard navigation useEffect(() => { const wrapper = wrapperRef.current if (!wrapper) return const handleKeyDown = (e: KeyboardEvent) => { if (!wrapper.contains(document.activeElement) && document.activeElement !== wrapper) return if (e.key === 'ArrowDown') { e.preventDefault() setFocusedRowIndex((prev) => Math.min(prev + 1, tasks.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setFocusedRowIndex((prev) => Math.max(prev - 1, 0)) } else if ((e.key === 'Enter' || e.key === ' ') && focusedRowIndex >= 0 && focusedRowIndex < tasks.length) { e.preventDefault() handleItemClick(tasks[focusedRowIndex].item) } else if (e.key === 'Escape') { if (detailItem) { setDetailItem(null) } else { setFocusedRowIndex(-1) wrapper.blur() } } else if (e.key === 'ArrowRight' && focusedRowIndex >= 0) { const task = tasks[focusedRowIndex] if (task?.hasChildren && collapsed.has(task.item.id)) { toggleCollapse(task.item.id) } } else if (e.key === 'ArrowLeft' && focusedRowIndex >= 0) { const task = tasks[focusedRowIndex] if (task?.hasChildren && !collapsed.has(task.item.id)) { toggleCollapse(task.item.id) } } } wrapper.addEventListener('keydown', handleKeyDown) return () => wrapper.removeEventListener('keydown', handleKeyDown) }, [tasks, focusedRowIndex, collapsed, detailItem, handleItemClick, toggleCollapse]) // Scroll focused row into view useEffect(() => { if (focusedRowIndex >= 0 && infoColumnRef.current) { const row = infoColumnRef.current.children[focusedRowIndex] as HTMLElement row?.scrollIntoView({ block: 'nearest' }) } }, [focusedRowIndex]) // Calculate date range const { startDate, days } = useMemo(() => { const now = new Date() const currentYear = now.getFullYear() let minDate = new Date(currentYear, 0, 1) let maxDate = new Date(currentYear, 11, 31) for (const task of tasks) { if (task.start < minDate) minDate = startOfDay(task.start) if (task.end > maxDate) maxDate = endOfDay(task.end) } minDate = startOfMonth(minDate) maxDate = endOfDay(addDays(maxDate, 30)) const dayCount = differenceInDays(maxDate, minDate) + 1 const daysArr: Date[] = [] for (let i = 0; i < dayCount; i++) daysArr.push(addDays(minDate, i)) return { startDate: minDate, days: daysArr } }, [tasks]) // Group days by month const months = useMemo(() => { const monthsArr: { month: Date; days: number; label: string }[] = [] let currentMonth = -1 let monthDays = 0 for (const day of days) { const m = getMonth(day) if (m !== currentMonth) { if (currentMonth !== -1) { monthsArr.push({ month: addDays(day, -1), days: monthDays, label: format(addDays(day, -1), 'MMM yyyy') }) } currentMonth = m monthDays = 1 } else { monthDays++ } } if (monthDays > 0 && days.length > 0) { monthsArr.push({ month: days[days.length - 1], days: monthDays, label: format(days[days.length - 1], 'MMM yyyy') }) } return monthsArr }, [days]) // Time header units const timeHeaderUnits = useMemo(() => { if (dayWidth >= 16) { return { mode: 'days' as const, units: days.map((day, i) => ({ date: day, width: dayWidth, label: day.getDate() === 1 || i === 0 ? format(day, 'd') : String(day.getDate()), isWeekend: isWeekend(day), isToday: mounted && isToday(day), })), } } else if (dayWidth >= 12) { return { mode: 'sparse-days' as const, units: days.map((day, i) => ({ date: day, width: dayWidth, label: (day.getDay() === 1 || day.getDate() === 1 || i === 0) ? String(day.getDate()) : '', isWeekend: isWeekend(day), isToday: mounted && isToday(day), })), } } else { const weeks: { startDate: Date; endDate: Date; days: number; label: string; hasToday: boolean }[] = [] let currentWeek = -1, weekDays = 0, weekStart: Date | null = null, weekHasToday = false for (const day of days) { const week = getWeek(day) if (week !== currentWeek) { if (currentWeek !== -1 && weekStart) { weeks.push({ startDate: weekStart, endDate: addDays(day, -1), days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday }) } currentWeek = week; weekDays = 1; weekStart = day; weekHasToday = mounted && isToday(day) } else { weekDays++ if (mounted && isToday(day)) weekHasToday = true } } if (weekDays > 0 && weekStart) { weeks.push({ startDate: weekStart, endDate: days[days.length - 1], days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday }) } return { mode: 'weeks' as const, units: weeks.map((w) => ({ ...w, width: w.days * dayWidth })) } } }, [days, dayWidth, mounted]) // Scroll to today on mount useEffect(() => { if (bodyScrollRef.current) { const today = new Date() const daysFromStart = differenceInDays(today, startDate) bodyScrollRef.current.scrollLeft = Math.max(0, daysFromStart * dayWidth - 200) } }, [startDate, dayWidth]) const handleBodyScroll = useCallback(() => { if (bodyScrollRef.current && headerTimelineRef.current) { headerTimelineRef.current.scrollLeft = bodyScrollRef.current.scrollLeft } }, []) const getBarPosition = useCallback((task: GanttTask, rowIndex: number, useDragDates = false) => { const dates = useDragDates && dragState?.taskId === task.item.id ? { start: dragState.currentStart, end: dragState.currentEnd } : { start: task.start, end: task.end } const startOffset = differenceInDays(dates.start, startDate) const duration = differenceInDays(dates.end, dates.start) + 1 const heights = [22, 20, 18, 16] const barHeight = heights[Math.min(task.depth, 3)] const topOffset = (ROW_HEIGHT - barHeight) / 2 return { left: startOffset * dayWidth, width: Math.max(duration * dayWidth - 2, 16), top: rowIndex * ROW_HEIGHT + topOffset, height: barHeight } }, [dragState, startDate, dayWidth]) // Drag handlers const handleDragStart = useCallback((e: React.MouseEvent, task: GanttTask, type: 'move' | 'resize-start' | 'resize-end') => { if (!onDateChange) return e.preventDefault(); e.stopPropagation() setDragState({ taskId: task.item.id, type, startX: e.clientX, originalStart: task.start, originalEnd: task.end, currentStart: task.start, currentEnd: task.end }) }, [onDateChange]) useEffect(() => { if (!dragState) return const handleMouseMove = (e: MouseEvent) => { const deltaDays = Math.round((e.clientX - dragState.startX) / dayWidth) if (deltaDays === 0 && dragState.currentStart.getTime() === dragState.originalStart.getTime()) return let newStart = dragState.originalStart, newEnd = dragState.originalEnd if (dragState.type === 'move') { newStart = addDays(dragState.originalStart, deltaDays); newEnd = addDays(dragState.originalEnd, deltaDays) } else if (dragState.type === 'resize-start') { newStart = addDays(dragState.originalStart, deltaDays); if (newStart >= newEnd) newStart = addDays(newEnd, -1) } else if (dragState.type === 'resize-end') { newEnd = addDays(dragState.originalEnd, deltaDays); if (newEnd <= newStart) newEnd = addDays(newStart, 1) } setDragState((prev) => prev ? { ...prev, currentStart: newStart, currentEnd: newEnd } : null) } const handleMouseUp = () => { if (dragState && onDateChange) { const startChanged = dragState.currentStart.getTime() !== dragState.originalStart.getTime() const endChanged = dragState.currentEnd.getTime() !== dragState.originalEnd.getTime() if (startChanged || endChanged) onDateChange(dragState.taskId, dragState.currentStart, dragState.currentEnd) } setDragState(null) } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp) } }, [dragState, dayWidth, onDateChange]) // Dependency arrow paths const dependencyPaths = useMemo(() => { const paths: Array<{ path: string; fromId: string; toId: string }> = [] const taskIndexMap = new Map() tasks.forEach((task, index) => taskIndexMap.set(task.item.id, index)) for (const dep of dependencies) { const fromIdx = taskIndexMap.get(dep.fromId) const toIdx = taskIndexMap.get(dep.toId) if (fromIdx !== undefined && toIdx !== undefined) { const fromPos = getBarPosition(tasks[fromIdx], fromIdx) const toPos = getBarPosition(tasks[toIdx], toIdx) const x1 = fromPos.left + fromPos.width, y1 = fromPos.top + fromPos.height / 2 const x2 = toPos.left, y2 = toPos.top + toPos.height / 2 const midX = (x1 + x2) / 2 paths.push({ path: `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`, fromId: dep.fromId, toId: dep.toId }) } } return paths }, [tasks, dependencies, getBarPosition]) const timelineWidth = days.length * dayWidth const bodyHeight = tasks.length * ROW_HEIGHT // Board view: group items by status const boardColumns = useMemo(() => { if (viewMode !== 'board') return [] const statuses = boardStatuses || uniqueStatuses const columns: Array<{ status: string; items: TimelineItem[] }> = statuses.map((s) => ({ status: s, items: [] })) const statusSet = new Set(statuses) const flatItems = (itemList: TimelineItem[]): TimelineItem[] => { const result: TimelineItem[] = [] for (const item of itemList) { result.push(item) if (item.children) result.push(...flatItems(item.children)) } return result } for (const item of flatItems(filteredItems)) { if (statusSet.has(item.status)) { columns.find((c) => c.status === item.status)?.items.push(item) } } return columns }, [viewMode, filteredItems, boardStatuses, uniqueStatuses]) // List view: flatten all items const listItems = useMemo(() => { if (viewMode !== 'list') return [] return tasks.map((t) => t) }, [viewMode, tasks]) return { // State zoomIndex, setZoomIndex, collapsed, mounted, dragState, filters, setFilters, isFullscreen, viewMode, setViewMode, detailItem, setDetailItem, focusedRowIndex, boardDragItem, setBoardDragItem, // Derived values dayWidth, hasActiveFilters, uniqueStatuses, uniqueCategories, filteredItems, tasks, startDate, days, months, timeHeaderUnits, dependencyPaths, timelineWidth, bodyHeight, boardColumns, listItems, // Refs bodyScrollRef, headerTimelineRef, wrapperRef, infoColumnRef, // Handlers toggleFullscreen, handleItemClick, toggleCollapse, handleBodyScroll, getBarPosition, handleDragStart, // Props pass-through (needed by the render shell) categoryColors, onDateChange, onItemEdit, onStatusChange, } } export type UseGanttStateReturn = ReturnType