feat: timeline UI overhaul with project/resource panel redesign, quick filters, and API improvements

Redesigned timeline project and resource panels with expanded detail views,
added quick filter toolbar, improved drag handling, and enhanced vacation/entitlement
router logic. Includes e2e test updates and minor API fixes.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-15 09:28:59 +01:00
parent fa2019f521
commit a83edb2f9d
23 changed files with 2464 additions and 734 deletions
@@ -248,19 +248,35 @@ function TimelineViewContent({
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const heatmapTooltipRef = useRef<HTMLDivElement>(null);
const vacationTooltipRef = useRef<HTMLDivElement>(null);
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting;
function openAllocationPopoverAt(
info: {
allocationId: string;
projectId: string;
},
anchorX: number,
anchorY: number,
) {
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
x: anchorX,
y: anchorY,
});
}
// Keep cellWidthRef in sync so the drag hook uses the correct value.
cellWidthRef.current = CELL_WIDTH;
// ─── Native mousemove listener — updates tooltips without React state ─────
useEffect(() => {
if (!hasActivePointerOverlay) return;
const el = canvasRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
@@ -279,18 +295,10 @@ function TimelineViewContent({
rangeHintRef.current.style.left = `${x + 12}px`;
rangeHintRef.current.style.top = `${y - 28}px`;
}
if (heatmapTooltipRef.current) {
heatmapTooltipRef.current.style.left = `${x + 16}px`;
heatmapTooltipRef.current.style.top = `${y - 52}px`;
}
if (vacationTooltipRef.current) {
vacationTooltipRef.current.style.left = `${x + 14}px`;
vacationTooltipRef.current.style.top = `${y - 8}px`;
}
};
el.addEventListener("mousemove", handler, { passive: true });
return () => el.removeEventListener("mousemove", handler);
}, [isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => {
@@ -336,6 +344,7 @@ function TimelineViewContent({
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasActivePointerOverlay) return;
onCanvasMouseMove(e);
};
@@ -396,10 +405,10 @@ function TimelineViewContent({
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
@@ -417,6 +426,7 @@ function TimelineViewContent({
onAllocTouchStart={onAllocTouchStart}
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -429,6 +439,7 @@ function TimelineViewContent({
{viewMode === "project" && (
<TimelineProjectPanel
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
@@ -440,6 +451,7 @@ function TimelineViewContent({
onRowTouchStart={onRowTouchStart}
onOpenPanel={setOpenPanelProjectId}
onOpenDemandClick={setOpenDemandToAssign}
onAllocationContextMenu={openAllocationPopoverAt}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}