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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HistoryEntry[]>([]);
|
||||
const future = useRef<HistoryEntry[]>([]);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<unknown> } };
|
||||
}
|
||||
).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<unknown> } };
|
||||
}
|
||||
).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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
|
||||
(
|
||||
previewRef: MutableRefObject<LivePreviewSession | null>,
|
||||
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<AllocationMovedSnapshot | null>(null);
|
||||
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
|
||||
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [optimisticAllocations, setOptimisticAllocations] = useState<
|
||||
Map<string, OptimisticTimelineOverride>
|
||||
>(() => 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);
|
||||
|
||||
Reference in New Issue
Block a user