diff --git a/apps/web/src/hooks/timelineAllocationActions.test.ts b/apps/web/src/hooks/timelineAllocationActions.test.ts new file mode 100644 index 0000000..2c04466 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationActions.test.ts @@ -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"), + }, + }, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationActions.ts b/apps/web/src/hooks/timelineAllocationActions.ts new file mode 100644 index 0000000..db325e1 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationActions.ts @@ -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), + }; +} diff --git a/apps/web/src/hooks/timelineAllocationFinalize.test.ts b/apps/web/src/hooks/timelineAllocationFinalize.test.ts index 4d9e186..42bb677 100644 --- a/apps/web/src/hooks/timelineAllocationFinalize.test.ts +++ b/apps/web/src/hooks/timelineAllocationFinalize.test.ts @@ -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", diff --git a/apps/web/src/hooks/timelineAllocationFinalize.ts b/apps/web/src/hooks/timelineAllocationFinalize.ts index 3e567af..ddf177f 100644 --- a/apps/web/src/hooks/timelineAllocationFinalize.ts +++ b/apps/web/src/hooks/timelineAllocationFinalize.ts @@ -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 || diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index acf36f6..9cf729a 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -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); diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 16b65f3..80001fe 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -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", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 7bf8aa7..55addba 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -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", ]);