From 37c6e03d238555230f0ad0a7566299ef49874893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 11:35:17 +0200 Subject: [PATCH] refactor(web): extract allocation release effects --- .../timelineAllocationReleaseEffects.test.ts | 270 ++++++++++++++++++ .../hooks/timelineAllocationReleaseEffects.ts | 104 +++++++ apps/web/src/hooks/useTimelineDrag.ts | 82 +----- scripts/check-architecture-guardrails.mjs | 35 ++- .../check-architecture-guardrails.test.mjs | 16 +- 5 files changed, 427 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/hooks/timelineAllocationReleaseEffects.test.ts create mode 100644 apps/web/src/hooks/timelineAllocationReleaseEffects.ts diff --git a/apps/web/src/hooks/timelineAllocationReleaseEffects.test.ts b/apps/web/src/hooks/timelineAllocationReleaseEffects.test.ts new file mode 100644 index 0000000..d3cf9cc --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationReleaseEffects.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, vi } from "vitest"; +import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js"; + +const BASE_ALLOC = { + isActive: true, + mode: "move" as const, + scope: "allocation" as const, + allocationId: "alloc-1", + mutationAllocationId: "alloc-1", + projectId: "project-1", + projectName: "Project One", + allocationStartDate: new Date("2025-01-01"), + allocationEndDate: new Date("2025-01-03"), + originalStartDate: new Date("2025-01-01"), + originalEndDate: new Date("2025-01-03"), + currentStartDate: new Date("2025-01-01"), + currentEndDate: new Date("2025-01-03"), + pointerDeltaX: 0, + daysDelta: 0, +}; + +function createOptimisticStateHarness() { + let optimistic = new Map(); + + return { + get value() { + return optimistic; + }, + setOptimisticAllocations( + updater: ( + prev: Map, + ) => Map, + ) { + optimistic = updater(optimistic); + }, + }; +} + +describe("timelineAllocationReleaseEffects", () => { + it("ignores inactive releases without touching preview or optimistic state", async () => { + const updatePosition = vi.fn(); + const preserve = vi.fn(); + const clear = vi.fn(); + const onShiftClick = vi.fn(); + const onBlockClick = vi.fn(); + const optimisticHarness = createOptimisticStateHarness(); + const pendingSnapshotRef = { current: null }; + const pendingOptimisticAllocationIdRef = { current: null as string | null }; + + await finalizeAllocationReleaseEffects({ + clientX: 24, + allocRef: { current: { ...BASE_ALLOC, isActive: false } }, + previewRef: { current: {} as never }, + updatePosition, + clickThresholdPx: 5, + wasShift: false, + onShiftClick, + onBlockClick, + pendingSnapshotRef, + pendingOptimisticAllocationIdRef, + setOptimisticAllocations: optimisticHarness.setOptimisticAllocations, + extractAllocationFragment: vi.fn(), + updateAllocation: vi.fn(), + clearPendingOptimisticAllocation: vi.fn(), + previewOps: { preserve, clear }, + }); + + expect(updatePosition).toHaveBeenCalledWith(24); + expect(preserve).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(onShiftClick).not.toHaveBeenCalled(); + expect(onBlockClick).not.toHaveBeenCalled(); + expect(pendingSnapshotRef.current).toBeNull(); + expect(pendingOptimisticAllocationIdRef.current).toBeNull(); + expect(optimisticHarness.value.size).toBe(0); + }); + + it("clears preview and dispatches click releases without starting mutations", async () => { + const clear = vi.fn(); + const onBlockClick = vi.fn(); + const optimisticHarness = createOptimisticStateHarness(); + const previewRef = { current: {} as never }; + + await finalizeAllocationReleaseEffects({ + clientX: 40, + allocRef: { current: BASE_ALLOC }, + previewRef, + updatePosition: vi.fn(), + clickThresholdPx: 5, + wasShift: false, + onBlockClick, + pendingSnapshotRef: { current: null }, + pendingOptimisticAllocationIdRef: { current: null }, + setOptimisticAllocations: optimisticHarness.setOptimisticAllocations, + extractAllocationFragment: vi.fn(), + updateAllocation: vi.fn(), + clearPendingOptimisticAllocation: vi.fn(), + resolveRelease: () => ({ + kind: "click", + clickInfo: { + allocationId: "alloc-1", + projectId: "project-1", + projectName: "Project One", + startDate: new Date("2025-01-01"), + endDate: new Date("2025-01-03"), + }, + preservePreview: true, + }), + previewOps: { preserve: vi.fn(), clear }, + }); + + expect(clear).toHaveBeenCalledOnce(); + expect(onBlockClick).toHaveBeenCalledWith( + expect.objectContaining({ allocationId: "alloc-1", projectId: "project-1" }), + ); + expect(previewRef.current).toBeNull(); + expect(optimisticHarness.value.size).toBe(0); + }); + + it("starts optimistic mutation flow and rewrites the pending snapshot after extraction", async () => { + const preserve = vi.fn(); + const clear = vi.fn(); + const extractAllocationFragment = vi.fn().mockResolvedValue({ + extractedAllocationId: "alloc-fragment-2", + }); + const updateAllocation = vi.fn(); + const optimisticHarness = createOptimisticStateHarness(); + const pendingSnapshotRef = { + current: null as { + allocationId: string; + mutationAllocationId: string; + projectName: string; + before: { startDate: Date; endDate: Date }; + after: { startDate: Date; endDate: Date }; + } | null, + }; + const pendingOptimisticAllocationIdRef = { current: null as string | null }; + + await finalizeAllocationReleaseEffects({ + clientX: 44, + allocRef: { current: BASE_ALLOC }, + previewRef: { current: {} as never }, + updatePosition: vi.fn(), + clickThresholdPx: 5, + wasShift: false, + pendingSnapshotRef, + pendingOptimisticAllocationIdRef, + setOptimisticAllocations: optimisticHarness.setOptimisticAllocations, + extractAllocationFragment, + updateAllocation, + clearPendingOptimisticAllocation: vi.fn(), + resolveRelease: () => ({ + kind: "mutation", + preservePreview: true, + mutationPlan: { + activeAllocationId: "alloc-1", + currentStartDate: new Date("2025-01-05"), + currentEndDate: new Date("2025-01-07"), + baseMutationAllocationId: "alloc-1", + requiresExtraction: true, + pendingSnapshot: { + allocationId: "alloc-1", + mutationAllocationId: "alloc-1", + projectName: "Project One", + before: { + startDate: new Date("2025-01-01"), + endDate: new Date("2025-01-03"), + }, + after: { + startDate: new Date("2025-01-05"), + endDate: new Date("2025-01-07"), + }, + }, + }, + }), + previewOps: { preserve, clear }, + }); + + expect(preserve).toHaveBeenCalledOnce(); + expect(clear).toHaveBeenCalledOnce(); + expect(pendingOptimisticAllocationIdRef.current).toBe("alloc-1"); + expect(optimisticHarness.value.get("alloc-1")).toEqual({ + startDate: new Date("2025-01-05"), + endDate: new Date("2025-01-07"), + }); + expect(extractAllocationFragment).toHaveBeenCalledWith({ + allocationId: "alloc-1", + startDate: new Date("2025-01-01"), + endDate: new Date("2025-01-03"), + }); + expect(pendingSnapshotRef.current).toEqual( + expect.objectContaining({ mutationAllocationId: "alloc-fragment-2" }), + ); + expect(updateAllocation).toHaveBeenCalledWith({ + allocationId: "alloc-fragment-2", + startDate: new Date("2025-01-05"), + endDate: new Date("2025-01-07"), + }); + }); + + it("clears optimistic state when extraction fails", async () => { + const optimisticHarness = createOptimisticStateHarness(); + const pendingSnapshotRef = { + current: null as { + allocationId: string; + mutationAllocationId: string; + projectName: string; + before: { startDate: Date; endDate: Date }; + after: { startDate: Date; endDate: Date }; + } | null, + }; + const pendingOptimisticAllocationIdRef = { current: null as string | null }; + const clearPendingOptimisticAllocation = vi.fn((allocationId: string) => { + pendingSnapshotRef.current = null; + pendingOptimisticAllocationIdRef.current = null; + optimisticHarness.setOptimisticAllocations((prev) => { + const next = new Map(prev); + next.delete(allocationId); + return next; + }); + }); + const updateAllocation = vi.fn(); + + await finalizeAllocationReleaseEffects({ + clientX: 52, + allocRef: { current: BASE_ALLOC }, + previewRef: { current: {} as never }, + updatePosition: vi.fn(), + clickThresholdPx: 5, + wasShift: false, + pendingSnapshotRef, + pendingOptimisticAllocationIdRef, + setOptimisticAllocations: optimisticHarness.setOptimisticAllocations, + extractAllocationFragment: vi.fn().mockRejectedValue(new Error("boom")), + updateAllocation, + clearPendingOptimisticAllocation, + resolveRelease: () => ({ + kind: "mutation", + preservePreview: true, + mutationPlan: { + activeAllocationId: "alloc-1", + currentStartDate: new Date("2025-01-05"), + currentEndDate: new Date("2025-01-07"), + baseMutationAllocationId: "alloc-1", + requiresExtraction: true, + pendingSnapshot: { + allocationId: "alloc-1", + mutationAllocationId: "alloc-1", + projectName: "Project One", + before: { + startDate: new Date("2025-01-01"), + endDate: new Date("2025-01-03"), + }, + after: { + startDate: new Date("2025-01-05"), + endDate: new Date("2025-01-07"), + }, + }, + }, + }), + previewOps: { preserve: vi.fn(), clear: vi.fn() }, + }); + + expect(clearPendingOptimisticAllocation).toHaveBeenCalledWith("alloc-1"); + expect(updateAllocation).not.toHaveBeenCalled(); + expect(pendingSnapshotRef.current).toBeNull(); + expect(pendingOptimisticAllocationIdRef.current).toBeNull(); + expect(optimisticHarness.value.size).toBe(0); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationReleaseEffects.ts b/apps/web/src/hooks/timelineAllocationReleaseEffects.ts new file mode 100644 index 0000000..8935cdb --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationReleaseEffects.ts @@ -0,0 +1,104 @@ +import { type AllocationMovedSnapshotLike } from "./timelineAllocationFinalize.js"; +import { clearLivePreview, preserveLivePreview, type LivePreviewSession } from "./timelineLivePreview.js"; +import { resolveAllocationRelease, type AllocationReleaseOutcome } from "./timelineAllocationRelease.js"; + +type MutableCurrent = { current: T }; +type AllocationDragReleaseLike = Parameters[0]; +type AllocationBlockClickInfo = Extract["clickInfo"]; +type OptimisticAllocationOverride = { startDate: Date; endDate: Date }; +type SetOptimisticAllocations = ( + updater: (prev: Map) => Map, +) => void; +type MutationInput = { allocationId: string; startDate: Date; endDate: Date }; +type PreviewOps = { + preserve: (preview: LivePreviewSession | null) => void; + clear: (preview: LivePreviewSession | null) => void; +}; + +type FinalizeAllocationReleaseEffectsParams = { + clientX: number; + allocRef: MutableCurrent; + previewRef: MutableCurrent; + updatePosition: (clientX: number) => void; + clickThresholdPx: number; + wasShift: boolean; + onShiftClick?: ((allocationId: string) => void) | undefined; + onBlockClick?: ((clickInfo: AllocationBlockClickInfo) => void) | undefined; + pendingSnapshotRef: MutableCurrent; + pendingOptimisticAllocationIdRef: MutableCurrent; + setOptimisticAllocations: SetOptimisticAllocations; + extractAllocationFragment: (input: MutationInput) => Promise<{ extractedAllocationId: string }>; + updateAllocation: (input: MutationInput) => void; + clearPendingOptimisticAllocation: (allocationId: string) => void; + resolveRelease?: ( + alloc: AllocationDragReleaseLike, + options: { clickThresholdPx: number; wasShift: boolean }, + ) => AllocationReleaseOutcome; + previewOps?: PreviewOps; +}; + +export async function finalizeAllocationReleaseEffects({ + clientX, + allocRef, + previewRef, + updatePosition, + clickThresholdPx, + wasShift, + onShiftClick, + onBlockClick, + pendingSnapshotRef, + pendingOptimisticAllocationIdRef, + setOptimisticAllocations, + extractAllocationFragment, + updateAllocation, + clearPendingOptimisticAllocation, + resolveRelease = resolveAllocationRelease, + previewOps = { preserve: preserveLivePreview, clear: clearLivePreview }, +}: FinalizeAllocationReleaseEffectsParams): Promise { + updatePosition(clientX); + const alloc = allocRef.current; + const release = resolveRelease(alloc, { clickThresholdPx, wasShift }); + if (release.kind === "ignore") return; + if (release.preservePreview) previewOps.preserve(previewRef.current); + previewOps.clear(previewRef.current); + previewRef.current = null; + if (release.kind === "shift-click") return void onShiftClick?.(release.allocationId); + if (release.kind === "click") return void onBlockClick?.(release.clickInfo); + if (release.kind !== "mutation") return; + + const { + activeAllocationId, + currentStartDate, + currentEndDate, + baseMutationAllocationId, + requiresExtraction, + pendingSnapshot, + } = release.mutationPlan; + + pendingSnapshotRef.current = pendingSnapshot; + pendingOptimisticAllocationIdRef.current = activeAllocationId; + setOptimisticAllocations((prev) => { + const next = new Map(prev); + next.set(activeAllocationId, { startDate: currentStartDate, endDate: currentEndDate }); + return next; + }); + + try { + let mutationAllocationId = baseMutationAllocationId; + if (requiresExtraction) { + if (!alloc.originalStartDate || !alloc.originalEndDate) throw new Error("missing original allocation dates"); + const extracted = await extractAllocationFragment({ + allocationId: mutationAllocationId, + startDate: alloc.originalStartDate, + endDate: alloc.originalEndDate, + }); + mutationAllocationId = extracted.extractedAllocationId; + } + pendingSnapshotRef.current = pendingSnapshotRef.current + ? { ...pendingSnapshotRef.current, mutationAllocationId } + : null; + updateAllocation({ allocationId: mutationAllocationId, startDate: currentStartDate, endDate: currentEndDate }); + } catch { + clearPendingOptimisticAllocation(activeAllocationId); + } +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 5b750f5..d110b09 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -11,7 +11,6 @@ import { scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; -import { buildAllocationMovedSnapshot } from "./timelineAllocationFinalize.js"; import { finalizeAllocationMultiDrag, isAllocationMultiSelected, @@ -19,9 +18,9 @@ import { updateAllocationMultiDrag, } from "./timelineAllocationMultiDrag.js"; import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js"; -import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { beginAllocationDragSession } from "./timelineAllocationDragSession.js"; +import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js"; import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; @@ -640,75 +639,22 @@ export function useTimelineDrag({ attachDrag: attachDocumentMouseDrag, updatePosition: updateAllocationDragPosition, finalize: (clientX) => { - updateAllocationDragPosition(clientX); - const alloc = allocDragRef.current; - const release = resolveAllocationRelease(alloc, { + void finalizeAllocationReleaseEffects({ + clientX, + allocRef: allocDragRef, + previewRef: allocPreviewRef, + updatePosition: updateAllocationDragPosition, clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, wasShift, + onShiftClick: onShiftClickAllocRef.current, + onBlockClick: onBlockClickRef.current, + pendingSnapshotRef, + pendingOptimisticAllocationIdRef, + setOptimisticAllocations, + extractAllocationFragment: extractAllocFragmentMutation.mutateAsync, + updateAllocation: updateAllocMutation.mutate, + clearPendingOptimisticAllocation, }); - if (release.kind === "ignore") return; - - if (release.preservePreview) { - preserveLivePreview(allocPreviewRef.current); - } - clearLivePreview(allocPreviewRef.current); - allocPreviewRef.current = null; - - if (release.kind === "shift-click") { - onShiftClickAllocRef.current?.(release.allocationId); - } else if (release.kind === "click") { - onBlockClickRef.current?.(release.clickInfo); - } else if (release.kind === "mutation") { - const { mutationPlan } = release; - const { - activeAllocationId, - currentStartDate, - currentEndDate, - baseMutationAllocationId, - requiresExtraction, - pendingSnapshot, - } = mutationPlan; - - pendingSnapshotRef.current = pendingSnapshot; - pendingOptimisticAllocationIdRef.current = activeAllocationId; - setOptimisticAllocations((prev) => { - const next = new Map(prev); - next.set(activeAllocationId, { - startDate: currentStartDate, - endDate: currentEndDate, - }); - return next; - }); - void (async () => { - try { - let mutationAllocationId = baseMutationAllocationId; - - if (requiresExtraction) { - const extracted = await extractAllocFragmentMutation.mutateAsync({ - allocationId: mutationAllocationId, - startDate: alloc.originalStartDate!, - endDate: alloc.originalEndDate!, - }); - mutationAllocationId = extracted.extractedAllocationId; - } - - pendingSnapshotRef.current = pendingSnapshotRef.current - ? { - ...pendingSnapshotRef.current, - mutationAllocationId, - } - : null; - - updateAllocMutation.mutate({ - allocationId: mutationAllocationId, - startDate: currentStartDate, - endDate: currentEndDate, - }); - } catch { - clearPendingOptimisticAllocation(activeAllocationId); - } - })(); - } allocDragRef.current = INITIAL_ALLOC_DRAG; setAllocDragState(INITIAL_ALLOC_DRAG); diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 1c03248..ca9bdc2 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -351,6 +351,25 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineAllocationReleaseEffects.ts", + maxLines: 130, + required: [ + { + pattern: /\bexport async function finalizeAllocationReleaseEffects\b/, + message: "timeline allocation release effect helpers must keep release side effects centralized", + }, + { + pattern: /from "\.\/timelineAllocationRelease\.js"/, + message: "timeline allocation release effect helpers must keep release classification delegated to the extracted helper module", + }, + { + pattern: /from "\.\/timelineLivePreview\.js"/, + message: "timeline allocation release effect helpers must keep preview lifecycle delegated to the extracted helper module", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineProjectDrag.ts", maxLines: 80, @@ -408,14 +427,6 @@ export const rules = [ pattern: /from "\.\/timelineOptimisticAllocations\.js"/, message: "timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", }, - { - pattern: /from "\.\/timelineAllocationFinalize\.js"/, - message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module", - }, - { - pattern: /from "\.\/timelineAllocationRelease\.js"/, - message: "timeline drag must keep allocation release classification delegated to the extracted helper module", - }, { pattern: /from "\.\/timelineDragCleanup\.js"/, message: "timeline drag must keep unmount teardown delegated to the extracted helper module", @@ -444,6 +455,10 @@ export const rules = [ pattern: /from "\.\/timelineAllocationDragSession\.js"/, message: "timeline drag must keep allocation drag document session wiring delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineAllocationReleaseEffects\.js"/, + message: "timeline drag must keep allocation release side effects delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineProjectDrag\.js"/, message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", @@ -502,6 +517,10 @@ export const rules = [ pattern: /\bfunction handle(?:Move|Up)\b/, message: "timeline drag must not re-inline extracted allocation drag session handlers", }, + { + pattern: /\bpendingSnapshotRef\.current = pendingSnapshot\b[\s\S]*updateAllocMutation\.mutate\(/, + message: "timeline drag must not re-inline extracted allocation release effect mutation wiring", + }, { pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/, message: "timeline drag must not re-inline extracted project drag helper implementations", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index d2a2d14..a7f5c15 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -89,6 +89,9 @@ describe("architecture guardrails", () => { const documentDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDocumentDrag.ts"); const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts"); const allocationDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragSession.ts"); + const allocationReleaseEffectsRule = rules.find( + (rule) => rule.file === "apps/web/src/hooks/timelineAllocationReleaseEffects.ts", + ); const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts"); const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts"); @@ -110,6 +113,7 @@ describe("architecture guardrails", () => { assert.ok(documentDragRule); assert.ok(allocationDragStateRule); assert.ok(allocationDragSessionRule); + assert.ok(allocationReleaseEffectsRule); assert.ok(projectDragRule); assert.ok(projectDragSessionRule); @@ -121,8 +125,6 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring 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 allocation drag completion rules delegated to the extracted helper module", - "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release classification delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", @@ -130,6 +132,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag document session wiring 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 document session wiring delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects 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 document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations", @@ -211,6 +214,12 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineAllocationDragSession.ts: missing guardrail anchor: timeline allocation drag session helpers must keep document drag lifecycle centralized", ]); + assert.deepEqual(evaluateRule(allocationReleaseEffectsRule, ""), [ + "apps/web/src/hooks/timelineAllocationReleaseEffects.ts: missing guardrail anchor: timeline allocation release effect helpers must keep release side effects centralized", + "apps/web/src/hooks/timelineAllocationReleaseEffects.ts: missing guardrail anchor: timeline allocation release effect helpers must keep release classification delegated to the extracted helper module", + "apps/web/src/hooks/timelineAllocationReleaseEffects.ts: missing guardrail anchor: timeline allocation release effect helpers must keep preview lifecycle delegated to the extracted helper module", + ]); + assert.deepEqual(evaluateRule(projectDragRule, "export function createProjectDragState() {}\n"), [ "apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized", ]); @@ -231,8 +240,6 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring 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 allocation drag completion rules delegated to the extracted helper module", - "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release classification delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", @@ -240,6 +247,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag document session wiring 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 document session wiring delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release side effects 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 document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",