feat(platform): checkpoint current implementation state

This commit is contained in:
2026-04-01 07:42:03 +02:00
parent 3e53471f05
commit 8c5be51251
125 changed files with 10269 additions and 17808 deletions
@@ -9,10 +9,21 @@ import {
type TimelineAssignmentEntry,
type TimelineDemandEntry,
} from "./TimelineContext.js";
import {
applyPointerOffsetPreviewRect,
applyVisualOverrides,
getDragPointerOffset,
type TimelineVisualOverrides,
} from "./allocationVisualState.js";
import { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
TimelineTooltip,
type DemandHoverData,
type HeatmapHoverData,
type VacationHoverData,
} from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
@@ -24,11 +35,31 @@ import { getProjectColor } from "~/lib/project-colors.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
import {
buildVacationBlocksByResource,
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
import {
buildDemandHoverData,
cancelHoverFrame,
collectResourcesWithVacations,
scheduleVacationHoverUpdate,
updateTooltipPosition,
} from "./timelineHover.js";
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
import {
buildProjectRowMetrics,
type ProjectDayMetric,
} from "./timelineProjectMetrics.js";
import {
buildProjectFlatRows,
estimateProjectRowHeight,
type OpenDemandRowLayout,
type ProjectFlatRow,
} from "./timelineProjectRows.js";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -46,11 +77,13 @@ interface TimelineProjectPanelProps {
onOpenPanel: (projectId: string) => void;
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void;
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
info: { allocationId: string; projectId: string; contextDate?: Date },
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
optimisticAllocations: TimelineVisualOverrides;
suppressHoverInteractions: boolean;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -82,57 +115,7 @@ export interface OpenDemandAssignment {
project?: { id: string; name: string; shortCode: string };
}
type HeatmapBreakdownEntry = {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
};
type HeatmapHoverState = {
date: Date;
totalH: number;
pct: number;
breakdown: HeatmapBreakdownEntry[];
};
type ProjectDayMetric = {
projH: number;
totalH: number;
};
type HeatmapBreakdownAccumulator = {
shortCode: string;
projectName: string;
orderType: string;
responsiblePerson: string | null;
hours: number;
};
type ProjectFlatRow =
| {
type: "header";
key: string;
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
}
| {
type: "open-demand";
key: string;
projectId: string;
openDemands: TimelineDemandEntry[];
}
| {
type: "resource";
key: string;
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
resource: NonNullable<
ReturnType<typeof useTimelineContext>["projectGroups"]
>[number]["resourceRows"][number]["resource"];
allocs: TimelineAssignmentEntry[];
metricsKey: string;
};
type HeatmapHoverState = HeatmapHoverData;
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
const SVG_XMLNS = "http://www.w3.org/2000/svg";
@@ -154,6 +137,8 @@ function TimelineProjectPanelInner({
onOpenDemandClick,
onAllocationContextMenu,
multiSelectState,
optimisticAllocations,
suppressHoverInteractions,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -175,6 +160,27 @@ function TimelineProjectPanelInner({
today,
} = useTimelineContext();
const visualAllocsByResource = useMemo(() => {
if (optimisticAllocations.size === 0) return allocsByResource;
const next = new Map<string, TimelineAssignmentEntry[]>();
for (const [resourceId, allocs] of allocsByResource) {
next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations));
}
return next;
}, [allocsByResource, optimisticAllocations]);
const visualProjectGroups = useMemo(
() => projectGroups.map((project) => ({
...project,
resourceRows: project.resourceRows.map((row) => ({
...row,
allocs: applyVisualOverrides(row.allocs, optimisticAllocations),
})),
})),
[projectGroups, optimisticAllocations],
);
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
const heatmapRafRef = useRef<number | null>(null);
const lastHeatmapDayRef = useRef<number>(-1);
@@ -193,239 +199,65 @@ function TimelineProjectPanelInner({
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
totalH: number;
pct: number;
breakdown: HeatmapBreakdownEntry[];
} | null>(null);
const [vacationHover, setVacationHover] = useState<null | {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
}>(null);
const [demandHover, setDemandHover] = useState<null | {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
}>(null);
const [heatmapHover, setHeatmapHover] = useState<HeatmapHoverState | null>(null);
const [vacationHover, setVacationHover] = useState<VacationHoverData | null>(null);
const [demandHover, setDemandHover] = useState<DemandHoverData | null>(null);
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map<number, number>();
dates.forEach((date, index) => {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
dateIndexByTime.set(normalized.getTime(), index);
});
const resourceCapacityById = useMemo(
() => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates),
[dates, vacationsByResource, visualAllocsByResource],
);
const nextHeatmapById = new Map<string, (HeatmapHoverState | null)[]>();
const nextTotalHoursById = new Map<string, number[]>();
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(
() => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById),
[dates, resourceCapacityById, visualAllocsByResource],
);
for (const [resourceId, allocs] of allocsByResource) {
if (allocs.length === 0) continue;
const totalHours = new Array<number>(dates.length).fill(0);
const breakdownMaps = Array.from({ length: dates.length }, () => new Map<string, HeatmapBreakdownAccumulator>());
for (const alloc of allocs) {
const current = new Date(alloc.startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(alloc.endDate);
end.setHours(0, 0, 0, 0);
while (current.getTime() <= end.getTime()) {
const dayIndex = dateIndexByTime.get(current.getTime());
if (dayIndex !== undefined) {
totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + alloc.hoursPerDay;
const dayBreakdown = breakdownMaps[dayIndex];
if (!dayBreakdown) {
current.setDate(current.getDate() + 1);
continue;
}
const existing = dayBreakdown.get(alloc.projectId);
if (existing) {
existing.hours += alloc.hoursPerDay;
} else {
dayBreakdown.set(alloc.projectId, {
shortCode: alloc.project.shortCode,
projectName: alloc.project.name,
orderType: alloc.project.orderType,
responsiblePerson:
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ??
null,
hours: alloc.hoursPerDay,
});
}
}
current.setDate(current.getDate() + 1);
}
}
nextTotalHoursById.set(resourceId, totalHours);
nextHeatmapById.set(
resourceId,
totalHours.map((totalH, dayIndex) => {
if (totalH === 0) return null;
const dayBreakdown = breakdownMaps[dayIndex];
if (!dayBreakdown) return null;
const breakdown: HeatmapBreakdownEntry[] = [...dayBreakdown.entries()]
.map(([projectId, value]) => ({
projectId,
shortCode: value.shortCode,
projectName: value.projectName,
orderType: value.orderType,
responsiblePerson: value.responsiblePerson,
hoursPerDay: value.hours,
}))
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
return {
date: dates[dayIndex] ?? new Date(),
totalH,
pct: (totalH / 8) * 100,
breakdown,
};
}),
);
}
return {
resourceHeatmapById: nextHeatmapById,
resourceTotalHoursById: nextTotalHoursById,
};
}, [allocsByResource, dates]);
const vacationBlocksByResource = useMemo(
() =>
buildVacationBlocksByResource(
vacationsByResource,
filters.showVacations,
toLeft,
toWidth,
CELL_WIDTH,
totalCanvasWidth,
),
[CELL_WIDTH, filters.showVacations, toLeft, toWidth, totalCanvasWidth, vacationsByResource],
);
const projectRowMetrics = useMemo(() => {
const dateIndexByTime = new Map<number, number>();
dates.forEach((date, index) => {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
dateIndexByTime.set(normalized.getTime(), index);
});
return buildProjectRowMetrics(
dates,
visualProjectGroups,
resourceTotalHoursById,
resourceCapacityById,
);
}, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]);
const nextMetrics = new Map<string, ProjectDayMetric[]>();
for (const project of projectGroups) {
for (const { resource, allocs } of project.resourceRows) {
const projectHours = new Array<number>(dates.length).fill(0);
for (const alloc of allocs) {
const current = new Date(alloc.startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(alloc.endDate);
end.setHours(0, 0, 0, 0);
while (current.getTime() <= end.getTime()) {
const dayIndex = dateIndexByTime.get(current.getTime());
if (dayIndex !== undefined) {
projectHours[dayIndex] = (projectHours[dayIndex] ?? 0) + alloc.hoursPerDay;
}
current.setDate(current.getDate() + 1);
}
}
const totalHours = resourceTotalHoursById.get(resource.id);
nextMetrics.set(
`${project.id}:${resource.id}`,
projectHours.map((projH, dayIndex) => ({
projH,
totalH: totalHours?.[dayIndex] ?? 0,
})),
);
}
}
return nextMetrics;
}, [dates, projectGroups, resourceTotalHoursById]);
const flatRows = useMemo(() => {
const rows: ProjectFlatRow[] = [];
for (const project of projectGroups) {
rows.push({ type: "header", key: `header-${project.id}`, project });
const openDemands = openDemandsByProject.get(project.id) ?? [];
if (openDemands.length > 0) {
rows.push({
type: "open-demand",
key: `open-demand-${project.id}`,
projectId: project.id,
openDemands,
});
}
for (const { resource, allocs } of project.resourceRows) {
rows.push({
type: "resource",
key: `${project.id}-${resource.id}`,
project,
resource,
allocs,
metricsKey: `${project.id}:${resource.id}`,
});
}
}
return rows;
}, [openDemandsByProject, projectGroups]);
const flatRows = useMemo(
() => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations),
[openDemandsByProject, optimisticAllocations, visualProjectGroups],
);
const rowVirtualizer = useVirtualizer({
count: flatRows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => {
const row = flatRows[index];
if (!row) return ROW_HEIGHT;
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
if (row.type === "open-demand") {
const laneCount = assignDemandLanes(row.openDemands).size > 0
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
: 1;
return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
}
return ROW_HEIGHT;
},
estimateSize: (index) => estimateProjectRowHeight(flatRows[index]),
overscan: 8,
getItemKey: (index) => flatRows[index]?.key ?? index,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalRowHeight = rowVirtualizer.getTotalSize();
const resourcesWithVacations = useMemo(() => {
const result = new Set<string>();
for (const [resourceId, vacations] of vacationsByResource) {
if (vacations.length > 0) {
result.add(resourceId);
}
}
return result;
}, [vacationsByResource]);
const resourcesWithVacations = useMemo(
() => collectResourcesWithVacations(vacationsByResource),
[vacationsByResource],
);
const handleRowHeatmapMove = useCallback(
(e: React.MouseEvent, resourceId: string) => {
heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 };
if (heatmapTooltipRef.current) {
heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`;
heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`;
}
updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52);
const rect = e.currentTarget.getBoundingClientRect();
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
@@ -477,52 +309,28 @@ function TimelineProjectPanelInner({
return;
}
vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 };
if (vacationTooltipRef.current) {
vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`;
vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`;
}
const rect = e.currentTarget.getBoundingClientRect();
const clientX = e.clientX;
if (vacationHoverRafRef.current !== null) return;
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const time = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
resourceVacations.find((vacation) => {
const start = new Date(vacation.startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(vacation.endDate);
end.setHours(0, 0, 0, 0);
return time >= start.getTime() && time <= end.getTime();
}) ?? null;
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
if (nextKey === hoveredVacationKeyRef.current) return;
hoveredVacationKeyRef.current = nextKey;
startTransition(() => {
setVacationHover(hit);
});
updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8);
scheduleVacationHoverUpdate({
frameRef: vacationHoverRafRef,
hoveredKeyRef: hoveredVacationKeyRef,
resourceId,
clientX: e.clientX,
rect: e.currentTarget.getBoundingClientRect(),
xToDate,
vacations: vacationsByResource.get(resourceId) ?? [],
onHoverChange: (hit) => {
startTransition(() => {
setVacationHover(hit);
});
},
});
},
[resourcesWithVacations, vacationsByResource, xToDate],
);
const clearHoverTooltips = useCallback(() => {
if (heatmapRafRef.current !== null) {
cancelAnimationFrame(heatmapRafRef.current);
heatmapRafRef.current = null;
}
if (vacationHoverRafRef.current !== null) {
cancelAnimationFrame(vacationHoverRafRef.current);
vacationHoverRafRef.current = null;
}
cancelHoverFrame(heatmapRafRef);
cancelHoverFrame(vacationHoverRafRef);
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
@@ -543,37 +351,10 @@ function TimelineProjectPanelInner({
const handleDemandHoverMove = useCallback(
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
if (demandTooltipRef.current) {
demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
}
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
startTransition(() => {
setDemandHover({
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
roleColor: demand.roleEntity?.color ?? "#f59e0b",
projectName: demand.project.name,
projectShortCode: demand.project.shortCode,
requestedHeadcount: demand.requestedHeadcount,
unfilledHeadcount: demand.unfilledHeadcount,
startDate: demand.startDate,
endDate: demand.endDate,
hoursPerDay: demand.hoursPerDay,
totalHours: demand.hoursPerDay * days,
percentage: demand.percentage,
status: demand.status,
...(demand.dailyCostCents > 0
? {
totalCostCents: demand.dailyCostCents * days,
dailyCostCents: demand.dailyCostCents,
}
: {}),
});
setDemandHover(buildDemandHoverData(demand));
});
},
[],
@@ -581,13 +362,18 @@ function TimelineProjectPanelInner({
useEffect(
() => () => {
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
cancelHoverFrame(heatmapRafRef);
cancelHoverFrame(vacationHoverRafRef);
},
[],
);
if (projectGroups.length === 0) {
useEffect(() => {
if (!suppressHoverInteractions) return;
clearHoverTooltips();
}, [clearHoverTooltips, suppressHoverInteractions]);
if (visualProjectGroups.length === 0) {
return (
<div className="text-center py-16 text-gray-400">
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
@@ -677,11 +463,14 @@ function TimelineProjectPanelInner({
{gridLines}
{projWidth > 0 && projLeft < totalCanvasWidth && (
<div
data-timeline-entry-type="project-bar"
data-timeline-drag-preview="project-shift"
data-timeline-project-id={project.id}
className={clsx(
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75 text-white",
"absolute rounded flex items-center px-2 gap-1.5 text-white",
isThisProjectShifting
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
: "cursor-grab transition-[opacity,box-shadow] duration-75 hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
)}
style={{
left: projLeft + 2,
@@ -689,18 +478,35 @@ function TimelineProjectPanelInner({
top: 8,
height: 24,
backgroundColor: customColor ?? projectColor.hex + "CC",
...(isThisProjectShifting
? {
transform: `translateX(${getDragPointerOffset(
dragState.pointerDeltaX,
dragState.daysDelta,
CELL_WIDTH,
)}px)`,
}
: {}),
}}
onClick={() => {
if (!dragState.isDragging) onOpenPanel(project.id);
}}
onMouseDown={(e) =>
onMouseDown={(e) => {
if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
if (!dragState.isDragging) {
onOpenPanel(project.id);
}
return;
}
onProjectBarMouseDown(e, {
projectId: project.id,
projectName: project.name,
startDate: project.startDate,
endDate: project.endDate,
})
}
});
}}
onTouchStart={(e) =>
onProjectBarTouchStart(e, {
projectId: project.id,
@@ -709,7 +515,13 @@ function TimelineProjectPanelInner({
endDate: project.endDate,
})
}
onContextMenu={(e) => e.preventDefault()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (!dragState.isDragging) {
onOpenPanel(project.id);
}
}}
>
<span className="text-xs font-semibold truncate">{project.name}</span>
</div>
@@ -720,7 +532,8 @@ function TimelineProjectPanelInner({
})()
) : row.type === "open-demand" ? (
renderOpenDemandRow(
row.openDemands,
row.openDemandCount,
row.layout,
row.projectId,
CELL_WIDTH,
totalCanvasWidth,
@@ -735,6 +548,7 @@ function TimelineProjectPanelInner({
clearHoverTooltips,
multiSelectState,
allocDragState,
suppressHoverInteractions,
)
) : (
<div
@@ -788,6 +602,7 @@ function TimelineProjectPanelInner({
});
}}
onMouseMove={(e) => {
if (suppressHoverInteractions) return;
handleRowHeatmapMove(e, row.resource.id);
handleRowVacationHover(e, row.resource.id);
}}
@@ -812,29 +627,20 @@ function TimelineProjectPanelInner({
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
suppressHoverInteractions,
)}
{filters.showVacations &&
renderVacationBlocks(
(vacationsByResource.get(row.resource.id) ?? []).reduce<VacationBlockInfo[]>(
(acc, v) => {
const vStart = new Date(v.startDate);
const vEnd = new Date(v.endDate);
const left = toLeft(vStart);
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
if (width > 0 && left < totalCanvasWidth) {
acc.push({ vacation: v, left, width });
}
return acc;
},
[],
),
vacationBlocksByResource.get(row.resource.id) ?? [],
ROW_HEIGHT,
)}
{blinkOverbookedDays &&
renderOverbookingBlink(
allocsByResource.get(row.resource.id) ?? [],
visualAllocsByResource.get(row.resource.id) ?? [],
dates,
CELL_WIDTH,
resourceCapacityById.get(row.resource.id)?.capacityHoursByDay,
resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay,
)}
{renderRangeOverlay(
rangeState,
@@ -870,41 +676,9 @@ function TimelineProjectPanelInner({
// ─── Pure render functions ──────────────────────────────────────────────────
/** Assign lane indices to demands so overlapping bars don't stack on top of each other. */
function assignDemandLanes(
demands: TimelineDemandEntry[],
): Map<string, number> {
const laneMap = new Map<string, number>();
// Each lane tracks the latest end-date occupying it
const laneEnds: Date[] = [];
// Sort by start date for greedy lane assignment
const sorted = [...demands].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
);
for (const d of sorted) {
const start = new Date(d.startDate);
let assigned = -1;
for (let i = 0; i < laneEnds.length; i++) {
if (laneEnds[i]! < start) {
assigned = i;
laneEnds[i] = new Date(d.endDate);
break;
}
}
if (assigned === -1) {
assigned = laneEnds.length;
laneEnds.push(new Date(d.endDate));
}
laneMap.set(d.id, assigned);
}
return laneMap;
}
function renderOpenDemandRow(
openDemands: TimelineDemandEntry[],
openDemandCount: number,
layout: OpenDemandRowLayout,
projectId: string,
CELL_WIDTH: number,
totalCanvasWidth: number,
@@ -915,7 +689,7 @@ function renderOpenDemandRow(
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
info: { allocationId: string; projectId: string; contextDate?: Date },
anchorX: number,
anchorY: number,
) => void,
@@ -923,12 +697,10 @@ function renderOpenDemandRow(
onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
suppressHoverInteractions: boolean,
) {
if (openDemands.length === 0) return null;
const laneMap = assignDemandLanes(openDemands);
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
const { visibleOpenDemands, laneMap, rowHeight } = layout;
if (visibleOpenDemands.length === 0) return null;
return (
<div
@@ -949,7 +721,7 @@ function renderOpenDemandRow(
<div className="min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
</div>
</div>
</div>
@@ -963,7 +735,7 @@ function renderOpenDemandRow(
{rowGridLines}
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
{openDemands.map((alloc) => {
{visibleOpenDemands.map((alloc) => {
const allocStart = new Date(alloc.startDate);
const allocEnd = new Date(alloc.endDate);
@@ -984,7 +756,26 @@ function renderOpenDemandRow(
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// Clamp negative left (bar starts before view) to avoid extending outside canvas
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;
@@ -1025,6 +816,10 @@ function renderOpenDemandRow(
return (
<div
key={alloc.id}
data-allocation-id={alloc.id}
data-timeline-entry-type="demand"
data-timeline-drag-preview="project-shift allocation"
data-timeline-project-id={alloc.projectId}
className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
isAllocDragged
@@ -1039,8 +834,14 @@ function renderOpenDemandRow(
height: blockHeight,
backgroundColor: `${roleColor}4D`,
border: `2px dashed ${roleColor}B3`,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
...((multiDragPx && multiDragMode === "move") || dragTransform
? {
transform: [dragTransform, multiDragPx && multiDragMode === "move"
? `translateX(${multiDragPx}px)`
: null]
.filter(Boolean)
.join(" "),
}
: {}),
}}
onMouseDown={(e) => {
@@ -1049,15 +850,20 @@ function renderOpenDemandRow(
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
e.clientY,
);
}}
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
onMouseMove={(e) => {
if (suppressHoverInteractions) return;
onDemandHoverMove(e, alloc);
}}
onClick={(e) => {
e.stopPropagation();
if (suppressHoverInteractions) return;
onOpenDemandClick(alloc, e.clientX, e.clientY);
}}
onKeyDown={(e) => {
@@ -1066,6 +872,7 @@ function renderOpenDemandRow(
}
e.preventDefault();
e.stopPropagation();
if (suppressHoverInteractions) return;
const rect = e.currentTarget.getBoundingClientRect();
onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2);
}}
@@ -1136,25 +943,24 @@ function renderProjectUtilOverlay(
const BAND_H = 7;
const BAR_H = ROW_HEIGHT - BAND_H - 11;
const REF_H = 8;
const useHeatmapColors = displayMode === "bar";
const svgParts: string[] = [
`<svg xmlns="${SVG_XMLNS}" width="${totalCanvasWidth}" height="${ROW_HEIGHT}" viewBox="0 0 ${totalCanvasWidth} ${ROW_HEIGHT}" preserveAspectRatio="none" shape-rendering="crispEdges">`,
];
dayMetrics.forEach(({ projH, totalH }, i) => {
if (totalH === 0 && projH === 0) return;
dayMetrics.forEach(({ projH, totalH, capacityH }, i) => {
if ((totalH === 0 && projH === 0) || capacityH <= 0) return;
const isOver = totalH > REF_H;
const isOver = totalH > capacityH;
const totalBarH = Math.max(
projH > 0 ? 2 : 0,
Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H),
Math.round((Math.min(totalH, capacityH) / capacityH) * BAR_H),
);
const projBarH =
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0;
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / capacityH) * BAR_H))) : 0;
const otherBarH = totalBarH - projBarH;
const projPct = (projH / REF_H) * 100;
const totalPct = (totalH / REF_H) * 100;
const projPct = (projH / capacityH) * 100;
const totalPct = (totalH / capacityH) * 100;
const projColor = useHeatmapColors
? heatmapColor(
projPct,
@@ -1229,11 +1035,12 @@ function renderProjectDragHandles(
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
info: { allocationId: string; projectId: string; contextDate?: Date },
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
suppressHoverInteractions: boolean,
) {
return allocs.map((alloc) => {
const allocStart = new Date(alloc.startDate);
@@ -1249,6 +1056,24 @@ function renderProjectDragHandles(
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
@@ -1283,6 +1108,10 @@ function renderProjectDragHandles(
return (
<div
key={`dh-${alloc.id}`}
data-allocation-id={alloc.id}
data-timeline-entry-type="allocation"
data-timeline-drag-preview="project-shift allocation"
data-timeline-project-id={alloc.projectId}
className={clsx(
"absolute flex items-stretch rounded",
hasRecurrence && "border-2 border-dashed border-brand-400/60",
@@ -1296,9 +1125,18 @@ function renderProjectDragHandles(
width: width - 4,
top: 2,
bottom: 2,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
: {}),
...((multiDragPx && multiDragMode === "move") || dragTransform
? {
transform: [
dragTransform,
multiDragPx && multiDragMode === "move"
? `translateX(${multiDragPx}px)`
: null,
]
.filter(Boolean)
.join(" "),
}
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
@@ -1306,6 +1144,7 @@ function renderProjectDragHandles(
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
@@ -1316,7 +1155,10 @@ function renderProjectDragHandles(
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
@@ -1327,7 +1169,10 @@ function renderProjectDragHandles(
"flex-1 min-w-0 flex items-center",
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
)}
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, allocInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
@@ -1342,7 +1187,10 @@ function renderProjectDragHandles(
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });