From aa6d466321b8c778fee82ec70504b22866436d80 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 19:08:47 -0600 Subject: [PATCH 1/2] 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% --- .../src/components/gantt/GanttChart.test.tsx | 148 +++++++++++++++++- apps/web/src/components/gantt/GanttChart.tsx | 136 +++++++++++++++- apps/web/src/components/gantt/index.test.ts | 23 +++ apps/web/src/components/gantt/index.ts | 8 +- apps/web/src/components/gantt/types.test.ts | 44 ++++++ apps/web/src/components/gantt/types.ts | 3 + 6 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/gantt/index.test.ts diff --git a/apps/web/src/components/gantt/GanttChart.test.tsx b/apps/web/src/components/gantt/GanttChart.test.tsx index 27f2877..2ed6f9b 100644 --- a/apps/web/src/components/gantt/GanttChart.test.tsx +++ b/apps/web/src/components/gantt/GanttChart.test.tsx @@ -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(); - // 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole("button", { name: /gantt bar.*regular task/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /milestone.*milestone/i })).toBeInTheDocument(); }); }); }); diff --git a/apps/web/src/components/gantt/GanttChart.tsx b/apps/web/src/components/gantt/GanttChart.tsx index 8190a2a..63b4363 100644 --- a/apps/web/src/components/gantt/GanttChart.tsx +++ b/apps/web/src/components/gantt/GanttChart.tsx @@ -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(); + + // 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, }; } -- 2.49.1 From 16697bfb7998d6440b44dfe4e1ee635550737c40 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 19:32:23 -0600 Subject: [PATCH 2/2] fix: address code review feedback - Replace type assertions with type guards in types.ts (isDateString, isStringArray) - Add useCallback for event handlers (handleTaskClick, handleKeyDown) - Replace styled-jsx with CSS modules (gantt.module.css) - Update tests to use CSS module class name patterns --- .../src/components/gantt/GanttChart.test.tsx | 4 +- apps/web/src/components/gantt/GanttChart.tsx | 50 ++++++++----------- .../web/src/components/gantt/gantt.module.css | 12 +++++ apps/web/src/components/gantt/types.ts | 36 +++++++++---- 4 files changed, 63 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/gantt/gantt.module.css diff --git a/apps/web/src/components/gantt/GanttChart.test.tsx b/apps/web/src/components/gantt/GanttChart.test.tsx index 2ed6f9b..8e25088 100644 --- a/apps/web/src/components/gantt/GanttChart.test.tsx +++ b/apps/web/src/components/gantt/GanttChart.test.tsx @@ -91,7 +91,7 @@ describe("GanttChart", () => { render(); 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(); const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']"); - expect(taskRow).toHaveClass(/in-progress/i); + expect(taskRow?.className).toMatch(/InProgress/i); }); }); diff --git a/apps/web/src/components/gantt/GanttChart.tsx b/apps/web/src/components/gantt/GanttChart.tsx index 63b4363..8539ba7 100644 --- a/apps/web/src/components/gantt/GanttChart.tsx +++ b/apps/web/src/components/gantt/GanttChart.tsx @@ -3,7 +3,8 @@ 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 @@ -111,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 ""; } @@ -232,20 +233,26 @@ export function GanttChart({ [showDependencies, sortedTasks, timelineRange] ); - const handleTaskClick = (task: GanttTask) => (): void => { - if (onTaskClick) { - onTaskClick(task); - } - }; - - const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent): 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): void => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (onTaskClick) { + onTaskClick(task); + } + } + }, + [onTaskClick] + ); 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/types.ts b/apps/web/src/components/gantt/types.ts index b4151b1..ef5ef3b 100644 --- a/apps/web/src/components/gantt/types.ts +++ b/apps/web/src/components/gantt/types.ts @@ -58,31 +58,49 @@ 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, }; } -- 2.49.1