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:
299
apps/web/src/components/gantt/GanttChart.tsx
Normal file
299
apps/web/src/components/gantt/GanttChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user