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
@@ -13,11 +13,13 @@ import { DateInput } from "~/components/ui/DateInput.js";
interface AllocationPopoverProps {
allocationId: string;
projectId: string;
initialAllocation?: AllocationPopoverAssignment | null;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
/** Pixel position relative to the viewport */
anchorX: number;
anchorY: number;
contextDate?: Date;
}
type AllocationPopoverAssignment = Assignment<AllocationLike>;
@@ -25,10 +27,12 @@ type AllocationPopoverAssignment = Assignment<AllocationLike>;
export function AllocationPopover({
allocationId,
projectId,
initialAllocation = null,
onClose,
onOpenPanel,
anchorX,
anchorY,
contextDate,
}: AllocationPopoverProps) {
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
@@ -41,15 +45,22 @@ export function AllocationPopover({
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
{ staleTime: 10_000 },
{ staleTime: 10_000, enabled: !initialAllocation },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined;
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
entry.id === allocationId
|| entry.entityId === allocationId
|| entry.sourceAllocationId === allocationId
|| getPlanningEntryMutationId(entry) === allocationId
)) as AllocationPopoverAssignment | undefined;
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [includeSaturday, setIncludeSaturday] = useState(false);
const [role, setRole] = useState("");
const [carveStartDate, setCarveStartDate] = useState("");
const [carveEndDate, setCarveEndDate] = useState("");
useEffect(() => {
if (allocation) {
@@ -59,8 +70,11 @@ export function AllocationPopover({
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
setIncludeSaturday(meta?.includeSaturday ?? false);
setRole(allocation.role ?? "");
const defaultCarveDate = contextDate ? toDateInput(contextDate) : "";
setCarveStartDate(defaultCarveDate);
setCarveEndDate(defaultCarveDate);
}
}, [allocation]);
}, [allocation, contextDate]);
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
@@ -70,6 +84,14 @@ export function AllocationPopover({
},
});
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
onSuccess: () => {
invalidateTimeline();
void utils.allocation.listView.invalidate();
onClose();
},
});
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -89,7 +111,16 @@ export function AllocationPopover({
});
}
if (isLoading || !allocation) {
function handleCarveRange() {
if (!allocation || !carveStartDate || !carveEndDate) return;
carveMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
startDate: new Date(carveStartDate),
endDate: new Date(carveEndDate),
});
}
if (isLoading) {
const loadingPopover = (
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
Loading...
@@ -98,13 +129,38 @@ export function AllocationPopover({
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
}
if (!allocation) {
const missingPopover = (
<div
ref={ref}
style={style}
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be resolved from the current timeline data.
</p>
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body);
}
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
const carveDateRangeInvalid =
Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate;
const popover = (
<div
ref={ref}
style={style}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
@@ -114,7 +170,7 @@ export function AllocationPopover({
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div className="p-4 space-y-3">
<div className="space-y-3 overflow-y-auto p-4">
{/* Resource */}
<div className="text-xs text-gray-500">
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
@@ -182,6 +238,9 @@ export function AllocationPopover({
{updateMutation.isError && (
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
)}
{carveMutation.isError && (
<p className="text-xs text-red-600">{carveMutation.error.message}</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
@@ -203,6 +262,57 @@ export function AllocationPopover({
</button>
</div>
<div className="border-t border-gray-100 pt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-gray-700">Remove Date Range</div>
<div className="text-[11px] text-gray-500">
{contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">From</label>
<DateInput
value={carveStartDate}
onChange={setCarveStartDate}
min={startDate}
max={endDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">To</label>
<DateInput
value={carveEndDate}
onChange={setCarveEndDate}
min={carveStartDate || startDate}
max={endDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
/>
</div>
</div>
{carveDateRangeInvalid && (
<p className="text-xs text-red-600">End date must be on or after the start date.</p>
)}
<button
onClick={handleCarveRange}
disabled={
carveMutation.isPending ||
!carveStartDate ||
!carveEndDate ||
carveDateRangeInvalid
}
className="w-full py-1.5 rounded-lg text-sm font-medium transition-colors bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{carveMutation.isPending ? "Removing…" : "Remove Selected Range"}
</button>
</div>
{/* Link to full panel */}
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
@@ -8,6 +8,8 @@ import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
const ENTITY_COMBOBOX_OVERLAY_SELECTOR = "[data-entity-combobox-overlay='true']";
interface BatchAssignPopoverProps {
resourceIds: string[];
startDate: Date;
@@ -49,13 +51,23 @@ export function BatchAssignPopover({
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
function handlePointerDown(event: PointerEvent) {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (ref.current?.contains(target)) {
return;
}
if (target instanceof Element && target.closest(ENTITY_COMBOBOX_OVERLAY_SELECTOR)) {
return;
}
if (ref.current) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [onClose]);
// Close on ESC
@@ -88,7 +100,7 @@ export function BatchAssignPopover({
const popover = (
<div
ref={ref}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[60] w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[9998] w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
@@ -44,6 +44,7 @@ export function NewAllocationPopover({
width: 320,
estimatedHeight: 440,
onClose,
ignoreSelectors: ["[data-entity-combobox-overlay='true']"],
});
const invalidateTimeline = useInvalidateTimeline();
@@ -82,7 +83,7 @@ export function NewAllocationPopover({
<div
ref={ref}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 dark:shadow-black/40"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
@@ -90,7 +91,7 @@ export function NewAllocationPopover({
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
<div className="p-4 space-y-3">
<div className="space-y-3 overflow-y-auto p-4">
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div>
@@ -19,6 +19,7 @@ export type TimelineDisplayMode = "strip" | "bar" | "heatmap";
import { addDays } from "./utils.js";
import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js";
import { DONE_STATUSES } from "./timelineConstants.js";
import { toLocalDateKey } from "./timelineAvailability.js";
// ─── Local timeline types ─────────────────────────────────────────────────────
// These re-declare the shapes that the original TimelineView used internally.
@@ -133,6 +134,13 @@ export type VacationEntry = {
startDate: Date | string;
endDate: Date | string;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
status: string;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
@@ -149,6 +157,13 @@ export type HolidayOverlayEntry = {
startDate: Date | string;
endDate: Date | string;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
};
// ─── Context shape ──────────────────────────────────────────────────────────
@@ -224,7 +239,7 @@ export function TimelineProvider({
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const isRoleLoading = sessionStatus !== "authenticated";
const isRoleLoading = sessionStatus === "loading";
const today = useMemo(() => {
const d = new Date();
@@ -289,7 +304,7 @@ export function TimelineProvider({
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
// ─── Data queries ──────────────────────────────────────────────────────────
const mountedRef = useRef(false);
const initialRefreshKeyRef = useRef<string | null>(null);
const timelineQueryInput = {
startDate: viewStart,
endDate: viewEnd,
@@ -338,13 +353,31 @@ export function TimelineProvider({
const assignments = entriesView?.assignments ?? [];
const demands = entriesView?.demands ?? [];
const {
data: vacationEntries = [],
refetch: refetchVacations,
} = trpc.vacation.list.useQuery(
// Avoid TS deep-instantiation blow-ups on the large TRPC hook type here.
const vacationListQuery = trpc.vacation.list.useQuery as unknown as (
input: {
startDate: Date;
endDate: Date;
status: VacationStatus[];
limit: number;
},
options: {
placeholderData: (prev: VacationEntry[] | undefined) => VacationEntry[] | undefined;
refetchOnWindowFocus: boolean;
staleTime: number;
},
) => {
data: VacationEntry[] | undefined;
refetch: () => Promise<unknown>;
};
const vacationEntriesQuery = vacationListQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const {
data: vacationEntries = [],
refetch: refetchVacations,
} = vacationEntriesQuery;
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
timelineQueryInput,
@@ -370,32 +403,63 @@ export function TimelineProvider({
refetch: refetchHolidayOverlays,
} = activeHolidayOverlayQuery;
useEffect(() => {
if (mountedRef.current) return;
if (isRoleLoading) return;
mountedRef.current = true;
const initialRefreshKey = useMemo(
() =>
JSON.stringify({
isSelfServiceTimeline,
start: viewStart.toISOString(),
end: viewEnd.toISOString(),
clients: filters.clientIds,
projects: filters.projectIds,
chapters: filters.chapters,
eids: filters.eids,
countries: filters.countryCodes,
}),
[
filters.chapters,
filters.clientIds,
filters.countryCodes,
filters.eids,
filters.projectIds,
isSelfServiceTimeline,
viewEnd,
viewStart,
],
);
// Harden client-side route transitions: the timeline must actively refresh
// its core read models once on mount instead of relying on a prefetched shell.
useEffect(() => {
if (isRoleLoading) return;
if (initialRefreshKeyRef.current === initialRefreshKey) return;
initialRefreshKeyRef.current = initialRefreshKey;
// Harden client-side route and filter transitions: refresh the core
// read models once per active timeline query context instead of trusting
// prefetched or placeholder state to self-heal.
void refetchEntriesView();
void refetchVacations();
void refetchHolidayOverlays();
}, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
}, [
initialRefreshKey,
isRoleLoading,
refetchEntriesView,
refetchHolidayOverlays,
refetchVacations,
]);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
const mergedEntries = [...(vacationEntries as VacationEntry[])];
const existingKeys = new Set(
mergedEntries.map((vacation) => {
const start = new Date(vacation.startDate).toISOString().slice(0, 10);
const end = new Date(vacation.endDate).toISOString().slice(0, 10);
const start = toLocalDateKey(vacation.startDate);
const end = toLocalDateKey(vacation.endDate);
return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
}),
);
for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
const start = new Date(holiday.startDate).toISOString().slice(0, 10);
const end = new Date(holiday.endDate).toISOString().slice(0, 10);
const start = toLocalDateKey(holiday.startDate);
const end = toLocalDateKey(holiday.endDate);
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
if (existingKeys.has(key)) {
continue;
@@ -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" });
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
"use client";
import { GERMAN_FEDERAL_STATES } from "@capakraken/shared";
import { createPortal } from "react-dom";
import { formatCents, formatDateLong } from "~/lib/format.js";
@@ -33,11 +34,73 @@ export type VacationHoverData = {
startDate: Date | string;
endDate: Date | string;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
};
function formatHolidayScope(scope?: string | null): string | null {
switch (scope) {
case "COUNTRY":
return "Country";
case "STATE":
return "State";
case "CITY":
return "City";
default:
return scope ? scope.replaceAll("_", " ") : null;
}
}
function formatHolidaySourceType(sourceType?: string | null): string | null {
switch (sourceType) {
case "SYSTEM":
return "Built-in";
case "CALENDAR":
return "Calendar";
default:
return sourceType ? sourceType.replaceAll("_", " ") : null;
}
}
function formatHolidayStateName(
federalState?: string | null,
countryCode?: string | null,
): string | null {
if (!federalState) {
return null;
}
if (countryCode === "DE") {
return GERMAN_FEDERAL_STATES[federalState] ?? federalState;
}
return federalState;
}
function buildHolidayLocationBasis(vacation: VacationHoverData): string | null {
const parts = [
vacation.countryName
? `Country: ${vacation.countryName}`
: vacation.countryCode
? `Country: ${vacation.countryCode}`
: null,
formatHolidayStateName(vacation.federalState, vacation.countryCode)
? `State: ${formatHolidayStateName(vacation.federalState, vacation.countryCode)}`
: null,
vacation.metroCityName ? `City: ${vacation.metroCityName}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(" · ") : null;
}
export type DemandHoverData = {
roleName: string;
roleColor: string;
@@ -67,6 +130,142 @@ interface TimelineTooltipProps {
demandHover?: DemandHoverData | null;
}
function renderTooltipPortal(content: React.ReactNode) {
return typeof document === "undefined" ? content : createPortal(content, document.body);
}
function TooltipSurface({
children,
position,
tooltipRef,
className,
dataTestId,
backgroundColor,
}: {
children: React.ReactNode;
position: { left: number; top: number };
tooltipRef?: React.Ref<HTMLDivElement>;
className: string;
dataTestId?: string;
backgroundColor: string;
}) {
return (
<div
ref={tooltipRef}
data-testid={dataTestId}
style={{
left: position.left,
top: position.top,
backgroundColor,
}}
className={className}
>
{children}
</div>
);
}
function TooltipMetric({
label,
value,
valueClassName = "font-medium text-gray-100",
}: {
label: string;
value: React.ReactNode;
valueClassName?: string;
}) {
return (
<div>
<div className="text-gray-500">{label}</div>
<div className={valueClassName}>{value}</div>
</div>
);
}
function HeatmapBreakdownList({ breakdown }: { breakdown: HeatmapHoverData["breakdown"] }) {
if (breakdown.length === 0) {
return <div className="text-[11px] text-gray-400">No bookings on this day.</div>;
}
return (
<>
{breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate ? (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" ? (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
) : null}
</div>
) : null}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))}
</>
);
}
function VacationSummary({
vacation,
title,
className,
}: {
vacation: VacationHoverData;
title: string;
className?: string;
}) {
const isPublicHoliday = vacation.type === "PUBLIC_HOLIDAY";
const scopeLabel = formatHolidayScope(vacation.scope);
const sourceLabel = formatHolidaySourceType(vacation.sourceType);
const holidayMeta = [scopeLabel, sourceLabel].filter(Boolean).join(" · ");
const holidayLocationBasis = isPublicHoliday ? buildHolidayLocationBasis(vacation) : null;
return (
<div className={className}>
<div className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-amber-500" />
<span className="font-semibold text-amber-300">{title}</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacation.startDate)} to {formatDateLong(vacation.endDate)}
</div>
{holidayMeta ? (
<div className="mt-1 text-[11px] text-amber-100/75">{holidayMeta}</div>
) : null}
{holidayLocationBasis ? (
<div className="mt-1 text-[11px] text-amber-100/85">{holidayLocationBasis}</div>
) : null}
{isPublicHoliday && vacation.calendarName ? (
<div className="mt-1 text-[11px] text-amber-200/60">
Calendar: {vacation.calendarName}
</div>
) : null}
{vacation.note && !isPublicHoliday ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacation.note}</div>
) : null}
</div>
);
}
export function TimelineTooltip({
heatmapTooltipRef,
heatmapTooltipPos,
@@ -79,18 +278,14 @@ export function TimelineTooltip({
demandHover,
}: TimelineTooltipProps) {
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
const renderTooltip = (content: React.ReactNode) =>
typeof document === "undefined" ? content : createPortal(content, document.body);
if (demandHover && demandTooltipRef && demandTooltipPos) {
return renderTooltip(
<div
ref={demandTooltipRef}
style={{
left: demandTooltipPos.left,
top: demandTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
return renderTooltipPortal(
<TooltipSurface
tooltipRef={demandTooltipRef}
dataTestId="timeline-demand-tooltip"
position={demandTooltipPos}
backgroundColor="rgba(3, 7, 18, 0.96)"
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-start justify-between gap-3">
@@ -115,73 +310,58 @@ export function TimelineTooltip({
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div>
<div className="text-gray-500">Requested</div>
<div className="font-medium text-gray-100">
{demandHover.requestedHeadcount} {demandHover.requestedHeadcount === 1 ? "seat" : "seats"}
</div>
</div>
<div>
<div className="text-gray-500">Open</div>
<div className="font-medium text-amber-300">
{demandHover.unfilledHeadcount} {demandHover.unfilledHeadcount === 1 ? "seat" : "seats"}
</div>
</div>
<div>
<div className="text-gray-500">Range</div>
<div className="font-medium text-gray-100">
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
</div>
</div>
<div>
<div className="text-gray-500">Load</div>
<div className="font-medium text-gray-100">
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
</div>
</div>
<TooltipMetric
label="Requested"
value={`${demandHover.requestedHeadcount} ${demandHover.requestedHeadcount === 1 ? "seat" : "seats"}`}
/>
<TooltipMetric
label="Open"
value={`${demandHover.unfilledHeadcount} ${demandHover.unfilledHeadcount === 1 ? "seat" : "seats"}`}
valueClassName="font-medium text-amber-300"
/>
<TooltipMetric
label="Range"
value={`${formatDateLong(demandHover.startDate)} to ${formatDateLong(demandHover.endDate)}`}
/>
<TooltipMetric
label="Load"
value={`${demandHover.hoursPerDay}h/day · ${demandHover.totalHours}h`}
/>
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
<div>
<div className="text-gray-500">Allocation</div>
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
</div>
<TooltipMetric label="Allocation" value={`${demandHover.percentage}%`} />
) : null}
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
<div>
<div className="text-gray-500">Cost</div>
<div className="font-medium text-gray-100">
{formatCents(demandHover.totalCostCents)} EUR
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
<TooltipMetric
label="Cost"
value={`${formatCents(demandHover.totalCostCents)} EUR${
typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
: ""}
</div>
</div>
: ""
}`}
/>
) : null}
</div>
<div className="mt-2 border-t border-gray-800/90 pt-2 text-[10px] uppercase tracking-[0.14em] text-gray-500">
Click for details and actions
</div>
</div>,
</TooltipSurface>,
);
}
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return renderTooltip(
<div
ref={(el) => {
// Wire both refs to the same element so position updates work from either handler
return renderTooltipPortal(
<TooltipSurface
tooltipRef={(el) => {
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
// Keep the merged tooltip attached to the heatmap pointer path only.
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = null;
}}
position={heatmapTooltipPos}
backgroundColor="rgba(3, 7, 18, 0.96)"
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
{/* Date + hours header */}
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
@@ -189,72 +369,26 @@ export function TimelineTooltip({
</span>
</div>
{/* Project breakdown */}
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate && (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" && (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
)}
</div>
)}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
</div>
{/* Vacation section — merged below */}
<div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300">{vacationTitle}</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
</div>,
<VacationSummary
vacation={vacationHover}
title={vacationTitle ?? "Vacation"}
className="mt-2 border-t border-amber-700/40 pt-2"
/>
</TooltipSurface>,
);
}
// Heatmap only
if (heatmapHover) {
return renderTooltip(
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
return renderTooltipPortal(
<TooltipSurface
tooltipRef={heatmapTooltipRef}
position={heatmapTooltipPos}
backgroundColor="rgba(3, 7, 18, 0.96)"
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
@@ -264,66 +398,23 @@ export function TimelineTooltip({
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate && (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" && (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
)}
</div>
)}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
</div>
</div>,
</TooltipSurface>,
);
}
// Vacation only
if (vacationHover) {
return renderTooltip(
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
return renderTooltipPortal(
<TooltipSurface
tooltipRef={vacationTooltipRef}
position={vacationTooltipPos}
backgroundColor="rgba(120, 53, 15, 0.95)"
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationTitle}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>,
<VacationSummary vacation={vacationHover} title={vacationTitle ?? "Vacation"} />
</TooltipSurface>,
);
}
@@ -9,6 +9,7 @@ import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
@@ -33,6 +34,7 @@ import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
import { ProjectColorLegend } from "./ProjectColorLegend.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
import type { TimelineVisualOverrides } from "./allocationVisualState.js";
// ─── Entry point ────────────────────────────────────────────────────────────
// Two-layer mount: the outer shell creates drag state + project context,
@@ -56,8 +58,10 @@ export function TimelineView() {
const [popover, setPopover] = useState<{
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string;
@@ -88,6 +92,8 @@ export function TimelineView() {
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -106,7 +112,7 @@ export function TimelineView() {
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidth: cellWidthRef.current,
cellWidthRef,
onBlockClick: (info) => {
setPopover({
allocationId: info.allocationId,
@@ -170,6 +176,8 @@ export function TimelineView() {
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
@@ -214,6 +222,8 @@ function TimelineViewContent({
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview,
@@ -251,6 +261,8 @@ function TimelineViewContent({
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
optimisticAllocations: TimelineVisualOverrides;
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
@@ -269,7 +281,14 @@ function TimelineViewContent({
onCanvasTouchMove: ReturnType<typeof useTimelineDrag>["onCanvasTouchMove"];
onCanvasTouchEnd: ReturnType<typeof useTimelineDrag>["onCanvasTouchEnd"];
contextResourceIds: string[];
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
popover: {
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: {
resourceId: string;
@@ -300,6 +319,8 @@ function TimelineViewContent({
viewStart,
viewEnd,
viewDays,
visibleAssignments,
visibleDemands,
setViewStart,
setViewDays,
filters,
@@ -348,6 +369,23 @@ function TimelineViewContent({
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
useEffect(() => {
if (optimisticAllocations.size === 0) return;
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
}, [
optimisticAllocations,
reconcileOptimisticAllocations,
visibleAssignments,
visibleDemands,
]);
useEffect(() => {
if (!hasActivePointerOverlay) return;
setPopover(null);
setDemandPopover(null);
setResourceHover(null);
}, [hasActivePointerOverlay]);
// ─── Keep selection overlay visible while popover is open ───────────────────
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
? rangeState
@@ -386,10 +424,12 @@ function TimelineViewContent({
info: {
allocationId: string;
projectId: string;
contextDate?: Date;
},
anchorX: number,
anchorY: number,
) {
if (hasActivePointerOverlay) return;
// Check if this is a demand (not an assignment) — route to DemandPopover
const demands = openDemandsByProject.get(info.projectId);
const demand = demands?.find((d) => d.id === info.allocationId);
@@ -397,11 +437,19 @@ function TimelineViewContent({
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
const allocation = visibleAssignments.find((entry) => (
entry.id === info.allocationId
|| entry.entityId === info.allocationId
|| entry.sourceAllocationId === info.allocationId
|| getPlanningEntryMutationId(entry) === info.allocationId
)) ?? null;
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
allocation,
x: anchorX,
y: anchorY,
...(info.contextDate ? { contextDate: info.contextDate } : {}),
});
}
@@ -505,12 +553,22 @@ function TimelineViewContent({
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
if (hasActivePointerOverlay) {
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
setResourceHover(null);
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const HOVER_DELAY = 400;
function onMouseOver(e: MouseEvent) {
if (hasActivePointerOverlay) return;
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
if (!target) return;
const rid = target.dataset.resourceHoverId;
@@ -532,6 +590,7 @@ function TimelineViewContent({
}
function onMouseOut(e: MouseEvent) {
if (hasActivePointerOverlay) return;
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
@@ -557,7 +616,7 @@ function TimelineViewContent({
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId, isInitialLoading]); // eslint-disable-line react-hooks/exhaustive-deps
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() {
@@ -682,6 +741,8 @@ function TimelineViewContent({
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
multiSelectState={multiSelectState}
optimisticAllocations={optimisticAllocations}
suppressHoverInteractions={hasActivePointerOverlay}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -707,9 +768,12 @@ function TimelineViewContent({
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId}
onOpenDemandClick={isSelfServiceTimeline ? () => undefined : (demand, anchorX, anchorY) => {
if (hasActivePointerOverlay) return;
setDemandPopover({ demand, x: anchorX, y: anchorY });
}}
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
optimisticAllocations={optimisticAllocations}
suppressHoverInteractions={hasActivePointerOverlay}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -827,7 +891,7 @@ function TimelineViewContent({
)}
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline && popover && (() => {
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
@@ -863,6 +927,7 @@ function TimelineViewContent({
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
@@ -870,12 +935,13 @@ function TimelineViewContent({
}}
anchorX={popover.x}
anchorY={popover.y}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover */}
{!isSelfServiceTimeline && demandPopover && (
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
@@ -962,7 +1028,7 @@ function TimelineViewContent({
)}
{/* Resource hover card */}
{resourceHover && (
{!hasActivePointerOverlay && resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
@@ -0,0 +1,89 @@
"use client";
export type TimelineVisualEntry = {
id: string;
startDate: Date | string;
endDate: Date | string;
};
export type TimelineVisualOverride = {
startDate: Date;
endDate: Date;
};
export type TimelineVisualOverrides = ReadonlyMap<string, TimelineVisualOverride>;
export function getDragPointerOffset(
pointerDeltaX: number,
daysDelta: number,
cellWidth: number,
): number {
return pointerDeltaX - daysDelta * cellWidth;
}
export function applyPointerOffsetPreviewRect({
left,
width,
mode,
pointerOffsetX,
minWidth,
}: {
left: number;
width: number;
mode: "move" | "resize-start" | "resize-end";
pointerOffsetX: number;
minWidth: number;
}): { left: number; width: number; transform?: string } {
if (pointerOffsetX === 0) {
return { left, width };
}
if (mode === "move") {
return { left, width, transform: `translateX(${pointerOffsetX}px)` };
}
if (mode === "resize-start") {
const nextWidth = width - pointerOffsetX;
if (nextWidth < minWidth) {
return {
left: left + (width - minWidth),
width: minWidth,
};
}
return {
left: left + pointerOffsetX,
width: nextWidth,
};
}
return {
left,
width: Math.max(minWidth, width + pointerOffsetX),
};
}
export function applyVisualOverrides<T extends TimelineVisualEntry>(
entries: readonly T[],
overrides: TimelineVisualOverrides,
): T[] {
if (overrides.size === 0) {
return entries as T[];
}
let changed = false;
const nextEntries = entries.map((entry) => {
const override = overrides.get(entry.id);
if (!override) {
return entry;
}
changed = true;
return {
...entry,
startDate: new Date(override.startDate),
endDate: new Date(override.endDate),
};
});
return changed ? nextEntries : (entries as T[]);
}
@@ -21,6 +21,34 @@ export interface VacationBlockInfo {
width: number;
}
export function buildVacationBlocksByResource(
vacationsByResource: Map<string, VacationEntry[]>,
showVacations: boolean,
toLeft: (date: Date) => number,
toWidth: (start: Date, end: Date) => number,
cellWidth: number,
totalCanvasWidth: number,
) {
if (!showVacations) return new Map<string, VacationBlockInfo[]>();
const result = new Map<string, VacationBlockInfo[]>();
for (const [resourceId, vacations] of vacationsByResource) {
const blocks: VacationBlockInfo[] = [];
for (const vacation of vacations) {
const start = new Date(vacation.startDate);
const end = new Date(vacation.endDate);
const left = toLeft(start);
const width = Math.max(cellWidth, toWidth(start, end));
if (width <= 0 || left >= totalCanvasWidth) continue;
blocks.push({ vacation, left, width });
}
if (blocks.length > 0) {
result.set(resourceId, blocks);
}
}
return result;
}
// ─── Vacation block overlays ─────────────────────────────────────────────────
export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: number) {
@@ -92,8 +120,9 @@ export function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
capacityHoursByDay?: number[],
bookingFactorsByDay?: number[],
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
@@ -106,9 +135,11 @@ export function renderOverbookingBlink(
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
if (t >= s.getTime() && t <= e.getTime()) {
totalH += a.hoursPerDay * (bookingFactorsByDay?.[i] ?? 1);
}
}
if (totalH > REF_H) overbooked.push(i);
if (totalH > (capacityHoursByDay?.[i] ?? 8)) overbooked.push(i);
}
if (overbooked.length === 0) return null;
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
buildAllocationWorkingDaySegments,
isAllocationScheduledOnDate,
toLocalDateKey,
} from "./timelineAvailability.js";
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
function createAllocation(overrides?: Partial<TimelineAssignmentEntry>): TimelineAssignmentEntry {
return {
id: "alloc-1",
resourceId: "resource-1",
projectId: "project-1",
startDate: new Date(2026, 2, 30),
endDate: new Date(2026, 3, 6),
hoursPerDay: 8,
metadata: null,
project: {
id: "project-1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
startDate: new Date(2026, 2, 30),
endDate: new Date(2026, 3, 6),
orderType: "CHARGEABLE",
},
resource: {
id: "resource-1",
displayName: "Peter Parker",
eid: "E-001",
chapter: "Delivery",
lcrCents: 10000,
availability: {
sunday: 0,
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 6,
},
},
...overrides,
} as TimelineAssignmentEntry;
}
describe("timelineAvailability", () => {
it("does not treat Saturday as scheduled unless includeSaturday is enabled", () => {
const allocation = createAllocation();
expect(isAllocationScheduledOnDate(allocation, new Date(2026, 3, 4))).toBe(false);
expect(isAllocationScheduledOnDate(allocation, new Date(2026, 3, 6))).toBe(true);
});
it("supports Saturday working allocations when includeSaturday is enabled", () => {
const allocation = createAllocation({
metadata: { includeSaturday: true },
});
expect(isAllocationScheduledOnDate(allocation, new Date(2026, 3, 4))).toBe(true);
});
it("splits working spans at non-working weekend days", () => {
const allocation = createAllocation();
const segments = buildAllocationWorkingDaySegments(allocation);
expect(segments).toEqual([
{ start: new Date(2026, 2, 30), end: new Date(2026, 3, 3) },
{ start: new Date(2026, 3, 6), end: new Date(2026, 3, 6) },
]);
});
it("formats local calendar days without shifting them through UTC serialization", () => {
expect(toLocalDateKey(new Date(2026, 3, 6))).toBe("2026-04-06");
expect(toLocalDateKey(new Date(2026, 3, 10))).toBe("2026-04-10");
});
});
@@ -0,0 +1,129 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
export const DEFAULT_TIMELINE_AVAILABILITY: WeekdayAvailability = {
sunday: 0,
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function toLocalStartOfDay(value: Date | string): Date {
const date = value instanceof Date ? new Date(value) : new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
export function toLocalDateKey(value: Date | string): string {
const date = toLocalStartOfDay(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function normalizeTimelineAvailability(value: unknown): WeekdayAvailability {
if (!isRecord(value)) {
return DEFAULT_TIMELINE_AVAILABILITY;
}
const normalized = { ...DEFAULT_TIMELINE_AVAILABILITY };
for (const key of DAY_KEYS) {
const next = value[key];
if (typeof next === "number" && Number.isFinite(next)) {
normalized[key] = next;
}
}
return normalized;
}
export function getTimelineAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = DAY_KEYS[toLocalStartOfDay(date).getDay()];
return dayKey ? (availability[dayKey] ?? 0) : 0;
}
export function getEffectiveAllocationAvailability(
allocation: Pick<TimelineAssignmentEntry, "resource" | "metadata">,
): WeekdayAvailability {
const availability = normalizeTimelineAvailability(allocation.resource?.availability);
const metadata = allocation.metadata as Record<string, unknown> | null;
const includeSaturday = metadata?.includeSaturday === true;
return includeSaturday ? availability : { ...availability, saturday: 0 };
}
export function isAllocationScheduledOnDate(
allocation: Pick<TimelineAssignmentEntry, "startDate" | "endDate" | "resource" | "metadata">,
date: Date,
): boolean {
const target = toLocalStartOfDay(date);
const start = toLocalStartOfDay(allocation.startDate);
const end = toLocalStartOfDay(allocation.endDate);
if (target < start || target > end) {
return false;
}
return getTimelineAvailabilityHoursForDate(getEffectiveAllocationAvailability(allocation), target) > 0;
}
export function buildAllocationWorkingDaySegments(
allocation: Pick<TimelineAssignmentEntry, "startDate" | "endDate" | "resource" | "metadata">,
rangeStart?: Date,
rangeEnd?: Date,
): Array<{ start: Date; end: Date }> {
const availability = getEffectiveAllocationAvailability(allocation);
const start = toLocalStartOfDay(rangeStart ?? allocation.startDate);
const end = toLocalStartOfDay(rangeEnd ?? allocation.endDate);
if (start > end) {
return [];
}
const segments: Array<{ start: Date; end: Date }> = [];
let segmentStart: Date | null = null;
let segmentEnd: Date | null = null;
const cursor = new Date(start);
while (cursor <= end) {
const isWorkingDay = getTimelineAvailabilityHoursForDate(availability, cursor) > 0;
if (isWorkingDay) {
if (!segmentStart) {
segmentStart = new Date(cursor);
}
segmentEnd = new Date(cursor);
} else if (segmentStart && segmentEnd) {
segments.push({ start: segmentStart, end: segmentEnd });
segmentStart = null;
segmentEnd = null;
}
cursor.setDate(cursor.getDate() + 1);
}
if (segmentStart && segmentEnd) {
segments.push({ start: segmentStart, end: segmentEnd });
}
return segments;
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
import type { TimelineAssignmentEntry, VacationEntry } from "./TimelineContext.js";
function createAssignmentWithAvailability(availability: Record<string, number>): TimelineAssignmentEntry {
return {
resource: {
id: "resource-1",
displayName: "Peter Parker",
eid: "E-001",
chapter: "Delivery",
lcrCents: 10000,
availability,
},
} as TimelineAssignmentEntry;
}
function buildSeries(args: { dates: Date[]; vacations?: VacationEntry[]; availability: Record<string, number> }) {
return buildResourceCapacitySeries(
new Map([["resource-1", [createAssignmentWithAvailability(args.availability)]]]),
new Map([["resource-1", args.vacations ?? []]]),
args.dates,
).get("resource-1");
}
describe("timelineCapacity", () => {
it("maps local weekdays without UTC drift", () => {
const series = buildSeries({
dates: [
new Date(2026, 2, 30),
new Date(2026, 3, 4),
new Date(2026, 3, 5),
],
availability: {
sunday: 1,
monday: 6,
tuesday: 7,
wednesday: 8,
thursday: 5,
friday: 4,
saturday: 2,
},
});
expect(series?.baseHoursByDay).toEqual([6, 2, 1]);
expect(series?.capacityHoursByDay).toEqual([6, 2, 1]);
expect(series?.bookingFactorsByDay).toEqual([1, 1, 1]);
});
it("applies approved absences using the local calendar day key", () => {
const series = buildSeries({
dates: [new Date(2026, 2, 30)],
availability: {
sunday: 0,
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
},
vacations: [
{
id: "vac-1",
resourceId: "resource-1",
type: "ANNUAL",
status: "APPROVED",
startDate: "2026-03-30",
endDate: "2026-03-30",
isHalfDay: false,
} as VacationEntry,
],
});
expect(series?.baseHoursByDay).toEqual([8]);
expect(series?.capacityHoursByDay).toEqual([0]);
expect(series?.bookingFactorsByDay).toEqual([0]);
});
});
@@ -0,0 +1,147 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import type { TimelineAssignmentEntry, VacationEntry } from "./TimelineContext.js";
import {
DEFAULT_TIMELINE_AVAILABILITY,
getTimelineAvailabilityHoursForDate,
normalizeTimelineAvailability,
toLocalDateKey,
toLocalStartOfDay,
} from "./timelineAvailability.js";
const DEFAULT_AVAILABILITY: WeekdayAvailability = DEFAULT_TIMELINE_AVAILABILITY;
export type ResourceCapacitySeries = {
baseHoursByDay: number[];
capacityHoursByDay: number[];
bookingFactorsByDay: number[];
};
function normalizeAvailability(value: unknown): WeekdayAvailability {
return normalizeTimelineAvailability(value);
}
function getAvailabilityHoursForDate(availability: WeekdayAvailability, date: Date): number {
return getTimelineAvailabilityHoursForDate(availability, date);
}
function resolveResourceAvailability(allocs: TimelineAssignmentEntry[]): WeekdayAvailability {
for (const alloc of allocs) {
const availability = alloc.resource?.availability;
if (availability !== null && availability !== undefined) {
return normalizeAvailability(availability);
}
}
return DEFAULT_AVAILABILITY;
}
function buildAbsenceFractionsByDate(
vacations: VacationEntry[],
periodStart: Date,
periodEnd: Date,
): Map<string, number> {
const holidayDates = new Set<string>();
const vacationFractionsByDate = new Map<string, number>();
const absenceFractionsByDate = new Map<string, number>();
const normalizedStart = toLocalStartOfDay(periodStart);
const normalizedEnd = toLocalStartOfDay(periodEnd);
for (const vacation of vacations) {
const isPublicHoliday = vacation.type === "PUBLIC_HOLIDAY";
const isApprovedVacation = vacation.status === "APPROVED";
if (!isPublicHoliday && !isApprovedVacation) {
continue;
}
const overlapStart = new Date(
Math.max(
toLocalStartOfDay(vacation.startDate).getTime(),
normalizedStart.getTime(),
),
);
const overlapEnd = new Date(
Math.min(
toLocalStartOfDay(vacation.endDate).getTime(),
normalizedEnd.getTime(),
),
);
if (overlapStart > overlapEnd) {
continue;
}
const fraction = vacation.isHalfDay ? 0.5 : 1;
const cursor = new Date(overlapStart);
while (cursor <= overlapEnd) {
const isoDate = toLocalDateKey(cursor);
if (isPublicHoliday) {
holidayDates.add(isoDate);
} else {
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
}
const existingAbsence = absenceFractionsByDate.get(isoDate) ?? 0;
if (isPublicHoliday || !holidayDates.has(isoDate)) {
absenceFractionsByDate.set(isoDate, Math.max(existingAbsence, fraction));
}
cursor.setDate(cursor.getDate() + 1);
}
}
for (const isoDate of holidayDates) {
const existingAbsence = absenceFractionsByDate.get(isoDate) ?? 0;
absenceFractionsByDate.set(isoDate, Math.max(existingAbsence, 1));
}
return absenceFractionsByDate;
}
export function buildResourceCapacitySeries(
allocsByResource: Map<string, TimelineAssignmentEntry[]>,
vacationsByResource: Map<string, VacationEntry[]>,
dates: Date[],
): Map<string, ResourceCapacitySeries> {
const result = new Map<string, ResourceCapacitySeries>();
if (dates.length === 0) {
return result;
}
const periodStart = dates[0]!;
const periodEnd = dates[dates.length - 1]!;
for (const [resourceId, allocs] of allocsByResource) {
const availability = resolveResourceAvailability(allocs);
const absenceFractionsByDate = buildAbsenceFractionsByDate(
vacationsByResource.get(resourceId) ?? [],
periodStart,
periodEnd,
);
const baseHoursByDay: number[] = [];
const capacityHoursByDay: number[] = [];
const bookingFactorsByDay: number[] = [];
for (const date of dates) {
const baseHours = getAvailabilityHoursForDate(availability, date);
const absenceFraction = absenceFractionsByDate.get(toLocalDateKey(date)) ?? 0;
const bookingFactor = baseHours > 0 ? Math.max(0, 1 - absenceFraction) : 0;
baseHoursByDay.push(baseHours);
bookingFactorsByDay.push(bookingFactor);
capacityHoursByDay.push(baseHours * bookingFactor);
}
result.set(resourceId, {
baseHoursByDay,
capacityHoursByDay,
bookingFactorsByDay,
});
}
return result;
}
@@ -0,0 +1,187 @@
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
import type { HeatmapHoverData } from "./TimelineTooltip.js";
import type { ResourceCapacitySeries } from "./timelineCapacity.js";
import { toLocalDateKey } from "./timelineAvailability.js";
type HeatmapAccumulator = {
shortCode: string;
projectName: string;
orderType: string;
hours: number;
responsiblePerson?: string | null;
role?: string | null;
status?: string;
startDate?: string;
endDate?: string;
};
export function buildResourceHeatmapHover(
date: Date,
allocations: TimelineAssignmentEntry[],
options?: {
capacityHours?: number;
bookingFactor?: number;
},
): HeatmapHoverData {
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const targetTime = target.getTime();
const bookingFactor = options?.bookingFactor ?? 1;
const capacityHours = options?.capacityHours ?? 8;
const projectHours = new Map<string, HeatmapAccumulator>();
for (const allocation of allocations) {
const start = new Date(allocation.startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(allocation.endDate);
end.setHours(0, 0, 0, 0);
if (targetTime < start.getTime() || targetTime > end.getTime()) continue;
const existing = projectHours.get(allocation.projectId);
if (existing) {
existing.hours += allocation.hoursPerDay * bookingFactor;
continue;
}
projectHours.set(allocation.projectId, {
shortCode: allocation.project.shortCode,
projectName: allocation.project.name,
orderType: allocation.project.orderType,
hours: allocation.hoursPerDay * bookingFactor,
responsiblePerson:
(allocation.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
role: allocation.role ?? allocation.roleEntity?.name ?? null,
status: allocation.status,
startDate: toLocalDateKey(allocation.startDate),
endDate: toLocalDateKey(allocation.endDate),
});
}
const breakdown: HeatmapHoverData["breakdown"] = [...projectHours.entries()]
.map(([projectId, value]) => ({
projectId,
shortCode: value.shortCode,
projectName: value.projectName,
orderType: value.orderType,
hoursPerDay: value.hours,
...(value.responsiblePerson !== undefined ? { responsiblePerson: value.responsiblePerson } : {}),
...(value.role !== undefined ? { role: value.role } : {}),
...(value.status !== undefined ? { status: value.status } : {}),
...(value.startDate !== undefined ? { startDate: value.startDate } : {}),
...(value.endDate !== undefined ? { endDate: value.endDate } : {}),
}))
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
const totalH = breakdown.reduce((sum, entry) => sum + entry.hoursPerDay, 0);
return {
date,
totalH,
pct: capacityHours > 0 ? (totalH / capacityHours) * 100 : 0,
breakdown,
};
}
export function buildResourceHeatmapSeries(
allocsByResource: Map<string, TimelineAssignmentEntry[]>,
dates: Date[],
resourceCapacityById?: Map<string, ResourceCapacitySeries>,
): {
resourceHeatmapById: Map<string, (HeatmapHoverData | null)[]>;
resourceTotalHoursById: Map<string, number[]>;
} {
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 resourceHeatmapById = new Map<string, (HeatmapHoverData | null)[]>();
const resourceTotalHoursById = new Map<string, number[]>();
for (const [resourceId, allocs] of allocsByResource) {
if (allocs.length === 0) continue;
const capacity = resourceCapacityById?.get(resourceId);
const totalHours = new Array<number>(dates.length).fill(0);
const breakdownMaps = Array.from(
{ length: dates.length },
() => new Map<string, HeatmapAccumulator>(),
);
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) {
const effectiveHours =
alloc.hoursPerDay * (capacity?.bookingFactorsByDay[dayIndex] ?? 1);
totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + effectiveHours;
const dayBreakdown = breakdownMaps[dayIndex];
if (dayBreakdown) {
const existing = dayBreakdown.get(alloc.projectId);
if (existing) {
existing.hours += effectiveHours;
} 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: effectiveHours,
});
}
}
}
current.setDate(current.getDate() + 1);
}
}
resourceTotalHoursById.set(resourceId, totalHours);
resourceHeatmapById.set(
resourceId,
totalHours.map((totalH, dayIndex) => {
if (totalH === 0) return null;
const dayBreakdown = breakdownMaps[dayIndex];
if (!dayBreakdown) return null;
const breakdown = [...dayBreakdown.entries()]
.map(([projectId, value]) => ({
projectId,
shortCode: value.shortCode,
projectName: value.projectName,
orderType: value.orderType,
responsiblePerson: value.responsiblePerson ?? null,
hoursPerDay: value.hours,
}))
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
return {
date: dates[dayIndex] ?? new Date(),
totalH,
pct:
(capacity?.capacityHoursByDay[dayIndex] ?? 8) > 0
? (totalH / (capacity?.capacityHoursByDay[dayIndex] ?? 8)) * 100
: 0,
breakdown,
};
}),
);
}
return {
resourceHeatmapById,
resourceTotalHoursById,
};
}
@@ -0,0 +1,124 @@
import type { MutableRefObject, RefObject } from "react";
import type { TimelineDemandEntry, VacationEntry } from "./TimelineContext.js";
import type { DemandHoverData } from "./TimelineTooltip.js";
export type TooltipPosition = {
left: number;
top: number;
};
export function updateTooltipPosition(
positionRef: MutableRefObject<TooltipPosition>,
tooltipRef: RefObject<HTMLDivElement | null>,
clientX: number,
clientY: number,
offsetX: number,
offsetY: number,
) {
positionRef.current = { left: clientX + offsetX, top: clientY + offsetY };
if (!tooltipRef.current) return;
tooltipRef.current.style.left = `${positionRef.current.left}px`;
tooltipRef.current.style.top = `${positionRef.current.top}px`;
}
export function cancelHoverFrame(frameRef: MutableRefObject<number | null>) {
if (frameRef.current === null) return;
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
export function findVacationHit<T extends { startDate: Date | string; endDate: Date | string }>(
vacations: T[],
date: Date,
): T | null {
const time = new Date(date);
time.setHours(0, 0, 0, 0);
const target = time.getTime();
return (
vacations.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 target >= start.getTime() && target <= end.getTime();
}) ?? null
);
}
export function collectResourcesWithVacations(
vacationsByResource: Map<string, VacationEntry[]>,
) {
const result = new Set<string>();
for (const [resourceId, vacations] of vacationsByResource) {
if (vacations.length > 0) {
result.add(resourceId);
}
}
return result;
}
export function scheduleVacationHoverUpdate<T extends { id: string; startDate: Date | string; endDate: Date | string }>(
args: {
frameRef: MutableRefObject<number | null>;
hoveredKeyRef: MutableRefObject<string | null>;
resourceId: string;
clientX: number;
rect: DOMRect;
xToDate: (clientX: number, rect: DOMRect) => Date;
vacations: T[];
onHoverChange: (vacation: T | null) => void;
},
) {
const {
frameRef,
hoveredKeyRef,
resourceId,
clientX,
rect,
xToDate,
vacations,
onHoverChange,
} = args;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const date = xToDate(clientX, rect);
const hit = findVacationHit(vacations, date);
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
if (nextKey === hoveredKeyRef.current) return;
hoveredKeyRef.current = nextKey;
onHoverChange(hit);
});
}
export function buildDemandHoverData(demand: TimelineDemandEntry): DemandHoverData {
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);
return {
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,
}
: {}),
};
}
@@ -0,0 +1,74 @@
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
import type { ResourceCapacitySeries } from "./timelineCapacity.js";
export type ProjectDayMetric = {
projH: number;
totalH: number;
capacityH: number;
};
type ProjectMetricRow = {
resource: { id: string };
allocs: TimelineAssignmentEntry[];
};
type ProjectMetricGroup = {
id: string;
resourceRows: ProjectMetricRow[];
};
export function getProjectRowMetricsKey(projectId: string, resourceId: string): string {
return `${projectId}:${resourceId}`;
}
export function buildProjectRowMetrics(
dates: Date[],
projectGroups: ProjectMetricGroup[],
resourceTotalHoursById: Map<string, number[]>,
resourceCapacityById: Map<string, ResourceCapacitySeries>,
): Map<string, ProjectDayMetric[]> {
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 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);
const capacity = resourceCapacityById.get(resource.id);
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 * (capacity?.bookingFactorsByDay[dayIndex] ?? 1);
}
current.setDate(current.getDate() + 1);
}
}
const totalHours = resourceTotalHoursById.get(resource.id);
nextMetrics.set(
getProjectRowMetricsKey(project.id, resource.id),
projectHours.map((projH, dayIndex) => ({
projH,
totalH: totalHours?.[dayIndex] ?? 0,
capacityH: capacity?.capacityHoursByDay[dayIndex] ?? 8,
})),
);
}
}
return nextMetrics;
}
@@ -0,0 +1,129 @@
import {
applyVisualOverrides,
type TimelineVisualOverrides,
} from "./allocationVisualState.js";
import type {
TimelineAssignmentEntry,
TimelineDemandEntry,
useTimelineContext,
} from "./TimelineContext.js";
import { PROJECT_HEADER_HEIGHT, ROW_HEIGHT, SUB_LANE_HEIGHT } from "./timelineConstants.js";
import { getProjectRowMetricsKey } from "./timelineProjectMetrics.js";
export type TimelineProjectGroup = NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
export type OpenDemandRowLayout = {
visibleOpenDemands: TimelineDemandEntry[];
laneMap: Map<string, number>;
laneCount: number;
rowHeight: number;
};
export type ProjectFlatRow =
| {
type: "header";
key: string;
project: TimelineProjectGroup;
}
| {
type: "open-demand";
key: string;
projectId: string;
openDemandCount: number;
layout: OpenDemandRowLayout;
}
| {
type: "resource";
key: string;
project: TimelineProjectGroup;
resource: TimelineProjectGroup["resourceRows"][number]["resource"];
allocs: TimelineAssignmentEntry[];
metricsKey: string;
};
export function estimateProjectRowHeight(row: ProjectFlatRow | undefined) {
if (!row) return ROW_HEIGHT;
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
if (row.type === "open-demand") return row.layout.rowHeight;
return ROW_HEIGHT;
}
export function buildProjectFlatRows(
visualProjectGroups: TimelineProjectGroup[],
openDemandsByProject: Map<string, TimelineDemandEntry[]>,
optimisticAllocations: TimelineVisualOverrides,
): ProjectFlatRow[] {
const rows: ProjectFlatRow[] = [];
for (const project of visualProjectGroups) {
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,
openDemandCount: openDemands.length,
layout: buildOpenDemandRowLayout(openDemands, optimisticAllocations),
});
}
for (const { resource, allocs } of project.resourceRows) {
rows.push({
type: "resource",
key: `${project.id}-${resource.id}`,
project,
resource,
allocs,
metricsKey: getProjectRowMetricsKey(project.id, resource.id),
});
}
}
return rows;
}
function assignDemandLanes(demands: TimelineDemandEntry[]): Map<string, number> {
const laneMap = new Map<string, number>();
const laneEnds: Date[] = [];
const sorted = [...demands].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
);
for (const demand of sorted) {
const start = new Date(demand.startDate);
let assigned = -1;
for (let i = 0; i < laneEnds.length; i++) {
if (laneEnds[i]! < start) {
assigned = i;
laneEnds[i] = new Date(demand.endDate);
break;
}
}
if (assigned === -1) {
assigned = laneEnds.length;
laneEnds.push(new Date(demand.endDate));
}
laneMap.set(demand.id, assigned);
}
return laneMap;
}
function buildOpenDemandRowLayout(
openDemands: TimelineDemandEntry[],
optimisticAllocations: TimelineVisualOverrides,
): OpenDemandRowLayout {
const visibleOpenDemands = applyVisualOverrides(openDemands, optimisticAllocations);
const laneMap = assignDemandLanes(visibleOpenDemands);
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
return {
visibleOpenDemands,
laneMap,
laneCount,
rowHeight: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16),
};
}