refactor(web): extract optimistic timeline reconciliation

This commit is contained in:
2026-04-01 09:53:40 +02:00
parent ea4074af8f
commit 54c6cf2e2d
3 changed files with 139 additions and 21 deletions
@@ -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,
});
});
});
@@ -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<string, OptimisticTimelineOverrideLike>,
entries: readonly OptimisticTimelineEntryLike[],
pendingOptimisticAllocationId: string | null,
): {
optimisticAllocations: Map<string, OptimisticTimelineOverrideLike>;
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,
};
}
+4 -21
View File
@@ -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;
});
}, []);