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}
{ onClose(); onOpenPanel(projectId); }}
+ 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
);
- 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.
{ onClose(); onOpenPanel(projectId); }}
+ 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
);
- 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({
@@ -356,7 +372,10 @@ export function AllocationPopover({
{/* Link to full panel */}
{ onClose(); onOpenPanel(projectId); }}
+ onClick={() => {
+ onClose();
+ onOpenPanel(projectId);
+ }}
className="w-full text-xs text-brand-600 hover:text-brand-800 text-center pt-1"
>
Open Project Panel →
diff --git a/apps/web/src/components/timeline/BatchAssignPopover.tsx b/apps/web/src/components/timeline/BatchAssignPopover.tsx
index 989fc52..4e8e705 100644
--- a/apps/web/src/components/timeline/BatchAssignPopover.tsx
+++ b/apps/web/src/components/timeline/BatchAssignPopover.tsx
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
-import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
+import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
const ENTITY_COMBOBOX_OVERLAY_SELECTOR = "[data-entity-combobox-overlay='true']";
@@ -34,16 +34,14 @@ export function BatchAssignPopover({
onCreated,
}: BatchAssignPopoverProps) {
const ref = useRef(null);
- const invalidateTimeline = useInvalidateTimeline();
+ const invalidatePlanningViews = useInvalidatePlanningViews();
- const [selectedProjectId, setSelectedProjectId] = useState(
- null,
- );
+ const [selectedProjectId, setSelectedProjectId] = useState(null);
const [hoursPerDay, setHoursPerDay] = useState(8);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
- invalidateTimeline();
+ void invalidatePlanningViews();
onCreated();
onClose();
},
@@ -94,8 +92,7 @@ export function BatchAssignPopover({
});
}
- const canAssign =
- !!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
+ const canAssign = !!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
const popover = (
{/* Header */}
-
- Batch Assign
-
+
Batch Assign
- {batchMutation.error.message}
-
+ {batchMutation.error.message}
)}
{/* Actions */}
@@ -198,9 +191,7 @@ export function BatchAssignPopover({
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
- {batchMutation.isPending
- ? "Assigning\u2026"
- : `Assign All (${resourceIds.length})`}
+ {batchMutation.isPending ? "Assigning\u2026" : `Assign All (${resourceIds.length})`}
(null);
const panelRef = useRef(null);
- const invalidateTimeline = useInvalidateTimeline();
+ const invalidatePlanningViews = useInvalidatePlanningViews();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMutation = (trpc.allocation.update.useMutation as any)({
onSuccess: () => {
- invalidateTimeline();
+ void invalidatePlanningViews();
onSaved();
},
onError: (err: { message: string }) => {
@@ -95,7 +95,9 @@ export function InlineAllocationEditor({
Edit Allocation