feat(#15): implement gantt chart component

- Add milestone support with diamond markers
- Implement dependency line rendering with SVG arrows
- Add isMilestone property to GanttTask type
- Create dependency calculation and visualization
- Add comprehensive tests for milestones and dependencies
- Add index module tests for exports
- Coverage: GanttChart 98.37%, types 91.66%, index 100%
This commit is contained in:
Jason Woltje
2026-01-29 19:08:47 -06:00
parent 9ff7718f9c
commit aa6d466321
6 changed files with 356 additions and 6 deletions

View File

@@ -5,6 +5,18 @@ import { TaskStatus } from "@mosaic/shared";
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
import { useMemo } from "react";
/**
* 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
*/
@@ -135,6 +147,65 @@ function generateTimelineLabels(range: TimelineRange): Array<{ label: string; po
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
*/
@@ -155,6 +226,12 @@ export function GanttChart({
// Generate timeline labels
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
// Calculate dependency lines
const dependencyLines = useMemo(
() => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []),
[showDependencies, sortedTasks, timelineRange]
);
const handleTaskClick = (task: GanttTask) => (): void => {
if (onTaskClick) {
onTaskClick(task);
@@ -242,11 +319,68 @@ export function GanttChart({
))}
</div>
{/* Task bars */}
{/* 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}