"use client"; import type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types"; import { TaskStatus } from "@mosaic/shared"; import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format"; import { useMemo, useCallback } from "react"; import styles from "./gantt.module.css"; /** * Represents a dependency line between two tasks */ interface DependencyLine { fromTaskId: string; toTaskId: string; fromX: number; fromY: number; toX: number; toY: number; } /** * Calculate the timeline range from a list of tasks */ function calculateTimelineRange(tasks: GanttTask[]): TimelineRange { if (tasks.length === 0) { const now = new Date(); const oneMonthLater = new Date(now); oneMonthLater.setMonth(oneMonthLater.getMonth() + 1); return { start: now, end: oneMonthLater, totalDays: 30, }; } let earliest = tasks[0].startDate; let latest = tasks[0].endDate; tasks.forEach((task) => { if (task.startDate < earliest) { earliest = task.startDate; } if (task.endDate > latest) { latest = task.endDate; } }); // Add padding (5% on each side) const totalMs = latest.getTime() - earliest.getTime(); const padding = totalMs * 0.05; const start = new Date(earliest.getTime() - padding); const end = new Date(latest.getTime() + padding); const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); return { start, end, totalDays }; } /** * Calculate the position and width for a task bar */ function calculateBarPosition( task: GanttTask, timelineRange: TimelineRange, rowIndex: number ): GanttBarPosition { const { start: rangeStart, totalDays } = timelineRange; const taskStartOffset = Math.max( 0, (task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24) ); const taskDuration = Math.max( 0.5, // Minimum 0.5 day width for visibility (task.endDate.getTime() - task.startDate.getTime()) / (1000 * 60 * 60 * 24) ); const leftPercent = (taskStartOffset / totalDays) * 100; const widthPercent = (taskDuration / totalDays) * 100; return { left: `${leftPercent}%`, width: `${widthPercent}%`, top: rowIndex * 48, // 48px row height }; } /** * Get CSS class for task status */ function getStatusClass(status: TaskStatus): string { switch (status) { case TaskStatus.COMPLETED: return "bg-green-500"; case TaskStatus.IN_PROGRESS: return "bg-blue-500"; case TaskStatus.PAUSED: return "bg-yellow-500"; case TaskStatus.ARCHIVED: return "bg-gray-400"; default: return "bg-gray-500"; } } /** * Get CSS class for task row status */ function getRowStatusClass(status: TaskStatus): string { switch (status) { case TaskStatus.COMPLETED: return styles.rowCompleted; case TaskStatus.IN_PROGRESS: return styles.rowInProgress; case TaskStatus.PAUSED: return styles.rowPaused; default: return ""; } } /** * Generate month labels for the timeline header */ function generateTimelineLabels(range: TimelineRange): Array<{ label: string; position: number }> { const labels: Array<{ label: string; position: number }> = []; const current = new Date(range.start); // Generate labels for each month in the range while (current <= range.end) { const position = ((current.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)) / range.totalDays; const label = current.toLocaleDateString("en-US", { month: "short", year: "numeric", }); labels.push({ label, position: position * 100 }); // Move to next month current.setMonth(current.getMonth() + 1); } return labels; } /** * Calculate dependency lines between tasks */ function calculateDependencyLines( tasks: GanttTask[], timelineRange: TimelineRange ): DependencyLine[] { const lines: DependencyLine[] = []; const taskIndexMap = new Map(); // Build index map tasks.forEach((task, index) => { taskIndexMap.set(task.id, index); }); const { start: rangeStart, totalDays } = timelineRange; tasks.forEach((task, toIndex) => { if (!task.dependencies || task.dependencies.length === 0) { return; } task.dependencies.forEach((depId) => { const fromIndex = taskIndexMap.get(depId); if (fromIndex === undefined) { return; } const fromTask = tasks[fromIndex]; // Calculate positions (as percentages) const fromEndOffset = Math.max( 0, (fromTask.endDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24) ); const toStartOffset = Math.max( 0, (task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24) ); const fromX = (fromEndOffset / totalDays) * 100; const toX = (toStartOffset / totalDays) * 100; const fromY = fromIndex * 48 + 24; // Center of the row const toY = toIndex * 48 + 24; lines.push({ fromTaskId: depId, toTaskId: task.id, fromX, fromY, toX, toY, }); }); }); return lines; } /** * Main Gantt Chart Component */ export function GanttChart({ tasks, onTaskClick, height = 400, showDependencies = false, }: GanttChartProps): JSX.Element { // Sort tasks by start date const sortedTasks = useMemo(() => { return [...tasks].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); }, [tasks]); // Calculate timeline range const timelineRange = useMemo(() => calculateTimelineRange(sortedTasks), [sortedTasks]); // Generate timeline labels const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]); // Calculate dependency lines const dependencyLines = useMemo( () => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []), [showDependencies, sortedTasks, timelineRange] ); const handleTaskClick = useCallback( (task: GanttTask) => (): void => { if (onTaskClick) { onTaskClick(task); } }, [onTaskClick] ); const handleKeyDown = useCallback( (task: GanttTask) => (e: React.KeyboardEvent): void => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (onTaskClick) { onTaskClick(task); } } }, [onTaskClick] ); return (
{/* Task list column */}
Tasks
{sortedTasks.map((task, index) => { const isPast = isPastTarget(task.endDate); const isApproaching = !isPast && isApproachingTarget(task.endDate); return (
{task.title}
{isPast && task.status !== TaskStatus.COMPLETED && (
Target passed
)} {isApproaching && task.status !== TaskStatus.COMPLETED && (
Approaching target
)}
); })}
{/* Timeline column */}
{/* Timeline header */}
{timelineLabels.map((label, index) => (
{label.label}
))}
{/* Timeline grid and bars */}
{/* Grid lines */}
{timelineLabels.map((label, index) => (
))}
{/* Dependency lines SVG */} {showDependencies && dependencyLines.length > 0 && ( )} {/* Task bars and milestones */} {sortedTasks.map((task, index) => { const position = calculateBarPosition(task, timelineRange, index); const statusClass = getStatusClass(task.status); // Render milestone as diamond shape if (task.isMilestone === true) { return (
); } return (
{task.title}
); })} {/* Spacer for scrolling */}
); }