- 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
300 lines
9.2 KiB
TypeScript
300 lines
9.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|