();
+
+ // 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({
))}
- {/* Task bars */}
+ {/* Dependency lines SVG */}
+ {showDependencies && dependencyLines.length > 0 && (
+
+ )}
+
+ {/* 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 (
+
+ );
+ }
+
return (
{
+ 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");
+ });
+});
diff --git a/apps/web/src/components/gantt/index.ts b/apps/web/src/components/gantt/index.ts
index 0775b57..6510951 100644
--- a/apps/web/src/components/gantt/index.ts
+++ b/apps/web/src/components/gantt/index.ts
@@ -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";
diff --git a/apps/web/src/components/gantt/types.test.ts b/apps/web/src/components/gantt/types.test.ts
index 9aff77e..cd4f231 100644
--- a/apps/web/src/components/gantt/types.test.ts
+++ b/apps/web/src/components/gantt/types.test.ts
@@ -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",
diff --git a/apps/web/src/components/gantt/types.ts b/apps/web/src/components/gantt/types.ts
index 06aa381..b4151b1 100644
--- a/apps/web/src/components/gantt/types.ts
+++ b/apps/web/src/components/gantt/types.ts
@@ -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,
};
}