diff --git a/apps/web/src/hooks/timelinePreviewSession.test.ts b/apps/web/src/hooks/timelinePreviewSession.test.ts new file mode 100644 index 0000000..57c4ab4 --- /dev/null +++ b/apps/web/src/hooks/timelinePreviewSession.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js"; + +function createFakeElement({ + left = "0px", + width = "0px", + transform = "", + closest = () => null, +}: { + left?: string; + width?: string; + transform?: string; + closest?: () => HTMLElement | null; +} = {}): HTMLElement { + return { + style: { left, width, transform }, + closest, + } as unknown as HTMLElement; +} + +function isFakeElement(value: EventTarget | null | undefined): value is HTMLElement { + return Boolean(value && typeof value === "object" && "style" in value); +} + +describe("timelinePreviewSession", () => { + it("returns null when project preview lookup finds nothing and no HTMLElement fallback exists", () => { + const session = createProjectPreviewSession({ + projectId: "proj_1", + currentTarget: null, + cellWidth: 32, + queryProjectTargets: () => [], + isElement: isFakeElement, + }); + + expect(session).toBeNull(); + }); + + it("falls back to the current target when project preview lookup returns no matching elements", () => { + const currentTarget = createFakeElement({ left: "12px", width: "48px" }); + + const session = createProjectPreviewSession({ + projectId: "proj_1", + currentTarget, + cellWidth: 32, + queryProjectTargets: () => [], + isElement: isFakeElement, + }); + + expect(session).toMatchObject({ + mode: "move", + cellWidth: 32, + pointerDeltaX: 0, + daysDelta: 0, + frame: null, + }); + expect(session?.targets).toHaveLength(1); + expect(session?.targets[0]).toMatchObject({ + element: currentTarget, + baseLeft: 12, + baseWidth: 48, + }); + }); + + it("keeps queried project preview targets authoritative when they exist", () => { + const queriedTarget = createFakeElement({ left: "20px", width: "64px" }); + const fallbackTarget = createFakeElement({ left: "99px", width: "99px" }); + + const session = createProjectPreviewSession({ + projectId: "proj_1", + currentTarget: fallbackTarget, + cellWidth: 16, + queryProjectTargets: () => [queriedTarget], + isElement: isFakeElement, + }); + + expect(session?.targets).toHaveLength(1); + expect(session?.targets[0]).toMatchObject({ + element: queriedTarget, + baseLeft: 20, + baseWidth: 64, + }); + }); + + it("returns null when allocation preview root cannot be resolved", () => { + const currentTarget = createFakeElement(); + + expect( + createAllocationPreviewSession({ + currentTarget, + cellWidth: 24, + resolveAllocationRoot: () => null, + }), + ).toBeNull(); + }); + + it("captures the nearest allocation preview root for allocation sessions", () => { + const root = createFakeElement({ left: "8px", width: "40px" }); + const child = createFakeElement(); + + const session = createAllocationPreviewSession({ + currentTarget: child, + mode: "resize-end", + cellWidth: 24, + resolveAllocationRoot: () => root, + }); + + expect(session).toMatchObject({ + mode: "resize-end", + cellWidth: 24, + pointerDeltaX: 0, + daysDelta: 0, + frame: null, + }); + expect(session?.targets).toHaveLength(1); + expect(session?.targets[0]).toMatchObject({ + element: root, + baseLeft: 8, + baseWidth: 40, + }); + }); +}); diff --git a/apps/web/src/hooks/timelinePreviewSession.ts b/apps/web/src/hooks/timelinePreviewSession.ts new file mode 100644 index 0000000..7a40f75 --- /dev/null +++ b/apps/web/src/hooks/timelinePreviewSession.ts @@ -0,0 +1,73 @@ +import { + captureLivePreviewTargets, + type LivePreviewMode, + type LivePreviewSession, + type LivePreviewTarget, +} from "./timelineLivePreview.js"; + +function isHTMLElement(value: EventTarget | null | undefined): value is HTMLElement { + return typeof HTMLElement !== "undefined" && value instanceof HTMLElement; +} + +function resolveAllocationPreviewRoot(currentTarget: EventTarget | null | undefined): HTMLElement | null { + return isHTMLElement(currentTarget) + ? currentTarget.closest('[data-timeline-drag-preview~="allocation"]') + : null; +} + +function createLivePreviewSession( + mode: LivePreviewMode, + cellWidth: number, + targets: LivePreviewTarget[], +): LivePreviewSession | null { + if (targets.length === 0) return null; + + return { + mode, + cellWidth, + targets, + pointerDeltaX: 0, + daysDelta: 0, + frame: null, + }; +} + +export function createProjectPreviewSession({ + projectId, + currentTarget, + cellWidth, + queryProjectTargets = (nextProjectId: string) => + document.querySelectorAll( + `[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${nextProjectId}"]`, + ), + isElement = isHTMLElement, +}: { + projectId: string; + currentTarget?: EventTarget | null | undefined; + cellWidth: number; + queryProjectTargets?: (projectId: string) => Iterable; + isElement?: (value: EventTarget | null | undefined) => value is HTMLElement; +}): LivePreviewSession | null { + const targets = captureLivePreviewTargets(queryProjectTargets(projectId)); + if (targets.length === 0 && isElement(currentTarget)) { + targets.push(...captureLivePreviewTargets([currentTarget])); + } + + return createLivePreviewSession("move", cellWidth, targets); +} + +export function createAllocationPreviewSession({ + currentTarget, + mode = "move", + cellWidth, + resolveAllocationRoot = resolveAllocationPreviewRoot, +}: { + currentTarget?: EventTarget | null | undefined; + mode?: LivePreviewMode; + cellWidth: number; + resolveAllocationRoot?: (currentTarget: EventTarget | null | undefined) => HTMLElement | null; +}): LivePreviewSession | null { + const root = resolveAllocationRoot(currentTarget); + const targets = root ? captureLivePreviewTargets([root]) : []; + return createLivePreviewSession(mode, cellWidth, targets); +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index a7367c4..a0db405 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -5,7 +5,6 @@ import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; import { pixelsToDays } from "~/components/timeline/dragMath.js"; import { - captureLivePreviewTargets, clearLivePreview, preserveLivePreview, scheduleLivePreview, @@ -39,6 +38,7 @@ import { } from "./timelineMultiSelect.js"; import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; +import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js"; import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js"; import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js"; @@ -283,50 +283,20 @@ export function useTimelineDrag({ const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => { clearLivePreview(projectPreviewRef.current); - - const projectTargets = captureLivePreviewTargets( - document.querySelectorAll( - `[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`, - ), - ); - - if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) { - projectTargets.push(...captureLivePreviewTargets([currentTarget])); - } - - projectPreviewRef.current = - projectTargets.length > 0 - ? { - mode: "move", - cellWidth: cellWidthRef.current, - targets: projectTargets, - pointerDeltaX: 0, - daysDelta: 0, - frame: null, - } - : null; + projectPreviewRef.current = createProjectPreviewSession({ + projectId, + currentTarget, + cellWidth: cellWidthRef.current, + }); }, []); const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => { clearLivePreview(allocPreviewRef.current); - - const root = - currentTarget instanceof HTMLElement - ? currentTarget.closest('[data-timeline-drag-preview~="allocation"]') - : null; - const targets = root ? captureLivePreviewTargets([root]) : []; - - allocPreviewRef.current = - targets.length > 0 - ? { - mode, - cellWidth: cellWidthRef.current, - targets, - pointerDeltaX: 0, - daysDelta: 0, - frame: null, - } - : null; + allocPreviewRef.current = createAllocationPreviewSession({ + currentTarget, + mode, + cellWidth: cellWidthRef.current, + }); }, []); const updateLivePreview = useCallback( diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index de2b19e..5c5dff0 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -255,6 +255,25 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelinePreviewSession.ts", + maxLines: 80, + required: [ + { + pattern: /\bexport function createProjectPreviewSession\b/, + message: "timeline preview session helpers must keep project preview target resolution centralized", + }, + { + pattern: /\bexport function createAllocationPreviewSession\b/, + message: "timeline preview session helpers must keep allocation preview target resolution centralized", + }, + { + pattern: /from "\.\/timelineLivePreview\.js"/, + message: "timeline preview session helpers must keep target capture delegated to the live preview helper module", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineAllocationFinalize.ts", maxLines: 100, @@ -492,6 +511,10 @@ export const rules = [ pattern: /from "\.\/timelineOptimisticAllocations\.js"/, message: "timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelinePreviewSession\.js"/, + message: "timeline drag must keep preview target setup delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineDragCleanup\.js"/, message: "timeline drag must keep unmount teardown delegated to the extracted helper module", @@ -594,6 +617,14 @@ export const rules = [ pattern: /\bconst mutationInput = buildProjectShiftMutationInput\(finalDrag\)\b[\s\S]*applyShiftMutation\.(?:mutate|mutateAsync)\(/, message: "timeline drag must not re-inline extracted project drag finalize flow", }, + { + pattern: /\bdocument\.querySelectorAll\([\s\S]*data-timeline-project-id/, + message: "timeline drag must not re-inline extracted project preview target lookup", + }, + { + pattern: /\bcurrentTarget\.closest\('\[data-timeline-drag-preview~=\"allocation\"\]'\)/, + message: "timeline drag must not re-inline extracted allocation preview target lookup", + }, { pattern: /\bconst selection = finalizeRangeSelection\(/, message: "timeline drag must not re-inline extracted range release resolution", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 770eb53..74b3a70 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -79,6 +79,7 @@ describe("architecture guardrails", () => { const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts"); const rangeReleaseRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeRelease.ts"); const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts"); + const previewSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelinePreviewSession.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 allocationMultiDragSessionRule = rules.find( @@ -110,6 +111,7 @@ describe("architecture guardrails", () => { assert.ok(rangeRule); assert.ok(rangeReleaseRule); assert.ok(optimisticRule); + assert.ok(previewSessionRule); assert.ok(allocationFinalizeRule); assert.ok(allocationMultiDragRule); assert.ok(allocationMultiDragSessionRule); @@ -133,6 +135,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 release and cancel 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 preview target setup 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", @@ -192,6 +195,11 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineOptimisticAllocations.ts: missing guardrail anchor: timeline optimistic helpers must keep server-reconciliation logic centralized", ]); + assert.deepEqual(evaluateRule(previewSessionRule, "export function createProjectPreviewSession() {}\n"), [ + "apps/web/src/hooks/timelinePreviewSession.ts: missing guardrail anchor: timeline preview session helpers must keep allocation preview target resolution centralized", + "apps/web/src/hooks/timelinePreviewSession.ts: missing guardrail anchor: timeline preview session helpers must keep target capture delegated to the live preview helper module", + ]); + assert.deepEqual(evaluateRule(allocationFinalizeRule, "export function hasAllocationDateChange() {}\n"), [ "apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep click-vs-drag classification centralized", "apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep segment extraction rules centralized", @@ -269,6 +277,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 release and cancel 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 preview target setup 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",