feat(gantt-timeline): enhance Gantt chart with weekend highlighting and overdue indicators
This commit is contained in:
parent
fd92956e9d
commit
56cfeb53b3
1 changed files with 299 additions and 85 deletions
|
|
@ -7,11 +7,16 @@ import {
|
|||
startOfWeek,
|
||||
endOfWeek,
|
||||
format,
|
||||
isWithinInterval,
|
||||
isWeekend,
|
||||
isSameDay,
|
||||
max,
|
||||
min,
|
||||
} from "date-fns";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
|
|
@ -21,9 +26,12 @@ const STATUS_COLORS: Record<string, string> = {
|
|||
IN_REVIEW: "var(--status-in-review)",
|
||||
CHANGES_REQUESTED: "var(--status-in-review)",
|
||||
APPROVED: "var(--status-approved)",
|
||||
DELIVERED: "var(--status-approved)",
|
||||
SKIPPED: "var(--status-skipped)",
|
||||
};
|
||||
|
||||
const TERMINAL_STATUSES = ["APPROVED", "DELIVERED", "SKIPPED", "BLOCKED"];
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
status: string;
|
||||
|
|
@ -44,21 +52,25 @@ interface Deliverable {
|
|||
stages: Stage[];
|
||||
}
|
||||
|
||||
const DAY_WIDTH = 28;
|
||||
const ROW_HEIGHT = 36;
|
||||
const HEADER_HEIGHT = 48;
|
||||
const LABEL_WIDTH = 200;
|
||||
const DAY_WIDTH = 32;
|
||||
const ROW_HEIGHT = 40;
|
||||
const HEADER_TOP_HEIGHT = 24;
|
||||
const HEADER_BOTTOM_HEIGHT = 26;
|
||||
const HEADER_HEIGHT = HEADER_TOP_HEIGHT + HEADER_BOTTOM_HEIGHT;
|
||||
const LABEL_WIDTH = 220;
|
||||
|
||||
export function GanttTimeline({
|
||||
deliverables,
|
||||
}: {
|
||||
deliverables: Deliverable[];
|
||||
}) {
|
||||
const today = new Date();
|
||||
|
||||
// Calculate the date range
|
||||
const { rangeStart, rangeEnd, totalDays } = useMemo(() => {
|
||||
const now = new Date();
|
||||
let earliest = now;
|
||||
let latest = addDays(now, 30);
|
||||
let latest = addDays(now, 14);
|
||||
|
||||
for (const deliv of deliverables) {
|
||||
for (const stage of deliv.stages) {
|
||||
|
|
@ -77,14 +89,59 @@ export function GanttTimeline({
|
|||
}
|
||||
}
|
||||
|
||||
const rangeStart = startOfWeek(addDays(earliest, -7));
|
||||
const rangeEnd = endOfWeek(addDays(latest, 7));
|
||||
const rangeStart = startOfWeek(addDays(earliest, -3), { weekStartsOn: 1 });
|
||||
const rangeEnd = endOfWeek(addDays(latest, 7), { weekStartsOn: 1 });
|
||||
const totalDays = differenceInDays(rangeEnd, rangeStart) + 1;
|
||||
|
||||
return { rangeStart, rangeEnd, totalDays };
|
||||
}, [deliverables]);
|
||||
|
||||
// Build rows: one per deliverable, showing stage bars
|
||||
// Build day columns
|
||||
const days = useMemo(() => {
|
||||
const result: {
|
||||
date: Date;
|
||||
dayIndex: number;
|
||||
dayLabel: string;
|
||||
dayOfWeek: string;
|
||||
isWeekendDay: boolean;
|
||||
isToday: boolean;
|
||||
}[] = [];
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
const d = addDays(rangeStart, i);
|
||||
result.push({
|
||||
date: d,
|
||||
dayIndex: i,
|
||||
dayLabel: format(d, "d"),
|
||||
dayOfWeek: format(d, "EEEEE"), // M, T, W, T, F, S, S
|
||||
isWeekendDay: isWeekend(d),
|
||||
isToday: isSameDay(d, today),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [rangeStart, totalDays, today]);
|
||||
|
||||
// Build week headers (top tier)
|
||||
const weekHeaders = useMemo(() => {
|
||||
const result: { label: string; startIndex: number; span: number }[] = [];
|
||||
let currentWeekStart = startOfWeek(rangeStart, { weekStartsOn: 1 });
|
||||
while (currentWeekStart <= rangeEnd) {
|
||||
const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
|
||||
const startIdx = Math.max(0, differenceInDays(currentWeekStart, rangeStart));
|
||||
const endIdx = Math.min(totalDays - 1, differenceInDays(weekEnd, rangeStart));
|
||||
const span = endIdx - startIdx + 1;
|
||||
if (span > 0) {
|
||||
result.push({
|
||||
label: format(currentWeekStart, "MMM d"),
|
||||
startIndex: startIdx,
|
||||
span,
|
||||
});
|
||||
}
|
||||
currentWeekStart = addDays(currentWeekStart, 7);
|
||||
}
|
||||
return result;
|
||||
}, [rangeStart, rangeEnd, totalDays]);
|
||||
|
||||
// Build rows
|
||||
const rows = useMemo(() => {
|
||||
return deliverables.map((deliv) => {
|
||||
const sortedStages = [...deliv.stages].sort(
|
||||
|
|
@ -106,123 +163,280 @@ export function GanttTimeline({
|
|||
const left = differenceInDays(start, rangeStart);
|
||||
const width = Math.max(differenceInDays(end, start), 1);
|
||||
|
||||
return { stage, left, width };
|
||||
// Calculate days remaining for countdown
|
||||
const isActive = !TERMINAL_STATUSES.includes(stage.status);
|
||||
const dueDate = stage.dueDate ? new Date(stage.dueDate) : null;
|
||||
let daysRemaining: number | null = null;
|
||||
let isOverdue = false;
|
||||
|
||||
if (isActive && dueDate) {
|
||||
daysRemaining = differenceInDays(dueDate, today);
|
||||
isOverdue = daysRemaining < 0;
|
||||
}
|
||||
|
||||
return { stage, left, width, daysRemaining, isOverdue, isActive };
|
||||
});
|
||||
|
||||
return { deliv, bars };
|
||||
});
|
||||
}, [deliverables, rangeStart]);
|
||||
|
||||
// Week headers
|
||||
const weeks = useMemo(() => {
|
||||
const result: { date: Date; label: string; dayIndex: number }[] = [];
|
||||
let d = rangeStart;
|
||||
while (d <= rangeEnd) {
|
||||
result.push({
|
||||
date: d,
|
||||
label: format(d, "MMM d"),
|
||||
dayIndex: differenceInDays(d, rangeStart),
|
||||
});
|
||||
d = addDays(d, 7);
|
||||
}
|
||||
return result;
|
||||
}, [rangeStart, rangeEnd]);
|
||||
}, [deliverables, rangeStart, today]);
|
||||
|
||||
const todayIndex = differenceInDays(today, rangeStart);
|
||||
const chartWidth = totalDays * DAY_WIDTH;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto rounded-md border"
|
||||
className="overflow-auto rounded-lg border bg-[var(--card)]"
|
||||
role="region"
|
||||
aria-label="Gantt timeline chart"
|
||||
>
|
||||
<div className="flex" style={{ minWidth: LABEL_WIDTH + chartWidth }}>
|
||||
{/* Labels column */}
|
||||
{/* ─── Labels column ─────────────────────── */}
|
||||
<div
|
||||
className="shrink-0 border-r bg-[var(--muted)]"
|
||||
className="shrink-0 border-r bg-[var(--card)]"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
{/* Header spacer */}
|
||||
<div
|
||||
className="flex items-center border-b px-3 text-xs font-semibold uppercase text-[var(--muted-foreground)]"
|
||||
className="border-b bg-[var(--muted)]/50"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
Deliverable
|
||||
<div
|
||||
className="flex items-end px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
Deliverable
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row labels */}
|
||||
{rows.map(({ deliv }) => (
|
||||
<div
|
||||
key={deliv.id}
|
||||
className="flex items-center border-b px-3 text-sm"
|
||||
className="flex items-center border-b px-3"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<span className="truncate">{deliv.name}</span>
|
||||
<span className="text-xs font-medium truncate">{deliv.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="flex-1" style={{ width: chartWidth }}>
|
||||
{/* Week headers */}
|
||||
{/* ─── Chart area ────────────────────────── */}
|
||||
<div className="flex-1 relative" style={{ width: chartWidth }}>
|
||||
{/* Two-tier header */}
|
||||
<div
|
||||
className="relative flex border-b bg-[var(--muted)]"
|
||||
className="sticky top-0 z-20 border-b bg-[var(--muted)]/50"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
{weeks.map((week) => (
|
||||
<div
|
||||
key={week.dayIndex}
|
||||
className="absolute border-l text-xs text-[var(--muted-foreground)]"
|
||||
style={{
|
||||
left: week.dayIndex * DAY_WIDTH,
|
||||
width: 7 * DAY_WIDTH,
|
||||
height: HEADER_HEIGHT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 6,
|
||||
}}
|
||||
>
|
||||
{week.label}
|
||||
</div>
|
||||
))}
|
||||
{/* Top tier — week labels */}
|
||||
<div className="relative" style={{ height: HEADER_TOP_HEIGHT }}>
|
||||
{weekHeaders.map((wh) => (
|
||||
<div
|
||||
key={wh.startIndex}
|
||||
className="absolute flex items-center border-l border-r border-[var(--border)]/40 pl-1.5 text-[9px] font-semibold text-[var(--muted-foreground)]"
|
||||
style={{
|
||||
left: wh.startIndex * DAY_WIDTH,
|
||||
width: wh.span * DAY_WIDTH,
|
||||
height: HEADER_TOP_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{wh.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom tier — individual days */}
|
||||
<div className="relative" style={{ height: HEADER_BOTTOM_HEIGHT }}>
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.dayIndex}
|
||||
className={cn(
|
||||
"absolute flex flex-col items-center justify-center border-l text-[8px] leading-tight",
|
||||
day.isToday
|
||||
? "bg-[var(--accent)]/20 font-bold text-[var(--accent)]"
|
||||
: day.isWeekendDay
|
||||
? "bg-[var(--muted)]/80 text-[var(--muted-foreground)]/50"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
)}
|
||||
style={{
|
||||
left: day.dayIndex * DAY_WIDTH,
|
||||
width: DAY_WIDTH,
|
||||
height: HEADER_BOTTOM_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold">{day.dayLabel}</span>
|
||||
<span className="text-[7px] opacity-60">{day.dayOfWeek}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows with bars */}
|
||||
{/* Today column highlight */}
|
||||
{todayIndex >= 0 && todayIndex < totalDays && (
|
||||
<div
|
||||
className="absolute z-[5] top-0 bg-[var(--accent)]/8"
|
||||
style={{
|
||||
left: todayIndex * DAY_WIDTH,
|
||||
width: DAY_WIDTH,
|
||||
height: HEADER_HEIGHT + rows.length * ROW_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Today line */}
|
||||
{todayIndex >= 0 && todayIndex < totalDays && (
|
||||
<div
|
||||
className="absolute z-10 top-0 w-0.5 bg-[var(--accent)]"
|
||||
style={{
|
||||
left: todayIndex * DAY_WIDTH + DAY_WIDTH / 2,
|
||||
height: HEADER_HEIGHT + rows.length * ROW_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data rows */}
|
||||
{rows.map(({ deliv, bars }) => (
|
||||
<div
|
||||
key={deliv.id}
|
||||
className="relative border-b"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
{/* Today line */}
|
||||
<div
|
||||
className="absolute top-0 h-full w-px bg-[var(--accent)]/30"
|
||||
style={{
|
||||
left:
|
||||
differenceInDays(new Date(), rangeStart) * DAY_WIDTH,
|
||||
}}
|
||||
/>
|
||||
{/* Day gridlines + weekend shading */}
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.dayIndex}
|
||||
className={cn(
|
||||
"absolute top-0 h-full border-l border-[var(--border)]/15",
|
||||
day.isWeekendDay && "bg-[var(--muted)]/20"
|
||||
)}
|
||||
style={{
|
||||
left: day.dayIndex * DAY_WIDTH,
|
||||
width: DAY_WIDTH,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Stage bars */}
|
||||
{bars.map(({ stage, left, width }) => (
|
||||
<Tooltip key={stage.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="absolute top-1.5 h-5 rounded-sm opacity-85 transition-opacity hover:opacity-100"
|
||||
style={{
|
||||
left: left * DAY_WIDTH,
|
||||
width: width * DAY_WIDTH,
|
||||
backgroundColor:
|
||||
STATUS_COLORS[stage.status] ??
|
||||
"var(--status-not-started)",
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{stage.template.name}</p>
|
||||
<p className="text-xs opacity-70">
|
||||
{stage.status.replace(/_/g, " ")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{bars.map(({ stage, left, width, daysRemaining, isOverdue, isActive }) => {
|
||||
const barPixelWidth = width * DAY_WIDTH;
|
||||
|
||||
return (
|
||||
<Tooltip key={stage.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1.5 rounded-[3px] transition-opacity hover:opacity-100 flex items-center",
|
||||
isOverdue
|
||||
? "opacity-95 ring-1 ring-red-500/50 ring-offset-1 ring-offset-[var(--card)]"
|
||||
: "opacity-85"
|
||||
)}
|
||||
style={{
|
||||
left: left * DAY_WIDTH + 1,
|
||||
width: barPixelWidth - 2,
|
||||
height: ROW_HEIGHT - 12,
|
||||
backgroundColor:
|
||||
STATUS_COLORS[stage.status] ?? "var(--status-not-started)",
|
||||
backgroundImage: isOverdue
|
||||
? "repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(0,0,0,0.15) 3px, rgba(0,0,0,0.15) 6px)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{/* Stage name on bar (if wide enough) */}
|
||||
{barPixelWidth > 60 && (
|
||||
<span className="text-[8px] font-semibold text-white/90 pl-1.5 truncate">
|
||||
{stage.template.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Days remaining countdown badge */}
|
||||
{isActive && daysRemaining !== null && barPixelWidth > 35 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-2.5 -right-1 text-[7px] font-bold px-1 rounded-full leading-none py-0.5",
|
||||
isOverdue
|
||||
? "bg-red-500 text-white"
|
||||
: daysRemaining <= 2
|
||||
? "bg-amber-400 text-amber-900"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
)}
|
||||
>
|
||||
{isOverdue
|
||||
? `${Math.abs(daysRemaining)}d over`
|
||||
: daysRemaining === 0
|
||||
? "Due today"
|
||||
: `${daysRemaining}d`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-semibold">{stage.template.name}</p>
|
||||
<p className="text-xs">{stage.status.replace(/_/g, " ")}</p>
|
||||
{stage.startDate && (
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Started: {format(new Date(stage.startDate), "MMM d, yyyy")}
|
||||
</p>
|
||||
)}
|
||||
{stage.dueDate && (
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Due: {format(new Date(stage.dueDate), "MMM d, yyyy")}
|
||||
</p>
|
||||
)}
|
||||
{stage.completedDate && (
|
||||
<p className="text-[10px] text-emerald-400">
|
||||
Completed: {format(new Date(stage.completedDate), "MMM d, yyyy")}
|
||||
</p>
|
||||
)}
|
||||
{isActive && daysRemaining !== null && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium mt-0.5",
|
||||
isOverdue ? "text-red-400" : daysRemaining <= 2 ? "text-amber-400" : ""
|
||||
)}
|
||||
>
|
||||
{isOverdue
|
||||
? `⚠ ${Math.abs(daysRemaining)} days overdue`
|
||||
: daysRemaining === 0
|
||||
? "⏰ Due today!"
|
||||
: `${daysRemaining} days remaining`}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Deliverable due date diamond */}
|
||||
{deliv.dueDate && (() => {
|
||||
const dueDayIdx = differenceInDays(new Date(deliv.dueDate), rangeStart);
|
||||
const delivOverdue =
|
||||
new Date(deliv.dueDate) < today && deliv.status !== "APPROVED";
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-3 h-3 w-3 rotate-45 border z-[6]",
|
||||
delivOverdue
|
||||
? "bg-red-500 border-red-600"
|
||||
: "bg-amber-400 border-amber-500"
|
||||
)}
|
||||
style={{ left: dueDayIdx * DAY_WIDTH + DAY_WIDTH / 2 - 6 }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">Deliverable Due Date</p>
|
||||
<p className="text-xs">
|
||||
{format(new Date(deliv.dueDate), "EEEE, MMM d, yyyy")}
|
||||
</p>
|
||||
{delivOverdue && (
|
||||
<p className="text-xs text-red-400 font-medium">⚠ Overdue</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue