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();
});
});
});