Spaces:
Running
Running
| 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<HTMLDivElement>(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<number | null>(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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| if (!trackRef.current?.hasPointerCapture(event.pointerId)) return; | |
| trackRef.current.releasePointerCapture(event.pointerId); | |
| if (enablePhaseDrag) finishPhaseDrag(); | |
| }; | |
| const onPointerCancel = (event: React.PointerEvent<HTMLDivElement>) => { | |
| 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 ( | |
| <> | |
| <div | |
| className={trackClassName} | |
| onPointerCancel={enablePhaseDrag ? onPointerCancel : undefined} | |
| onPointerDown={enablePhaseDrag ? onPointerDown : undefined} | |
| onPointerMove={enablePhaseDrag ? onPointerMove : undefined} | |
| onPointerUp={enablePhaseDrag ? onPointerUp : undefined} | |
| ref={trackRef} | |
| style={enablePhaseDrag ? ({ "--planner-cols": totalDays } as React.CSSProperties) : undefined} | |
| > | |
| {mode !== "project-line" ? <div className="planner-day-grid" /> : null} | |
| {enablePhaseDrag | |
| ? dayDates.map((day, index) => ( | |
| <div | |
| className={`planner-day-hit${format(day, "yyyy-MM-dd") === format(new Date(), "yyyy-MM-dd") ? " is-today" : ""}`} | |
| key={day.toISOString()} | |
| style={{ | |
| left: `${(index / totalDays) * 100}%`, | |
| width: `${(1 / totalDays) * 100}%`, | |
| }} | |
| /> | |
| )) | |
| : null} | |
| {enableMilestones && mode === "project-line" | |
| ? dayDates.map((day, index) => ( | |
| <div | |
| className={`planner-milestone-day${hoveredDayIndex === index ? " is-hovered" : ""}`} | |
| key={day.toISOString()} | |
| onMouseEnter={() => setHoveredDayIndex(index)} | |
| onMouseLeave={() => setHoveredDayIndex((prev) => (prev === index ? null : prev))} | |
| style={{ | |
| left: `${(index / totalDays) * 100}%`, | |
| width: `${(1 / totalDays) * 100}%`, | |
| }} | |
| > | |
| <button | |
| aria-label={`Add milestone on ${format(day, "d MMM")}`} | |
| className="milestone-add-button" | |
| onClick={(event) => { | |
| event.stopPropagation(); | |
| const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); | |
| openMilestoneAt(index, rect.left + rect.width / 2, rect.bottom); | |
| }} | |
| type="button" | |
| > | |
| + | |
| </button> | |
| </div> | |
| )) | |
| : null} | |
| {showScheduleSpan && | |
| lineSegment && | |
| mode === "project-line" && | |
| (!project.is_tentative || showTentative) ? ( | |
| <div | |
| className={`project-span-line${project.is_tentative ? " tentative" : ""}`} | |
| style={{ | |
| ...projectBarStyle( | |
| { | |
| startIndex: lineSegment.startIndex, | |
| endIndex: lineSegment.endIndex, | |
| className: "project-span-line", | |
| label: "", | |
| }, | |
| totalDays, | |
| ), | |
| background: project.color, | |
| }} | |
| title={project.name} | |
| /> | |
| ) : null} | |
| {mode === "phases" | |
| ? phases.map((phase) => ( | |
| <PhaseBar key={phase.id} dayDates={dayDates} phase={phase} totalDays={totalDays} /> | |
| )) | |
| : null} | |
| {previewSegment && enablePhaseDrag ? ( | |
| <div | |
| className="phase-bar preview" | |
| style={{ | |
| ...projectBarStyle( | |
| { | |
| startIndex: previewSegment.startIndex, | |
| endIndex: previewSegment.endIndex, | |
| className: "phase-bar", | |
| label: "Phase", | |
| }, | |
| totalDays, | |
| ), | |
| background: phasePreviewColor, | |
| }} | |
| > | |
| Phase | |
| </div> | |
| ) : null} | |
| {enableMilestones && mode === "project-line" | |
| ? milestones.map((milestone) => ( | |
| <MilestonePin dayDates={dayDates} key={milestone.id} milestone={milestone} totalDays={totalDays} /> | |
| )) | |
| : null} | |
| </div> | |
| {popover?.type === "milestone" | |
| ? createPortal( | |
| <MilestoneAddPopover | |
| anchor={popover.anchor} | |
| dueDate={popover.dueDate} | |
| isSaving={isSaving} | |
| onCancel={() => setPopover(null)} | |
| onCreate={(name, note) => { | |
| onCreateMilestone(popover.dueDate, name, note); | |
| setPopover(null); | |
| }} | |
| />, | |
| document.body, | |
| ) | |
| : null} | |
| {popover?.type === "phase" | |
| ? createPortal( | |
| <PhaseCreatePopover | |
| anchor={popover.anchor} | |
| endDate={popover.endDate} | |
| isSaving={isSaving} | |
| onCancel={() => 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 ( | |
| <div | |
| className="phase-bar" | |
| onPointerDown={(event) => event.stopPropagation()} | |
| style={{ | |
| ...projectBarStyle(segment, totalDays), | |
| background: phase.color, | |
| }} | |
| title={phase.name} | |
| > | |
| {phase.name} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <span | |
| className={`milestone-pin${milestone.is_completed ? " completed" : ""}`} | |
| style={{ left: `${left}%` }} | |
| title={`${milestone.name} · ${milestone.due_date}`} | |
| /> | |
| ); | |
| } | |