refactor(web): extract allocation release effects

This commit is contained in:
2026-04-01 11:35:17 +02:00
parent f4e9831dea
commit 37c6e03d23
5 changed files with 427 additions and 80 deletions
@@ -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<string, { startDate: Date; endDate: Date }>();
return {
get value() {
return optimistic;
},
setOptimisticAllocations(
updater: (
prev: Map<string, { startDate: Date; endDate: Date }>,
) => Map<string, { startDate: Date; endDate: Date }>,
) {
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);
});
});
@@ -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<T> = { current: T };
type AllocationDragReleaseLike = Parameters<typeof resolveAllocationRelease>[0];
type AllocationBlockClickInfo = Extract<AllocationReleaseOutcome, { kind: "click" }>["clickInfo"];
type OptimisticAllocationOverride = { startDate: Date; endDate: Date };
type SetOptimisticAllocations = (
updater: (prev: Map<string, OptimisticAllocationOverride>) => Map<string, OptimisticAllocationOverride>,
) => 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<AllocationDragReleaseLike>;
previewRef: MutableCurrent<LivePreviewSession | null>;
updatePosition: (clientX: number) => void;
clickThresholdPx: number;
wasShift: boolean;
onShiftClick?: ((allocationId: string) => void) | undefined;
onBlockClick?: ((clickInfo: AllocationBlockClickInfo) => void) | undefined;
pendingSnapshotRef: MutableCurrent<AllocationMovedSnapshotLike | null>;
pendingOptimisticAllocationIdRef: MutableCurrent<string | null>;
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<void> {
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);
}
}
+14 -68
View File
@@ -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);
+27 -8
View File
@@ -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",
+12 -4
View File
@@ -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",