feat(#15): Implement Gantt chart component #103

Merged
jason.woltje merged 3 commits from feature/15-gantt-chart into develop 2026-01-30 01:42:15 +00:00
7 changed files with 418 additions and 44 deletions

View File

@@ -91,7 +91,7 @@ describe("GanttChart", () => {
render(<GanttChart tasks={tasks} />);
const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']");
expect(taskRow).toHaveClass(/completed/i);
expect(taskRow?.className).toMatch(/Completed/i);
});
it("should visually distinguish in-progress tasks", () => {
@@ -106,7 +106,7 @@ describe("GanttChart", () => {
render(<GanttChart tasks={tasks} />);
const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']");
expect(taskRow).toHaveClass(/in-progress/i);
expect(taskRow?.className).toMatch(/InProgress/i);
});
});
@@ -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

@@ -3,7 +3,20 @@
import type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
import { TaskStatus } from "@mosaic/shared";
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
import { useMemo } from "react";
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
@@ -99,11 +112,11 @@ function getStatusClass(status: TaskStatus): string {
function getRowStatusClass(status: TaskStatus): string {
switch (status) {
case TaskStatus.COMPLETED:
return "gantt-row-completed";
return styles.rowCompleted;
case TaskStatus.IN_PROGRESS:
return "gantt-row-in-progress";
return styles.rowInProgress;
case TaskStatus.PAUSED:
return "gantt-row-paused";
return styles.rowPaused;
default:
return "";
}
@@ -135,6 +148,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,20 +227,32 @@ export function GanttChart({
// Generate timeline labels
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
const handleTaskClick = (task: GanttTask) => (): void => {
if (onTaskClick) {
onTaskClick(task);
}
};
// Calculate dependency lines
const dependencyLines = useMemo(
() => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []),
[showDependencies, sortedTasks, timelineRange]
);
const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent<HTMLDivElement>): void => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
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
@@ -242,11 +326,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}
@@ -281,19 +422,6 @@ export function GanttChart({
</div>
</div>
</div>
{/* CSS for status classes */}
<style jsx>{`
.gantt-row-completed {
background-color: #f0fdf4;
}
.gantt-row-in-progress {
background-color: #eff6ff;
}
.gantt-row-paused {
background-color: #fefce8;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,12 @@
/* Gantt Chart Status Row Styles */
.rowCompleted {
background-color: #f0fdf4; /* green-50 */
}
.rowInProgress {
background-color: #eff6ff; /* blue-50 */
}
.rowPaused {
background-color: #fefce8; /* yellow-50 */
}

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;
}
/**
@@ -56,31 +58,50 @@ export interface GanttChartProps {
showDependencies?: boolean;
}
/**
* Type guard to check if a value is a valid date string
*/
function isDateString(value: unknown): value is string {
return typeof value === 'string' && !isNaN(Date.parse(value));
}
/**
* Type guard to check if a value is an array of strings
*/
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
/**
* Helper to convert a base Task to GanttTask
* Uses createdAt as startDate if not in metadata, dueDate as endDate
*/
export function toGanttTask(task: Task): GanttTask | null {
// For Gantt chart, we need both start and end dates
const startDate =
(task.metadata?.startDate as string | undefined)
? new Date(task.metadata.startDate as string)
: task.createdAt;
const endDate = task.dueDate || new Date();
const metadataStartDate = task.metadata?.startDate;
const startDate = isDateString(metadataStartDate)
? new Date(metadataStartDate)
: task.createdAt;
const endDate = task.dueDate ?? new Date();
// Validate dates
if (!startDate || !endDate) {
return null;
}
// Extract dependencies with type guard
const metadataDependencies = task.metadata?.dependencies;
const dependencies = isStringArray(metadataDependencies)
? metadataDependencies
: undefined;
return {
...task,
startDate,
endDate,
dependencies: Array.isArray(task.metadata?.dependencies)
? (task.metadata.dependencies as string[])
: undefined,
dependencies,
isMilestone: task.metadata?.isMilestone === true,
};
}