- {rowGridLines}
-
-
- {visibleOpenDemands.map((alloc) => {
- const allocStart = new Date(alloc.startDate);
- const allocEnd = new Date(alloc.endDate);
-
- const isAllocDragged =
- allocDragState.isActive && allocDragState.allocationId === alloc.id;
- const dispStart =
- isAllocDragged && allocDragState.currentStartDate
- ? allocDragState.currentStartDate
- : allocStart;
- const dispEnd =
- isAllocDragged && allocDragState.currentEndDate
- ? allocDragState.currentEndDate
- : allocEnd;
-
- // Multi-drag visual offset
- const isMultiDragTarget =
- multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
- const multiDragPx = isMultiDragTarget
- ? multiSelectState.multiDragDaysDelta * CELL_WIDTH
- : 0;
- const multiDragMode = multiSelectState.multiDragMode;
-
- let left = toLeft(dispStart);
- let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
- let dragTransform: string | undefined;
-
- if (isAllocDragged) {
- const preview = applyPointerOffsetPreviewRect({
- left,
- width,
- mode: allocDragState.mode,
- pointerOffsetX: getDragPointerOffset(
- allocDragState.pointerDeltaX,
- allocDragState.daysDelta,
- CELL_WIDTH,
- ),
- minWidth: CELL_WIDTH,
- });
- left = preview.left;
- width = preview.width;
- dragTransform = preview.transform;
- }
-
- // Clamp negative left (bar starts before view) to avoid extending outside canvas.
- if (left < 0) {
- width += left;
- left = 0;
- }
- if (width <= 0 || left >= totalCanvasWidth) return null;
-
- if (isMultiDragTarget && multiDragMode === "resize-start") {
- left += multiDragPx;
- width = Math.max(CELL_WIDTH, width - multiDragPx);
- } else if (isMultiDragTarget && multiDragMode === "resize-end") {
- width = Math.max(CELL_WIDTH, width + multiDragPx);
- }
-
- const roleEntity = (
- alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
- ).roleEntity;
- const roleName =
- roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
- const roleColor = roleEntity?.color ?? "#f59e0b";
- const headcount = (alloc as { headcount?: number }).headcount ?? 1;
- const lane = laneMap.get(alloc.id) ?? 0;
- const top = 8 + lane * SUB_LANE_HEIGHT;
- const blockHeight = SUB_LANE_HEIGHT - 8;
-
- const HANDLE_W = width >= 48 ? 8 : 6;
-
- const allocInfo: AllocMouseDownInfo = {
- mode: "move",
- allocationId: alloc.id,
- mutationAllocationId: getPlanningEntryMutationId(alloc),
- projectId: alloc.projectId,
- projectName: alloc.project.name,
- resourceId: null,
- startDate: allocStart,
- endDate: allocEnd,
- };
-
- return (
-
{
- if (e.button === 2) e.stopPropagation();
- }}
- onContextMenu={(e) => {
- e.preventDefault();
- e.stopPropagation();
- if (suppressHoverInteractions) return;
- onAllocationContextMenu(
- { allocationId: alloc.id, projectId: alloc.projectId },
- e.clientX,
- e.clientY,
- );
- }}
- onMouseMove={(e) => {
- if (suppressHoverInteractions) return;
- onDemandHoverMove(e, alloc);
- }}
- onClick={(e) => {
- e.stopPropagation();
- if (suppressHoverInteractions) return;
- onOpenDemandClick(alloc, e.clientX, e.clientY);
- }}
- onKeyDown={(e) => {
- if (e.key !== "Enter" && e.key !== " ") {
- return;
- }
- e.preventDefault();
- e.stopPropagation();
- if (suppressHoverInteractions) return;
- const rect = e.currentTarget.getBoundingClientRect();
- onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2);
- }}
- role="button"
- tabIndex={0}
- aria-label={`Open demand details for ${roleName} on ${alloc.project.name}`}
- >
- {/* Left resize handle */}
-
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
- }}
- />
-
- {/* Center — move + click */}
-
{
- e.stopPropagation();
- onAllocMouseDown(e, allocInfo);
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, allocInfo);
- }}
- >
-
- {roleName}
- {headcount > 1 ? ` x${headcount}` : ""}
-
-
-
- {/* Right resize handle */}
-
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
- }}
- />
-
- );
- })}
-
-
- );
-}
-
-// ─── Project-view: per-resource utilisation band ────────────────────────────
-
-function renderProjectUtilOverlay(
- dayMetrics: ProjectDayMetric[],
- CELL_WIDTH: number,
- displayMode: string,
- heatmapScheme: string,
- totalCanvasWidth: number,
-) {
- if (dayMetrics.length === 0 || totalCanvasWidth <= 0) return null;
-
- const BAND_H = 7;
- const BAR_H = ROW_HEIGHT - BAND_H - 11;
- const useHeatmapColors = displayMode === "bar";
- const svgParts: string[] = [
- `
");
- const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`;
-
- return (
-
- );
-}
-
-// ─── Project-view: transparent drag handles ─────────────────────────────────
-
-function renderProjectDragHandles(
- allocs: TimelineAssignmentEntry[],
- 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,
-) {
- const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
- return allocs.map((alloc) => {
- const allocStart = new Date(alloc.startDate);
- const allocEnd = new Date(alloc.endDate);
-
- const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
- const dispStart =
- isAllocDragged && allocDragState.currentStartDate
- ? allocDragState.currentStartDate
- : allocStart;
- const dispEnd =
- isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
-
- let left = toLeft(dispStart);
- let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
- let dragTransform: string | undefined;
-
- if (isAllocDragged) {
- const preview = applyPointerOffsetPreviewRect({
- left,
- width,
- mode: allocDragState.mode,
- pointerOffsetX: getDragPointerOffset(
- allocDragState.pointerDeltaX,
- allocDragState.daysDelta,
- CELL_WIDTH,
- ),
- minWidth: CELL_WIDTH,
- });
- left = preview.left;
- width = preview.width;
- dragTransform = preview.transform;
- }
- if (width <= 0 || left >= totalCanvasWidth) return null;
-
- // Multi-drag visual offset
- const isMultiDragTarget =
- multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
- const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
- const multiDragMode = multiSelectState.multiDragMode;
-
- if (isMultiDragTarget && multiDragMode === "resize-start") {
- left += multiDragPx;
- width = Math.max(CELL_WIDTH, width - multiDragPx);
- } else if (isMultiDragTarget && multiDragMode === "resize-end") {
- width = Math.max(CELL_WIDTH, width + multiDragPx);
- }
-
- // Always show resize handles — for narrow bars, use overlapping handles
- const HANDLE_W = width >= 48 ? 8 : 6;
- 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,
- };
-
- return (
- {
- if (e.button === 2) e.stopPropagation();
- }}
- onContextMenu={(e) => {
- e.preventDefault();
- e.stopPropagation();
- if (suppressHoverInteractions) return;
- onAllocationContextMenu(
- {
- allocationId: getPlanningEntryMutationId(alloc),
- projectId: alloc.projectId,
- },
- e.clientX,
- e.clientY,
- );
- }}
- >
-
{
- e.stopPropagation();
- onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
- }}
- />
-
{
- e.stopPropagation();
- onAllocMouseDown(e, allocInfo);
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, allocInfo);
- }}
- >
- {hasRecurrence && width > 28 && (
-
- ↻
-
- )}
-
-
{
- e.stopPropagation();
- onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
- }}
- />
-
- );
- });
-}
-
export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
diff --git a/apps/web/src/components/timeline/timelineProjectRenderers.tsx b/apps/web/src/components/timeline/timelineProjectRenderers.tsx
new file mode 100644
index 0000000..646722a
--- /dev/null
+++ b/apps/web/src/components/timeline/timelineProjectRenderers.tsx
@@ -0,0 +1,550 @@
+import { clsx } from "clsx";
+import type { TimelineAssignmentEntry, TimelineDemandEntry } from "./TimelineContext.js";
+import { applyPointerOffsetPreviewRect, getDragPointerOffset } from "./allocationVisualState.js";
+import { heatmapColor } from "./heatmapUtils.js";
+import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
+import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
+import type { AllocDragState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
+import type { AllocMouseDownInfo } from "./TimelineResourcePanel.js";
+import type { OpenDemandRowLayout } from "./timelineProjectRows.js";
+import type { ProjectDayMetric } from "./timelineProjectMetrics.js";
+
+const SVG_XMLNS = "http://www.w3.org/2000/svg";
+
+// ─── Open-demand row ───────────────────────────────────────────────────────
+
+export function renderOpenDemandRow(
+ openDemandCount: number,
+ layout: OpenDemandRowLayout,
+ projectId: string,
+ CELL_WIDTH: number,
+ totalCanvasWidth: number,
+ toLeft: (d: Date) => number,
+ toWidth: (s: Date, e: Date) => number,
+ rowGridLines: React.ReactNode,
+ onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void,
+ 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,
+ onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
+ onClearHoverTooltips: () => void,
+ multiSelectState: MultiSelectState,
+ allocDragState: AllocDragState,
+ suppressHoverInteractions: boolean,
+) {
+ const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
+ const { visibleOpenDemands, laneMap, rowHeight } = layout;
+ if (visibleOpenDemands.length === 0) return null;
+
+ return (
+
+
+
+
+
+ ?
+
+
+
+ Open demand
+
+
+ {openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
+
+
+
+
+
+
+ {rowGridLines}
+
+
+ {visibleOpenDemands.map((alloc) => {
+ const allocStart = new Date(alloc.startDate);
+ const allocEnd = new Date(alloc.endDate);
+
+ const isAllocDragged =
+ allocDragState.isActive && allocDragState.allocationId === alloc.id;
+ const dispStart =
+ isAllocDragged && allocDragState.currentStartDate
+ ? allocDragState.currentStartDate
+ : allocStart;
+ const dispEnd =
+ isAllocDragged && allocDragState.currentEndDate
+ ? allocDragState.currentEndDate
+ : allocEnd;
+
+ // Multi-drag visual offset
+ const isMultiDragTarget =
+ multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
+ const multiDragPx = isMultiDragTarget
+ ? multiSelectState.multiDragDaysDelta * CELL_WIDTH
+ : 0;
+ const multiDragMode = multiSelectState.multiDragMode;
+
+ let left = toLeft(dispStart);
+ let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
+ let dragTransform: string | undefined;
+
+ if (isAllocDragged) {
+ const preview = applyPointerOffsetPreviewRect({
+ left,
+ width,
+ mode: allocDragState.mode,
+ pointerOffsetX: getDragPointerOffset(
+ allocDragState.pointerDeltaX,
+ allocDragState.daysDelta,
+ CELL_WIDTH,
+ ),
+ minWidth: CELL_WIDTH,
+ });
+ left = preview.left;
+ width = preview.width;
+ dragTransform = preview.transform;
+ }
+
+ // Clamp negative left (bar starts before view) to avoid extending outside canvas.
+ if (left < 0) {
+ width += left;
+ left = 0;
+ }
+ if (width <= 0 || left >= totalCanvasWidth) return null;
+
+ if (isMultiDragTarget && multiDragMode === "resize-start") {
+ left += multiDragPx;
+ width = Math.max(CELL_WIDTH, width - multiDragPx);
+ } else if (isMultiDragTarget && multiDragMode === "resize-end") {
+ width = Math.max(CELL_WIDTH, width + multiDragPx);
+ }
+
+ const roleEntity = (
+ alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
+ ).roleEntity;
+ const roleName =
+ roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
+ const roleColor = roleEntity?.color ?? "#f59e0b";
+ const headcount = (alloc as { headcount?: number }).headcount ?? 1;
+ const lane = laneMap.get(alloc.id) ?? 0;
+ const top = 8 + lane * SUB_LANE_HEIGHT;
+ const blockHeight = SUB_LANE_HEIGHT - 8;
+
+ const HANDLE_W = width >= 48 ? 8 : 6;
+
+ const allocInfo: AllocMouseDownInfo = {
+ mode: "move",
+ allocationId: alloc.id,
+ mutationAllocationId: getPlanningEntryMutationId(alloc),
+ projectId: alloc.projectId,
+ projectName: alloc.project.name,
+ resourceId: null,
+ startDate: allocStart,
+ endDate: allocEnd,
+ };
+
+ return (
+
{
+ if (e.button === 2) e.stopPropagation();
+ }}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (suppressHoverInteractions) return;
+ onAllocationContextMenu(
+ { allocationId: alloc.id, projectId: alloc.projectId },
+ e.clientX,
+ e.clientY,
+ );
+ }}
+ onMouseMove={(e) => {
+ if (suppressHoverInteractions) return;
+ onDemandHoverMove(e, alloc);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (suppressHoverInteractions) return;
+ onOpenDemandClick(alloc, e.clientX, e.clientY);
+ }}
+ onKeyDown={(e) => {
+ if (e.key !== "Enter" && e.key !== " ") {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ if (suppressHoverInteractions) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2);
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={`Open demand details for ${roleName} on ${alloc.project.name}`}
+ >
+ {/* Left resize handle */}
+
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
+ }}
+ />
+
+ {/* Center — move + click */}
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, allocInfo);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, allocInfo);
+ }}
+ >
+
+ {roleName}
+ {headcount > 1 ? ` x${headcount}` : ""}
+
+
+
+ {/* Right resize handle */}
+
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
+ }}
+ />
+
+ );
+ })}
+
+
+ );
+}
+
+// ─── Project-view: per-resource utilisation band ────────────────────────────
+
+export function renderProjectUtilOverlay(
+ dayMetrics: ProjectDayMetric[],
+ CELL_WIDTH: number,
+ displayMode: string,
+ heatmapScheme: string,
+ totalCanvasWidth: number,
+) {
+ if (dayMetrics.length === 0 || totalCanvasWidth <= 0) return null;
+
+ const BAND_H = 7;
+ const BAR_H = ROW_HEIGHT - BAND_H - 11;
+ const useHeatmapColors = displayMode === "bar";
+ const svgParts: string[] = [
+ `
");
+ const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`;
+
+ return (
+
+ );
+}
+
+// ─── Project-view: transparent drag handles ─────────────────────────────────
+
+export function renderProjectDragHandles(
+ allocs: TimelineAssignmentEntry[],
+ 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,
+) {
+ const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
+ return allocs.map((alloc) => {
+ const allocStart = new Date(alloc.startDate);
+ const allocEnd = new Date(alloc.endDate);
+
+ const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
+ const dispStart =
+ isAllocDragged && allocDragState.currentStartDate
+ ? allocDragState.currentStartDate
+ : allocStart;
+ const dispEnd =
+ isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
+
+ let left = toLeft(dispStart);
+ let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
+ let dragTransform: string | undefined;
+
+ if (isAllocDragged) {
+ const preview = applyPointerOffsetPreviewRect({
+ left,
+ width,
+ mode: allocDragState.mode,
+ pointerOffsetX: getDragPointerOffset(
+ allocDragState.pointerDeltaX,
+ allocDragState.daysDelta,
+ CELL_WIDTH,
+ ),
+ minWidth: CELL_WIDTH,
+ });
+ left = preview.left;
+ width = preview.width;
+ dragTransform = preview.transform;
+ }
+ if (width <= 0 || left >= totalCanvasWidth) return null;
+
+ // Multi-drag visual offset
+ const isMultiDragTarget =
+ multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
+ const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
+ const multiDragMode = multiSelectState.multiDragMode;
+
+ if (isMultiDragTarget && multiDragMode === "resize-start") {
+ left += multiDragPx;
+ width = Math.max(CELL_WIDTH, width - multiDragPx);
+ } else if (isMultiDragTarget && multiDragMode === "resize-end") {
+ width = Math.max(CELL_WIDTH, width + multiDragPx);
+ }
+
+ // Always show resize handles — for narrow bars, use overlapping handles
+ const HANDLE_W = width >= 48 ? 8 : 6;
+ 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,
+ };
+
+ return (
+ {
+ if (e.button === 2) e.stopPropagation();
+ }}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (suppressHoverInteractions) return;
+ onAllocationContextMenu(
+ {
+ allocationId: getPlanningEntryMutationId(alloc),
+ projectId: alloc.projectId,
+ },
+ e.clientX,
+ e.clientY,
+ );
+ }}
+ >
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
+ }}
+ />
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, allocInfo);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, allocInfo);
+ }}
+ >
+ {hasRecurrence && width > 28 && (
+
+ ↻
+
+ )}
+
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
+ }}
+ />
+
+ );
+ });
+}