feat(#15): Implement Gantt chart component #103
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
apps/web/src/components/gantt/gantt.module.css
Normal file
12
apps/web/src/components/gantt/gantt.module.css
Normal 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 */
|
||||
}
|
||||
23
apps/web/src/components/gantt/index.test.ts
Normal file
23
apps/web/src/components/gantt/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user