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 && (
-
- )}
-
-
-
-
-
{
- e.stopPropagation();
- onAllocMouseDown(e, segmentInfo);
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, segmentInfo);
- }}
- >
- {hasRecurrence && segmentWidth > 28 && (
- ↻
- )}
- {isFirstSegment && segmentWidth > 60 ? (
- {alloc.project.name}
- ) : isFirstSegment ? (
- {alloc.project.shortCode}
- ) : null}
- {isFirstSegment && segmentWidth > 130 && (
- {alloc.role}
- )}
- {isFirstSegment && segmentWidth > 190 && (
- {alloc.hoursPerDay}h
- )}
-
-
-
{
- e.stopPropagation();
- onAllocMouseDown(e, { ...segmentInfo, mode: "resize-end" });
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, { ...segmentInfo, mode: "resize-end" });
- }}
- >
- {handleWidth >= 10 && (
-
- )}
-
-
- );
- });
- });
-}
-
-// ─── 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 (
-
- {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 (
-
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 }}
- />
- );
- })}
-
- );
-}
-
-// ─── 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 (
-
- );
- });
-}
-
-// ─── 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 (
-
{
- 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 && (
-
{
- 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);
- }}
- />
- {isLastDay && EDGE_W > 0 && (
-
{
- e.stopPropagation();
- onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
- }}
- onTouchStart={(e) => {
- e.stopPropagation();
- onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
- }}
- />
- )}
-
- );
- });
-
- if (isOver) {
- segs.push(
-
,
- );
- }
-
- 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";
diff --git a/apps/web/src/components/timeline/timelineResourceRender.tsx b/apps/web/src/components/timeline/timelineResourceRender.tsx
new file mode 100644
index 0000000..062bc55
--- /dev/null
+++ b/apps/web/src/components/timeline/timelineResourceRender.tsx
@@ -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
,
+) {
+ 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 && (
+
+ )}
+
+
+
+
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, segmentInfo);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, segmentInfo);
+ }}
+ >
+ {hasRecurrence && segmentWidth > 28 && (
+ ↻
+ )}
+ {isFirstSegment && segmentWidth > 60 ? (
+ {alloc.project.name}
+ ) : isFirstSegment ? (
+
+ {alloc.project.shortCode}
+
+ ) : null}
+ {isFirstSegment && segmentWidth > 130 && (
+ {alloc.role}
+ )}
+ {isFirstSegment && segmentWidth > 190 && (
+ {alloc.hoursPerDay}h
+ )}
+
+
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, { ...segmentInfo, mode: "resize-end" });
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, { ...segmentInfo, mode: "resize-end" });
+ }}
+ >
+ {handleWidth >= 10 && (
+
+ )}
+
+
+ );
+ });
+ });
+}
+
+// ─── 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 (
+
+ {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 (
+
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 }}
+ />
+ );
+ })}
+
+ );
+}
+
+// ─── 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 (
+
+ );
+ });
+}
+
+// ─── 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 (
+
{
+ 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 && (
+
{
+ 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);
+ }}
+ />
+ {isLastDay && EDGE_W > 0 && (
+
{
+ e.stopPropagation();
+ onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
+ }}
+ />
+ )}
+
+ );
+ });
+
+ if (isOver) {
+ segs.push(
+
,
+ );
+ }
+
+ return segs.filter(Boolean);
+ });
+}