resource-portal / frontend /src /components /projects /ProjectCalendarTrack.tsx
Gowrisankar
Add Hours/Days effort toggle and simplify project timeline row.
b00cff7
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}`}
/>
);
}