- {/* 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 (
-
- {/* CSS for status classes */}
-
);
}
diff --git a/apps/web/src/components/gantt/gantt.module.css b/apps/web/src/components/gantt/gantt.module.css
new file mode 100644
index 0000000..a81d090
--- /dev/null
+++ b/apps/web/src/components/gantt/gantt.module.css
@@ -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 */
+}
diff --git a/apps/web/src/components/gantt/index.test.ts b/apps/web/src/components/gantt/index.test.ts
new file mode 100644
index 0000000..538986c
--- /dev/null
+++ b/apps/web/src/components/gantt/index.test.ts
@@ -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");
+ });
+});
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..ef5ef3b 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;
}
/**
@@ -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,
};
}