From 56cfeb53b3f8b71990d9a7dfc45d343f02007e8e Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Tue, 3 Mar 2026 16:01:37 -0600 Subject: [PATCH] feat(gantt-timeline): enhance Gantt chart with weekend highlighting and overdue indicators --- src/components/views/gantt-timeline.tsx | 384 ++++++++++++++++++------ 1 file changed, 299 insertions(+), 85 deletions(-) diff --git a/src/components/views/gantt-timeline.tsx b/src/components/views/gantt-timeline.tsx index faad510..a92a886 100644 --- a/src/components/views/gantt-timeline.tsx +++ b/src/components/views/gantt-timeline.tsx @@ -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 = { @@ -21,9 +26,12 @@ const STATUS_COLORS: Record = { 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 (
- {/* Labels column */} + {/* ─── Labels column ─────────────────────── */}
+ {/* Header spacer */}
- Deliverable +
+ Deliverable +
+ + {/* Row labels */} {rows.map(({ deliv }) => (
- {deliv.name} + {deliv.name}
))}
- {/* Chart area */} -
- {/* Week headers */} + {/* ─── Chart area ────────────────────────── */} +
+ {/* Two-tier header */}
- {weeks.map((week) => ( -
- {week.label} -
- ))} + {/* Top tier — week labels */} +
+ {weekHeaders.map((wh) => ( +
+ {wh.label} +
+ ))} +
+ + {/* Bottom tier — individual days */} +
+ {days.map((day) => ( +
+ {day.dayLabel} + {day.dayOfWeek} +
+ ))} +
- {/* Rows with bars */} + {/* Today column highlight */} + {todayIndex >= 0 && todayIndex < totalDays && ( +
+ )} + + {/* Today line */} + {todayIndex >= 0 && todayIndex < totalDays && ( +
+ )} + + {/* Data rows */} {rows.map(({ deliv, bars }) => (
- {/* Today line */} -
+ {/* Day gridlines + weekend shading */} + {days.map((day) => ( +
+ ))} {/* Stage bars */} - {bars.map(({ stage, left, width }) => ( - - -
- - -

{stage.template.name}

-

- {stage.status.replace(/_/g, " ")} -

-
- - ))} + {bars.map(({ stage, left, width, daysRemaining, isOverdue, isActive }) => { + const barPixelWidth = width * DAY_WIDTH; + + return ( + + +
+ {/* Stage name on bar (if wide enough) */} + {barPixelWidth > 60 && ( + + {stage.template.name} + + )} + + {/* Days remaining countdown badge */} + {isActive && daysRemaining !== null && barPixelWidth > 35 && ( + + {isOverdue + ? `${Math.abs(daysRemaining)}d over` + : daysRemaining === 0 + ? "Due today" + : `${daysRemaining}d`} + + )} +
+
+ +

{stage.template.name}

+

{stage.status.replace(/_/g, " ")}

+ {stage.startDate && ( +

+ Started: {format(new Date(stage.startDate), "MMM d, yyyy")} +

+ )} + {stage.dueDate && ( +

+ Due: {format(new Date(stage.dueDate), "MMM d, yyyy")} +

+ )} + {stage.completedDate && ( +

+ Completed: {format(new Date(stage.completedDate), "MMM d, yyyy")} +

+ )} + {isActive && daysRemaining !== null && ( +

+ {isOverdue + ? `⚠ ${Math.abs(daysRemaining)} days overdue` + : daysRemaining === 0 + ? "⏰ Due today!" + : `${daysRemaining} days remaining`} +

+ )} +
+
+ ); + })} + + {/* 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 ( + + +
+ + +

Deliverable Due Date

+

+ {format(new Date(deliv.dueDate), "EEEE, MMM d, yyyy")} +

+ {delivOverdue && ( +

⚠ Overdue

+ )} +
+ + ); + })()}
))}