refactor(web): extract allocation drag action plans

This commit is contained in:
2026-04-01 10:15:54 +02:00
parent 203bb8751d
commit c941b1e5cf
7 changed files with 246 additions and 40 deletions
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { buildAllocationBlockClickInfo, buildAllocationMutationPlan } from "./timelineAllocationActions.js";
const baseAlloc = {
allocationId: "alloc-1",
mutationAllocationId: null,
projectId: "project-1",
projectName: "Alpha",
scope: "allocation" as const,
allocationStartDate: new Date("2025-01-01T00:00:00.000Z"),
allocationEndDate: new Date("2025-01-05T00:00:00.000Z"),
originalStartDate: new Date("2025-01-01T00:00:00.000Z"),
originalEndDate: new Date("2025-01-05T00:00:00.000Z"),
currentStartDate: new Date("2025-01-02T00:00:00.000Z"),
currentEndDate: new Date("2025-01-06T00:00:00.000Z"),
};
describe("timelineAllocationActions", () => {
it("returns null click info when the drag lacks an allocation id or original dates", () => {
expect(
buildAllocationBlockClickInfo({
allocationId: null,
projectId: "project-1",
projectName: "Alpha",
originalStartDate: baseAlloc.originalStartDate,
originalEndDate: baseAlloc.originalEndDate,
}),
).toBeNull();
expect(
buildAllocationBlockClickInfo({
allocationId: "alloc-1",
projectId: "project-1",
projectName: "Alpha",
originalStartDate: null,
originalEndDate: baseAlloc.originalEndDate,
}),
).toBeNull();
});
it("builds click info with empty-string fallbacks for nullable project metadata", () => {
expect(
buildAllocationBlockClickInfo({
allocationId: "alloc-1",
projectId: null,
projectName: null,
originalStartDate: baseAlloc.originalStartDate,
originalEndDate: baseAlloc.originalEndDate,
}),
).toEqual({
allocationId: "alloc-1",
projectId: "",
projectName: "",
startDate: new Date("2025-01-01T00:00:00.000Z"),
endDate: new Date("2025-01-05T00:00:00.000Z"),
});
});
it("returns no mutation plan when the drag never produced complete current dates", () => {
expect(
buildAllocationMutationPlan({
...baseAlloc,
allocationId: null,
}),
).toBeNull();
expect(
buildAllocationMutationPlan({
...baseAlloc,
currentEndDate: null,
}),
).toBeNull();
});
it("builds mutation plans with fallback mutation ids and extraction flags", () => {
expect(
buildAllocationMutationPlan({
...baseAlloc,
scope: "segment",
allocationStartDate: new Date("2024-12-31T00:00:00.000Z"),
projectName: null,
}),
).toEqual({
activeAllocationId: "alloc-1",
currentStartDate: new Date("2025-01-02T00:00:00.000Z"),
currentEndDate: new Date("2025-01-06T00:00:00.000Z"),
baseMutationAllocationId: "alloc-1",
requiresExtraction: true,
pendingSnapshot: {
allocationId: "alloc-1",
mutationAllocationId: "alloc-1",
projectName: "",
before: {
startDate: new Date("2025-01-01T00:00:00.000Z"),
endDate: new Date("2025-01-05T00:00:00.000Z"),
},
after: {
startDate: new Date("2025-01-02T00:00:00.000Z"),
endDate: new Date("2025-01-06T00:00:00.000Z"),
},
},
});
});
});
@@ -0,0 +1,62 @@
import {
buildAllocationMovedSnapshot,
requiresAllocationFragmentExtraction,
type AllocationMovedSnapshotLike,
} from "./timelineAllocationFinalize.js";
type AllocationActionLike = {
allocationId: string | null;
mutationAllocationId: string | null;
projectId: string | null;
projectName: string | null;
scope: "allocation" | "segment";
allocationStartDate: Date | null;
allocationEndDate: Date | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
};
export function buildAllocationBlockClickInfo(
alloc: Pick<
AllocationActionLike,
"allocationId" | "projectId" | "projectName" | "originalStartDate" | "originalEndDate"
>,
): { allocationId: string; projectId: string; projectName: string; startDate: Date; endDate: Date } | null {
if (!alloc.allocationId || !alloc.originalStartDate || !alloc.originalEndDate) {
return null;
}
return {
allocationId: alloc.allocationId,
projectId: alloc.projectId ?? "",
projectName: alloc.projectName ?? "",
startDate: alloc.originalStartDate,
endDate: alloc.originalEndDate,
};
}
export function buildAllocationMutationPlan(
alloc: AllocationActionLike,
): {
activeAllocationId: string;
currentStartDate: Date;
currentEndDate: Date;
baseMutationAllocationId: string;
requiresExtraction: boolean;
pendingSnapshot: AllocationMovedSnapshotLike | null;
} | null {
if (!alloc.allocationId || !alloc.currentStartDate || !alloc.currentEndDate) {
return null;
}
return {
activeAllocationId: alloc.allocationId,
currentStartDate: alloc.currentStartDate,
currentEndDate: alloc.currentEndDate,
baseMutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
requiresExtraction: requiresAllocationFragmentExtraction(alloc),
pendingSnapshot: buildAllocationMovedSnapshot(alloc),
};
}
@@ -76,28 +76,21 @@ describe("timelineAllocationFinalize", () => {
it("requires extraction only for segment drags that no longer match allocation boundaries", () => { it("requires extraction only for segment drags that no longer match allocation boundaries", () => {
expect( expect(
requiresAllocationFragmentExtraction({ requiresAllocationFragmentExtraction({
mode: "move",
scope: "segment", scope: "segment",
allocationId: "alloc-1", originalStartDate: baseDates.originalStartDate,
mutationAllocationId: null, originalEndDate: baseDates.originalEndDate,
projectName: "Alpha",
...baseDates,
allocationStartDate: new Date("2024-12-31T00:00:00.000Z"), allocationStartDate: new Date("2024-12-31T00:00:00.000Z"),
pointerDeltaX: 0, allocationEndDate: baseDates.allocationEndDate,
daysDelta: 1,
}), }),
).toBe(true); ).toBe(true);
expect( expect(
requiresAllocationFragmentExtraction({ requiresAllocationFragmentExtraction({
mode: "move",
scope: "allocation", scope: "allocation",
allocationId: "alloc-1", originalStartDate: baseDates.originalStartDate,
mutationAllocationId: null, originalEndDate: baseDates.originalEndDate,
projectName: "Alpha", allocationStartDate: baseDates.allocationStartDate,
...baseDates, allocationEndDate: baseDates.allocationEndDate,
pointerDeltaX: 0,
daysDelta: 1,
}), }),
).toBe(false); ).toBe(false);
}); });
@@ -105,14 +98,13 @@ describe("timelineAllocationFinalize", () => {
it("returns null snapshots when required dates or ids are missing", () => { it("returns null snapshots when required dates or ids are missing", () => {
expect( expect(
buildAllocationMovedSnapshot({ buildAllocationMovedSnapshot({
mode: "move",
scope: "allocation",
allocationId: null, allocationId: null,
mutationAllocationId: null, mutationAllocationId: null,
projectName: "Alpha", projectName: "Alpha",
...baseDates, originalStartDate: baseDates.originalStartDate,
pointerDeltaX: 0, originalEndDate: baseDates.originalEndDate,
daysDelta: 1, currentStartDate: baseDates.currentStartDate,
currentEndDate: baseDates.currentEndDate,
}), }),
).toBeNull(); ).toBeNull();
}); });
@@ -120,16 +112,13 @@ describe("timelineAllocationFinalize", () => {
it("builds a mutation snapshot with fallback mutation allocation ids", () => { it("builds a mutation snapshot with fallback mutation allocation ids", () => {
expect( expect(
buildAllocationMovedSnapshot({ buildAllocationMovedSnapshot({
mode: "move",
scope: "allocation",
allocationId: "alloc-1", allocationId: "alloc-1",
mutationAllocationId: null, mutationAllocationId: null,
projectName: null, projectName: null,
...baseDates, originalStartDate: baseDates.originalStartDate,
originalEndDate: baseDates.originalEndDate,
currentStartDate: new Date("2025-01-02T00:00:00.000Z"), currentStartDate: new Date("2025-01-02T00:00:00.000Z"),
currentEndDate: new Date("2025-01-06T00:00:00.000Z"), currentEndDate: new Date("2025-01-06T00:00:00.000Z"),
pointerDeltaX: 32,
daysDelta: 1,
}), }),
).toEqual({ ).toEqual({
allocationId: "alloc-1", allocationId: "alloc-1",
@@ -42,7 +42,12 @@ export function shouldTreatAllocationDragAsClick(
return alloc.mode === "move" && alloc.daysDelta === 0 && Math.abs(alloc.pointerDeltaX) <= clickThresholdPx; return alloc.mode === "move" && alloc.daysDelta === 0 && Math.abs(alloc.pointerDeltaX) <= clickThresholdPx;
} }
export function requiresAllocationFragmentExtraction(alloc: AllocDragFinalizeLike): boolean { export function requiresAllocationFragmentExtraction(
alloc: Pick<
AllocDragFinalizeLike,
"scope" | "originalStartDate" | "allocationStartDate" | "originalEndDate" | "allocationEndDate"
>,
): boolean {
return ( return (
alloc.scope === "segment" && alloc.scope === "segment" &&
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) || (!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
@@ -51,7 +56,16 @@ export function requiresAllocationFragmentExtraction(alloc: AllocDragFinalizeLik
} }
export function buildAllocationMovedSnapshot( export function buildAllocationMovedSnapshot(
alloc: AllocDragFinalizeLike, alloc: Pick<
AllocDragFinalizeLike,
| "allocationId"
| "mutationAllocationId"
| "projectName"
| "originalStartDate"
| "originalEndDate"
| "currentStartDate"
| "currentEndDate"
>,
): AllocationMovedSnapshotLike | null { ): AllocationMovedSnapshotLike | null {
if ( if (
!alloc.allocationId || !alloc.allocationId ||
+21 -14
View File
@@ -14,9 +14,9 @@ import {
import { import {
buildAllocationMovedSnapshot, buildAllocationMovedSnapshot,
hasAllocationDateChange, hasAllocationDateChange,
requiresAllocationFragmentExtraction,
shouldTreatAllocationDragAsClick, shouldTreatAllocationDragAsClick,
} from "./timelineAllocationFinalize.js"; } from "./timelineAllocationFinalize.js";
import { buildAllocationBlockClickInfo, buildAllocationMutationPlan } from "./timelineAllocationActions.js";
import { import {
finalizeAllocationMultiDrag, finalizeAllocationMultiDrag,
isAllocationMultiSelected, isAllocationMultiSelected,
@@ -729,22 +729,29 @@ export function useTimelineDrag({
onShiftClickAllocRef.current?.(alloc.allocationId); onShiftClickAllocRef.current?.(alloc.allocationId);
} else { } else {
// Normal click → open alloc popover // Normal click → open alloc popover
onBlockClickRef.current?.({ const clickInfo = buildAllocationBlockClickInfo(alloc);
allocationId: alloc.allocationId, if (clickInfo) {
projectId: alloc.projectId ?? "", onBlockClickRef.current?.(clickInfo);
projectName: alloc.projectName ?? "", }
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
} }
} else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) { } else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
const activeAllocationId = alloc.allocationId; const mutationPlan = buildAllocationMutationPlan(alloc);
const currentStartDate = alloc.currentStartDate; if (!mutationPlan) {
const currentEndDate = alloc.currentEndDate; allocDragRef.current = INITIAL_ALLOC_DRAG;
const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId; setAllocDragState(INITIAL_ALLOC_DRAG);
const requiresExtraction = requiresAllocationFragmentExtraction(alloc); return;
}
pendingSnapshotRef.current = buildAllocationMovedSnapshot(alloc); const {
activeAllocationId,
currentStartDate,
currentEndDate,
baseMutationAllocationId,
requiresExtraction,
pendingSnapshot,
} = mutationPlan;
pendingSnapshotRef.current = pendingSnapshot;
pendingOptimisticAllocationIdRef.current = activeAllocationId; pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => { setOptimisticAllocations((prev) => {
const next = new Map(prev); const next = new Map(prev);
+23
View File
@@ -217,6 +217,21 @@ export const rules = [
], ],
forbidden: [], forbidden: [],
}, },
{
file: "apps/web/src/hooks/timelineAllocationActions.ts",
maxLines: 90,
required: [
{
pattern: /\bexport function buildAllocationBlockClickInfo\b/,
message: "timeline allocation action helpers must keep popover click payload derivation centralized",
},
{
pattern: /\bexport function buildAllocationMutationPlan\b/,
message: "timeline allocation action helpers must keep mutation plan derivation centralized",
},
],
forbidden: [],
},
{ {
file: "apps/web/src/hooks/timelineAllocationDragState.ts", file: "apps/web/src/hooks/timelineAllocationDragState.ts",
maxLines: 80, maxLines: 80,
@@ -270,6 +285,10 @@ export const rules = [
pattern: /from "\.\/timelineAllocationFinalize\.js"/, pattern: /from "\.\/timelineAllocationFinalize\.js"/,
message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module", message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module",
}, },
{
pattern: /from "\.\/timelineAllocationActions\.js"/,
message: "timeline drag must keep allocation click and mutation plan derivation delegated to the extracted helper module",
},
{ {
pattern: /from "\.\/timelineAllocationMultiDrag\.js"/, pattern: /from "\.\/timelineAllocationMultiDrag\.js"/,
message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
@@ -296,6 +315,10 @@ export const rules = [
pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/, pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/,
message: "timeline drag must not re-inline extracted optimistic or allocation finalize helper implementations", message: "timeline drag must not re-inline extracted optimistic or allocation finalize helper implementations",
}, },
{
pattern: /\bfunction (?:buildAllocationBlockClickInfo|buildAllocationMutationPlan)\b/,
message: "timeline drag must not re-inline extracted allocation action helper implementations",
},
{ {
pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/, pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/,
message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations", message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations",
@@ -77,6 +77,7 @@ describe("architecture guardrails", () => {
const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts"); const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts");
const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts"); const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts");
const allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts"); const allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts");
const allocationActionsRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationActions.ts");
const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts"); const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts");
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts"); const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
@@ -88,6 +89,7 @@ describe("architecture guardrails", () => {
assert.ok(optimisticRule); assert.ok(optimisticRule);
assert.ok(allocationFinalizeRule); assert.ok(allocationFinalizeRule);
assert.ok(allocationMultiDragRule); assert.ok(allocationMultiDragRule);
assert.ok(allocationActionsRule);
assert.ok(allocationDragStateRule); assert.ok(allocationDragStateRule);
assert.ok(projectDragRule); assert.ok(projectDragRule);
@@ -98,6 +100,7 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation click and mutation plan derivation delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
@@ -136,6 +139,10 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior centralized", "apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior centralized",
]); ]);
assert.deepEqual(evaluateRule(allocationActionsRule, "export function buildAllocationBlockClickInfo() {}\n"), [
"apps/web/src/hooks/timelineAllocationActions.ts: missing guardrail anchor: timeline allocation action helpers must keep mutation plan derivation centralized",
]);
assert.deepEqual(evaluateRule(allocationDragStateRule, ""), [ assert.deepEqual(evaluateRule(allocationDragStateRule, ""), [
"apps/web/src/hooks/timelineAllocationDragState.ts: missing guardrail anchor: timeline allocation drag state helpers must keep drag bootstrap centralized", "apps/web/src/hooks/timelineAllocationDragState.ts: missing guardrail anchor: timeline allocation drag state helpers must keep drag bootstrap centralized",
]); ]);