- Replace type assertions with type guards in types.ts (isDateString, isStringArray) - Add useCallback for event handlers (handleTaskClick, handleKeyDown) - Replace styled-jsx with CSS modules (gantt.module.css) - Update tests to use CSS module class name patterns
428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
"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<string, number>();
|
|
|
|
// 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<HTMLDivElement>): void => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
if (onTaskClick) {
|
|
onTaskClick(task);
|
|
}
|
|
}
|
|
},
|
|
[onTaskClick]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
role="region"
|
|
aria-label="Gantt Chart"
|
|
className="gantt-chart bg-white rounded-lg border border-gray-200 overflow-hidden"
|
|
style={{ height: `${height}px` }}
|
|
>
|
|
<div className="gantt-container flex h-full">
|
|
{/* Task list column */}
|
|
<div className="gantt-task-list w-64 border-r border-gray-200 overflow-y-auto flex-shrink-0">
|
|
<div className="gantt-task-list-header bg-gray-50 p-3 border-b border-gray-200 font-semibold sticky top-0 z-10">
|
|
Tasks
|
|
</div>
|
|
<div className="gantt-task-list-body">
|
|
{sortedTasks.map((task, index) => {
|
|
const isPast = isPastTarget(task.endDate);
|
|
const isApproaching = !isPast && isApproachingTarget(task.endDate);
|
|
|
|
return (
|
|
<div
|
|
key={task.id}
|
|
role="row"
|
|
className={`gantt-task-row p-3 border-b border-gray-100 h-12 flex items-center ${getRowStatusClass(
|
|
task.status
|
|
)}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-gray-900 truncate">
|
|
{task.title}
|
|
</div>
|
|
{isPast && task.status !== TaskStatus.COMPLETED && (
|
|
<div className="text-xs text-amber-600">Target passed</div>
|
|
)}
|
|
{isApproaching && task.status !== TaskStatus.COMPLETED && (
|
|
<div className="text-xs text-orange-600">Approaching target</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline column */}
|
|
<div className="gantt-timeline flex-1 overflow-x-auto overflow-y-auto">
|
|
<div role="region" aria-label="Timeline" className="gantt-timeline-container min-w-full">
|
|
{/* Timeline header */}
|
|
<div className="gantt-timeline-header bg-gray-50 border-b border-gray-200 h-12 sticky top-0 z-10 relative">
|
|
{timelineLabels.map((label, index) => (
|
|
<div
|
|
key={index}
|
|
className="absolute top-0 bottom-0 flex items-center text-xs text-gray-600 px-2"
|
|
style={{ left: `${label.position}%` }}
|
|
>
|
|
{label.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Timeline grid and bars */}
|
|
<div className="gantt-timeline-body relative">
|
|
{/* Grid lines */}
|
|
<div className="gantt-grid absolute inset-0 pointer-events-none">
|
|
{timelineLabels.map((label, index) => (
|
|
<div
|
|
key={index}
|
|
className="absolute top-0 bottom-0 w-px bg-gray-200"
|
|
style={{ left: `${label.position}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Dependency lines SVG */}
|
|
{showDependencies && dependencyLines.length > 0 && (
|
|
<svg
|
|
className="gantt-dependencies absolute inset-0 pointer-events-none overflow-visible"
|
|
style={{ width: "100%", height: `${sortedTasks.length * 48}px` }}
|
|
aria-hidden="true"
|
|
>
|
|
<defs>
|
|
<marker
|
|
id="arrowhead"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
|
|
</marker>
|
|
</defs>
|
|
{dependencyLines.map((line) => (
|
|
<path
|
|
key={`dep-${line.fromTaskId}-${line.toTaskId}`}
|
|
d={`M ${line.fromX}% ${line.fromY} C ${line.fromX + 2}% ${line.fromY}, ${line.toX - 2}% ${line.toY}, ${line.toX}% ${line.toY}`}
|
|
stroke="#6b7280"
|
|
strokeWidth="2"
|
|
fill="none"
|
|
markerEnd="url(#arrowhead)"
|
|
className="dependency-line"
|
|
/>
|
|
))}
|
|
</svg>
|
|
)}
|
|
|
|
{/* 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 (
|
|
<div
|
|
key={task.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Milestone: ${task.title}, on ${formatDate(task.startDate)}`}
|
|
className="gantt-milestone absolute cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
style={{
|
|
left: position.left,
|
|
top: `${position.top + 8}px`,
|
|
}}
|
|
onClick={handleTaskClick(task)}
|
|
onKeyDown={handleKeyDown(task)}
|
|
>
|
|
<div
|
|
className={`w-6 h-6 transform rotate-45 ${statusClass}`}
|
|
title={task.title}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={task.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Gantt bar for ${task.title}, from ${formatDate(
|
|
task.startDate
|
|
)} to ${formatDate(task.endDate)}`}
|
|
className={`gantt-bar absolute h-8 rounded cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500 ${statusClass}`}
|
|
style={{
|
|
left: position.left,
|
|
width: position.width,
|
|
top: `${position.top + 8}px`, // Center in row
|
|
}}
|
|
onClick={handleTaskClick(task)}
|
|
onKeyDown={handleKeyDown(task)}
|
|
>
|
|
<div className="px-2 text-xs text-white truncate leading-8">
|
|
{task.title}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Spacer for scrolling */}
|
|
<div
|
|
style={{
|
|
height: `${sortedTasks.length * 48}px`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|