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:
2026-04-11 08:24:33 +02:00
parent f18777c365
commit f3fa902773
11 changed files with 372 additions and 229 deletions
@@ -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...
</div>
);
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"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be loaded right now.
</p>
<p className="text-xs text-gray-500">The selected booking could not be loaded right now.</p>
<p className="text-xs text-red-600">{allocationError.message}</p>
<button
onClick={() => { 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
</button>
</div>
);
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.
</p>
<button
onClick={() => { 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
</button>
</div>
);
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({
<div>
<span className="text-sm font-semibold text-gray-800">{role}</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-lg leading-none"
>
&times;
</button>
</div>
<div className="space-y-3 overflow-y-auto p-4">
{/* Resource */}
<div className="text-xs text-gray-500">
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
{" "}· <span className="text-gray-400">{allocation.resource?.eid}</span>
Resource:{" "}
<span className="font-medium text-gray-700">{allocation.resource?.displayName}</span> ·{" "}
<span className="text-gray-400">{allocation.resource?.eid}</span>
</div>
{/* Role */}
@@ -308,7 +325,9 @@ export function AllocationPopover({
<div>
<div className="text-xs font-medium text-gray-700">Remove Date Range</div>
<div className="text-[11px] text-gray-500">
{contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."}
{contextDate
? `Prefilled from ${toDateInput(contextDate)}`
: "Create a gap or split this booking."}
</div>
</div>
</div>
@@ -343,10 +362,7 @@ export function AllocationPopover({
<button
onClick={handleCarveRange}
disabled={
carveMutation.isPending ||
!carveStartDate ||
!carveEndDate ||
carveDateRangeInvalid
carveMutation.isPending || !carveStartDate || !carveEndDate || carveDateRangeInvalid
}
className="w-full py-1.5 rounded-lg text-sm font-medium transition-colors bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
@@ -356,7 +372,10 @@ export function AllocationPopover({
{/* Link to full panel */}
<button
onClick={() => { 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