diff --git a/src/components/roadmap/project-section.tsx b/src/components/roadmap/project-section.tsx new file mode 100644 index 0000000..6249eb0 --- /dev/null +++ b/src/components/roadmap/project-section.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useMemo } from "react"; +import type { ReactNode } from "react"; + +import { GanttBar } from "@/components/roadmap/gantt-bar"; +import { + datePosition, + monthTicks, + weekTicks, + type TimelineRange, +} from "@/lib/roadmap/timeline"; +import type { PlanCard, PlanSection } from "@/lib/roadmap/load"; + +const ROW_HEIGHT = 28; +const BAR_HEIGHT_CARD = 14; +const BAR_HEIGHT_SUBTASK = 10; +const HEADER_HEIGHT = 56; +const SUBTASK_INSET = 18; +const LABEL_WIDTH = 220; +const LABEL_PAD_X = 12; + +type Row = + | { kind: "card"; planCard: PlanCard } + | { kind: "subtask"; planCard: PlanCard; subtaskId: string } + | { kind: "undated"; planCard: PlanCard }; + +interface ProjectSectionProps { + section: PlanSection; + range: TimelineRange; +} + +export function ProjectSection({ section, range }: ProjectSectionProps) { + const { project, client, cards } = section; + + /** One flat array drives both sides of the chart so a label and its + * bar can never drift apart. */ + const rows = useMemo(() => { + const out: Row[] = []; + for (const planCard of cards) { + const isDated = Boolean(planCard.startDate || planCard.targetDate); + if (isDated) { + out.push({ kind: "card", planCard }); + for (const subtask of planCard.datedSubtasks) { + out.push({ kind: "subtask", planCard, subtaskId: subtask.id }); + } + } else { + out.push({ kind: "undated", planCard }); + } + } + return out; + }, [cards]); + + const months = monthTicks(range); + const weeks = weekTicks(range); + const today = new Date().toISOString().slice(0, 10); + const todayFraction = datePosition(today, range); + + const trackHeight = HEADER_HEIGHT + Math.max(1, rows.length) * ROW_HEIGHT + 12; + + const undatedCardCount = cards.filter( + (c) => !c.startDate && !c.targetDate + ).length; + const undatedSubtaskTotal = cards.reduce( + (n, c) => n + c.undatedSubtaskCount, + 0 + ); + + return ( +
+ {/* Section header */} +
+

+ {project.name} +

+

+ {client.name} · {cards.length} card{cards.length === 1 ? "" : "s"} +

+
+ +
+
+ {/* Label column */} +
+
+ {rows.map((row, idx) => ( +
+ {labelFor(row)} +
+ ))} +
+ + {/* Timeline track */} +
+ {/* Month + week header */} +
+ {months.map((tick) => ( + + {tick.label} + + ))} + {weeks.map((tick) => ( + + {weekLabel(tick.date)} + + ))} +
+ + {/* Today marker */} + {todayFraction > 0 && todayFraction < 1 && ( + <> +
+ + Today + + + )} + + {/* Rows — each row is its own absolutely-positioned band so the + bar and the matching label cell line up vertically. */} + {rows.map((row, idx) => { + const rowTop = HEADER_HEIGHT + idx * ROW_HEIGHT; + + if (row.kind === "undated") { + return ( +
+ — not scheduled — +
+ ); + } + if (row.kind === "subtask") { + const subtask = row.planCard.datedSubtasks.find( + (s) => s.id === row.subtaskId + ); + if (!subtask) return null; + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); + })} +
+
+
+ + {/* Footer */} +
+ + {undatedCardCount} card{undatedCardCount === 1 ? "" : "s"} not scheduled + {" · "} + {undatedSubtaskTotal} subtask{undatedSubtaskTotal === 1 ? "" : "s"}{" "} + not scheduled + +
+
+ ); +} + +function labelKey(row: Row, idx: number): string { + if (row.kind === "subtask") return `s-${row.planCard.id}-${row.subtaskId}-${idx}`; + if (row.kind === "undated") return `u-${row.planCard.id}-${idx}`; + return `c-${row.planCard.id}`; +} + +/** "W1..W4" labels: which week-of-month this Monday belongs to. */ +function weekLabel(isoDate: string): string { + if (!isoDate) return ""; + const day = Number(isoDate.slice(8, 10)); + const week = Math.ceil(day / 7); + return `W${Math.min(4, Math.max(1, week))}`; +} + +function labelFor(row: Row): ReactNode { + if (row.kind === "subtask") { + const subtask = row.planCard.datedSubtasks.find((s) => s.id === row.subtaskId); + return ( + + {subtask?.title ?? ""} + + ); + } + if (row.kind === "undated") { + return ( + {row.planCard.title} + ); + } + return ( + + + {row.planCard.title} + + {row.planCard.assignee && ( + + {row.planCard.assignee} + + )} + + ); +}