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
+54 -14
View File
@@ -14,6 +14,8 @@ interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
crossAlign?: VerticalAlign;
matchTriggerWidth?: boolean;
triggerRef?: RefObject<TTrigger | null>;
ignoreElements?: Array<HTMLElement | null>;
ignoreSelectors?: string[];
}
interface OverlayPosition {
@@ -32,15 +34,19 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
crossAlign = "start",
matchTriggerWidth = false,
triggerRef: externalTriggerRef,
ignoreElements = [],
ignoreSelectors = [],
}: UseAnchoredOverlayOptions<TTrigger>) {
const internalTriggerRef = useRef<TTrigger | null>(null);
const triggerRef = externalTriggerRef ?? internalTriggerRef;
const panelRef = useRef<HTMLDivElement | null>(null);
const frameRef = useRef<number | null>(null);
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) {
if (!trigger || !trigger.isConnected) {
onClose();
return;
}
@@ -89,18 +95,30 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
left: boundedLeft,
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
});
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
}, [align, crossAlign, matchTriggerWidth, offset, onClose, side, triggerRef, viewportPadding]);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
function handlePointerDown(event: PointerEvent) {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
if (
target instanceof Element
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
) {
return;
}
onClose();
}
@@ -110,30 +128,52 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("keydown", handleEscape);
};
}, [onClose, open]);
}, [ignoreElements, ignoreSelectors, onClose, open, triggerRef]);
useEffect(() => {
if (!open) {
return;
}
updatePosition();
const rafId = window.requestAnimationFrame(updatePosition);
function cancelScheduledFrame() {
if (frameRef.current === null) {
return;
}
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
function scheduleUpdate() {
if (frameRef.current !== null) {
return;
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
updatePosition();
});
}
updatePosition();
scheduleUpdate();
window.addEventListener("resize", scheduleUpdate, { passive: true });
window.addEventListener("scroll", scheduleUpdate, true);
window.visualViewport?.addEventListener("resize", scheduleUpdate, { passive: true });
window.visualViewport?.addEventListener("scroll", scheduleUpdate, { passive: true });
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
cancelScheduledFrame();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
window.visualViewport?.removeEventListener("resize", scheduleUpdate);
window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
};
}, [open, updatePosition]);
@@ -9,6 +9,7 @@ export function useInvalidateTimeline() {
void utils.timeline.getMyEntriesView.invalidate();
void utils.timeline.getHolidayOverlays.invalidate();
void utils.timeline.getMyHolidayOverlays.invalidate();
void utils.vacation.list.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
@@ -31,6 +32,7 @@ export function useInvalidatePlanningViews() {
void utils.timeline.getMyEntriesView.invalidate();
void utils.timeline.getHolidayOverlays.invalidate();
void utils.timeline.getMyHolidayOverlays.invalidate();
void utils.vacation.list.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
+557 -101
View File
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
const DRAG_CLICK_THRESHOLD_PX = 5;
// ─── Project-shift drag state ───────────────────────────────────────────────
export interface DragState {
@@ -17,6 +19,7 @@ export interface DragState {
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
originalLeft: number;
blockWidth: number;
daysDelta: number;
@@ -50,6 +53,7 @@ const INITIAL_DRAG_STATE: DragState = {
currentStartDate: null,
currentEndDate: null,
startMouseX: 0,
pointerDeltaX: 0,
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
@@ -58,39 +62,175 @@ const INITIAL_DRAG_STATE: DragState = {
// ─── Per-allocation drag state ──────────────────────────────────────────────
export type AllocDragMode = "move" | "resize-start" | "resize-end";
export type AllocDragScope = "allocation" | "segment";
export interface AllocDragState {
isActive: boolean;
mode: AllocDragMode;
scope: AllocDragScope;
allocationId: string | null;
mutationAllocationId: string | null;
projectId: string | null;
projectName: string | null;
resourceId: string | null;
allocationStartDate: Date | null;
allocationEndDate: Date | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
daysDelta: number;
}
const INITIAL_ALLOC_DRAG: AllocDragState = {
isActive: false,
mode: "move",
scope: "allocation",
allocationId: null,
mutationAllocationId: null,
projectId: null,
projectName: null,
resourceId: null,
allocationStartDate: null,
allocationEndDate: null,
originalStartDate: null,
originalEndDate: null,
currentStartDate: null,
currentEndDate: null,
startMouseX: 0,
pointerDeltaX: 0,
daysDelta: 0,
};
type LivePreviewMode = AllocDragMode;
type LivePreviewTarget = {
element: HTMLElement;
baseLeft: number;
baseWidth: number;
baseTransform: string;
};
type LivePreviewSession = {
mode: LivePreviewMode;
cellWidth: number;
targets: LivePreviewTarget[];
pointerDeltaX: number;
daysDelta: number;
frame: number | null;
};
function toPxValue(value: string): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function joinTransforms(...parts: Array<string | undefined>): string {
return parts
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part) && part !== "none")
.join(" ");
}
function captureLivePreviewTargets(elements: Iterable<HTMLElement>): LivePreviewTarget[] {
const seen = new Set<HTMLElement>();
const targets: LivePreviewTarget[] = [];
for (const element of elements) {
if (seen.has(element)) continue;
seen.add(element);
targets.push({
element,
baseLeft: toPxValue(element.style.left),
baseWidth: toPxValue(element.style.width),
baseTransform: element.style.transform,
});
}
return targets;
}
function renderLivePreview(session: LivePreviewSession) {
const pointerOffsetX = session.pointerDeltaX - session.daysDelta * session.cellWidth;
for (const target of session.targets) {
let left = target.baseLeft;
let width = target.baseWidth;
if (session.mode === "move") {
left += session.daysDelta * session.cellWidth;
} else if (session.mode === "resize-start") {
left += session.daysDelta * session.cellWidth;
width = Math.max(session.cellWidth, width - session.daysDelta * session.cellWidth);
} else {
width = Math.max(session.cellWidth, width + session.daysDelta * session.cellWidth);
}
if (session.mode === "resize-start") {
const nextWidth = width - pointerOffsetX;
if (nextWidth < session.cellWidth) {
left += width - session.cellWidth;
width = session.cellWidth;
} else {
left += pointerOffsetX;
width = nextWidth;
}
} else if (session.mode === "resize-end") {
width = Math.max(session.cellWidth, width + pointerOffsetX);
}
target.element.style.left = `${left}px`;
target.element.style.width = `${Math.max(session.cellWidth, width)}px`;
target.element.style.transform = joinTransforms(
target.baseTransform,
session.mode === "move" && pointerOffsetX !== 0
? `translateX(${pointerOffsetX}px)`
: undefined,
);
}
}
function scheduleLivePreview(session: LivePreviewSession) {
if (session.frame !== null) return;
session.frame = requestAnimationFrame(() => {
session.frame = null;
renderLivePreview(session);
});
}
function clearLivePreview(session: LivePreviewSession | null) {
if (!session) return;
if (session.frame !== null) {
cancelAnimationFrame(session.frame);
}
for (const target of session.targets) {
target.element.style.left = `${target.baseLeft}px`;
target.element.style.width = `${target.baseWidth}px`;
target.element.style.transform = target.baseTransform;
}
}
function datesMatch(a: Date | null, b: Date | null) {
return Boolean(a && b) && a!.getTime() === b!.getTime();
}
function preserveLivePreview(session: LivePreviewSession | null) {
if (!session) return;
if (session.frame !== null) {
cancelAnimationFrame(session.frame);
session.frame = null;
}
for (const target of session.targets) {
target.baseLeft = toPxValue(target.element.style.left);
target.baseWidth = toPxValue(target.element.style.width);
target.baseTransform = target.element.style.transform;
}
}
// ─── Range-select state ─────────────────────────────────────────────────────
export interface RangeState {
@@ -163,8 +303,19 @@ export interface AllocationMovedSnapshot {
after: { startDate: Date; endDate: Date };
}
export interface OptimisticTimelineEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
}
export interface OptimisticTimelineOverride {
startDate: Date;
endDate: Date;
}
export function useTimelineDrag({
cellWidth,
cellWidthRef,
onShiftApplied,
onBlockClick,
onRangeSelected,
@@ -172,7 +323,7 @@ export function useTimelineDrag({
onShiftClickAlloc,
onMultiDragComplete,
}: {
cellWidth: number;
cellWidthRef: MutableRefObject<number>;
onShiftApplied?: (projectId: string) => void;
onBlockClick?: (info: BlockClickInfo) => void;
onRangeSelected?: (info: RangeSelectedInfo) => void;
@@ -189,13 +340,14 @@ export function useTimelineDrag({
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
const projectPreviewRef = useRef<LivePreviewSession | null>(null);
const allocPreviewRef = useRef<LivePreviewSession | null>(null);
const projectDragCleanupRef = useRef<(() => void) | null>(null);
const allocDragCleanupRef = useRef<(() => void) | null>(null);
const multiSelectCleanupRef = useRef<(() => void) | null>(null);
// Keep ref in sync with state so document-level handlers read the latest selection
multiSelectRef.current = multiSelectState;
// Keep always-current refs for values used inside document event handlers
const cellWidthRef = useRef(cellWidth);
cellWidthRef.current = cellWidth;
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
x: 0,
@@ -218,6 +370,148 @@ export function useTimelineDrag({
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
clearLivePreview(projectPreviewRef.current);
const projectTargets = captureLivePreviewTargets(
document.querySelectorAll<HTMLElement>(
`[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`,
),
);
if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) {
projectTargets.push(...captureLivePreviewTargets([currentTarget]));
}
projectPreviewRef.current =
projectTargets.length > 0
? {
mode: "move",
cellWidth: cellWidthRef.current,
targets: projectTargets,
pointerDeltaX: 0,
daysDelta: 0,
frame: null,
}
: null;
}, []);
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
clearLivePreview(allocPreviewRef.current);
const root =
currentTarget instanceof HTMLElement
? currentTarget.closest<HTMLElement>('[data-timeline-drag-preview~="allocation"]')
: null;
const targets = root ? captureLivePreviewTargets([root]) : [];
allocPreviewRef.current =
targets.length > 0
? {
mode,
cellWidth: cellWidthRef.current,
targets,
pointerDeltaX: 0,
daysDelta: 0,
frame: null,
}
: null;
}, []);
const updateLivePreview = useCallback(
(previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
const preview = previewRef.current;
if (!preview) return;
preview.cellWidth = cellWidthRef.current;
preview.pointerDeltaX = pointerDeltaX;
preview.daysDelta = daysDelta;
scheduleLivePreview(preview);
},
[],
);
const updateProjectDragPosition = useCallback(
(clientX: number) => {
const drag = dragStateRef.current;
if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false;
const deltaX = clientX - drag.startMouseX;
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
updateLivePreview(projectPreviewRef, deltaX, daysDelta);
if (daysDelta === drag.daysDelta) {
if (deltaX !== drag.pointerDeltaX) {
dragStateRef.current = { ...drag, pointerDeltaX: deltaX };
}
return true;
}
const { start: newStart, end: newEnd } = computeDragDates(
"move",
drag.originalStartDate,
drag.originalEndDate,
daysDelta,
);
const updated: DragState = {
...drag,
currentStartDate: newStart,
currentEndDate: newEnd,
pointerDeltaX: deltaX,
daysDelta,
};
dragStateRef.current = updated;
setDragState(updated);
return true;
},
[updateLivePreview],
);
const updateAllocationDragPosition = useCallback(
(clientX: number) => {
const alloc = allocDragRef.current;
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false;
const pointerDeltaX = clientX - alloc.startMouseX;
const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current);
updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta);
if (daysDelta === alloc.daysDelta) {
if (pointerDeltaX !== alloc.pointerDeltaX) {
allocDragRef.current = { ...alloc, pointerDeltaX };
}
return true;
}
const { start: newStart, end: newEnd } = computeDragDates(
alloc.mode,
alloc.originalStartDate,
alloc.originalEndDate,
daysDelta,
);
const updated: AllocDragState = {
...alloc,
currentStartDate: newStart,
currentEndDate: newEnd,
pointerDeltaX,
daysDelta,
};
allocDragRef.current = updated;
setAllocDragState(updated);
return true;
},
[updateLivePreview],
);
const clearProjectDragSession = useCallback(() => {
projectDragCleanupRef.current?.();
projectDragCleanupRef.current = null;
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = null;
dragStateRef.current = INITIAL_DRAG_STATE;
setDragState(INITIAL_DRAG_STATE);
}, []);
// Project-shift preview
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
{
@@ -248,7 +542,46 @@ export function useTimelineDrag({
mutateAsync: (...args: unknown[]) => Promise<unknown>;
};
const finalizeProjectDrag = useCallback(
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => {
updateProjectDragPosition(clientX);
const finalDrag = dragStateRef.current;
if (!finalDrag.isDragging) return null;
const mutationInput =
finalDrag.daysDelta !== 0 &&
finalDrag.projectId &&
finalDrag.currentStartDate &&
finalDrag.currentEndDate
? {
projectId: finalDrag.projectId,
newStartDate: finalDrag.currentStartDate,
newEndDate: finalDrag.currentEndDate,
}
: null;
if (finalDrag.daysDelta !== 0) {
preserveLivePreview(projectPreviewRef.current);
}
clearProjectDragSession();
if (!mutationInput) return null;
if (mode === "mutateAsync") {
return applyShiftMutation.mutateAsync(mutationInput);
}
applyShiftMutation.mutate(mutationInput);
return null;
},
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
);
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
() => new Map(),
);
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
@@ -259,8 +592,60 @@ export function useTimelineDrag({
pendingSnapshotRef.current = null;
}
},
onError: () => {
clearPendingOptimisticAllocation();
},
});
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
onSuccess: () => {
invalidateTimeline();
},
});
const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => {
pendingSnapshotRef.current = null;
const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current;
if (!optimisticAllocationId) {
pendingOptimisticAllocationIdRef.current = null;
return;
}
setOptimisticAllocations((prev) => {
if (!prev.has(optimisticAllocationId)) return prev;
const next = new Map(prev);
next.delete(optimisticAllocationId);
return next;
});
pendingOptimisticAllocationIdRef.current = null;
}, []);
const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
setOptimisticAllocations((prev) => {
if (prev.size === 0) return prev;
const next = new Map(prev);
for (const entry of entries) {
const override = next.get(entry.id);
if (!override) continue;
const startTime = new Date(entry.startDate).getTime();
const endTime = new Date(entry.endDate).getTime();
if (
startTime === override.startDate.getTime() &&
endTime === override.endDate.getTime()
) {
next.delete(entry.id);
if (pendingOptimisticAllocationIdRef.current === entry.id) {
pendingOptimisticAllocationIdRef.current = null;
}
}
}
return next.size === prev.size ? prev : next;
});
}, []);
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
const onProjectBarMouseDown = useCallback(
@@ -286,14 +671,36 @@ export function useTimelineDrag({
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX,
pointerDeltaX: 0,
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
};
dragStateRef.current = state;
setDragState(state);
setProjectPreviewTargets(opts.projectId, e.currentTarget);
projectDragCleanupRef.current?.();
function handleMove(ev: MouseEvent) {
updateProjectDragPosition(ev.clientX);
}
function handleUp(ev: MouseEvent) {
projectDragCleanupRef.current?.();
projectDragCleanupRef.current = null;
void finalizeProjectDrag(ev.clientX);
ev.preventDefault();
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
projectDragCleanupRef.current = () => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
};
},
[],
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
);
// Legacy — kept for backward compat (triggers project shift from allocation block)
@@ -323,6 +730,7 @@ export function useTimelineDrag({
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX,
pointerDeltaX: 0,
originalLeft: opts.blockLeft,
blockWidth: opts.blockWidth,
daysDelta: 0,
@@ -351,6 +759,9 @@ export function useTimelineDrag({
resourceId: string | null;
startDate: Date;
endDate: Date;
allocationStartDate?: Date;
allocationEndDate?: Date;
scope?: AllocDragScope;
},
) => {
if (e.button !== 0) return;
@@ -373,6 +784,7 @@ export function useTimelineDrag({
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
multiSelectCleanupRef.current?.();
function handleMultiMove(ev: MouseEvent) {
const deltaX = ev.clientX - startMouseX;
@@ -384,11 +796,11 @@ export function useTimelineDrag({
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
}
function handleMultiUp() {
document.removeEventListener("mousemove", handleMultiMove);
document.removeEventListener("mouseup", handleMultiUp);
function handleMultiUp(ev: MouseEvent) {
multiSelectCleanupRef.current?.();
multiSelectCleanupRef.current = null;
const finalDelta = currentDaysDelta;
const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current);
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
@@ -402,6 +814,10 @@ export function useTimelineDrag({
document.addEventListener("mousemove", handleMultiMove);
document.addEventListener("mouseup", handleMultiUp);
multiSelectCleanupRef.current = () => {
document.removeEventListener("mousemove", handleMultiMove);
document.removeEventListener("mouseup", handleMultiUp);
};
return;
}
@@ -410,56 +826,57 @@ export function useTimelineDrag({
const initial: AllocDragState = {
isActive: true,
mode: opts.mode,
scope: opts.scope ?? "allocation",
allocationId: opts.allocationId,
mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
projectId: opts.projectId,
projectName: opts.projectName,
resourceId: opts.resourceId,
allocationStartDate: opts.allocationStartDate ?? opts.startDate,
allocationEndDate: opts.allocationEndDate ?? opts.endDate,
originalStartDate: opts.startDate,
originalEndDate: opts.endDate,
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX,
pointerDeltaX: 0,
daysDelta: 0,
};
allocDragRef.current = initial;
setAllocDragState(initial);
setAllocationPreviewTarget(e.currentTarget, opts.mode);
allocDragCleanupRef.current?.();
// ── document handlers ────────────────────────────────────────────────
function handleMove(ev: MouseEvent) {
const alloc = allocDragRef.current;
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
const deltaX = ev.clientX - alloc.startMouseX;
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
if (daysDelta === alloc.daysDelta) return;
const { start: newStart, end: newEnd } = computeDragDates(
alloc.mode,
alloc.originalStartDate,
alloc.originalEndDate,
daysDelta,
);
const updated: AllocDragState = {
...alloc,
currentStartDate: newStart,
currentEndDate: newEnd,
daysDelta,
};
allocDragRef.current = updated;
setAllocDragState(updated);
updateAllocationDragPosition(ev.clientX);
}
function handleUp() {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
function handleUp(ev: MouseEvent) {
allocDragCleanupRef.current?.();
allocDragCleanupRef.current = null;
updateAllocationDragPosition(ev.clientX);
const alloc = allocDragRef.current;
if (!alloc.isActive) return;
const pointerDelta = Math.abs(alloc.pointerDeltaX);
const hasDateChange =
Boolean(alloc.originalStartDate && alloc.currentStartDate && alloc.originalEndDate && alloc.currentEndDate) &&
(
alloc.originalStartDate!.getTime() !== alloc.currentStartDate!.getTime() ||
alloc.originalEndDate!.getTime() !== alloc.currentEndDate!.getTime()
);
if (alloc.daysDelta === 0 && alloc.allocationId) {
if (hasDateChange) {
preserveLivePreview(allocPreviewRef.current);
}
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = null;
const shouldTreatAsClick =
alloc.mode === "move" &&
alloc.daysDelta === 0 &&
pointerDelta <= DRAG_CLICK_THRESHOLD_PX;
if (shouldTreatAsClick && alloc.allocationId) {
// No movement → treat as click
if (wasShift) {
// Shift+Click → toggle multi-selection for this allocation
@@ -474,19 +891,61 @@ export function useTimelineDrag({
endDate: alloc.originalEndDate!,
});
}
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
} else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
const activeAllocationId = alloc.allocationId;
const currentStartDate = alloc.currentStartDate;
const currentEndDate = alloc.currentEndDate;
const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId;
const requiresExtraction =
alloc.scope === "segment" &&
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
!datesMatch(alloc.originalEndDate, alloc.allocationEndDate));
pendingSnapshotRef.current = {
allocationId: alloc.allocationId,
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
allocationId: activeAllocationId,
mutationAllocationId: baseMutationAllocationId,
projectName: alloc.projectName ?? "",
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
after: { startDate: currentStartDate, endDate: currentEndDate },
};
updateAllocMutation.mutate({
allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
startDate: alloc.currentStartDate,
endDate: alloc.currentEndDate,
pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => {
const next = new Map(prev);
next.set(activeAllocationId, {
startDate: currentStartDate,
endDate: currentEndDate,
});
return next;
});
void (async () => {
try {
let mutationAllocationId = baseMutationAllocationId;
if (requiresExtraction) {
const extracted = await extractAllocFragmentMutation.mutateAsync({
allocationId: mutationAllocationId,
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
mutationAllocationId = extracted.extractedAllocationId;
}
pendingSnapshotRef.current = pendingSnapshotRef.current
? {
...pendingSnapshotRef.current,
mutationAllocationId,
}
: null;
updateAllocMutation.mutate({
allocationId: mutationAllocationId,
startDate: currentStartDate,
endDate: currentEndDate,
});
} catch {
clearPendingOptimisticAllocation(activeAllocationId);
}
})();
}
allocDragRef.current = INITIAL_ALLOC_DRAG;
@@ -495,8 +954,18 @@ export function useTimelineDrag({
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
allocDragCleanupRef.current = () => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
};
},
[updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
[
clearPendingOptimisticAllocation,
extractAllocFragmentMutation,
setAllocationPreviewTarget,
updateAllocationDragPosition,
updateAllocMutation,
],
);
// ── Range-select ────────────────────────────────────────────────────────────
@@ -531,27 +1000,7 @@ export function useTimelineDrag({
const onCanvasMouseMove = useCallback(
(e: React.MouseEvent) => {
// Project shift
const drag = dragStateRef.current;
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
const deltaX = e.clientX - drag.startMouseX;
const daysDelta = pixelsToDays(deltaX, cellWidth);
if (daysDelta !== drag.daysDelta) {
const { start: newStart, end: newEnd } = computeDragDates(
"move",
drag.originalStartDate,
drag.originalEndDate,
daysDelta,
);
const updated: DragState = {
...drag,
currentStartDate: newStart,
currentEndDate: newEnd,
daysDelta,
};
dragStateRef.current = updated;
setDragState(updated);
}
if (updateProjectDragPosition(e.clientX)) {
return;
}
@@ -559,7 +1008,7 @@ export function useTimelineDrag({
const range = rangeStateRef.current;
if (range.isSelecting && range.startDate) {
const deltaX = e.clientX - range.startClientX;
const daysDelta = pixelsToDays(deltaX, cellWidth);
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
const currentDate = new Date(range.startDate);
currentDate.setDate(currentDate.getDate() + daysDelta);
@@ -573,7 +1022,7 @@ export function useTimelineDrag({
setRangeState(updated);
}
},
[cellWidth],
[updateProjectDragPosition],
);
const onCanvasMouseUp = useCallback(
@@ -581,29 +1030,11 @@ export function useTimelineDrag({
// Project shift
const drag = dragStateRef.current;
if (drag.isDragging) {
if (drag.daysDelta === 0) {
if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
onBlockClick?.({
allocationId: drag.allocationId ?? "",
projectId: drag.projectId,
projectName: drag.projectName ?? "",
startDate: drag.originalStartDate,
endDate: drag.originalEndDate,
});
}
} else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
try {
await applyShiftMutation.mutateAsync({
projectId: drag.projectId,
newStartDate: drag.currentStartDate,
newEndDate: drag.currentEndDate,
});
} catch {
// Validation error — revert visually
}
try {
await finalizeProjectDrag(e.clientX, "mutateAsync");
} catch {
// Validation error — revert visually
}
dragStateRef.current = INITIAL_DRAG_STATE;
setDragState(INITIAL_DRAG_STATE);
return;
}
@@ -627,16 +1058,12 @@ export function useTimelineDrag({
setRangeState(INITIAL_RANGE_STATE);
}
},
[applyShiftMutation, onBlockClick, onRangeSelected],
[finalizeProjectDrag, onRangeSelected],
);
const onCanvasMouseLeave = useCallback(() => {
// Only cancel project-shift and range-select on canvas leave.
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
if (dragStateRef.current.isDragging) {
dragStateRef.current = INITIAL_DRAG_STATE;
setDragState(INITIAL_DRAG_STATE);
}
if (rangeStateRef.current.isSelecting) {
rangeStateRef.current = INITIAL_RANGE_STATE;
setRangeState(INITIAL_RANGE_STATE);
@@ -664,6 +1091,7 @@ export function useTimelineDrag({
};
multiSelectRef.current = initial;
setMultiSelectState(initial);
multiSelectCleanupRef.current?.();
function handleMove(ev: MouseEvent) {
const ms = multiSelectRef.current;
@@ -679,8 +1107,8 @@ export function useTimelineDrag({
}
function handleUp(ev: MouseEvent) {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
multiSelectCleanupRef.current?.();
multiSelectCleanupRef.current = null;
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
@@ -711,6 +1139,10 @@ export function useTimelineDrag({
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
multiSelectCleanupRef.current = () => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
};
}, []);
const clearMultiSelect = useCallback(() => {
@@ -761,6 +1193,9 @@ export function useTimelineDrag({
resourceId: string | null;
startDate: Date;
endDate: Date;
allocationStartDate?: Date;
allocationEndDate?: Date;
scope?: AllocDragScope;
},
) => {
e.preventDefault();
@@ -831,6 +1266,25 @@ export function useTimelineDrag({
[onCanvasMouseUp],
);
useEffect(() => {
return () => {
projectDragCleanupRef.current?.();
allocDragCleanupRef.current?.();
multiSelectCleanupRef.current?.();
projectDragCleanupRef.current = null;
allocDragCleanupRef.current = null;
multiSelectCleanupRef.current = null;
clearLivePreview(projectPreviewRef.current);
clearLivePreview(allocPreviewRef.current);
projectPreviewRef.current = null;
allocPreviewRef.current = null;
dragStateRef.current = INITIAL_DRAG_STATE;
allocDragRef.current = INITIAL_ALLOC_DRAG;
rangeStateRef.current = INITIAL_RANGE_STATE;
multiSelectRef.current = INITIAL_MULTI_SELECT;
};
}, []);
// ── Derived ─────────────────────────────────────────────────────────────────
const shiftPreview: ShiftPreviewData | null =
@@ -852,6 +1306,8 @@ export function useTimelineDrag({
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
shiftPreview,
isPreviewLoading,
isApplying: applyShiftMutation.isPending,
+136 -31
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
type PopoverAnchor =
| { kind: "point"; x: number; y: number }
@@ -17,6 +17,8 @@ interface UseViewportPopoverOptions {
offset?: number;
viewportPadding?: number;
ignoreElements?: Array<HTMLElement | null>;
ignoreSelectors?: string[];
zIndex?: number;
}
export function useViewportPopover({
@@ -29,37 +31,23 @@ export function useViewportPopover({
offset = 8,
viewportPadding = 16,
ignoreElements = [],
ignoreSelectors = [],
zIndex = 9998,
}: UseViewportPopoverOptions) {
const ref = useRef<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (ref.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
onClose();
const computeStyle = (): CSSProperties => {
if (typeof window === "undefined") {
return {
position: "fixed",
left: viewportPadding,
top: viewportPadding,
width,
zIndex,
};
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [ignoreElements, onClose]);
const style = useMemo<CSSProperties>(() => {
let left = 0;
let top = 0;
@@ -94,17 +82,134 @@ export function useViewportPopover({
}
}
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
const measuredWidth = ref.current?.offsetWidth ?? width;
const measuredHeight = ref.current?.offsetHeight ?? estimatedHeight;
const maxLeft = Math.max(viewportPadding, window.innerWidth - measuredWidth - viewportPadding);
const maxTop = Math.max(viewportPadding, window.innerHeight - measuredHeight - viewportPadding);
return {
position: "fixed",
left: Math.min(Math.max(left, viewportPadding), maxLeft),
top: Math.min(Math.max(top, viewportPadding), maxTop),
width,
zIndex: 60,
zIndex,
};
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
};
const [style, setStyle] = useState<CSSProperties>(() => computeStyle());
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (ref.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
if (
target instanceof Element
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("keydown", handleEscape);
};
}, [ignoreElements, ignoreSelectors, onClose]);
useEffect(() => {
setStyle(computeStyle());
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
useEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
setStyle(computeStyle());
});
observer.observe(element);
return () => {
observer.disconnect();
};
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
useEffect(() => {
function cancelScheduledFrame() {
if (frameRef.current === null) return;
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
function updateOrClose() {
if (anchor.kind === "element" && !anchor.element.isConnected) {
onClose();
return;
}
setStyle(computeStyle());
}
function scheduleUpdate(reason: "scroll" | "resize") {
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
updateOrClose();
});
}
updateOrClose();
const handleScroll = () => {
scheduleUpdate("scroll");
};
const handleResize = () => {
scheduleUpdate("resize");
};
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize, { passive: true });
window.visualViewport?.addEventListener("resize", handleResize, { passive: true });
window.visualViewport?.addEventListener("scroll", handleScroll, { passive: true });
return () => {
cancelScheduledFrame();
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("scroll", handleScroll);
};
}, [
align,
anchor,
estimatedHeight,
offset,
onClose,
side,
viewportPadding,
width,
zIndex,
]);
return { ref, style };
}