Merge pull request 'feat(#15): Implement Gantt chart component' (#103) from feature/15-gantt-chart into develop
Reviewed-on: #103
This commit was merged in pull request #103.
This commit is contained in:
@@ -91,7 +91,7 @@ describe("GanttChart", () => {
|
|||||||
render(<GanttChart tasks={tasks} />);
|
render(<GanttChart tasks={tasks} />);
|
||||||
|
|
||||||
const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']");
|
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", () => {
|
it("should visually distinguish in-progress tasks", () => {
|
||||||
@@ -106,7 +106,7 @@ describe("GanttChart", () => {
|
|||||||
render(<GanttChart tasks={tasks} />);
|
render(<GanttChart tasks={tasks} />);
|
||||||
|
|
||||||
const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']");
|
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", () => {
|
it("should render dependency lines when showDependencies is true", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createGanttTask({
|
createGanttTask({
|
||||||
id: "task-1",
|
id: "task-1",
|
||||||
title: "Foundation",
|
title: "Foundation",
|
||||||
|
startDate: new Date("2026-02-01"),
|
||||||
|
endDate: new Date("2026-02-10"),
|
||||||
}),
|
}),
|
||||||
createGanttTask({
|
createGanttTask({
|
||||||
id: "task-2",
|
id: "task-2",
|
||||||
title: "Build on top",
|
title: "Build on top",
|
||||||
|
startDate: new Date("2026-02-11"),
|
||||||
|
endDate: new Date("2026-02-20"),
|
||||||
dependencies: ["task-1"],
|
dependencies: ["task-1"],
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<GanttChart tasks={tasks} showDependencies={true} />);
|
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 });
|
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||||
expect(chart).toBeInTheDocument();
|
expect(chart).toBeInTheDocument();
|
||||||
|
|
||||||
// Specific dependency rendering will depend on implementation
|
// Look for dependency path element
|
||||||
// This is a basic check that the prop is accepted
|
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", () => {
|
it("should not render dependencies by default", () => {
|
||||||
@@ -396,6 +404,138 @@ describe("GanttChart", () => {
|
|||||||
// Dependencies should not be shown by default
|
// Dependencies should not be shown by default
|
||||||
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
const chart = screen.getByRole("region", { name: /gantt chart/i });
|
||||||
expect(chart).toBeInTheDocument();
|
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 type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
|
||||||
import { TaskStatus } from "@mosaic/shared";
|
import { TaskStatus } from "@mosaic/shared";
|
||||||
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
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
|
* Calculate the timeline range from a list of tasks
|
||||||
@@ -99,11 +112,11 @@ function getStatusClass(status: TaskStatus): string {
|
|||||||
function getRowStatusClass(status: TaskStatus): string {
|
function getRowStatusClass(status: TaskStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case TaskStatus.COMPLETED:
|
case TaskStatus.COMPLETED:
|
||||||
return "gantt-row-completed";
|
return styles.rowCompleted;
|
||||||
case TaskStatus.IN_PROGRESS:
|
case TaskStatus.IN_PROGRESS:
|
||||||
return "gantt-row-in-progress";
|
return styles.rowInProgress;
|
||||||
case TaskStatus.PAUSED:
|
case TaskStatus.PAUSED:
|
||||||
return "gantt-row-paused";
|
return styles.rowPaused;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -135,6 +148,65 @@ function generateTimelineLabels(range: TimelineRange): Array<{ label: string; po
|
|||||||
return labels;
|
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
|
* Main Gantt Chart Component
|
||||||
*/
|
*/
|
||||||
@@ -155,20 +227,32 @@ export function GanttChart({
|
|||||||
// Generate timeline labels
|
// Generate timeline labels
|
||||||
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
|
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
|
||||||
|
|
||||||
const handleTaskClick = (task: GanttTask) => (): void => {
|
// Calculate dependency lines
|
||||||
if (onTaskClick) {
|
const dependencyLines = useMemo(
|
||||||
onTaskClick(task);
|
() => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []),
|
||||||
}
|
[showDependencies, sortedTasks, timelineRange]
|
||||||
};
|
);
|
||||||
|
|
||||||
const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent<HTMLDivElement>): void => {
|
const handleTaskClick = useCallback(
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
(task: GanttTask) => (): void => {
|
||||||
e.preventDefault();
|
|
||||||
if (onTaskClick) {
|
if (onTaskClick) {
|
||||||
onTaskClick(task);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -242,11 +326,68 @@ export function GanttChart({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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) => {
|
{sortedTasks.map((task, index) => {
|
||||||
const position = calculateBarPosition(task, timelineRange, index);
|
const position = calculateBarPosition(task, timelineRange, index);
|
||||||
const statusClass = getStatusClass(task.status);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
@@ -281,19 +422,6 @@ export function GanttChart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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
|
* Gantt Chart component exports
|
||||||
|
* @module gantt
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { GanttChart } from "./GanttChart";
|
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";
|
export { toGanttTask, toGanttTasks } from "./types";
|
||||||
|
|||||||
@@ -116,6 +116,50 @@ describe("Gantt Types Helpers", () => {
|
|||||||
expect(ganttTask?.dependencies).toBeUndefined();
|
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", () => {
|
it("should preserve all original task properties", () => {
|
||||||
const task = createTask({
|
const task = createTask({
|
||||||
id: "special-task",
|
id: "special-task",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface GanttTask extends Task {
|
|||||||
endDate: Date;
|
endDate: Date;
|
||||||
/** Optional array of task IDs that this task depends on */
|
/** Optional array of task IDs that this task depends on */
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
|
/** Whether this task is a milestone (zero-duration marker) */
|
||||||
|
isMilestone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,31 +58,50 @@ export interface GanttChartProps {
|
|||||||
showDependencies?: boolean;
|
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
|
* Helper to convert a base Task to GanttTask
|
||||||
* Uses createdAt as startDate if not in metadata, dueDate as endDate
|
* Uses createdAt as startDate if not in metadata, dueDate as endDate
|
||||||
*/
|
*/
|
||||||
export function toGanttTask(task: Task): GanttTask | null {
|
export function toGanttTask(task: Task): GanttTask | null {
|
||||||
// For Gantt chart, we need both start and end dates
|
// For Gantt chart, we need both start and end dates
|
||||||
const startDate =
|
const metadataStartDate = task.metadata?.startDate;
|
||||||
(task.metadata?.startDate as string | undefined)
|
const startDate = isDateString(metadataStartDate)
|
||||||
? new Date(task.metadata.startDate as string)
|
? new Date(metadataStartDate)
|
||||||
: task.createdAt;
|
: task.createdAt;
|
||||||
|
|
||||||
const endDate = task.dueDate || new Date();
|
const endDate = task.dueDate ?? new Date();
|
||||||
|
|
||||||
// Validate dates
|
// Validate dates
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract dependencies with type guard
|
||||||
|
const metadataDependencies = task.metadata?.dependencies;
|
||||||
|
const dependencies = isStringArray(metadataDependencies)
|
||||||
|
? metadataDependencies
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
dependencies: Array.isArray(task.metadata?.dependencies)
|
dependencies,
|
||||||
? (task.metadata.dependencies as string[])
|
isMilestone: task.metadata?.isMilestone === true,
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user