feat(gantt-timeline): enhance Gantt chart with weekend highlighting and overdue indicators

This commit is contained in:
Leivur Djurhuus 2026-03-03 16:01:37 -06:00
parent fd92956e9d
commit 56cfeb53b3

View file

@ -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>