refactor(web): extract allocation drag action plans
This commit is contained in:
@@ -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", () => {
|
||||
expect(
|
||||
requiresAllocationFragmentExtraction({
|
||||
mode: "move",
|
||||
scope: "segment",
|
||||
allocationId: "alloc-1",
|
||||
mutationAllocationId: null,
|
||||
projectName: "Alpha",
|
||||
...baseDates,
|
||||
originalStartDate: baseDates.originalStartDate,
|
||||
originalEndDate: baseDates.originalEndDate,
|
||||
allocationStartDate: new Date("2024-12-31T00:00:00.000Z"),
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 1,
|
||||
allocationEndDate: baseDates.allocationEndDate,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
requiresAllocationFragmentExtraction({
|
||||
mode: "move",
|
||||
scope: "allocation",
|
||||
allocationId: "alloc-1",
|
||||
mutationAllocationId: null,
|
||||
projectName: "Alpha",
|
||||
...baseDates,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 1,
|
||||
originalStartDate: baseDates.originalStartDate,
|
||||
originalEndDate: baseDates.originalEndDate,
|
||||
allocationStartDate: baseDates.allocationStartDate,
|
||||
allocationEndDate: baseDates.allocationEndDate,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -105,14 +98,13 @@ describe("timelineAllocationFinalize", () => {
|
||||
it("returns null snapshots when required dates or ids are missing", () => {
|
||||
expect(
|
||||
buildAllocationMovedSnapshot({
|
||||
mode: "move",
|
||||
scope: "allocation",
|
||||
allocationId: null,
|
||||
mutationAllocationId: null,
|
||||
projectName: "Alpha",
|
||||
...baseDates,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 1,
|
||||
originalStartDate: baseDates.originalStartDate,
|
||||
originalEndDate: baseDates.originalEndDate,
|
||||
currentStartDate: baseDates.currentStartDate,
|
||||
currentEndDate: baseDates.currentEndDate,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -120,16 +112,13 @@ describe("timelineAllocationFinalize", () => {
|
||||
it("builds a mutation snapshot with fallback mutation allocation ids", () => {
|
||||
expect(
|
||||
buildAllocationMovedSnapshot({
|
||||
mode: "move",
|
||||
scope: "allocation",
|
||||
allocationId: "alloc-1",
|
||||
mutationAllocationId: null,
|
||||
projectName: null,
|
||||
...baseDates,
|
||||
originalStartDate: baseDates.originalStartDate,
|
||||
originalEndDate: baseDates.originalEndDate,
|
||||
currentStartDate: new Date("2025-01-02T00:00:00.000Z"),
|
||||
currentEndDate: new Date("2025-01-06T00:00:00.000Z"),
|
||||
pointerDeltaX: 32,
|
||||
daysDelta: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
allocationId: "alloc-1",
|
||||
|
||||
@@ -42,7 +42,12 @@ export function shouldTreatAllocationDragAsClick(
|
||||
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 (
|
||||
alloc.scope === "segment" &&
|
||||
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
|
||||
@@ -51,7 +56,16 @@ export function requiresAllocationFragmentExtraction(alloc: AllocDragFinalizeLik
|
||||
}
|
||||
|
||||
export function buildAllocationMovedSnapshot(
|
||||
alloc: AllocDragFinalizeLike,
|
||||
alloc: Pick<
|
||||
AllocDragFinalizeLike,
|
||||
| "allocationId"
|
||||
| "mutationAllocationId"
|
||||
| "projectName"
|
||||
| "originalStartDate"
|
||||
| "originalEndDate"
|
||||
| "currentStartDate"
|
||||
| "currentEndDate"
|
||||
>,
|
||||
): AllocationMovedSnapshotLike | null {
|
||||
if (
|
||||
!alloc.allocationId ||
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
import {
|
||||
buildAllocationMovedSnapshot,
|
||||
hasAllocationDateChange,
|
||||
requiresAllocationFragmentExtraction,
|
||||
shouldTreatAllocationDragAsClick,
|
||||
} from "./timelineAllocationFinalize.js";
|
||||
import { buildAllocationBlockClickInfo, buildAllocationMutationPlan } from "./timelineAllocationActions.js";
|
||||
import {
|
||||
finalizeAllocationMultiDrag,
|
||||
isAllocationMultiSelected,
|
||||
@@ -729,22 +729,29 @@ export function useTimelineDrag({
|
||||
onShiftClickAllocRef.current?.(alloc.allocationId);
|
||||
} else {
|
||||
// Normal click → open alloc popover
|
||||
onBlockClickRef.current?.({
|
||||
allocationId: alloc.allocationId,
|
||||
projectId: alloc.projectId ?? "",
|
||||
projectName: alloc.projectName ?? "",
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
const clickInfo = buildAllocationBlockClickInfo(alloc);
|
||||
if (clickInfo) {
|
||||
onBlockClickRef.current?.(clickInfo);
|
||||
}
|
||||
}
|
||||
} else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
const activeAllocationId = alloc.allocationId;
|
||||
const currentStartDate = alloc.currentStartDate;
|
||||
const currentEndDate = alloc.currentEndDate;
|
||||
const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId;
|
||||
const requiresExtraction = requiresAllocationFragmentExtraction(alloc);
|
||||
const mutationPlan = buildAllocationMutationPlan(alloc);
|
||||
if (!mutationPlan) {
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
setAllocDragState(INITIAL_ALLOC_DRAG);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSnapshotRef.current = buildAllocationMovedSnapshot(alloc);
|
||||
const {
|
||||
activeAllocationId,
|
||||
currentStartDate,
|
||||
currentEndDate,
|
||||
baseMutationAllocationId,
|
||||
requiresExtraction,
|
||||
pendingSnapshot,
|
||||
} = mutationPlan;
|
||||
|
||||
pendingSnapshotRef.current = pendingSnapshot;
|
||||
pendingOptimisticAllocationIdRef.current = activeAllocationId;
|
||||
setOptimisticAllocations((prev) => {
|
||||
const next = new Map(prev);
|
||||
|
||||
@@ -217,6 +217,21 @@ export const rules = [
|
||||
],
|
||||
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",
|
||||
maxLines: 80,
|
||||
@@ -270,6 +285,10 @@ export const rules = [
|
||||
pattern: /from "\.\/timelineAllocationFinalize\.js"/,
|
||||
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"/,
|
||||
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/,
|
||||
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/,
|
||||
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 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 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 projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
|
||||
|
||||
@@ -88,6 +89,7 @@ describe("architecture guardrails", () => {
|
||||
assert.ok(optimisticRule);
|
||||
assert.ok(allocationFinalizeRule);
|
||||
assert.ok(allocationMultiDragRule);
|
||||
assert.ok(allocationActionsRule);
|
||||
assert.ok(allocationDragStateRule);
|
||||
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 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 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 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",
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
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, ""), [
|
||||
"apps/web/src/hooks/timelineAllocationDragState.ts: missing guardrail anchor: timeline allocation drag state helpers must keep drag bootstrap centralized",
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user