refactor(web): extract 4 pure render functions from TimelineResourcePanel

Move renderAllocBlocksFromData, renderLoadGraph, renderHeatmapOverlay,
renderDailyBars into timelineResourceRender.tsx (707 lines).

TimelineResourcePanel reduced from 1,270 to 589 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 22:57:19 +02:00
parent 9bd7172018
commit 6830bfb314
2 changed files with 745 additions and 727 deletions
@@ -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,7 +402,8 @@ function TimelineResourcePanelInner({
// Utilization background tint — only highlight over-utilization
const utilPct = utilizationByResource.get(resource.id) ?? 0;
const utilBg = utilPct > 100
const utilBg =
utilPct > 100
? "rgba(254,202,202,0.18)" // red tint for over-utilized
: undefined;
@@ -518,20 +508,14 @@ function TimelineResourcePanelInner({
onInlineEdit,
scrollLeft,
containerWidth,
optimisticAllocations.size > 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<string>,
) {
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<string, unknown> | 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 (
<div
key={segmentKey}
data-allocation-id={alloc.id}
data-timeline-entry-type="allocation"
data-timeline-drag-preview="project-shift allocation"
data-timeline-project-id={alloc.projectId}
data-allocation-segment-index={segmentIndex}
data-allocation-segment-start={toLocalDateKey(segment.start)}
data-allocation-segment-end={toLocalDateKey(segment.end)}
className={clsx(
"absolute text-white group/block",
hasRecurrence && "opacity-80",
isBeingDragged
? "opacity-90 z-20"
: isOtherDragged
? "opacity-30 z-[10]"
: "transition-[opacity] duration-75 z-[10]",
selectedAllocationSet.has(alloc.id) && "z-20",
pendingMutationIds?.has(alloc.id) && !isBeingDragged && "animate-pulse",
)}
style={{
left: segmentLeft + 2,
width: segmentWidth - 4,
top: blockTop,
height: blockHeight,
...((multiDragPx && multiDragMode === "move") || dragTransform
? {
transform: [
dragTransform,
multiDragPx && multiDragMode === "move"
? `translateX(${multiDragPx}px)`
: null,
]
.filter(Boolean)
.join(" "),
}
: {}),
}}
onMouseDown={(e) => {
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,
);
}}
>
<div
data-allocation-handle="start"
className="absolute inset-y-0 left-0 z-30 flex items-center justify-center cursor-ew-resize rounded-l-md hover:bg-black/15 transition-colors"
style={{ width: handleWidth }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" });
}}
>
{handleWidth >= 10 && (
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
)}
</div>
<div
aria-hidden="true"
className={clsx(
"pointer-events-none absolute inset-0 z-0 rounded-md overflow-hidden",
hasRecurrence && "border-2 border-dashed border-white/60",
isBeingDragged
? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1",
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1",
)}
style={{
backgroundColor: blockBgColor,
}}
/>
<div
data-allocation-interaction="body"
className={clsx(
"absolute inset-y-0 z-20 flex min-w-0 select-none items-center gap-1 px-1 pointer-events-auto",
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
)}
style={{ left: dragInset, right: dragInset }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, segmentInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, segmentInfo);
}}
>
{hasRecurrence && segmentWidth > 28 && (
<span className="text-[10px] opacity-80 flex-shrink-0"></span>
)}
{isFirstSegment && segmentWidth > 60 ? (
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
) : isFirstSegment ? (
<span className="text-[9px] font-bold truncate opacity-90">{alloc.project.shortCode}</span>
) : null}
{isFirstSegment && segmentWidth > 130 && (
<span className="text-[10px] opacity-75 truncate">{alloc.role}</span>
)}
{isFirstSegment && segmentWidth > 190 && (
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
)}
</div>
<div
data-allocation-handle="end"
className="absolute inset-y-0 right-0 z-30 flex items-center justify-center cursor-ew-resize rounded-r-md hover:bg-black/15 transition-colors"
style={{ width: handleWidth }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-end" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-end" });
}}
>
{handleWidth >= 10 && (
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
)}
</div>
</div>
);
});
});
}
// ─── Strip-mode: daily load graph ────────────────────────────────────────────
function renderLoadGraph(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
capacity: ResourceCapacitySeries | undefined,
) {
const GRAPH_H = 12;
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
const currentDate = new Date(t);
return list.reduce((sum, a) => {
return isAllocationScheduledOnDate(a, currentDate) ? sum + a.hoursPerDay : sum;
}, 0);
}
return (
<div className="absolute inset-x-0 bottom-1 pointer-events-none" style={{ height: GRAPH_H }}>
{dates.map((date, i) => {
const t = date.getTime();
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
const totalH = hoursOnDay(allocs, t) * bookingFactor;
if (totalH === 0) return null;
const totalBarH =
capacityHours > 0
? Math.min(GRAPH_H, Math.round((Math.min(totalH, capacityHours) / capacityHours) * GRAPH_H))
: GRAPH_H;
const utilizationPct = capacityHours > 0 ? (totalH / capacityHours) * 100 : 100;
return (
<div
key={i}
className={clsx(
"absolute bottom-0 rounded-t-sm",
utilizationPct > 100
? "bg-red-500 opacity-80"
: utilizationPct > 75
? "bg-amber-400 opacity-80"
: "bg-brand-500 opacity-80",
)}
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }}
/>
);
})}
</div>
);
}
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
function renderHeatmapOverlay(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
heatmapScheme: HeatmapColorScheme,
capacity: ResourceCapacitySeries | undefined,
) {
return dates.map((date, i) => {
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
const totalH = allocs.reduce((sum, a) => {
return isAllocationScheduledOnDate(a, date) ? sum + a.hoursPerDay * bookingFactor : sum;
}, 0);
const pct = capacityHours > 0 ? (totalH / capacityHours) * 100 : totalH > 0 ? 100 : 0;
const bg = heatmapBgColor(pct, heatmapScheme);
if (!bg) return null;
return (
<div
key={`hm-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH, backgroundColor: bg }}
/>
);
});
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars(
allocs: TimelineAssignmentEntry[],
rowHeight: number,
CELL_WIDTH: number,
dates: Date[],
allocDragState: AllocDragState,
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,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
capacity: ResourceCapacitySeries | undefined,
multiSelectState: MultiSelectState,
suppressHoverInteractions: boolean,
) {
const BAR_AREA = rowHeight - 8;
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
return dates.flatMap((date, i) => {
const dayTimestamp = date.getTime();
const covering = allocs.filter((a) => {
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
return isAllocationScheduledOnDate(
{
...a,
startDate:
isDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: a.startDate,
endDate:
isDragged && allocDragState.currentEndDate
? allocDragState.currentEndDate
: a.endDate,
},
date,
);
});
if (covering.length === 0) return [];
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay * bookingFactor, 0);
const isOver = totalH > capacityHours;
let stackedH = 0;
const segs: React.ReactNode[] = covering.map((alloc) => {
const customColor = (alloc.project as { color?: string | null }).color;
const projectColor = getProjectColor(alloc.projectId);
const segBgColor = customColor ?? projectColor.hex + "B3";
const effectiveHours = alloc.hoursPerDay * bookingFactor;
if (effectiveHours <= 0 || capacityHours <= 0) {
return null;
}
const segH = Math.max(
2,
Math.min(
BAR_AREA - stackedH,
Math.round(
((capacityHours > 0 ? effectiveHours / capacityHours : effectiveHours > 0 ? 1 : 0) *
BAR_AREA),
),
),
);
const bottom = 4 + stackedH;
stackedH += segH;
const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart = new Date(
isBeingDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: alloc.startDate,
);
const dispEnd = new Date(
isBeingDragged && allocDragState.currentEndDate
? allocDragState.currentEndDate
: alloc.endDate,
);
dispStart.setHours(0, 0, 0, 0);
dispEnd.setHours(0, 0, 0, 0);
const isFirstDay = dayTimestamp === dispStart.getTime();
const isLastDay = dayTimestamp === dispEnd.getTime();
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
const dragPointerOffset = isBeingDragged
? getDragPointerOffset(
allocDragState.pointerDeltaX,
allocDragState.daysDelta,
CELL_WIDTH,
)
: 0;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: alloc.resourceId,
startDate: new Date(alloc.startDate),
endDate: new Date(alloc.endDate),
};
return (
<div
key={`bar-${i}-${alloc.id}`}
data-allocation-id={alloc.id}
data-timeline-entry-type="allocation"
data-timeline-drag-preview="allocation"
data-timeline-project-id={alloc.projectId}
className={clsx(
"absolute rounded-sm flex items-stretch overflow-hidden",
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "transition-opacity duration-75 hover:opacity-80 z-[10]",
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{
left: i * CELL_WIDTH + 2,
width: CELL_WIDTH - 4,
height: segH,
bottom,
backgroundColor: segBgColor,
...((multiSelectState.isMultiDragging &&
selectedAllocationSet.has(alloc.id)) ||
dragPointerOffset
? {
transform: [
dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null,
multiSelectState.isMultiDragging &&
selectedAllocationSet.has(alloc.id)
? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)`
: null,
]
.filter(Boolean)
.join(" "),
}
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())),
},
e.clientX,
e.clientY,
);
}}
>
{isFirstDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
)}
<div
className={clsx("flex-1 min-w-0", "cursor-grab")}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, allocInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
}}
/>
{isLastDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
)}
</div>
);
});
if (isOver) {
segs.push(
<div
key={`bar-${i}-over`}
className="absolute bg-red-500/70 rounded-t-sm pointer-events-none z-30"
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, top: 4, height: 3 }}
/>,
);
}
return segs.filter(Boolean);
});
}
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
// ─── Re-export tooltip types for the parent ─────────────────────────────────
export type { AllocBlockData };
// ─── Re-export types for consumers ──────────────────────────────────────────
export type { AllocBlockData, AllocMouseDownInfo } from "./timelineResourceRender.js";
export type { VacationBlockInfo } from "./renderHelpers.js";
@@ -0,0 +1,700 @@
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { clsx } from "clsx";
import React from "react";
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
import { applyPointerOffsetPreviewRect, getDragPointerOffset } from "./allocationVisualState.js";
import { heatmapBgColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { SUB_LANE_HEIGHT } from "./timelineConstants.js";
import { getProjectColor } from "~/lib/project-colors.js";
import type { DragState, AllocDragState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
import type { ResourceCapacitySeries } from "./timelineCapacity.js";
import {
buildAllocationWorkingDaySegments,
isAllocationScheduledOnDate,
toLocalDateKey,
} from "./timelineAvailability.js";
// ─── Shared interaction types ────────────────────────────────────────────────
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";
}
// ─── Helper types ───────────────────────────────────────────────────────────
export interface AllocBlockData {
alloc: TimelineAssignmentEntry;
lane: number;
}
// ─── Pure render functions (no hooks, extracted from TimelineResourcePanel) ───
export 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<string>,
) {
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<string, unknown> | 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 (
<div
key={segmentKey}
data-allocation-id={alloc.id}
data-timeline-entry-type="allocation"
data-timeline-drag-preview="project-shift allocation"
data-timeline-project-id={alloc.projectId}
data-allocation-segment-index={segmentIndex}
data-allocation-segment-start={toLocalDateKey(segment.start)}
data-allocation-segment-end={toLocalDateKey(segment.end)}
className={clsx(
"absolute text-white group/block",
hasRecurrence && "opacity-80",
isBeingDragged
? "opacity-90 z-20"
: isOtherDragged
? "opacity-30 z-[10]"
: "transition-[opacity] duration-75 z-[10]",
selectedAllocationSet.has(alloc.id) && "z-20",
pendingMutationIds?.has(alloc.id) && !isBeingDragged && "animate-pulse",
)}
style={{
left: segmentLeft + 2,
width: segmentWidth - 4,
top: blockTop,
height: blockHeight,
...((multiDragPx && multiDragMode === "move") || dragTransform
? {
transform: [
dragTransform,
multiDragPx && multiDragMode === "move" ? `translateX(${multiDragPx}px)` : null,
]
.filter(Boolean)
.join(" "),
}
: {}),
}}
onMouseDown={(e) => {
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,
);
}}
>
<div
data-allocation-handle="start"
className="absolute inset-y-0 left-0 z-30 flex items-center justify-center cursor-ew-resize rounded-l-md hover:bg-black/15 transition-colors"
style={{ width: handleWidth }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" });
}}
>
{handleWidth >= 10 && (
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
)}
</div>
<div
aria-hidden="true"
className={clsx(
"pointer-events-none absolute inset-0 z-0 rounded-md overflow-hidden",
hasRecurrence && "border-2 border-dashed border-white/60",
isBeingDragged
? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1",
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1",
)}
style={{
backgroundColor: blockBgColor,
}}
/>
<div
data-allocation-interaction="body"
className={clsx(
"absolute inset-y-0 z-20 flex min-w-0 select-none items-center gap-1 px-1 pointer-events-auto",
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
)}
style={{ left: dragInset, right: dragInset }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, segmentInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, segmentInfo);
}}
>
{hasRecurrence && segmentWidth > 28 && (
<span className="text-[10px] opacity-80 flex-shrink-0"></span>
)}
{isFirstSegment && segmentWidth > 60 ? (
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
) : isFirstSegment ? (
<span className="text-[9px] font-bold truncate opacity-90">
{alloc.project.shortCode}
</span>
) : null}
{isFirstSegment && segmentWidth > 130 && (
<span className="text-[10px] opacity-75 truncate">{alloc.role}</span>
)}
{isFirstSegment && segmentWidth > 190 && (
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
)}
</div>
<div
data-allocation-handle="end"
className="absolute inset-y-0 right-0 z-30 flex items-center justify-center cursor-ew-resize rounded-r-md hover:bg-black/15 transition-colors"
style={{ width: handleWidth }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-end" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-end" });
}}
>
{handleWidth >= 10 && (
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
)}
</div>
</div>
);
});
});
}
// ─── Strip-mode: daily load graph ────────────────────────────────────────────
export function renderLoadGraph(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
capacity: ResourceCapacitySeries | undefined,
) {
const GRAPH_H = 12;
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
const currentDate = new Date(t);
return list.reduce((sum, a) => {
return isAllocationScheduledOnDate(a, currentDate) ? sum + a.hoursPerDay : sum;
}, 0);
}
return (
<div className="absolute inset-x-0 bottom-1 pointer-events-none" style={{ height: GRAPH_H }}>
{dates.map((date, i) => {
const t = date.getTime();
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
const totalH = hoursOnDay(allocs, t) * bookingFactor;
if (totalH === 0) return null;
const totalBarH =
capacityHours > 0
? Math.min(
GRAPH_H,
Math.round((Math.min(totalH, capacityHours) / capacityHours) * GRAPH_H),
)
: GRAPH_H;
const utilizationPct = capacityHours > 0 ? (totalH / capacityHours) * 100 : 100;
return (
<div
key={i}
className={clsx(
"absolute bottom-0 rounded-t-sm",
utilizationPct > 100
? "bg-red-500 opacity-80"
: utilizationPct > 75
? "bg-amber-400 opacity-80"
: "bg-brand-500 opacity-80",
)}
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }}
/>
);
})}
</div>
);
}
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
export function renderHeatmapOverlay(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
heatmapScheme: HeatmapColorScheme,
capacity: ResourceCapacitySeries | undefined,
) {
return dates.map((date, i) => {
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
const totalH = allocs.reduce((sum, a) => {
return isAllocationScheduledOnDate(a, date) ? sum + a.hoursPerDay * bookingFactor : sum;
}, 0);
const pct = capacityHours > 0 ? (totalH / capacityHours) * 100 : totalH > 0 ? 100 : 0;
const bg = heatmapBgColor(pct, heatmapScheme);
if (!bg) return null;
return (
<div
key={`hm-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH, backgroundColor: bg }}
/>
);
});
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
export function renderDailyBars(
allocs: TimelineAssignmentEntry[],
rowHeight: number,
CELL_WIDTH: number,
dates: Date[],
allocDragState: AllocDragState,
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,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
capacity: ResourceCapacitySeries | undefined,
multiSelectState: MultiSelectState,
suppressHoverInteractions: boolean,
) {
const BAR_AREA = rowHeight - 8;
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
return dates.flatMap((date, i) => {
const dayTimestamp = date.getTime();
const covering = allocs.filter((a) => {
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
return isAllocationScheduledOnDate(
{
...a,
startDate:
isDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: a.startDate,
endDate:
isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate,
},
date,
);
});
if (covering.length === 0) return [];
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay * bookingFactor, 0);
const isOver = totalH > capacityHours;
let stackedH = 0;
const segs: React.ReactNode[] = covering.map((alloc) => {
const customColor = (alloc.project as { color?: string | null }).color;
const projectColor = getProjectColor(alloc.projectId);
const segBgColor = customColor ?? projectColor.hex + "B3";
const effectiveHours = alloc.hoursPerDay * bookingFactor;
if (effectiveHours <= 0 || capacityHours <= 0) {
return null;
}
const segH = Math.max(
2,
Math.min(
BAR_AREA - stackedH,
Math.round(
(capacityHours > 0 ? effectiveHours / capacityHours : effectiveHours > 0 ? 1 : 0) *
BAR_AREA,
),
),
);
const bottom = 4 + stackedH;
stackedH += segH;
const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart = new Date(
isBeingDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: alloc.startDate,
);
const dispEnd = new Date(
isBeingDragged && allocDragState.currentEndDate
? allocDragState.currentEndDate
: alloc.endDate,
);
dispStart.setHours(0, 0, 0, 0);
dispEnd.setHours(0, 0, 0, 0);
const isFirstDay = dayTimestamp === dispStart.getTime();
const isLastDay = dayTimestamp === dispEnd.getTime();
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
const dragPointerOffset = isBeingDragged
? getDragPointerOffset(allocDragState.pointerDeltaX, allocDragState.daysDelta, CELL_WIDTH)
: 0;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: alloc.resourceId,
startDate: new Date(alloc.startDate),
endDate: new Date(alloc.endDate),
};
return (
<div
key={`bar-${i}-${alloc.id}`}
data-allocation-id={alloc.id}
data-timeline-entry-type="allocation"
data-timeline-drag-preview="allocation"
data-timeline-project-id={alloc.projectId}
className={clsx(
"absolute rounded-sm flex items-stretch overflow-hidden",
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "transition-opacity duration-75 hover:opacity-80 z-[10]",
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{
left: i * CELL_WIDTH + 2,
width: CELL_WIDTH - 4,
height: segH,
bottom,
backgroundColor: segBgColor,
...((multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id)) ||
dragPointerOffset
? {
transform: [
dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null,
multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id)
? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)`
: null,
]
.filter(Boolean)
.join(" "),
}
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
contextDate: new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
),
},
e.clientX,
e.clientY,
);
}}
>
{isFirstDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
)}
<div
className={clsx("flex-1 min-w-0", "cursor-grab")}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, allocInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
}}
/>
{isLastDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
)}
</div>
);
});
if (isOver) {
segs.push(
<div
key={`bar-${i}-over`}
className="absolute bg-red-500/70 rounded-t-sm pointer-events-none z-30"
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, top: 4, height: 3 }}
/>,
);
}
return segs.filter(Boolean);
});
}