From f3fa902773dd817c57916597b12e657e33df6a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 11 Apr 2026 08:24:33 +0200 Subject: [PATCH] fix(web): make invalidation hooks async with Promise.all and fix cross-view staleness - useInvalidateTimeline and useInvalidatePlanningViews now return Promise.all instead of fire-and-forget void calls - Timeline mutations now use useInvalidatePlanningViews to also invalidate allocation list views, preventing stale data - AllocationsClient sequential awaits replaced with single invalidatePlanningViews() call (parallel invalidation) Co-Authored-By: Claude Opus 4.6 --- .../allocations/AllocationsClient.tsx | 21 +-- .../timeline/AllocationPopover.test.tsx | 2 +- .../components/timeline/AllocationPopover.tsx | 67 ++++--- .../timeline/BatchAssignPopover.tsx | 25 +-- .../timeline/InlineAllocationEditor.tsx | 18 +- .../timeline/NewAllocationPopover.tsx | 37 ++-- .../src/components/timeline/ProjectPanel.tsx | 166 +++++++++++------- .../src/components/timeline/TimelineView.tsx | 10 +- apps/web/src/hooks/useAllocationHistory.ts | 61 ++++--- .../src/hooks/useInvalidatePlanningViews.ts | 62 +++---- apps/web/src/hooks/useTimelineDrag.ts | 132 +++++++++----- 11 files changed, 372 insertions(+), 229 deletions(-) diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 1239aea..feaa8bf 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -5,6 +5,7 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js"; import { useLocalStorage } from "~/hooks/useLocalStorage.js"; import { formatDate } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { AllocationModal } from "./AllocationModal.js"; import type { AllocationLike, @@ -171,6 +172,7 @@ export function AllocationsClient() { const selection = useSelection(); const utils = trpc.useUtils(); + const invalidatePlanningViews = useInvalidatePlanningViews(); const { canViewCosts } = usePermissions(); // ─── Column visibility ──────────────────────────────────────────────────── @@ -205,31 +207,23 @@ export function AllocationsClient() { const allocationQueryFailure = isError ? getAllocationQueryFailure(error) : null; const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({ - onSuccess: async () => { - await utils.allocation.list.invalidate(); - await utils.allocation.listView.invalidate(); - }, + onSuccess: () => invalidatePlanningViews(), }); const deleteAssignmentMutation = trpc.allocation.deleteAssignment.useMutation({ - onSuccess: async () => { - await utils.allocation.list.invalidate(); - await utils.allocation.listView.invalidate(); - }, + onSuccess: () => invalidatePlanningViews(), }); const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({ onSuccess: async () => { - await utils.allocation.list.invalidate(); - await utils.allocation.listView.invalidate(); + await invalidatePlanningViews(); selection.clear(); }, }); const batchStatusMutation = trpc.allocation.batchUpdateStatus.useMutation({ onSuccess: async () => { - await utils.allocation.list.invalidate(); - await utils.allocation.listView.invalidate(); + await invalidatePlanningViews(); selection.clear(); setShowStatusToast(true); }, @@ -237,8 +231,7 @@ export function AllocationsClient() { const batchDateShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({ onSuccess: async () => { - await utils.allocation.list.invalidate(); - await utils.allocation.listView.invalidate(); + await invalidatePlanningViews(); selection.clear(); setShowDateShiftModal(false); }, diff --git a/apps/web/src/components/timeline/AllocationPopover.test.tsx b/apps/web/src/components/timeline/AllocationPopover.test.tsx index 243f5be..495402c 100644 --- a/apps/web/src/components/timeline/AllocationPopover.test.tsx +++ b/apps/web/src/components/timeline/AllocationPopover.test.tsx @@ -33,7 +33,7 @@ vi.mock("~/lib/trpc/client.js", () => ({ })); vi.mock("~/hooks/useInvalidatePlanningViews.js", () => ({ - useInvalidateTimeline: () => vi.fn(), + useInvalidatePlanningViews: () => vi.fn().mockResolvedValue(undefined), })); vi.mock("~/hooks/useViewportPopover.js", () => ({ diff --git a/apps/web/src/components/timeline/AllocationPopover.tsx b/apps/web/src/components/timeline/AllocationPopover.tsx index 15b2788..cf38e6f 100644 --- a/apps/web/src/components/timeline/AllocationPopover.tsx +++ b/apps/web/src/components/timeline/AllocationPopover.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import type { AllocationLike, Assignment } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; -import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; +import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { useViewportPopover } from "~/hooks/useViewportPopover.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; @@ -38,7 +38,7 @@ export function AllocationPopover({ ignoreScrollContainers, }: AllocationPopoverProps) { const utils = trpc.useUtils(); - const invalidateTimeline = useInvalidateTimeline(); + const invalidatePlanningViews = useInvalidatePlanningViews(); const { ref, style } = useViewportPopover({ anchor: { kind: "point", x: anchorX, y: anchorY }, width: 300, @@ -85,18 +85,16 @@ export function AllocationPopover({ const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { - invalidateTimeline(); + void invalidatePlanningViews(); void utils.allocation.getAssignmentById.invalidate({ id: allocationId }); - void utils.allocation.listView.invalidate(); onClose(); }, }); const carveMutation = trpc.timeline.carveAllocationRange.useMutation({ onSuccess: () => { - invalidateTimeline(); + void invalidatePlanningViews(); void utils.allocation.getAssignmentById.invalidate({ id: allocationId }); - void utils.allocation.listView.invalidate(); onClose(); }, }); @@ -140,7 +138,9 @@ export function AllocationPopover({ Loading... ); - return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body); + return typeof document === "undefined" + ? loadingPopover + : createPortal(loadingPopover, document.body); } if (allocationError) { @@ -152,19 +152,22 @@ export function AllocationPopover({ className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-red-200 bg-white p-4 shadow-xl" >
Allocation unavailable
-

- The selected booking could not be loaded right now. -

+

The selected booking could not be loaded right now.

{allocationError.message}

); - return typeof document === "undefined" ? errorPopover : createPortal(errorPopover, document.body); + return typeof document === "undefined" + ? errorPopover + : createPortal(errorPopover, document.body); } if (!allocation) { @@ -180,17 +183,25 @@ export function AllocationPopover({ The selected booking could not be resolved from the current timeline data.

); - return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body); + return typeof document === "undefined" + ? missingPopover + : createPortal(missingPopover, document.body); } - const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2); + const dailyCostEUR = ( + ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0)) / + 100 + ).toFixed(2); const carveDateRangeInvalid = Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate; @@ -208,14 +219,20 @@ export function AllocationPopover({
{role}
- +
{/* Resource */}
- Resource: {allocation.resource?.displayName} - {" "}· {allocation.resource?.eid} + Resource:{" "} + {allocation.resource?.displayName} ·{" "} + {allocation.resource?.eid}
{/* Role */} @@ -308,7 +325,9 @@ export function AllocationPopover({
Remove Date Range
- {contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."} + {contextDate + ? `Prefilled from ${toDateInput(contextDate)}` + : "Create a gap or split this booking."}
@@ -343,10 +362,7 @@ export function AllocationPopover({ + + Assign to Project + +
{/* Date range */}
- +
- + - + - + - +
void invalidatePlanningViews(), }); const deleteMutation = trpc.allocation.deleteAssignment.useMutation({ - onSuccess: invalidateTimeline, + onSuccess: () => void invalidatePlanningViews(), }); const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({ onSuccess: () => { - invalidateTimeline(); + void invalidatePlanningViews(); setAddingMember(false); setResourceSearch(""); }, @@ -121,7 +121,16 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { const [addingMember, setAddingMember] = useState(false); const [resourceSearch, setResourceSearch] = useState(""); const [pendingEdits, setPendingEdits] = useState< - Record + Record< + string, + { + hoursPerDay?: number; + startDate?: string; + endDate?: string; + includeSaturday?: boolean; + role?: string; + } + > >({}); const [confirmDelete, setConfirmDelete] = useState(null); @@ -142,19 +151,20 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { const staffingReqs = (project.staffingReqs as unknown as StaffingRequirement[]) ?? []; const effectiveAssignments = assignments as unknown as ProjectPanelAssignment[]; const projectDemands = demands as unknown as ProjectPanelDemand[]; - const effectiveDemands: DemandSummary[] = projectDemands.length > 0 - ? projectDemands.map((demand) => ({ - id: demand.id, - role: demand.roleEntity?.name ?? demand.role ?? "Unassigned", - hoursPerDay: demand.hoursPerDay, - requestedHeadcount: demand.requestedHeadcount, - })) - : staffingReqs.map((req, index) => ({ - id: `staffing-${index}`, - role: req.role, - hoursPerDay: req.hoursPerDay, - requestedHeadcount: req.headcount, - })); + const effectiveDemands: DemandSummary[] = + projectDemands.length > 0 + ? projectDemands.map((demand) => ({ + id: demand.id, + role: demand.roleEntity?.name ?? demand.role ?? "Unassigned", + hoursPerDay: demand.hoursPerDay, + requestedHeadcount: demand.requestedHeadcount, + })) + : staffingReqs.map((req, index) => ({ + id: `staffing-${index}`, + role: req.role, + hoursPerDay: req.hoursPerDay, + requestedHeadcount: req.headcount, + })); // Demand vs supply matching const reqMatches = effectiveDemands.map((demand) => { @@ -186,7 +196,7 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { return pendingEdits[id] ?? {}; } - function setEdit(id: string, patch: typeof pendingEdits[string]) { + function setEdit(id: string, patch: (typeof pendingEdits)[string]) { setPendingEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? {}), ...patch } })); } @@ -237,16 +247,18 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {

{project.name}

- + {project.orderType} {project.status} @@ -258,14 +270,17 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
- {/* Budget section */}
-

Budget

+

+ Budget +

Allocated - €{allocatedEUR} / €{budgetEUR} + + €{allocatedEUR} / €{budgetEUR} +
{w.message} @@ -306,33 +321,48 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
{reqMatches.map(({ demand, matched, fulfilled, partial }) => (
-
-
- {demand.role} - {demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day -
- - {fulfilled ? "✓ Filled" : partial ? `${matched.length}/${demand.requestedHeadcount}` : "Unfilled"} + ? "bg-amber-50 dark:bg-amber-900/20" + : "bg-red-50 dark:bg-red-900/20", + )} + > +
+ + {demand.role} + + + {demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day + +
+ + {fulfilled + ? "✓ Filled" + : partial + ? `${matched.length}/${demand.requestedHeadcount}` + : "Unfilled"}
{matched.length > 0 && (
{matched.map((a) => ( -
+
{a.resource?.displayName} {a.hoursPerDay}h/day
@@ -347,7 +377,9 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
Unmatched assignments
{unmatchedAssignments.map((a) => (
- {a.resource?.displayName} — {a.role} + + {a.resource?.displayName} — {a.role} + {a.hoursPerDay}h/day
))} @@ -392,7 +424,10 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { ))}
@@ -493,7 +535,9 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { onChange={(e) => setEdit(alloc.id, { includeSaturday: e.target.checked })} className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-400" /> - Include Saturdays + + Include Saturdays + {/* Save button */} diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 505cc95..3542ab1 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -9,7 +9,7 @@ import { useProjectDragContext } from "~/hooks/useProjectDragContext.js"; 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 { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { AllocationPopover } from "./AllocationPopover.js"; @@ -95,9 +95,9 @@ export function TimelineView() { // We start with 40 (day zoom default) and update via a ref. const cellWidthRef = useRef(40); - const invalidateTimeline = useInvalidateTimeline(); + const invalidatePlanningViews = useInvalidatePlanningViews(); const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({ - onSuccess: invalidateTimeline, + onSuccess: () => void invalidatePlanningViews(), }); const [dragErrorToast, setDragErrorToast] = useState(null); @@ -389,10 +389,10 @@ function TimelineViewContent({ const resourceHoverTimerRef = useRef | null>(null); const previousViewModeRef = useRef(viewMode); - const invalidateTimelineInner = useInvalidateTimeline(); + const invalidatePlanningViewsInner = useInvalidatePlanningViews(); const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({ onSuccess: () => { - invalidateTimelineInner(); + void invalidatePlanningViewsInner(); clearMultiSelect(); }, }); diff --git a/apps/web/src/hooks/useAllocationHistory.ts b/apps/web/src/hooks/useAllocationHistory.ts index fd25b82..ce57804 100644 --- a/apps/web/src/hooks/useAllocationHistory.ts +++ b/apps/web/src/hooks/useAllocationHistory.ts @@ -1,7 +1,7 @@ "use client"; import { useCallback, useRef, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; -import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; +import { useInvalidatePlanningViews } from "./useInvalidatePlanningViews.js"; import type { AllocationMovedSnapshot } from "./useTimelineDrag.js"; export type { AllocationMovedSnapshot }; @@ -9,7 +9,12 @@ export type { AllocationMovedSnapshot }; /** A single allocation move or a batch shift of multiple allocations */ export type HistoryEntry = | { type: "single"; snapshot: AllocationMovedSnapshot } - | { type: "batch"; allocationIds: string[]; daysDelta: number; mode: "move" | "resize-start" | "resize-end" }; + | { + type: "batch"; + allocationIds: string[]; + daysDelta: number; + mode: "move" | "resize-start" | "resize-end"; + }; const DEFAULT_MAX_HISTORY = 50; @@ -19,7 +24,7 @@ export function useAllocationHistory() { const past = useRef([]); const future = useRef([]); - const invalidateTimeline = useInvalidateTimeline(); + const invalidatePlanningViews = useInvalidatePlanningViews(); // Configurable max steps from system settings const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, { @@ -28,26 +33,39 @@ export function useAllocationHistory() { const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY; const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ - onSuccess: invalidateTimeline, + onSuccess: () => void invalidatePlanningViews(), }); const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({ - onSuccess: invalidateTimeline, + onSuccess: () => void invalidatePlanningViews(), }); - const push = useCallback((snapshot: AllocationMovedSnapshot) => { - past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }]; - future.current = []; - setCanUndo(true); - setCanRedo(false); - }, [maxHistory]); + const push = useCallback( + (snapshot: AllocationMovedSnapshot) => { + past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }]; + future.current = []; + setCanUndo(true); + setCanRedo(false); + }, + [maxHistory], + ); - const pushBatch = useCallback((allocationIds: string[], daysDelta: number, mode: "move" | "resize-start" | "resize-end" = "move") => { - past.current = [...past.current.slice(-(maxHistory - 1)), { type: "batch", allocationIds, daysDelta, mode }]; - future.current = []; - setCanUndo(true); - setCanRedo(false); - }, [maxHistory]); + const pushBatch = useCallback( + ( + allocationIds: string[], + daysDelta: number, + mode: "move" | "resize-start" | "resize-end" = "move", + ) => { + past.current = [ + ...past.current.slice(-(maxHistory - 1)), + { type: "batch", allocationIds, daysDelta, mode }, + ]; + future.current = []; + setCanUndo(true); + setCanRedo(false); + }, + [maxHistory], + ); const undo = useCallback(async () => { const last = past.current[past.current.length - 1]; @@ -65,9 +83,12 @@ export function useAllocationHistory() { }); } else { // Batch: reverse the shift (for resize modes, reverse means shifting the same edge back) - const reverseMode = last.mode === "resize-start" ? "resize-start" - : last.mode === "resize-end" ? "resize-end" - : "move"; + const reverseMode = + last.mode === "resize-start" + ? "resize-start" + : last.mode === "resize-end" + ? "resize-end" + : "move"; await batchShiftMutation.mutateAsync({ allocationIds: last.allocationIds, daysDelta: -last.daysDelta, diff --git a/apps/web/src/hooks/useInvalidatePlanningViews.ts b/apps/web/src/hooks/useInvalidatePlanningViews.ts index 2800875..0279679 100644 --- a/apps/web/src/hooks/useInvalidatePlanningViews.ts +++ b/apps/web/src/hooks/useInvalidatePlanningViews.ts @@ -1,39 +1,41 @@ import { trpc } from "~/lib/trpc/client.js"; -/** Invalidates just the 4 timeline queries */ +/** Invalidates just the timeline queries (parallel). */ export function useInvalidateTimeline() { const utils = trpc.useUtils(); - return () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - 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(); - }; + return () => + Promise.all([ + utils.timeline.getEntries.invalidate(), + utils.timeline.getEntriesView.invalidate(), + utils.timeline.getMyEntriesView.invalidate(), + utils.timeline.getHolidayOverlays.invalidate(), + utils.timeline.getMyHolidayOverlays.invalidate(), + utils.vacation.list.invalidate(), + utils.timeline.getProjectContext.invalidate(), + utils.timeline.getBudgetStatus.invalidate(), + ]); } -/** Invalidates all 8 planning-related queries (4 timeline + 4 allocation) */ +/** Invalidates all planning-related queries (timeline + allocation, parallel). */ export function useInvalidatePlanningViews() { const utils = trpc.useUtils(); - return () => { - void utils.allocation.list.invalidate(); - void ( - utils as { - allocation: { listView: { invalidate: () => Promise } }; - } - ).allocation.listView.invalidate(); - void utils.allocation.listDemands.invalidate(); - void utils.allocation.listAssignments.invalidate(); - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - 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(); - }; + return () => + Promise.all([ + utils.allocation.list.invalidate(), + ( + utils as { + allocation: { listView: { invalidate: () => Promise } }; + } + ).allocation.listView.invalidate(), + utils.allocation.listDemands.invalidate(), + utils.allocation.listAssignments.invalidate(), + utils.timeline.getEntries.invalidate(), + utils.timeline.getEntriesView.invalidate(), + utils.timeline.getMyEntriesView.invalidate(), + utils.timeline.getHolidayOverlays.invalidate(), + utils.timeline.getMyHolidayOverlays.invalidate(), + utils.vacation.list.invalidate(), + utils.timeline.getProjectContext.invalidate(), + utils.timeline.getBudgetStatus.invalidate(), + ]); } diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 11859f6..ba35860 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -1,8 +1,15 @@ "use client"; -import { useCallback, useDeferredValue, useEffect, useRef, useState, type MutableRefObject } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useRef, + useState, + type MutableRefObject, +} from "react"; import { trpc } from "~/lib/trpc/client.js"; -import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; +import { useInvalidatePlanningViews } from "./useInvalidatePlanningViews.js"; import { pixelsToDays } from "~/components/timeline/dragMath.js"; import { clearLivePreview, @@ -20,8 +27,14 @@ import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSe import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { beginAllocationDragSession } from "./timelineAllocationDragSession.js"; import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js"; -import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js"; -import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; +import { + cancelTransientMultiSelectState, + cleanupTimelineDragState, +} from "./timelineDragCleanup.js"; +import { + resolveAllocationDragPosition, + resolveProjectDragPosition, +} from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js"; import { createProjectDragState } from "./timelineProjectDrag.js"; @@ -38,8 +51,14 @@ import { } from "./timelineMultiSelect.js"; import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; -import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js"; -import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js"; +import { + createAllocationPreviewSession, + createProjectPreviewSession, +} from "./timelinePreviewSession.js"; +import { + resolveRangeSelectionCancel, + resolveRangeSelectionRelease, +} from "./timelineRangeRelease.js"; import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js"; @@ -284,28 +303,38 @@ export function useTimelineDrag({ onMutationErrorRef.current = onMutationError; const utils = trpc.useUtils(); - const invalidateTimeline = useInvalidateTimeline(); + const invalidatePlanningViews = useInvalidatePlanningViews(); - const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => { - clearLivePreview(projectPreviewRef.current); - projectPreviewRef.current = createProjectPreviewSession({ - projectId, - currentTarget, - cellWidth: cellWidthRef.current, - }); - }, []); + const setProjectPreviewTargets = useCallback( + (projectId: string, currentTarget?: EventTarget | null) => { + clearLivePreview(projectPreviewRef.current); + projectPreviewRef.current = createProjectPreviewSession({ + projectId, + currentTarget, + cellWidth: cellWidthRef.current, + }); + }, + [], + ); - const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => { - clearLivePreview(allocPreviewRef.current); - allocPreviewRef.current = createAllocationPreviewSession({ - currentTarget, - mode, - cellWidth: cellWidthRef.current, - }); - }, []); + const setAllocationPreviewTarget = useCallback( + (currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => { + clearLivePreview(allocPreviewRef.current); + allocPreviewRef.current = createAllocationPreviewSession({ + currentTarget, + mode, + cellWidth: cellWidthRef.current, + }); + }, + [], + ); const updateLivePreview = useCallback( - (previewRef: MutableRefObject, pointerDeltaX: number, daysDelta: number) => { + ( + previewRef: MutableRefObject, + pointerDeltaX: number, + daysDelta: number, + ) => { const preview = previewRef.current; if (!preview) return; preview.cellWidth = cellWidthRef.current; @@ -318,7 +347,11 @@ export function useTimelineDrag({ const updateProjectDragPosition = useCallback( (clientX: number) => { - const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current); + const result = resolveProjectDragPosition( + dragStateRef.current, + clientX, + cellWidthRef.current, + ); if (!result.handled) return false; updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta); @@ -333,7 +366,11 @@ export function useTimelineDrag({ const updateAllocationDragPosition = useCallback( (clientX: number) => { - const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current); + const result = resolveAllocationDragPosition( + allocDragRef.current, + clientX, + cellWidthRef.current, + ); if (!result.handled) return false; updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta); @@ -415,7 +452,7 @@ export function useTimelineDrag({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({ onSuccess: (data: { project: { id: string } }) => { - invalidateTimeline(); + void invalidatePlanningViews(); void utils.project.list.invalidate(); onShiftApplied?.(data.project.id); }, @@ -442,13 +479,13 @@ export function useTimelineDrag({ const pendingSnapshotRef = useRef(null); const pendingOptimisticAllocationIdRef = useRef(null); - const [optimisticAllocations, setOptimisticAllocations] = useState>( - () => new Map(), - ); + const [optimisticAllocations, setOptimisticAllocations] = useState< + Map + >(() => new Map()); const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { - invalidateTimeline(); + void invalidatePlanningViews(); const snap = pendingSnapshotRef.current; if (snap) { onAllocationMovedRef.current?.(snap); @@ -457,7 +494,8 @@ export function useTimelineDrag({ }, onError: (error) => { console.error("[timeline] updateAllocationInline failed:", error); - const message = (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden."; + const message = + (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden."; onMutationErrorRef.current?.(message); clearPendingOptimisticAllocation(); }, @@ -465,7 +503,7 @@ export function useTimelineDrag({ const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({ onSuccess: () => { - invalidateTimeline(); + void invalidatePlanningViews(); }, }); @@ -486,13 +524,20 @@ export function useTimelineDrag({ pendingOptimisticAllocationIdRef.current = null; }, []); - const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => { - setOptimisticAllocations((prev) => { - const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current); - pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId; - return result.changed ? result.optimisticAllocations : prev; - }); - }, []); + const reconcileOptimisticAllocations = useCallback( + (entries: readonly OptimisticTimelineEntry[]) => { + setOptimisticAllocations((prev) => { + const result = reconcileOptimisticEntries( + prev, + entries, + pendingOptimisticAllocationIdRef.current, + ); + pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId; + return result.changed ? result.optimisticAllocations : prev; + }); + }, + [], + ); // ── Project-bar drag (shifts all allocations) ────────────────────────────── @@ -740,7 +785,12 @@ export function useTimelineDrag({ } // Range select - const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE); + const release = resolveRangeSelectionRelease( + rangeStateRef.current, + e.clientX, + e.clientY, + INITIAL_RANGE_STATE, + ); if (release.kind !== "complete") return; onRangeSelected?.(release.selection);