diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index f4f1c5c..ca51952 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -1,34 +1,18 @@ "use client"; -import { MILLISECONDS_PER_DAY } from "@capakraken/shared"; import { clsx } from "clsx"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { - useTimelineContext, - type TimelineAssignmentEntry, -} from "./TimelineContext.js"; -import { - applyPointerOffsetPreviewRect, - applyVisualOverrides, - getDragPointerOffset, - type TimelineVisualOverrides, -} from "./allocationVisualState.js"; +import { useTimelineContext, type TimelineAssignmentEntry } from "./TimelineContext.js"; +import { applyVisualOverrides, type TimelineVisualOverrides } from "./allocationVisualState.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; -import { heatmapBgColor } from "./heatmapUtils.js"; -import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { TimelineTooltip, type HeatmapHoverData, type VacationHoverData, } from "./TimelineTooltip.js"; -import { - ROW_HEIGHT, - SUB_LANE_HEIGHT, - LABEL_WIDTH, -} from "./timelineConstants.js"; -import { getProjectColor } from "~/lib/project-colors.js"; +import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; import type { DragState, AllocDragState, @@ -36,7 +20,6 @@ import type { ShiftPreviewData, MultiSelectState, } from "~/hooks/useTimelineDrag.js"; -import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import { buildVacationBlocksByResource, renderVacationBlocks, @@ -50,15 +33,16 @@ import { updateTooltipPosition, } from "./timelineHover.js"; import { buildResourceHeatmapHover } from "./timelineHeatmap.js"; +import { buildResourceCapacitySeries } from "./timelineCapacity.js"; +import { isAllocationScheduledOnDate } from "./timelineAvailability.js"; import { - buildResourceCapacitySeries, - type ResourceCapacitySeries, -} from "./timelineCapacity.js"; -import { - buildAllocationWorkingDaySegments, - isAllocationScheduledOnDate, - toLocalDateKey, -} from "./timelineAvailability.js"; + renderAllocBlocksFromData, + renderLoadGraph, + renderHeatmapOverlay, + renderDailyBars, + type AllocBlockData, + type AllocMouseDownInfo, +} from "./timelineResourceRender.js"; // ─── Props ────────────────────────────────────────────────────────────────── @@ -81,7 +65,11 @@ interface TimelineResourcePanelProps { multiSelectState: MultiSelectState; optimisticAllocations: TimelineVisualOverrides; suppressHoverInteractions: boolean; - onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void; + onInlineEdit?: ( + allocationId: string, + initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, + barRect: DOMRect, + ) => void; /** Horizontal virtualization: current scroll offset and visible container width */ scrollLeft?: number; containerWidth?: number; @@ -95,20 +83,6 @@ interface TimelineResourcePanelProps { xToDate: (clientX: number, rect: DOMRect) => Date; } -export interface AllocMouseDownInfo { - mode: "move" | "resize-start" | "resize-end"; - allocationId: string; - mutationAllocationId: string; - projectId: string; - projectName: string; - resourceId: string | null; - startDate: Date; - endDate: Date; - allocationStartDate?: Date; - allocationEndDate?: Date; - scope?: "allocation" | "segment"; -} - export interface RowMouseDownInfo { resourceId: string; startDate: Date; @@ -223,7 +197,15 @@ function TimelineResourcePanelInner({ totalCanvasWidth, filters.showWeekends, ), - [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations, filters.showWeekends], + [ + vacationsByResource, + toLeft, + toWidth, + CELL_WIDTH, + totalCanvasWidth, + filters.showVacations, + filters.showWeekends, + ], ); // ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ── @@ -332,7 +314,14 @@ function TimelineResourcePanelInner({ // ─── Vacation hover ─────────────────────────────────────────────────────── const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { - updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8); + updateTooltipPosition( + vacationTooltipPosRef, + vacationTooltipRef, + e.clientX, + e.clientY, + 14, + -8, + ); scheduleVacationHoverUpdate({ frameRef: vacationHoverRafRef, hoveredKeyRef: hoveredVacationKeyRef, @@ -413,9 +402,10 @@ function TimelineResourcePanelInner({ // Utilization background tint — only highlight over-utilization const utilPct = utilizationByResource.get(resource.id) ?? 0; - const utilBg = utilPct > 100 - ? "rgba(254,202,202,0.18)" // red tint for over-utilized - : undefined; + const utilBg = + utilPct > 100 + ? "rgba(254,202,202,0.18)" // red tint for over-utilized + : undefined; return (
0 ? new Set(optimisticAllocations.keys()) : undefined, + optimisticAllocations.size > 0 + ? new Set(optimisticAllocations.keys()) + : undefined, )} {filters.showVacations && - renderVacationBlocks( - vacationBlocksByResource.get(resource.id) ?? [], - rowHeight, - )} + renderVacationBlocks(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)} {displayMode === "strip" && - renderLoadGraph( - allocs, - dates, - CELL_WIDTH, - resourceCapacityById.get(resource.id), - )} + renderLoadGraph(allocs, dates, CELL_WIDTH, resourceCapacityById.get(resource.id))} {displayMode === "heatmap" && renderHeatmapOverlay( allocs, @@ -597,674 +581,8 @@ function TimelineResourcePanelInner({ // ResourcePanelTooltips removed — now uses shared TimelineTooltip component -// ─── Helper types ─────────────────────────────────────────────────────────── - -interface AllocBlockData { - alloc: TimelineAssignmentEntry; - lane: number; -} - -// ─── Pure render functions (no hooks, extracted from TimelineView) ─────────── - -function renderAllocBlocksFromData( - blockData: AllocBlockData[], - _allocs: TimelineAssignmentEntry[], - dragState: DragState, - allocDragState: AllocDragState, - toLeft: (d: Date) => number, - toWidth: (s: Date, e: Date) => number, - CELL_WIDTH: number, - totalCanvasWidth: number, - onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, - onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, - onAllocationContextMenu: ( - info: { allocationId: string; projectId: string; contextDate?: Date }, - anchorX: number, - anchorY: number, - ) => void, - multiSelectState: MultiSelectState, - suppressHoverInteractions: boolean, - onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void, - scrollLeft = 0, - containerWidth = 1200, - pendingMutationIds?: ReadonlySet, -) { - const OVERSCAN_PX = 10 * CELL_WIDTH; - const visibleLeft = scrollLeft - OVERSCAN_PX; - const visibleRight = scrollLeft + containerWidth + OVERSCAN_PX; - const anyDragActive = dragState.isDragging || allocDragState.isActive; - const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); - - function toUtcDay(value: Date): Date { - return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate())); - } - - function addUtcDays(value: Date, days: number): Date { - const next = new Date(value); - next.setUTCDate(next.getUTCDate() + days); - return next; - } - - function resolveSegmentContextDate( - clientX: number, - rect: DOMRect, - segmentStart: Date, - segmentEnd: Date, - ): Date { - const start = toUtcDay(segmentStart); - const end = toUtcDay(segmentEnd); - const rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH); - const maxIndex = Math.max( - 0, - Math.round((end.getTime() - start.getTime()) / MILLISECONDS_PER_DAY), - ); - const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex); - return addUtcDays(start, dayIndex); - } - - function sameDate(a: Date | null, b: Date | null) { - return Boolean(a && b) && a!.getTime() === b!.getTime(); - } - - return blockData.flatMap(({ alloc, lane }) => { - const allocStart = toUtcDay(new Date(alloc.startDate)); - const allocEnd = toUtcDay(new Date(alloc.endDate)); - - const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId; - - let dispStart = allocStart; - let dispEnd = allocEnd; - if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) { - dispStart = dragState.currentStartDate; - dispEnd = dragState.currentEndDate; - } - - // Multi-drag offset: shift selected allocations visually during multi-drag - const isMultiDragTarget = - multiSelectState.isMultiDragging && - selectedAllocationSet.has(alloc.id); - const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; - const multiDragMode = multiSelectState.multiDragMode; - - const blockTop = 8 + lane * SUB_LANE_HEIGHT; - const blockHeight = SUB_LANE_HEIGHT - 8; - - const customColor = (alloc.project as { color?: string | null }).color; - const projectColor = getProjectColor(alloc.projectId); - const blockBgColor = customColor ?? projectColor.hex + "B3"; - const hasRecurrence = !!(alloc.metadata as Record | null)?.recurrence; - - const allocInfo: AllocMouseDownInfo = { - mode: "move", - allocationId: alloc.id, - mutationAllocationId: getPlanningEntryMutationId(alloc), - projectId: alloc.projectId, - projectName: alloc.project.name, - resourceId: alloc.resourceId, - startDate: allocStart, - endDate: allocEnd, - allocationStartDate: allocStart, - allocationEndDate: allocEnd, - scope: "allocation", - }; - - const segments = buildAllocationWorkingDaySegments( - { ...alloc, startDate: dispStart, endDate: dispEnd }, - dispStart, - dispEnd, - ); - - if (segments.length === 0) { - return []; - } - - return segments.flatMap((segment, segmentIndex) => { - const isFirstSegment = segmentIndex === 0; - const segmentKey = `${alloc.id}-${segmentIndex}`; - const draggedSegmentActive = - allocDragState.isActive && - allocDragState.allocationId === alloc.id && - sameDate(allocDragState.originalStartDate, toUtcDay(segment.start)) && - sameDate(allocDragState.originalEndDate, toUtcDay(segment.end)); - const isBeingDragged = isProjectShifted || draggedSegmentActive; - const isOtherDragged = anyDragActive && !isBeingDragged; - - let segmentLeft = toLeft(segment.start); - let segmentWidth = Math.max(CELL_WIDTH, toWidth(segment.start, segment.end)); - let dragTransform: string | undefined; - - if (isProjectShifted) { - const preview = applyPointerOffsetPreviewRect({ - left: segmentLeft, - width: segmentWidth, - mode: "move", - pointerOffsetX: getDragPointerOffset( - dragState.pointerDeltaX, - dragState.daysDelta, - CELL_WIDTH, - ), - minWidth: CELL_WIDTH, - }); - segmentLeft = preview.left; - segmentWidth = preview.width; - dragTransform = preview.transform; - } else if (draggedSegmentActive) { - const preview = applyPointerOffsetPreviewRect({ - left: segmentLeft, - width: segmentWidth, - mode: allocDragState.mode, - pointerOffsetX: getDragPointerOffset( - allocDragState.pointerDeltaX, - allocDragState.daysDelta, - CELL_WIDTH, - ), - minWidth: CELL_WIDTH, - }); - segmentLeft = preview.left; - segmentWidth = preview.width; - dragTransform = preview.transform; - } - - if (isMultiDragTarget && multiDragMode === "resize-start") { - segmentLeft += multiDragPx; - segmentWidth = Math.max(CELL_WIDTH, segmentWidth - multiDragPx); - } else if (isMultiDragTarget && multiDragMode === "resize-end") { - segmentWidth = Math.max(CELL_WIDTH, segmentWidth + multiDragPx); - } - - if (segmentWidth <= 0 || segmentLeft >= totalCanvasWidth) { - return []; - } - - // Horizontal virtualization: skip bars entirely outside the visible window - const effectiveRight = segmentLeft + segmentWidth; - if (effectiveRight < visibleLeft || segmentLeft > visibleRight) { - return []; - } - - const handleWidth = segmentWidth >= 48 ? 10 : 6; - const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4))); - const segmentInfo: AllocMouseDownInfo = { - ...allocInfo, - startDate: toUtcDay(segment.start), - endDate: toUtcDay(segment.end), - scope: "segment", - }; - - return ( -
{ - if (e.button === 2) e.stopPropagation(); - }} - onDoubleClick={(e) => { - if (suppressHoverInteractions || !onInlineEdit) return; - e.stopPropagation(); - const toUtcDate = (v: Date | string) => { - const d = new Date(v); - return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); - }; - onInlineEdit( - alloc.id, - { - startDate: toUtcDate(alloc.startDate), - endDate: toUtcDate(alloc.endDate), - hoursPerDay: alloc.hoursPerDay, - }, - e.currentTarget.getBoundingClientRect(), - ); - }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (suppressHoverInteractions) return; - onAllocationContextMenu( - { - allocationId: getPlanningEntryMutationId(alloc), - projectId: alloc.projectId, - contextDate: resolveSegmentContextDate( - e.clientX, - e.currentTarget.getBoundingClientRect(), - segment.start, - segment.end, - ), - }, - e.clientX, - e.clientY, - ); - }} - > -
{ - e.stopPropagation(); - onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" }); - }} - onTouchStart={(e) => { - e.stopPropagation(); - onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" }); - }} - > - {handleWidth >= 10 && ( -
-
-
-
- )} -
- -