From 84c5760392e35e3f1743393b23cec5010a9b2ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 10:17:39 +0200 Subject: [PATCH] refactor(web): extract range selection bootstrap --- .../src/hooks/timelineRangeSelection.test.ts | 19 ++++++++++++++++++- apps/web/src/hooks/timelineRangeSelection.ts | 16 ++++++++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 16 +++++++--------- scripts/check-architecture-guardrails.mjs | 6 +++++- .../check-architecture-guardrails.test.mjs | 1 + 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/web/src/hooks/timelineRangeSelection.test.ts b/apps/web/src/hooks/timelineRangeSelection.test.ts index 56315d4..d957ace 100644 --- a/apps/web/src/hooks/timelineRangeSelection.test.ts +++ b/apps/web/src/hooks/timelineRangeSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; +import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; type TestRangeState = { isSelecting: boolean; @@ -11,6 +11,23 @@ type TestRangeState = { }; describe("timelineRangeSelection", () => { + it("creates a selection draft with currentDate anchored to the start date", () => { + expect( + createRangeSelectionState( + "res-1", + new Date("2025-01-15T12:00:00.000Z"), + 100, + ), + ).toEqual({ + isSelecting: true, + resourceId: "res-1", + startDate: new Date("2025-01-15T12:00:00.000Z"), + currentDate: new Date("2025-01-15T12:00:00.000Z"), + suggestedProjectId: null, + startClientX: 100, + }); + }); + it("ignores updates when no full day boundary was crossed", () => { const state: TestRangeState = { isSelecting: true, diff --git a/apps/web/src/hooks/timelineRangeSelection.ts b/apps/web/src/hooks/timelineRangeSelection.ts index 2eedf0b..df478d8 100644 --- a/apps/web/src/hooks/timelineRangeSelection.ts +++ b/apps/web/src/hooks/timelineRangeSelection.ts @@ -18,6 +18,22 @@ export type RangeSelectionResult = { anchorY: number; }; +export function createRangeSelectionState( + resourceId: string, + startDate: Date, + startClientX: number, + suggestedProjectId?: string | null, +): TState { + return { + isSelecting: true, + resourceId, + startDate, + currentDate: startDate, + suggestedProjectId: suggestedProjectId ?? null, + startClientX, + } as TState; +} + export function updateRangeSelectionDraft( state: TState, currentClientX: number, diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 9cf729a..07a4f51 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -31,7 +31,7 @@ import { updateMultiSelectDraft, } from "./timelineMultiSelect.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; -import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; +import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; const DRAG_CLICK_THRESHOLD_PX = 5; @@ -826,14 +826,12 @@ export function useTimelineDrag({ if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return; if (e.button !== 0) return; e.preventDefault(); - const state: RangeState = { - isSelecting: true, - resourceId: opts.resourceId, - startDate: opts.startDate, - currentDate: opts.startDate, - suggestedProjectId: opts.suggestedProjectId ?? null, - startClientX: e.clientX, - }; + const state = createRangeSelectionState( + opts.resourceId, + opts.startDate, + e.clientX, + opts.suggestedProjectId, + ); rangeStateRef.current = state; setRangeState(state); }, diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 80001fe..fce1fdb 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -153,6 +153,10 @@ export const rules = [ file: "apps/web/src/hooks/timelineRangeSelection.ts", maxLines: 90, required: [ + { + pattern: /\bexport function createRangeSelectionState\b/, + message: "timeline range helpers must keep selection bootstrap centralized", + }, { pattern: /\bexport function updateRangeSelectionDraft\b/, message: "timeline range helpers must keep preview date derivation centralized", @@ -304,7 +308,7 @@ export const rules = [ ], forbidden: [ { - pattern: /\bfunction (?:toPxValue|joinTransforms|captureLivePreviewTargets|renderLivePreview|scheduleLivePreview|clearLivePreview|datesMatch|preserveLivePreview)\b/, + pattern: /\bfunction (?:toPxValue|joinTransforms|captureLivePreviewTargets|renderLivePreview|scheduleLivePreview|clearLivePreview|datesMatch|preserveLivePreview|createRangeSelectionState)\b/, message: "timeline drag must not re-inline live preview helper implementations", }, { diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 55addba..9a20177 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -121,6 +121,7 @@ describe("architecture guardrails", () => { ]); assert.deepEqual(evaluateRule(rangeRule, "export function updateRangeSelectionDraft() {}\n"), [ + "apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep selection bootstrap centralized", "apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep ordered range finalization centralized", ]);