refactor(web): extract preview target setup

This commit is contained in:
2026-04-01 11:59:10 +02:00
parent 2a7769a0de
commit e103174d39
5 changed files with 245 additions and 41 deletions
@@ -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,
});
});
});
@@ -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<HTMLElement>('[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<HTMLElement>(
`[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<HTMLElement>;
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);
}
+11 -41
View File
@@ -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<HTMLElement>(
`[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<HTMLElement>('[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(
+31
View File
@@ -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<HTMLElement>\([\s\S]*data-timeline-project-id/,
message: "timeline drag must not re-inline extracted project preview target lookup",
},
{
pattern: /\bcurrentTarget\.closest<HTMLElement>\('\[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",
@@ -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",