Files
stack/apps/web/src/components/gantt/GanttChart.tsx
Jason Woltje 16697bfb79 fix: address code review feedback
- Replace type assertions with type guards in types.ts (isDateString, isStringArray)
- Add useCallback for event handlers (handleTaskClick, handleKeyDown)
- Replace styled-jsx with CSS modules (gantt.module.css)
- Update tests to use CSS module class name patterns
2026-01-29 19:32:23 -06:00

428 lines
13 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, useCallback } from "react";
import styles from "./gantt.module.css";
/**
* Represents a dependency line between two tasks
*/
interface DependencyLine {
fromTaskId: string;
toTaskId: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}
/**
* 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 styles.rowCompleted;
case TaskStatus.IN_PROGRESS:
return styles.rowInProgress;
case TaskStatus.PAUSED:
return styles.rowPaused;
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;
}
/**
* Calculate dependency lines between tasks
*/
function calculateDependencyLines(
tasks: GanttTask[],
timelineRange: TimelineRange
): DependencyLine[] {
const lines: DependencyLine[] = [];
const taskIndexMap = new Map<string, number>();
// Build index map
tasks.forEach((task, index) => {
taskIndexMap.set(task.id, index);
});
const { start: rangeStart, totalDays } = timelineRange;
tasks.forEach((task, toIndex) => {
if (!task.dependencies || task.dependencies.length === 0) {
return;
}
task.dependencies.forEach((depId) => {
const fromIndex = taskIndexMap.get(depId);
if (fromIndex === undefined) {
return;
}
const fromTask = tasks[fromIndex];
// Calculate positions (as percentages)
const fromEndOffset = Math.max(
0,
(fromTask.endDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
);
const toStartOffset = Math.max(
0,
(task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
);
const fromX = (fromEndOffset / totalDays) * 100;
const toX = (toStartOffset / totalDays) * 100;
const fromY = fromIndex * 48 + 24; // Center of the row
const toY = toIndex * 48 + 24;
lines.push({
fromTaskId: depId,
toTaskId: task.id,
fromX,
fromY,
toX,
toY,
});
});
});
return lines;
}
/**
* 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]);
// Calculate dependency lines
const dependencyLines = useMemo(
() => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []),
[showDependencies, sortedTasks, timelineRange]
);
const handleTaskClick = useCallback(
(task: GanttTask) => (): void => {
if (onTaskClick) {
onTaskClick(task);
}
},
[onTaskClick]
);
const handleKeyDown = useCallback(
(task: GanttTask) => (e: React.KeyboardEvent<HTMLDivElement>): void => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onTaskClick) {
onTaskClick(task);
}
}
},
[onTaskClick]
);
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>
{/* Dependency lines SVG */}
{showDependencies && dependencyLines.length > 0 && (
<svg
className="gantt-dependencies absolute inset-0 pointer-events-none overflow-visible"
style={{ width: "100%", height: `${sortedTasks.length * 48}px` }}
aria-hidden="true"
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
</marker>
</defs>
{dependencyLines.map((line) => (
<path
key={`dep-${line.fromTaskId}-${line.toTaskId}`}
d={`M ${line.fromX}% ${line.fromY} C ${line.fromX + 2}% ${line.fromY}, ${line.toX - 2}% ${line.toY}, ${line.toX}% ${line.toY}`}
stroke="#6b7280"
strokeWidth="2"
fill="none"
markerEnd="url(#arrowhead)"
className="dependency-line"
/>
))}
</svg>
)}
{/* Task bars and milestones */}
{sortedTasks.map((task, index) => {
const position = calculateBarPosition(task, timelineRange, index);
const statusClass = getStatusClass(task.status);
// Render milestone as diamond shape
if (task.isMilestone === true) {
return (
<div
key={task.id}
role="button"
tabIndex={0}
aria-label={`Milestone: ${task.title}, on ${formatDate(task.startDate)}`}
className="gantt-milestone absolute cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500"
style={{
left: position.left,
top: `${position.top + 8}px`,
}}
onClick={handleTaskClick(task)}
onKeyDown={handleKeyDown(task)}
>
<div
className={`w-6 h-6 transform rotate-45 ${statusClass}`}
title={task.title}
/>
</div>
);
}
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>
</div>
);
}