feat(#15): implement Gantt chart component

- Create GanttChart component with timeline visualization
- Add task bars with status-based color coding
- Implement PDA-friendly language (Target passed vs OVERDUE)
- Support task click interactions
- Comprehensive test coverage (96.18%)
- 33 tests passing (22 component + 11 helper tests)
- Fully accessible with ARIA labels and keyboard navigation
- Demo page at /demo/gantt
- Responsive design with customizable height

Technical details:
- Uses Next.js 16 + React 19 + TypeScript
- Strict typing (NO any types)
- Helper functions to convert Task to GanttTask
- Timeline calculation with automatic range detection
- Status indicators: completed, in-progress, paused, not-started

Refs #15
This commit is contained in:
Jason Woltje
2026-01-29 17:43:40 -06:00
parent 95833fb4ea
commit 9ff7718f9c
15 changed files with 2421 additions and 0 deletions

View File

@@ -0,0 +1,401 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GanttChart } from "./GanttChart";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
import type { GanttTask } from "./types";
describe("GanttChart", () => {
const baseDate = new Date("2026-02-01T00:00:00Z");
const createGanttTask = (overrides: Partial<GanttTask> = {}): GanttTask => ({
id: `task-${Math.random()}`,
workspaceId: "workspace-1",
title: "Sample Task",
description: null,
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-15T00:00:00Z"),
assigneeId: null,
creatorId: "user-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: baseDate,
updatedAt: baseDate,
startDate: new Date("2026-02-01T00:00:00Z"),
endDate: new Date("2026-02-15T00:00:00Z"),
...overrides,
});
describe("Rendering", () => {
it("should render without crashing with empty task list", () => {
render(<GanttChart tasks={[]} />);
expect(screen.getByRole("region", { name: /gantt chart/i })).toBeInTheDocument();
});
it("should render task names in the task list", () => {
const tasks = [
createGanttTask({ id: "task-1", title: "Design mockups" }),
createGanttTask({ id: "task-2", title: "Implement frontend" }),
];
render(<GanttChart tasks={tasks} />);
// Tasks appear in both the list and bars, so use getAllByText
expect(screen.getAllByText("Design mockups").length).toBeGreaterThan(0);
expect(screen.getAllByText("Implement frontend").length).toBeGreaterThan(0);
});
it("should render timeline bars for each task", () => {
const tasks = [
createGanttTask({ id: "task-1", title: "Task 1" }),
createGanttTask({ id: "task-2", title: "Task 2" }),
];
render(<GanttChart tasks={tasks} />);
const bars = screen.getAllByRole("button", { name: /gantt bar/i });
expect(bars).toHaveLength(2);
});
it("should display date headers for the timeline", () => {
const tasks = [
createGanttTask({
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-10"),
}),
];
render(<GanttChart tasks={tasks} />);
// Should show month or date indicators
const timeline = screen.getByRole("region", { name: /timeline/i });
expect(timeline).toBeInTheDocument();
});
});
describe("Task Status Indicators", () => {
it("should visually distinguish completed tasks", () => {
const tasks = [
createGanttTask({
id: "completed-task",
title: "Completed Task",
status: TaskStatus.COMPLETED,
completedAt: new Date("2026-02-10"),
}),
];
render(<GanttChart tasks={tasks} />);
const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']");
expect(taskRow).toHaveClass(/completed/i);
});
it("should visually distinguish in-progress tasks", () => {
const tasks = [
createGanttTask({
id: "active-task",
title: "Active Task",
status: TaskStatus.IN_PROGRESS,
}),
];
render(<GanttChart tasks={tasks} />);
const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']");
expect(taskRow).toHaveClass(/in-progress/i);
});
});
describe("PDA-friendly language", () => {
it('should show "Target passed" for tasks past their end date', () => {
const pastTask = createGanttTask({
id: "past-task",
title: "Past Task",
startDate: new Date("2020-01-01"),
endDate: new Date("2020-01-15"),
status: TaskStatus.NOT_STARTED,
});
render(<GanttChart tasks={[pastTask]} />);
// Should show "Target passed" not "OVERDUE"
expect(screen.getByText(/target passed/i)).toBeInTheDocument();
expect(screen.queryByText(/overdue/i)).not.toBeInTheDocument();
});
it('should show "Approaching target" for tasks near their end date', () => {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const soonTask = createGanttTask({
id: "soon-task",
title: "Soon Task",
startDate: today,
endDate: tomorrow,
status: TaskStatus.IN_PROGRESS,
});
render(<GanttChart tasks={[soonTask]} />);
expect(screen.getByText(/approaching target/i)).toBeInTheDocument();
});
});
describe("Task Interactions", () => {
it("should call onTaskClick when a task bar is clicked", async () => {
const user = userEvent.setup();
const onTaskClick = vi.fn();
const task = createGanttTask({ id: "clickable-task", title: "Click Me" });
render(<GanttChart tasks={[task]} onTaskClick={onTaskClick} />);
const taskBar = screen.getByRole("button", { name: /gantt bar.*click me/i });
await user.click(taskBar);
expect(onTaskClick).toHaveBeenCalledWith(task);
});
it("should not crash when clicking a task without onTaskClick handler", async () => {
const user = userEvent.setup();
const task = createGanttTask({ id: "task-1", title: "No Handler" });
render(<GanttChart tasks={[task]} />);
const taskBar = screen.getByRole("button", { name: /gantt bar/i });
await user.click(taskBar);
// Should not throw
expect(taskBar).toBeInTheDocument();
});
});
describe("Timeline Calculations", () => {
it("should calculate timeline range from task dates", () => {
const tasks = [
createGanttTask({
id: "early-task",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-10"),
}),
createGanttTask({
id: "late-task",
startDate: new Date("2026-03-01"),
endDate: new Date("2026-03-31"),
}),
];
render(<GanttChart tasks={tasks} />);
// Timeline should span from earliest start to latest end
const timeline = screen.getByRole("region", { name: /timeline/i });
expect(timeline).toBeInTheDocument();
});
it("should position task bars proportionally to their dates", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Task 1",
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-05"), // 4 days
}),
createGanttTask({
id: "task-2",
title: "Task 2",
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-11"), // 10 days
}),
];
render(<GanttChart tasks={tasks} />);
const bars = screen.getAllByRole("button", { name: /gantt bar/i });
expect(bars).toHaveLength(2);
// Second bar should be wider (more days)
const bar1Width = bars[0].style.width;
const bar2Width = bars[1].style.width;
// Basic check that widths are set (exact values depend on implementation)
expect(bar1Width).toBeTruthy();
expect(bar2Width).toBeTruthy();
});
});
describe("Accessibility", () => {
it("should have proper ARIA labels for the chart region", () => {
render(<GanttChart tasks={[]} />);
expect(screen.getByRole("region", { name: /gantt chart/i })).toBeInTheDocument();
});
it("should have proper ARIA labels for task bars", () => {
const task = createGanttTask({
id: "task-1",
title: "Accessible Task",
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-15"),
});
render(<GanttChart tasks={[task]} />);
const taskBar = screen.getByRole("button", {
name: /gantt bar.*accessible task/i,
});
expect(taskBar).toHaveAccessibleName();
});
it("should be keyboard navigable", async () => {
const user = userEvent.setup();
const onTaskClick = vi.fn();
const task = createGanttTask({ id: "task-1", title: "Keyboard Task" });
render(<GanttChart tasks={[task]} onTaskClick={onTaskClick} />);
const taskBar = screen.getByRole("button", { name: /gantt bar/i });
// Tab to focus
await user.tab();
expect(taskBar).toHaveFocus();
// Enter to activate
await user.keyboard("{Enter}");
expect(onTaskClick).toHaveBeenCalled();
});
});
describe("Responsive Design", () => {
it("should accept custom height prop", () => {
const tasks = [createGanttTask({ id: "task-1" })];
render(<GanttChart tasks={tasks} height={600} />);
const chart = screen.getByRole("region", { name: /gantt chart/i });
expect(chart).toHaveStyle({ height: "600px" });
});
it("should use default height when not specified", () => {
const tasks = [createGanttTask({ id: "task-1" })];
render(<GanttChart tasks={tasks} />);
const chart = screen.getByRole("region", { name: /gantt chart/i });
expect(chart).toBeInTheDocument();
// Default height should be set in implementation
});
});
describe("Edge Cases", () => {
it("should handle tasks with same start and end date", () => {
const sameDay = new Date("2026-02-01");
const task = createGanttTask({
id: "same-day",
title: "Same Day Task",
startDate: sameDay,
endDate: sameDay,
});
render(<GanttChart tasks={[task]} />);
expect(screen.getAllByText("Same Day Task").length).toBeGreaterThan(0);
const bar = screen.getByRole("button", { name: /gantt bar/i });
expect(bar).toBeInTheDocument();
// Bar should have minimum width
});
it("should handle tasks with very long duration", () => {
const task = createGanttTask({
id: "long-task",
title: "Long Task",
startDate: new Date("2026-01-01"),
endDate: new Date("2027-12-31"), // 2 years
});
render(<GanttChart tasks={[task]} />);
expect(screen.getAllByText("Long Task").length).toBeGreaterThan(0);
});
it("should sort tasks by start date", () => {
const tasks = [
createGanttTask({
id: "late-task",
title: "Late Task",
startDate: new Date("2026-03-01"),
}),
createGanttTask({
id: "early-task",
title: "Early Task",
startDate: new Date("2026-01-01"),
}),
createGanttTask({
id: "mid-task",
title: "Mid Task",
startDate: new Date("2026-02-01"),
}),
];
render(<GanttChart tasks={tasks} />);
const taskNames = screen.getAllByRole("row").map((row) => row.textContent);
// Early Task should appear first
const earlyIndex = taskNames.findIndex((name) => name?.includes("Early Task"));
const lateIndex = taskNames.findIndex((name) => name?.includes("Late Task"));
expect(earlyIndex).toBeLessThan(lateIndex);
});
});
describe("Dependencies (stretch goal)", () => {
it("should render dependency lines when showDependencies is true", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Foundation",
}),
createGanttTask({
id: "task-2",
title: "Build on top",
dependencies: ["task-1"],
}),
];
render(<GanttChart tasks={tasks} showDependencies={true} />);
// Check if dependency visualization 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
});
it("should not render dependencies by default", () => {
const tasks = [
createGanttTask({
id: "task-1",
title: "Task 1",
}),
createGanttTask({
id: "task-2",
title: "Task 2",
dependencies: ["task-1"],
}),
];
render(<GanttChart tasks={tasks} />);
// Dependencies should not be shown by default
const chart = screen.getByRole("region", { name: /gantt chart/i });
expect(chart).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,299 @@
"use client";
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";
/**
* Calculate the timeline range from a list of tasks
*/
function calculateTimelineRange(tasks: GanttTask[]): TimelineRange {
if (tasks.length === 0) {
const now = new Date();
const oneMonthLater = new Date(now);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
return {
start: now,
end: oneMonthLater,
totalDays: 30,
};
}
let earliest = tasks[0].startDate;
let latest = tasks[0].endDate;
tasks.forEach((task) => {
if (task.startDate < earliest) {
earliest = task.startDate;
}
if (task.endDate > latest) {
latest = task.endDate;
}
});
// Add padding (5% on each side)
const totalMs = latest.getTime() - earliest.getTime();
const padding = totalMs * 0.05;
const start = new Date(earliest.getTime() - padding);
const end = new Date(latest.getTime() + padding);
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
return { start, end, totalDays };
}
/**
* Calculate the position and width for a task bar
*/
function calculateBarPosition(
task: GanttTask,
timelineRange: TimelineRange,
rowIndex: number
): GanttBarPosition {
const { start: rangeStart, totalDays } = timelineRange;
const taskStartOffset = Math.max(
0,
(task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
);
const taskDuration = Math.max(
0.5, // Minimum 0.5 day width for visibility
(task.endDate.getTime() - task.startDate.getTime()) / (1000 * 60 * 60 * 24)
);
const leftPercent = (taskStartOffset / totalDays) * 100;
const widthPercent = (taskDuration / totalDays) * 100;
return {
left: `${leftPercent}%`,
width: `${widthPercent}%`,
top: rowIndex * 48, // 48px row height
};
}
/**
* Get CSS class for task status
*/
function getStatusClass(status: TaskStatus): string {
switch (status) {
case TaskStatus.COMPLETED:
return "bg-green-500";
case TaskStatus.IN_PROGRESS:
return "bg-blue-500";
case TaskStatus.PAUSED:
return "bg-yellow-500";
case TaskStatus.ARCHIVED:
return "bg-gray-400";
default:
return "bg-gray-500";
}
}
/**
* Get CSS class for task row status
*/
function getRowStatusClass(status: TaskStatus): string {
switch (status) {
case TaskStatus.COMPLETED:
return "gantt-row-completed";
case TaskStatus.IN_PROGRESS:
return "gantt-row-in-progress";
case TaskStatus.PAUSED:
return "gantt-row-paused";
default:
return "";
}
}
/**
* Generate month labels for the timeline header
*/
function generateTimelineLabels(range: TimelineRange): Array<{ label: string; position: number }> {
const labels: Array<{ label: string; position: number }> = [];
const current = new Date(range.start);
// Generate labels for each month in the range
while (current <= range.end) {
const position =
((current.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)) / range.totalDays;
const label = current.toLocaleDateString("en-US", {
month: "short",
year: "numeric",
});
labels.push({ label, position: position * 100 });
// Move to next month
current.setMonth(current.getMonth() + 1);
}
return labels;
}
/**
* Main Gantt Chart Component
*/
export function GanttChart({
tasks,
onTaskClick,
height = 400,
showDependencies = false,
}: GanttChartProps): JSX.Element {
// Sort tasks by start date
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
}, [tasks]);
// Calculate timeline range
const timelineRange = useMemo(() => calculateTimelineRange(sortedTasks), [sortedTasks]);
// Generate timeline labels
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
const handleTaskClick = (task: GanttTask) => (): void => {
if (onTaskClick) {
onTaskClick(task);
}
};
const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent<HTMLDivElement>): void => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onTaskClick) {
onTaskClick(task);
}
}
};
return (
<div
role="region"
aria-label="Gantt Chart"
className="gantt-chart bg-white rounded-lg border border-gray-200 overflow-hidden"
style={{ height: `${height}px` }}
>
<div className="gantt-container flex h-full">
{/* Task list column */}
<div className="gantt-task-list w-64 border-r border-gray-200 overflow-y-auto flex-shrink-0">
<div className="gantt-task-list-header bg-gray-50 p-3 border-b border-gray-200 font-semibold sticky top-0 z-10">
Tasks
</div>
<div className="gantt-task-list-body">
{sortedTasks.map((task, index) => {
const isPast = isPastTarget(task.endDate);
const isApproaching = !isPast && isApproachingTarget(task.endDate);
return (
<div
key={task.id}
role="row"
className={`gantt-task-row p-3 border-b border-gray-100 h-12 flex items-center ${getRowStatusClass(
task.status
)}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{task.title}
</div>
{isPast && task.status !== TaskStatus.COMPLETED && (
<div className="text-xs text-amber-600">Target passed</div>
)}
{isApproaching && task.status !== TaskStatus.COMPLETED && (
<div className="text-xs text-orange-600">Approaching target</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Timeline column */}
<div className="gantt-timeline flex-1 overflow-x-auto overflow-y-auto">
<div role="region" aria-label="Timeline" className="gantt-timeline-container min-w-full">
{/* Timeline header */}
<div className="gantt-timeline-header bg-gray-50 border-b border-gray-200 h-12 sticky top-0 z-10 relative">
{timelineLabels.map((label, index) => (
<div
key={index}
className="absolute top-0 bottom-0 flex items-center text-xs text-gray-600 px-2"
style={{ left: `${label.position}%` }}
>
{label.label}
</div>
))}
</div>
{/* Timeline grid and bars */}
<div className="gantt-timeline-body relative">
{/* Grid lines */}
<div className="gantt-grid absolute inset-0 pointer-events-none">
{timelineLabels.map((label, index) => (
<div
key={index}
className="absolute top-0 bottom-0 w-px bg-gray-200"
style={{ left: `${label.position}%` }}
/>
))}
</div>
{/* Task bars */}
{sortedTasks.map((task, index) => {
const position = calculateBarPosition(task, timelineRange, index);
const statusClass = getStatusClass(task.status);
return (
<div
key={task.id}
role="button"
tabIndex={0}
aria-label={`Gantt bar for ${task.title}, from ${formatDate(
task.startDate
)} to ${formatDate(task.endDate)}`}
className={`gantt-bar absolute h-8 rounded cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500 ${statusClass}`}
style={{
left: position.left,
width: position.width,
top: `${position.top + 8}px`, // Center in row
}}
onClick={handleTaskClick(task)}
onKeyDown={handleKeyDown(task)}
>
<div className="px-2 text-xs text-white truncate leading-8">
{task.title}
</div>
</div>
);
})}
{/* Spacer for scrolling */}
<div
style={{
height: `${sortedTasks.length * 48}px`,
}}
/>
</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>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Gantt Chart component exports
*/
export { GanttChart } from "./GanttChart";
export type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types";
export { toGanttTask, toGanttTasks } from "./types";

View File

@@ -0,0 +1,204 @@
import { describe, it, expect } from "vitest";
import { toGanttTask, toGanttTasks } from "./types";
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
describe("Gantt Types Helpers", () => {
const baseDate = new Date("2026-02-01T00:00:00Z");
const createTask = (overrides: Partial<Task> = {}): Task => ({
id: "task-1",
workspaceId: "workspace-1",
title: "Sample Task",
description: null,
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-15T00:00:00Z"),
assigneeId: null,
creatorId: "user-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: baseDate,
updatedAt: baseDate,
...overrides,
});
describe("toGanttTask", () => {
it("should convert a Task with metadata.startDate to GanttTask", () => {
const task = createTask({
metadata: {
startDate: "2026-02-05",
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.startDate).toBeInstanceOf(Date);
expect(ganttTask?.endDate).toBeInstanceOf(Date);
expect(ganttTask?.startDate.getTime()).toBe(new Date("2026-02-05").getTime());
expect(ganttTask?.endDate.getTime()).toBe(new Date("2026-02-15").getTime());
});
it("should use createdAt as startDate if metadata.startDate is not provided", () => {
const task = createTask({
createdAt: new Date("2026-02-01"),
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.startDate.getTime()).toBe(new Date("2026-02-01").getTime());
});
it("should use current date as endDate if dueDate is null", () => {
const beforeConversion = Date.now();
const task = createTask({
dueDate: null,
metadata: {
startDate: "2026-02-01",
},
});
const ganttTask = toGanttTask(task);
const afterConversion = Date.now();
expect(ganttTask).not.toBeNull();
expect(ganttTask?.endDate.getTime()).toBeGreaterThanOrEqual(beforeConversion);
expect(ganttTask?.endDate.getTime()).toBeLessThanOrEqual(afterConversion);
});
it("should extract dependencies from metadata", () => {
const task = createTask({
metadata: {
startDate: "2026-02-01",
dependencies: ["task-a", "task-b"],
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.dependencies).toEqual(["task-a", "task-b"]);
});
it("should handle missing dependencies in metadata", () => {
const task = createTask({
metadata: {
startDate: "2026-02-01",
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.dependencies).toBeUndefined();
});
it("should handle non-array dependencies in metadata", () => {
const task = createTask({
metadata: {
startDate: "2026-02-01",
dependencies: "not-an-array",
},
dueDate: new Date("2026-02-15"),
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.dependencies).toBeUndefined();
});
it("should preserve all original task properties", () => {
const task = createTask({
id: "special-task",
title: "Special Task",
description: "This is special",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
metadata: {
startDate: "2026-02-01",
},
});
const ganttTask = toGanttTask(task);
expect(ganttTask).not.toBeNull();
expect(ganttTask?.id).toBe("special-task");
expect(ganttTask?.title).toBe("Special Task");
expect(ganttTask?.description).toBe("This is special");
expect(ganttTask?.status).toBe(TaskStatus.IN_PROGRESS);
expect(ganttTask?.priority).toBe(TaskPriority.HIGH);
});
});
describe("toGanttTasks", () => {
it("should convert multiple tasks to GanttTasks", () => {
const tasks = [
createTask({
id: "task-1",
metadata: { startDate: "2026-02-01" },
dueDate: new Date("2026-02-10"),
}),
createTask({
id: "task-2",
metadata: { startDate: "2026-02-11" },
dueDate: new Date("2026-02-20"),
}),
];
const ganttTasks = toGanttTasks(tasks);
expect(ganttTasks).toHaveLength(2);
expect(ganttTasks[0].id).toBe("task-1");
expect(ganttTasks[1].id).toBe("task-2");
});
it("should filter out tasks that cannot be converted", () => {
const tasks = [
createTask({
id: "task-1",
metadata: { startDate: "2026-02-01" },
dueDate: new Date("2026-02-10"),
}),
createTask({
id: "task-2",
metadata: { startDate: "2026-02-11" },
dueDate: new Date("2026-02-20"),
}),
];
const ganttTasks = toGanttTasks(tasks);
// All valid tasks should be converted
expect(ganttTasks).toHaveLength(2);
});
it("should handle empty array", () => {
const ganttTasks = toGanttTasks([]);
expect(ganttTasks).toEqual([]);
});
it("should maintain order of tasks", () => {
const tasks = [
createTask({ id: "first", metadata: { startDate: "2026-03-01" } }),
createTask({ id: "second", metadata: { startDate: "2026-02-01" } }),
createTask({ id: "third", metadata: { startDate: "2026-01-01" } }),
];
const ganttTasks = toGanttTasks(tasks);
expect(ganttTasks[0].id).toBe("first");
expect(ganttTasks[1].id).toBe("second");
expect(ganttTasks[2].id).toBe("third");
});
});
});

View File

@@ -0,0 +1,95 @@
/**
* Gantt chart types
* Extends base Task type with start/end dates for timeline visualization
*/
import type { Task, TaskStatus, TaskPriority } from "@mosaic/shared";
/**
* Extended task type for Gantt chart display
* Adds explicit start and end dates required for timeline visualization
*/
export interface GanttTask extends Task {
/** Start date for the task (required for Gantt visualization) */
startDate: Date;
/** End date for the task (maps to dueDate but explicit for clarity) */
endDate: Date;
/** Optional array of task IDs that this task depends on */
dependencies?: string[];
}
/**
* Position and dimensions for a Gantt bar in the timeline
*/
export interface GanttBarPosition {
/** Left offset from timeline start (in pixels or percentage) */
left: string;
/** Width of the bar (in pixels or percentage) */
width: string;
/** Top offset for vertical positioning */
top: number;
}
/**
* Date range for the entire Gantt chart timeline
*/
export interface TimelineRange {
/** Earliest date to display */
start: Date;
/** Latest date to display */
end: Date;
/** Total number of days in the range */
totalDays: number;
}
/**
* Props for the main GanttChart component
*/
export interface GanttChartProps {
/** Tasks to display in the Gantt chart */
tasks: GanttTask[];
/** Optional callback when a task bar is clicked */
onTaskClick?: (task: GanttTask) => void;
/** Optional height for the chart container */
height?: number;
/** Whether to show task dependencies (default: false) */
showDependencies?: boolean;
}
/**
* 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();
// Validate dates
if (!startDate || !endDate) {
return null;
}
return {
...task,
startDate,
endDate,
dependencies: Array.isArray(task.metadata?.dependencies)
? (task.metadata.dependencies as string[])
: undefined,
};
}
/**
* Helper to get all GanttTasks from an array of Tasks
* Filters out tasks that don't have valid date ranges
*/
export function toGanttTasks(tasks: Task[]): GanttTask[] {
return tasks
.map(toGanttTask)
.filter((task): task is GanttTask => task !== null);
}