From 54c6cf2e2d2a17b8d3983f2d11890ce9dd2acbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 09:53:40 +0200 Subject: [PATCH] refactor(web): extract optimistic timeline reconciliation --- .../timelineOptimisticAllocations.test.ts | 84 +++++++++++++++++++ .../hooks/timelineOptimisticAllocations.ts | 51 +++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 25 +----- 3 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/hooks/timelineOptimisticAllocations.test.ts create mode 100644 apps/web/src/hooks/timelineOptimisticAllocations.ts diff --git a/apps/web/src/hooks/timelineOptimisticAllocations.test.ts b/apps/web/src/hooks/timelineOptimisticAllocations.test.ts new file mode 100644 index 0000000..4674b95 --- /dev/null +++ b/apps/web/src/hooks/timelineOptimisticAllocations.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; + +describe("timelineOptimisticAllocations", () => { + it("keeps state unchanged when no optimistic allocations exist", () => { + expect( + reconcileOptimisticEntries( + new Map(), + [{ id: "alloc-1", startDate: "2025-01-01T00:00:00.000Z", endDate: "2025-01-02T00:00:00.000Z" }], + "alloc-1", + ), + ).toEqual({ + optimisticAllocations: new Map(), + pendingOptimisticAllocationId: "alloc-1", + changed: false, + }); + }); + + it("keeps unmatched optimistic entries and pending ids", () => { + const optimisticAllocations = new Map([ + [ + "alloc-1", + { + startDate: new Date("2025-01-03T00:00:00.000Z"), + endDate: new Date("2025-01-04T00:00:00.000Z"), + }, + ], + ]); + + expect( + reconcileOptimisticEntries( + optimisticAllocations, + [{ id: "alloc-1", startDate: "2025-01-05T00:00:00.000Z", endDate: "2025-01-06T00:00:00.000Z" }], + "alloc-1", + ), + ).toEqual({ + optimisticAllocations, + pendingOptimisticAllocationId: "alloc-1", + changed: false, + }); + }); + + it("drops optimistic entries once server data matches the optimistic override", () => { + const optimisticAllocations = new Map([ + [ + "alloc-1", + { + startDate: new Date("2025-01-03T00:00:00.000Z"), + endDate: new Date("2025-01-04T00:00:00.000Z"), + }, + ], + [ + "alloc-2", + { + startDate: new Date("2025-01-07T00:00:00.000Z"), + endDate: new Date("2025-01-08T00:00:00.000Z"), + }, + ], + ]); + + expect( + reconcileOptimisticEntries( + optimisticAllocations, + [ + { id: "alloc-1", startDate: "2025-01-03T00:00:00.000Z", endDate: "2025-01-04T00:00:00.000Z" }, + { id: "alloc-2", startDate: "2025-01-10T00:00:00.000Z", endDate: "2025-01-11T00:00:00.000Z" }, + ], + "alloc-1", + ), + ).toEqual({ + optimisticAllocations: new Map([ + [ + "alloc-2", + { + startDate: new Date("2025-01-07T00:00:00.000Z"), + endDate: new Date("2025-01-08T00:00:00.000Z"), + }, + ], + ]), + pendingOptimisticAllocationId: null, + changed: true, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineOptimisticAllocations.ts b/apps/web/src/hooks/timelineOptimisticAllocations.ts new file mode 100644 index 0000000..3c6d7fa --- /dev/null +++ b/apps/web/src/hooks/timelineOptimisticAllocations.ts @@ -0,0 +1,51 @@ +export type OptimisticTimelineEntryLike = { + id: string; + startDate: Date | string; + endDate: Date | string; +}; + +export type OptimisticTimelineOverrideLike = { + startDate: Date; + endDate: Date; +}; + +export function reconcileOptimisticEntries( + optimisticAllocations: ReadonlyMap, + entries: readonly OptimisticTimelineEntryLike[], + pendingOptimisticAllocationId: string | null, +): { + optimisticAllocations: Map; + pendingOptimisticAllocationId: string | null; + changed: boolean; +} { + if (optimisticAllocations.size === 0) { + return { + optimisticAllocations: new Map(optimisticAllocations), + pendingOptimisticAllocationId, + changed: false, + }; + } + + const next = new Map(optimisticAllocations); + let nextPendingId = pendingOptimisticAllocationId; + + 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 (nextPendingId === entry.id) { + nextPendingId = null; + } + } + } + + return { + optimisticAllocations: next, + pendingOptimisticAllocationId: nextPendingId, + changed: next.size !== optimisticAllocations.size || nextPendingId !== pendingOptimisticAllocationId, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index a8936ec..d85ab6e 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -17,6 +17,7 @@ import { finalizeMultiSelectDraft, updateMultiSelectDraft, } from "./timelineMultiSelect.js"; +import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; @@ -510,27 +511,9 @@ export function useTimelineDrag({ 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; + const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current); + pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId; + return result.changed ? result.optimisticAllocations : prev; }); }, []);