import { format } from "date-fns"; import { useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { buildAllocationBarSegment, dateStringsFromDayIndices, dayIndexFromPointer, projectBarStyle, } from "../../planner/allocationTimeline"; import type { Milestone, Project, ProjectPhase } from "../../types"; import { MilestoneAddPopover } from "./MilestoneAddPopover"; import { DEFAULT_PHASE_COLOR, PhaseCreatePopover, phasePopoverAnchorFromTrack, } from "./PhaseCreatePopover"; type TrackMode = "project-line" | "phases"; interface ProjectCalendarTrackProps { mode: TrackMode; project: Project; dayDates: Date[]; showTentative: boolean; /** When false, the row shows milestones only (schedule dates live in the project drawer). */ showScheduleSpan?: boolean; enableMilestones?: boolean; enablePhaseDrag?: boolean; onCreateMilestone: (dueDate: string, name: string, note?: string) => void; onCreatePhase: (input: { name: string; start_date: string; end_date: string; color?: string; }) => void; isSaving?: boolean; } export function ProjectCalendarTrack({ mode, project, dayDates, showTentative, showScheduleSpan = false, enableMilestones = false, enablePhaseDrag = false, onCreateMilestone, onCreatePhase, isSaving = false, }: ProjectCalendarTrackProps) { const trackRef = useRef(null); const dragRangeRef = useRef<{ start: number; end: number } | null>(null); const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null); const [popover, setPopover] = useState< | { type: "milestone"; dueDate: string; anchor: { top: number; left: number } } | { type: "phase"; startDate: string; endDate: string; anchor: { top: number; left: number } } | null >(null); const [phasePreviewColor] = useState(DEFAULT_PHASE_COLOR); const [hoveredDayIndex, setHoveredDayIndex] = useState(null); const totalDays = dayDates.length; const lineSegment = mode === "project-line" ? buildAllocationBarSegment(dayDates, project.start_date, project.end_date) : null; const previewSegment = dragRange && totalDays > 0 ? { startIndex: Math.min(dragRange.start, dragRange.end), endIndex: Math.max(dragRange.start, dragRange.end), } : null; const syncDragRange = (range: { start: number; end: number } | null) => { dragRangeRef.current = range; setDragRange(range); }; const openMilestoneAt = (index: number, clientX: number, clientY: number) => { const dueDate = format(dayDates[index], "yyyy-MM-dd"); setPopover({ type: "milestone", dueDate, anchor: { top: clientY + 8, left: clientX - 140 } }); }; const finishPhaseDrag = useCallback(() => { const range = dragRangeRef.current; const track = trackRef.current; if (!range || !track || totalDays <= 0) { syncDragRange(null); return; } const { start_date, end_date } = dateStringsFromDayIndices(dayDates, range.start, range.end); const anchor = phasePopoverAnchorFromTrack( track.getBoundingClientRect(), range.start, range.end, totalDays, ); syncDragRange(null); setPopover({ type: "phase", startDate: start_date, endDate: end_date, anchor }); }, [dayDates, totalDays]); const onPointerDown = (event: React.PointerEvent) => { if (!trackRef.current || event.button !== 0 || !enablePhaseDrag) return; if ((event.target as HTMLElement).closest(".milestone-pin, .phase-bar")) return; const index = dayIndexFromPointer(event.clientX, trackRef.current.getBoundingClientRect(), totalDays); syncDragRange({ start: index, end: index }); trackRef.current.setPointerCapture(event.pointerId); event.preventDefault(); }; const onPointerMove = (event: React.PointerEvent) => { if (!dragRangeRef.current || !trackRef.current) return; const index = dayIndexFromPointer(event.clientX, trackRef.current.getBoundingClientRect(), totalDays); syncDragRange({ start: dragRangeRef.current.start, end: index }); }; const onPointerUp = (event: React.PointerEvent) => { if (!trackRef.current?.hasPointerCapture(event.pointerId)) return; trackRef.current.releasePointerCapture(event.pointerId); if (enablePhaseDrag) finishPhaseDrag(); }; const onPointerCancel = (event: React.PointerEvent) => { if (!trackRef.current?.hasPointerCapture(event.pointerId)) return; trackRef.current.releasePointerCapture(event.pointerId); syncDragRange(null); }; const phases = project.phases ?? []; const milestones = project.milestones ?? []; const trackClassName = [ "planner-timeline", "planner-calendar-track", `planner-calendar-${mode}`, enablePhaseDrag ? "planner-drag-timeline" : "", dragRange ? "is-dragging" : "", ] .filter(Boolean) .join(" "); return ( <>
{mode !== "project-line" ?
: null} {enablePhaseDrag ? dayDates.map((day, index) => (
)) : null} {enableMilestones && mode === "project-line" ? dayDates.map((day, index) => (
setHoveredDayIndex(index)} onMouseLeave={() => setHoveredDayIndex((prev) => (prev === index ? null : prev))} style={{ left: `${(index / totalDays) * 100}%`, width: `${(1 / totalDays) * 100}%`, }} >
)) : null} {showScheduleSpan && lineSegment && mode === "project-line" && (!project.is_tentative || showTentative) ? (
) : null} {mode === "phases" ? phases.map((phase) => ( )) : null} {previewSegment && enablePhaseDrag ? (
Phase
) : null} {enableMilestones && mode === "project-line" ? milestones.map((milestone) => ( )) : null}
{popover?.type === "milestone" ? createPortal( setPopover(null)} onCreate={(name, note) => { onCreateMilestone(popover.dueDate, name, note); setPopover(null); }} />, document.body, ) : null} {popover?.type === "phase" ? createPortal( setPopover(null)} onSave={(input) => { onCreatePhase(input); setPopover(null); }} startDate={popover.startDate} />, document.body, ) : null} ); } function PhaseBar({ phase, dayDates, totalDays, }: { phase: ProjectPhase; dayDates: Date[]; totalDays: number; }) { const segment = buildAllocationBarSegment(dayDates, phase.start_date, phase.end_date); if (!segment) return null; return (
event.stopPropagation()} style={{ ...projectBarStyle(segment, totalDays), background: phase.color, }} title={phase.name} > {phase.name}
); } function MilestonePin({ milestone, dayDates, totalDays, }: { milestone: Milestone; dayDates: Date[]; totalDays: number; }) { const index = dayDates.findIndex((day) => format(day, "yyyy-MM-dd") === milestone.due_date); if (index < 0) return null; const left = ((index + 0.5) / totalDays) * 100; return ( ); }