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