feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
} | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
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 { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map<number, number>();
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
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 =
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
const shouldClearDemand = demandHover !== null;
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation) {
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
if (shouldClearDemand) setDemandHover(null);
});
}
}, []);
}, [demandHover]);
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);
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,
}
: {}),
});
});
},
[],
);
useEffect(
() => () => {
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
handleDemandHoverMove,
clearHoverTooltips,
multiSelectState,
allocDragState,
)
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
</div>
<div
data-testid="timeline-project-resource-row-canvas"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="relative overflow-hidden touch-none"
style={{
width: totalCanvasWidth,
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
demandTooltipRef={demandTooltipRef}
demandTooltipPos={demandTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
demandHover={demandHover}
/>
</div>
);
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
anchorX: number,
anchorY: number,
) => void,
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
) {
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
<div
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight }}
onMouseLeave={onClearHoverTooltips}
>
{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" />
@@ -962,7 +1032,6 @@ function renderOpenDemandRow(
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} ${formatDateLong(allocEnd)}`}
style={{
left: left + 2,
width: width - 4,
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
e.clientY,
);
}}
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
>
{/* Left resize handle */}
<div