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

@@ -354,28 +354,36 @@ describe("GanttChart", () => {
});
});
describe("Dependencies (stretch goal)", () => {
describe("Dependencies", () => {
it("should render dependency lines when showDependencies is true", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Foundation",
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-10"),
}),
createGanttTask({
id: "task-2",
title: "Build on top",
startDate: new Date("2026-02-11"),
endDate: new Date("2026-02-20"),
dependencies: ["task-1"],
}),
];
render(<GanttChart tasks={tasks} showDependencies={true} />);
// Check if dependency visualization exists
// Check if dependency SVG exists
const chart = screen.getByRole("region", { name: /gantt chart/i });
expect(chart).toBeInTheDocument();
// Specific dependency rendering will depend on implementation
// This is a basic check that the prop is accepted
// Look for dependency path element
const svg = chart.querySelector(".gantt-dependencies");
expect(svg).toBeInTheDocument();
const paths = chart.querySelectorAll(".dependency-line");
expect(paths).toHaveLength(1);
});
it("should not render dependencies by default", () => {
@@ -396,6 +404,138 @@ describe("GanttChart", () => {
// Dependencies should not be shown by default
const chart = screen.getByRole("region", { name: /gantt chart/i });
expect(chart).toBeInTheDocument();
const svg = chart.querySelector(".gantt-dependencies");
expect(svg).not.toBeInTheDocument();
});
it("should handle tasks with non-existent dependencies gracefully", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Task 1",
dependencies: ["non-existent-task"],
}),
];
render(<GanttChart tasks={tasks} showDependencies={true} />);
// Should not crash
const chart = screen.getByRole("region", { name: /gantt chart/i });
expect(chart).toBeInTheDocument();
});
it("should render multiple dependency lines", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Task 1",
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-05"),
}),
createGanttTask({
id: "task-2",
title: "Task 2",
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-05"),
}),
createGanttTask({
id: "task-3",
title: "Task 3",
startDate: new Date("2026-02-06"),
endDate: new Date("2026-02-10"),
dependencies: ["task-1", "task-2"],
}),
];
render(<GanttChart tasks={tasks} showDependencies={true} />);
const chart = screen.getByRole("region", { name: /gantt chart/i });
const paths = chart.querySelectorAll(".dependency-line");
expect(paths).toHaveLength(2);
});
});
describe("Milestones", () => {
it("should render milestone as diamond shape", () => {
const milestone = createGanttTask({
id: "milestone-1",
title: "Phase 1 Complete",
startDate: new Date("2026-02-15"),
endDate: new Date("2026-02-15"),
isMilestone: true,
});
render(<GanttChart tasks={[milestone]} />);
const milestoneElement = screen.getByRole("button", {
name: /milestone.*phase 1 complete/i,
});
expect(milestoneElement).toBeInTheDocument();
expect(milestoneElement).toHaveClass("gantt-milestone");
});
it("should handle click on milestone", async () => {
const user = userEvent.setup();
const onTaskClick = vi.fn();
const milestone = createGanttTask({
id: "milestone-1",
title: "Milestone Task",
isMilestone: true,
});
render(<GanttChart tasks={[milestone]} onTaskClick={onTaskClick} />);
const milestoneElement = screen.getByRole("button", {
name: /milestone.*milestone task/i,
});
await user.click(milestoneElement);
expect(onTaskClick).toHaveBeenCalledWith(milestone);
});
it("should support keyboard navigation for milestones", async () => {
const user = userEvent.setup();
const onTaskClick = vi.fn();
const milestone = createGanttTask({
id: "milestone-1",
title: "Keyboard Milestone",
isMilestone: true,
});
render(<GanttChart tasks={[milestone]} onTaskClick={onTaskClick} />);
const milestoneElement = screen.getByRole("button", {
name: /milestone/i,
});
await user.tab();
expect(milestoneElement).toHaveFocus();
await user.keyboard("{Enter}");
expect(onTaskClick).toHaveBeenCalled();
});
it("should render milestones and regular tasks together", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Regular Task",
isMilestone: false,
}),
createGanttTask({
id: "milestone-1",
title: "Milestone",
isMilestone: true,
}),
];
render(<GanttChart tasks={tasks} />);
expect(screen.getByRole("button", { name: /gantt bar.*regular task/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /milestone.*milestone/i })).toBeInTheDocument();
});
});
});

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}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import {
GanttChart,
toGanttTask,
toGanttTasks,
} from "./index";
describe("Gantt module exports", () => {
it("should export GanttChart component", () => {
expect(GanttChart).toBeDefined();
expect(typeof GanttChart).toBe("function");
});
it("should export toGanttTask helper", () => {
expect(toGanttTask).toBeDefined();
expect(typeof toGanttTask).toBe("function");
});
it("should export toGanttTasks helper", () => {
expect(toGanttTasks).toBeDefined();
expect(typeof toGanttTasks).toBe("function");
});
});

View File

@@ -1,7 +1,13 @@
/**
* Gantt Chart component exports
* @module gantt
*/
export { GanttChart } from "./GanttChart";
export type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
export type {
GanttTask,
GanttChartProps,
TimelineRange,
GanttBarPosition,
} from "./types";
export { toGanttTask, toGanttTasks } from "./types";

View File

@@ -116,6 +116,50 @@ describe("Gantt Types Helpers", () => {
expect(ganttTask?.dependencies).toBeUndefined();
});
it("should extract isMilestone from metadata", () => {
const task = createTask({
metadata: {
startDate: "2026-02-01",
isMilestone: true,
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.isMilestone).toBe(true);
});
it("should default isMilestone to false when not specified", () => {
const task = createTask({
metadata: {
startDate: "2026-02-01",
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.isMilestone).toBe(false);
});
it("should handle non-boolean isMilestone in metadata", () => {
const task = createTask({
metadata: {
startDate: "2026-02-01",
isMilestone: "yes",
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.isMilestone).toBe(false);
});
it("should preserve all original task properties", () => {
const task = createTask({
id: "special-task",

View File

@@ -16,6 +16,8 @@ export interface GanttTask extends Task {
endDate: Date;
/** Optional array of task IDs that this task depends on */
dependencies?: string[];
/** Whether this task is a milestone (zero-duration marker) */
isMilestone?: boolean;
}
/**
@@ -81,6 +83,7 @@ export function toGanttTask(task: Task): GanttTask | null {
dependencies: Array.isArray(task.metadata?.dependencies)
? (task.metadata.dependencies as string[])
: undefined,
isMilestone: task.metadata?.isMilestone === true,
};
}